diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 23d5f26..508a2d3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,16 +1,28 @@ -name: Build - +name: Build and test on: push: - branches: [main] + branches: [main, 'codex/**'] pull_request: branches: [main] - +permissions: + contents: read jobs: - build: + test: runs-on: macos-15 - + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - name: Build - run: swift build + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: '26.3' + - run: python3 -m unittest discover -s Tests/Release -v + - run: swift test + - name: Build and verify Universal app + env: + ARCHS: arm64 x86_64 + run: ./scripts/make-app.sh + - name: Check bundled resources and version + run: | + test -d .build/app/SMARTastic.app/Contents/Resources/SMARTastic_SMARTastic.bundle + ./scripts/check-architectures.sh .build/app/SMARTastic.app/Contents/MacOS/SMARTastic + codesign --verify --deep --strict .build/app/SMARTastic.app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8321764 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,139 @@ +name: Signed release and Homebrew +on: + workflow_dispatch: + inputs: + version: + description: 'Version (x.y.z)' + required: true + type: string + resume_run_id: + description: 'Optional original run ID for an interrupted notarization' + required: false + type: string +permissions: + contents: write + actions: read +concurrency: + group: smartastic-release + cancel-in-progress: false +jobs: + release: + runs-on: macos-15 + timeout-minutes: 60 + env: + VERSION: ${{ inputs.version }} + RELEASE_REPOSITORY: ${{ github.repository }} + NOTARY_PROFILE: smartastic-release + steps: + - name: Set temporary keychain paths + run: | + printf 'CODE_SIGN_KEYCHAIN=%s/smartastic-signing.keychain-db\n' "$RUNNER_TEMP" >> "$GITHUB_ENV" + printf 'NOTARY_KEYCHAIN=%s/smartastic-signing.keychain-db\n' "$RUNNER_TEMP" >> "$GITHUB_ENV" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: '26.3' + - name: Check release and tap access + env: + GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + run: | + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 + test -n "$GH_TOKEN" + test "$(gh api repos/localfoundry/homebrew-tap --jq .permissions.push)" = true + - name: Resolve release or restore notarization state + id: state + env: + GH_TOKEN: ${{ github.token }} + RESUME_RUN_ID: ${{ inputs.resume_run_id }} + run: python3 scripts/prepare-release.py + - name: Run tests + if: steps.state.outputs.mode == 'build' + run: | + python3 -m unittest discover -s Tests/Release -v + swift test + - name: Import signing certificate and notary credentials + if: steps.state.outputs.mode == 'build' + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + for variable in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + [[ -n "${!variable}" ]] || { echo "Required secret missing: $variable" >&2; exit 1; } + done + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Version must be x.y.z.' >&2; exit 1; } + keychain_password="$(openssl rand -hex 32)" + echo "::add-mask::$keychain_password" + printf '%s' "$CSC_LINK" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security create-keychain -p "$keychain_password" "$CODE_SIGN_KEYCHAIN" + security set-keychain-settings -lut 3600 "$CODE_SIGN_KEYCHAIN" + security unlock-keychain -p "$keychain_password" "$CODE_SIGN_KEYCHAIN" + # codesign also searches for the private key and certificate chain here. + python3 - <<'PYTHON' + import os, shlex, subprocess + existing = shlex.split(subprocess.check_output( + ["security", "list-keychains", "-d", "user"], text=True)) + subprocess.run(["security", "list-keychains", "-d", "user", "-s", + os.environ["CODE_SIGN_KEYCHAIN"], *existing], check=True) + PYTHON + security import "$RUNNER_TEMP/certificate.p12" -P "$CSC_KEY_PASSWORD" -k "$CODE_SIGN_KEYCHAIN" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$CODE_SIGN_KEYCHAIN" + rm "$RUNNER_TEMP/certificate.p12" + # Use the sole valid Developer ID identity imported for this team. + python3 - <<'PYTHON' + import os, re, subprocess + result = subprocess.run( + ["security", "find-identity", "-v", "-p", "codesigning", os.environ["CODE_SIGN_KEYCHAIN"]], + check=True, capture_output=True, text=True) + identities = re.findall(r'"(Developer ID Application: [^"\n]+)"', result.stdout) + identities = [name for name in identities if name.endswith("(" + os.environ["APPLE_TEAM_ID"] + ")")] + if len(identities) != 1: + raise SystemExit("Expected exactly one valid Developer ID Application identity for APPLE_TEAM_ID.") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output: + output.write("CODE_SIGN_IDENTITY=" + identities[0] + "\n") + PYTHON + xcrun notarytool store-credentials "$NOTARY_PROFILE" --keychain "$NOTARY_KEYCHAIN" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" + - name: Build Universal app and notarize + if: steps.state.outputs.mode == 'build' + env: + ARCHS: arm64 x86_64 + run: ./scripts/release.sh .build/releases + - name: Verify final or restored archive + run: ./scripts/verify-release.sh + - name: Publish verified release + if: steps.state.outputs.mode != 'tap' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v$VERSION" \ + ".build/releases/SMARTastic-$VERSION.zip" \ + ".build/releases/SMARTastic-$VERSION.zip.sha256" \ + ".build/releases/Casks/smartastic.rb" \ + --target "$GITHUB_SHA" --title "SMARTastic $VERSION" --generate-notes + mkdir -p .build/download-check + gh release download "v$VERSION" --pattern "SMARTastic-$VERSION.zip*" --dir .build/download-check + (cd .build/download-check && shasum -a 256 -c "SMARTastic-$VERSION.zip.sha256") + - name: Update and verify Homebrew tap + env: + GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + run: ./scripts/update-tap.sh + - name: Remove temporary signing material + if: always() + run: | + rm -f "$RUNNER_TEMP/certificate.p12" + if [[ -f "$CODE_SIGN_KEYCHAIN" ]]; then + security delete-keychain "$CODE_SIGN_KEYCHAIN" + fi + - name: Preserve non-secret notarization state + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: smartastic-notarization-${{ github.run_id }} + path: .build/releases + overwrite: true + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index 25f5c2f..f5e2641 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ .DS_Store *.dSYM *.dSYM/** + +__pycache__/ diff --git a/Package.swift b/Package.swift index f666f6d..6dd35e3 100644 --- a/Package.swift +++ b/Package.swift @@ -3,12 +3,13 @@ import PackageDescription let package = Package( name: "SMARTastic", - defaultLocalization: "de", + defaultLocalization: "en", platforms: [.macOS(.v14)], targets: [ .executableTarget( name: "SMARTastic", resources: [.process("Resources")] - ) + ), + .testTarget(name: "SMARTasticTests", dependencies: ["SMARTastic"]) ] ) diff --git a/README.md b/README.md index 15b9312..5c48bab 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,165 @@ +

SMARTastic logo

+ # SMARTastic -Native macOS app for visualizing SSD and HDD SMART data. +A native macOS app for understanding the health of your SSDs and hard drives. +Built with SwiftUI, powered by [smartmontools](https://www.smartmontools.org/), +and developed by [Robin Bially](https://github.com/RobinBially). -

- macOS - Swift - MIT +

+ macOS 14 or later + Native SwiftUI app + MIT license

---- + + + SMARTastic showing an NVMe SSD's health, temperature, remaining rated endurance and usage + + +*Actual app screenshots, using clearly labelled synthetic demo data.* + +## What it shows + +- **Internal and external drives**, including Apple SSDs, NVMe drives and ATA disks. +- **Clear health states:** good, warning, critical or unknown. Failed SMART status, + NVMe critical warnings and depleted endurance are never hidden by a green score. +- **Available measurements:** temperature, rated endurance, spare capacity, + media/sector error counters, data read/written, power-on hours and power cycles. +- **Daily write history:** a native bar chart for 7, 30 or 90 days, with a today + summary and selectable bars. Uses real timestamps and counter differences, + independently of SMART power-on hours. +- **Drive search** by model or interface, with native keyboard selection and + Escape to clear the focused search field. +- **Refresh controls:** native segments for pause/manual, every 30 seconds, + every minute (default), or every five minutes. The last successful scan time stays visible. +- **JSON report export** with a versioned schema and measurement timestamp. + Serial-number fields are omitted; review diagnostic text before sharing. +- **Read diagnostics** with a full-width clickable disclosure row and useful guidance when smartmontools or SMART access is + unavailable. A failed scan preserves the previous snapshot and shows a warning. +- **Compact layout and native circular actions**, with content scrolling beneath + the transparent window header. +- **A sun/moon appearance switch**, with system appearance by default (right-click + the switch to restore it), plus English, German, French, Spanish and + Simplified Chinese translations selected from your system preferences. + +SMARTastic showing an HDD with sector warnings and unavailable measurements displayed as dashes + +## Daily write history + + + + Daily write history with a mint today bar and visible gaps, using synthetic demo data + + + +History starts when this version first scans a drive. SMART only exposes a lifetime +counter; it cannot reconstruct earlier daily usage. Keep SMARTastic open with +periodic refresh enabled to collect regular measurements. There is no background +agent when the app is quit. ⌘W minimizes the window so measurement can continue; +⌘Q quits the app. The standard ⌘M shortcut also remains available. + +The chart shows measured GB, not extrapolated full-day totals. Missing days stay +empty; an observed zero is shown as a dot. Today's value and other partially +observed days are incomplete. Select a bar to see its measured amount and covered +hours. Hover over the chart for a floating daily detail card, including explicit +missing-data messages. Click to pin a day; click it again to clear the selection. +Buttons and period controls include contextual tooltips. Short intervals crossing midnight (up to 10 minutes) are split in proportion +to elapsed time. Longer intervals crossing days are reported separately rather +than assigned to invented daily totals. Their entire volume is reported if the +interval overlaps the selected period, so it may include writes outside that period. + +Up to 90 calendar days are kept locally in +`~/Library/Application Support/SMARTastic/write-history.json`. A hash of model and +serial identifies each drive across device-path changes; raw serial numbers are +not stored. Drives without a reliable serial or write counter cannot be tracked. +Counter decreases establish a fresh baseline. The calendar time zone is fixed when +the history is created and displayed under the chart's measurement explanation. +Demo history stays in memory and never enters the real history file. The existing +JSON report exports the current SMART snapshot, not this local history. + +## Install + +Requires **macOS 14 Sonoma or later** and **smartmontools 7 or later**. +The app supports Apple Silicon and Intel. Homebrew distribution uses the +[LocalFoundry tap](https://github.com/localfoundry/homebrew-tap). + +Install the signed and Apple-notarized Universal app through Homebrew: + +```sh +brew install --cask localfoundry/tap/smartastic +``` -

- SMARTastic Screenshot -

+The cask also installs smartmontools. For a manual app download from +[GitHub Releases](https://github.com/RobinBially/SMARTastic/releases), install +smartmontools separately: + +```sh +brew install smartmontools +``` -**SMARTastic** shows drive health at a glance — temperature, wear, errors, TB written, and estimated remaining life. Built with SwiftUI, powered by `smartctl`. +SMARTastic looks for smartctl at `/opt/homebrew/bin/smartctl` on Apple Silicon +and `/usr/local/bin/smartctl` on Intel. It does not ask for administrator access, +install a privileged helper, start disk self-tests, or change drive settings. -## Features +## Understanding the numbers -- **NVMe SSDs & ATA HDDs** — Full SMART data where available -- **Health gauges** — Temperature, spare, media errors, usage -- **Usage stats** — Data read/written, daily write rate, power-on hours -- **Life estimate** — Based on current wear rate -- **Auto-refresh** — Every 30 seconds -- **Color-coded** — Green/orange/red at a glance +SMART reports what a drive and its controller expose. USB adapters, RAID +controllers and access permissions can prevent some or all SMART data from being +read. The app still shows the available macOS drive information. -## Requirements +**A dash means unavailable, not zero.** ATA attribute meanings vary by vendor; +unsupported SSD wear and traffic counters are not guessed. For NVMe, one data +unit is 512,000 bytes, and TB/GB use decimal units. ATA error counts aggregate +reported reallocated, pending and offline-uncorrectable counters; categories can +overlap and are not a count of distinct failing sectors. -- macOS 14+ -- `brew install smartmontools` +**Remaining rated endurance is not a lifespan prediction.** It is 100 minus the +manufacturer's wear indicator, clamped at zero. SMART power-on hours can exclude +low-power states. “Written per 24 SMART hours” divides lifetime written bytes by +the drive-reported power-on hours and normalizes to 24 hours. It is not necessarily +a calendar-day average and is not the current write speed. A measured calendar-day +average would require counter differences between timestamped observations. +The calendar lifespan forecast and arbitrary HDD health percentage were removed +because they suggested more certainty than SMART provides. See the [NVMe SMART log field definitions](https://manpages.debian.org/testing/libnvme-dev/nvme_smart_log.2.en.html). -## Quick Start +A good SMART result cannot rule out a sudden failure. Keep backups regardless of +the displayed status. SMARTastic makes no network requests; reports are saved +only to the destination you choose. -```bash +## Build from source + +Use full **Xcode 26.3 or later** and its command-line tools. If the selected +Command Line Tools SDK lacks the SwiftUI macro plugin, select full Xcode or set +`DEVELOPER_DIR` for the build; see [release documentation](docs/RELEASING.md). + +```sh brew install smartmontools git clone https://github.com/RobinBially/SMARTastic.git cd SMARTastic -swift build -bash scripts/make-app.sh -open SMARTastic.app +swift test +./scripts/make-app.sh +open .build/app/SMARTastic.app ``` -Or download from [Releases](https://github.com/RobinBially/SMARTastic/releases). +The script creates a release build for the current architecture and signs it ad +hoc for local development. To build both architectures: -## How it works +```sh +ARCHS="arm64 x86_64" ./scripts/make-app.sh +``` -| Interface | Data shown | -|-----------|-----------| -| **Sidebar** | All drives with health status, temperature, usage | -| **Detail** | Health overview, usage metrics, life prognosis, drive info | +Launch a synthetic demo for screenshots (no drive reads): + +```sh +open .build/app/SMARTastic.app --args --demo --light -AppleLanguages '(en)' +# Use the Appearance switch for System, Light or Dark. +``` -HDDs behind USB bridges that don't pass SMART commands show basic info (model, size) with a note that SMART is unavailable. +Build, signing, notarization, release resumption and Homebrew maintenance are +covered in [docs/RELEASING.md](docs/RELEASING.md). The review findings and actual +verification scope are recorded in [docs/REVIEW-1.1.0.md](docs/REVIEW-1.1.0.md). ## License -MIT +[MIT](LICENSE). diff --git a/Sources/SMARTastic/Localized.swift b/Sources/SMARTastic/Localized.swift index 68fb5c3..5db397d 100644 --- a/Sources/SMARTastic/Localized.swift +++ b/Sources/SMARTastic/Localized.swift @@ -1,9 +1,12 @@ import Foundation -func loc(_ key: String) -> String { - String(localized: String.LocalizationValue(key), bundle: .module) -} - -func locf(_ key: String, _ args: CVarArg...) -> String { - String(format: String(localized: String.LocalizationValue(key), bundle: .module), arguments: args) +enum AppResources { + static let bundle: Bundle = { + // Distributed apps must not depend on SwiftPM's absolute build-directory fallback. + if let url = Bundle.main.resourceURL?.appendingPathComponent("SMARTastic_SMARTastic.bundle"), + let bundle = Bundle(url: url) { return bundle } + return Bundle.module + }() } +func loc(_ key: String) -> String { String(localized: String.LocalizationValue(key), bundle: AppResources.bundle) } +func locf(_ key: String, _ args: CVarArg...) -> String { String(format: loc(key), arguments: args) } diff --git a/Sources/SMARTastic/Models/DemoData.swift b/Sources/SMARTastic/Models/DemoData.swift new file mode 100644 index 0000000..7ec1c45 --- /dev/null +++ b/Sources/SMARTastic/Models/DemoData.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Synthetic examples for documentation; never mixed with live measurements. +enum DemoData { + @MainActor static func seedHistory(_ store: WriteHistoryStore, now: Date = .now) { + let calendar = store.history.calendar + let today = calendar.startOfDay(for: now) + var disk = disks[0] + var total = 20.0 + // Deliberately leave two gaps to demonstrate honest missing-data handling. + for offset in -89...0 { + if offset == -4 || offset == -3 { continue } + let start = calendar.date(byAdding: .day, value: offset, to: today)! + let end = offset == 0 ? now : calendar.date(byAdding: .day, value: 1, to: start)!.addingTimeInterval(-1) + disk.dataWrittenTB = total + store.record([disk], at: start) + total += (Double((offset + 90) * 17 % 53) + 8 + (offset == -5 ? 85 : 0)) / 1000 + disk.dataWrittenTB = total + store.record([disk], at: end) + } + } + static let disks: [DiskInfo] = [ + DiskInfo(id: "demo-nvme", model: "Samsung SSD 990 PRO 2TB", serial: "DEMO-NVME-001", firmware: "4B2QJXD7", + capacityBytes: 2e12, driveType: .ssd, interface: "NVMe", smartAvailable: true, smartPassed: true, + temperature: 39, percentageUsed: 7, availableSpare: 100, spareThreshold: 10, criticalWarning: 0, + dataReadTB: 48.6, dataWrittenTB: 32.4, powerOnHours: 6840, powerCycles: 426, unsafeShutdowns: 3, mediaErrors: 0), + DiskInfo(id: "demo-hdd", model: "WDC Red Plus 4TB", serial: "DEMO-ATA-002", firmware: "83.00A83", + capacityBytes: 4e12, driveType: .hdd, interface: "ATA", smartAvailable: true, smartPassed: true, + temperature: 34, powerOnHours: 18240, powerCycles: 812, mediaErrors: 8), + DiskInfo(id: "demo-usb", model: "Portable SSD", capacityBytes: 1e12, driveType: .ssd, interface: "USB") + ] +} diff --git a/Sources/SMARTastic/Models/SmartData.swift b/Sources/SMARTastic/Models/SmartData.swift index 4ea8bd2..053c9bd 100644 --- a/Sources/SMARTastic/Models/SmartData.swift +++ b/Sources/SMARTastic/Models/SmartData.swift @@ -1,122 +1,84 @@ import SwiftUI -enum DriveType: String, CaseIterable { - case ssd = "SSD" - case hdd = "HDD" - case unknown = "?" +enum DriveType: String, Codable, CaseIterable { + case ssd = "SSD", hdd = "HDD", unknown = "—" } -struct DiskInfo: Identifiable, Hashable { - let id: String - let model: String - let serial: String - let firmware: String - let size: String - let driveType: DriveType - let interface: String - - let smartAvailable: Bool - let smartPassed: Bool - let temperature: Double - let percentageUsed: Double - let availableSpare: Double - let dataReadTB: Double - let dataWrittenTB: Double - let powerOnHours: Int - let powerCycles: Int - let unsafeShutdowns: Int - let mediaErrors: Int - - // MARK: - Computed - - var icon: String { - switch driveType { - case .ssd: "internaldrive" - case .hdd: "externaldrive" - case .unknown: "externaldrive" +enum HealthStatus: String, Codable { + case healthy, warning, critical, unknown + var color: Color { + switch self { + case .healthy: .green + case .warning: .orange + case .critical: .red + case .unknown: .secondary } } - - var powerOnFormatted: String { - let d = powerOnHours / 24 - let y = d / 365 - let r = d % 365 - if y > 0 { return locf("power_on_years_days", y, r) } - return locf("power_on_days", d) + var symbol: String { + switch self { + case .healthy: "checkmark.shield.fill" + case .warning: "exclamationmark.triangle.fill" + case .critical: "xmark.shield.fill" + case .unknown: "questionmark.circle" + } } +} - var dailyWriteGB: Double { - guard powerOnHours > 0 else { return 0 } - return (dataWrittenTB * 1000) / (Double(powerOnHours) / 24.0) - } +struct DiskInfo: Identifiable, Hashable, Codable { + let id: String + var model: String + var serial: String? + var firmware: String? + var capacityBytes: Double? + var driveType: DriveType + var interface: String + var smartAvailable: Bool = false + var smartPassed: Bool? + var temperature: Double? + var percentageUsed: Double? + var availableSpare: Double? + var spareThreshold: Double? + var criticalWarning: Int? + var dataReadTB: Double? + var dataWrittenTB: Double? + var powerOnHours: Int? + var powerCycles: Int? + var unsafeShutdowns: Int? + var mediaErrors: Int? + var diagnostic: String? - var dailyReadGB: Double { - guard powerOnHours > 0 else { return 0 } - return (dataReadTB * 1000) / (Double(powerOnHours) / 24.0) + var icon: String { driveType == .ssd ? "internaldrive" : "externaldrive" } + var size: String { + guard let capacityBytes else { return "—" } + return capacityBytes >= 1e12 ? String(format: "%.2f TB", capacityBytes / 1e12) + : String(format: "%.0f GB", capacityBytes / 1e9) } - - var remainingLifeEstimate: String { - guard percentageUsed > 0, powerOnHours > 0 else { return "\u{2014}" } - let hoursPerPct = Double(powerOnHours) / percentageUsed - let remainingHours = hoursPerPct * (100 - percentageUsed) - let d = Int(remainingHours / 24) - let y = d / 365 - let r = d % 365 - if y > 0 { return locf("remaining_years_days", y, r) } - return locf("remaining_days", d) + var health: HealthStatus { + if smartPassed == false || (criticalWarning ?? 0) != 0 { return .critical } + if let spare = availableSpare, let threshold = spareThreshold, spare < threshold { return .critical } + if (percentageUsed ?? 0) >= 100 { return .critical } + if (mediaErrors ?? 0) > 0 || (percentageUsed ?? 0) >= 80 || (temperature ?? 0) >= 70 { return .warning } + guard smartAvailable, smartPassed == true else { return .unknown } + return .healthy } - - var healthScore: Int { - guard smartAvailable else { return 0 } - if driveType == .ssd { - return max(0, min(100, 100 - Int(percentageUsed))) - } - if mediaErrors == 0 { return 95 } - if mediaErrors < 10 { return 60 } - return 30 - } - var healthLabel: String { - guard smartAvailable else { return loc("health.unknown") } - if driveType == .ssd { - switch percentageUsed { - case 0..<10: return loc("health.excellent") - case 10..<25: return loc("health.very_good") - case 25..<50: return loc("health.good") - case 50..<75: return loc("health.acceptable") - case 75..<90: return loc("health.warning") - default: return loc("health.critical") - } - } - if mediaErrors == 0 { return loc("health.good") } - if mediaErrors < 10 { return loc("health.warning") } - return loc("health.critical") + loc(health == .healthy ? "health.good" : health == .warning ? "health.warning" : health == .critical ? "health.critical" : "health.unknown") } - - var healthColor: Color { - let s = healthScore - if s >= 75 { return Color.green } - if s >= 40 { return Color.orange } - return Color.red + var healthColor: Color { health.color } + var remainingEndurance: Double? { percentageUsed.map { max(0, 100 - $0) } } + /// Lifetime write volume normalized to 24 drive-reported hours, not calendar time. + var writtenGBPer24PowerOnHours: Double? { + guard let hours = powerOnHours, hours > 0, let written = dataWrittenTB else { return nil } + return written * 1000 / Double(hours) * 24 } - - var tempLabel: String { - guard smartAvailable else { return "\u{2014}" } - switch temperature { - case ..<40: return loc("temp.very_cool") - case 40..<55: return loc("temp.normal") - case 55..<70: return loc("temp.warm") - default: return loc("temp.hot") - } - } - var tempColor: Color { - guard smartAvailable else { return .gray } - switch temperature { - case ..<40: return Color.teal - case 40..<55: return Color.green - case 55..<70: return Color.orange - default: return Color.red - } + guard let temperature else { return .secondary } + return temperature >= 70 ? .red : temperature >= 55 ? .orange : .teal } } + +func metric(_ value: Double?, suffix: String = "", decimals: Int = 0) -> String { + guard let value, value.isFinite else { return "—" } + return String(format: "%.*f", decimals, value) + suffix +} +func metric(_ value: Int?) -> String { value.map { $0.formatted() } ?? "—" } diff --git a/Sources/SMARTastic/Models/WriteHistory.swift b/Sources/SMARTastic/Models/WriteHistory.swift new file mode 100644 index 0000000..41c6861 --- /dev/null +++ b/Sources/SMARTastic/Models/WriteHistory.swift @@ -0,0 +1,107 @@ +import Foundation +import CryptoKit + +/// Persist daily counter differences, never extrapolate a SMART lifetime average. +struct WriteHistory: Codable { + struct Day: Codable, Identifiable { + var date: Date + var gb: Double = 0 + var seconds: Double = 0 + var estimated = false + var id: Date { date } + } + struct Gap: Codable { + let start: Date + let end: Date + let gb: Double + } + struct Drive: Codable { + var timestamp: Date + var writtenTB: Double + var days: [Day] = [] + var gaps: [Gap] = [] + } + var version = 1 + var timeZoneID = TimeZone.current.identifier + var drives: [String: Drive] = [:] + var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: timeZoneID) ?? .gmt + return calendar + } + static func key(for disk: DiskInfo) -> String? { + guard let serial = disk.serial?.trimmingCharacters(in: .whitespacesAndNewlines), !serial.isEmpty else { return nil } + // Device paths can change or be reused. Do not persist raw serial numbers. + return SHA256.hash(data: Data((disk.model + "\u{0}" + serial).utf8)).map { String(format: "%02x", $0) }.joined() + } + mutating func record(_ disk: DiskInfo, at now: Date) { + guard disk.smartAvailable, let key = Self.key(for: disk), + let total = disk.dataWrittenTB, total.isFinite, total >= 0 else { return } + guard var drive = drives[key] else { + drives[key] = Drive(timestamp: now, writtenTB: total) + return + } + // Ignore stale/duplicate samples; a decreasing counter starts a new baseline. + guard now > drive.timestamp else { return } + let start = drive.timestamp + let elapsed = now.timeIntervalSince(start) + let gb = (total - drive.writtenTB) * 1000 + if gb >= 0 { + if calendar.isDate(start, inSameDayAs: now) || elapsed <= 600 { + var cursor = start + while cursor < now { + let day = calendar.startOfDay(for: cursor) + let next = calendar.date(byAdding: .day, value: 1, to: day)! + let end = min(now, next) + let seconds = end.timeIntervalSince(cursor) + let index = drive.days.firstIndex { $0.date == day } ?? drive.days.count + if index == drive.days.count { drive.days.append(Day(date: day)) } + drive.days[index].gb += gb * seconds / elapsed + drive.days[index].seconds += seconds + drive.days[index].estimated = drive.days[index].estimated || !calendar.isDate(start, inSameDayAs: now) + cursor = end + } + } else { + drive.gaps.append(Gap(start: start, end: now, gb: gb)) + } + } + drive.timestamp = now + drive.writtenTB = total + let cutoff = calendar.date(byAdding: .day, value: -89, to: calendar.startOfDay(for: now))! + drive.days.removeAll { $0.date < cutoff } + drive.gaps.removeAll { $0.end < cutoff } + drives[key] = drive + drives = drives.filter { $0.value.timestamp >= cutoff } + } +} + +@MainActor @Observable +final class WriteHistoryStore { + private(set) var history = WriteHistory() + private(set) var failed = false + private let url: URL? + private var loadFailed = false + + init(url: URL? = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("SMARTastic/write-history.json")) { + self.url = url + guard let url, FileManager.default.fileExists(atPath: url.path) else { return } + do { + history = try JSONDecoder().decode(WriteHistory.self, from: Data(contentsOf: url)) + guard history.version == 1, TimeZone(identifier: history.timeZoneID) != nil else { throw CocoaError(.fileReadCorruptFile) } + } catch { failed = true; loadFailed = true } + } + func record(_ disks: [DiskInfo], at now: Date) { + guard !loadFailed else { return } // Preserve unreadable history for recovery. + for disk in disks { history.record(disk, at: now) } + guard let url else { return } + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(history).write(to: url, options: .atomic) + failed = false + } catch { failed = true } + } + func drive(for disk: DiskInfo) -> WriteHistory.Drive? { + WriteHistory.key(for: disk).flatMap { history.drives[$0] } + } +} diff --git a/Sources/SMARTastic/Resources/de.lproj/Localizable.strings b/Sources/SMARTastic/Resources/de.lproj/Localizable.strings index a8fd65a..71a4d0a 100644 --- a/Sources/SMARTastic/Resources/de.lproj/Localizable.strings +++ b/Sources/SMARTastic/Resources/de.lproj/Localizable.strings @@ -1,19 +1,30 @@ +"badge.smart_error" = "SMART FEHLER"; "badge.smart_na" = "SMART n/a"; "badge.smart_na_card" = "SMART n/v"; "badge.smart_ok" = "SMART OK"; -"badge.smart_error" = "SMART FEHLER"; "button.refresh.help" = "Aktualisieren"; "button.retry" = "Erneut versuchen"; "button.scan" = "Scannen"; -"detail.avg_write_rate" = "Durchschn. Schreibrate"; +"data.disclaimer" = "— bedeutet: Das Laufwerk liefert diesen Wert nicht. SMART kann einen Ausfall nicht ausschließen."; +"demo.notice" = "Demo · Beispiel-Laufwerke mit fiktiven Daten"; "detail.life_consumed" = "Verbrauchte Lebensdauer"; "detail.no_selection" = "Wähle ein Laufwerk aus"; "detail.power_cycles" = "Power Cycles"; "detail.power_on" = "Betriebszeit"; "detail.remaining_life" = "Geschätzte Restlebensdauer"; +"diagnostics.title" = "Auslese-Diagnose"; "disk_count_one" = "%lld Laufwerk"; "disk_count_other" = "%lld Laufwerke"; +"empty.help" = "Verbinde ein Laufwerk und starte einen Scan. Der SMART-Zugriff hängt von Laufwerk und Controller ab."; "empty.no_drives" = "Keine Laufwerke gefunden"; +"endurance.explanation" = "Die verbleibende Ausdauer ist 100 minus dem Verschleißwert des Herstellers. Sie sagt keinen Ausfallzeitpunkt voraus."; +"endurance.remaining" = "Rest-Ausdauer"; +"endurance.title" = "Zur SSD-Ausdauer"; +"error.discovery" = "Laufwerkserkennung fehlgeschlagen. Verbinde das Laufwerk erneut und wiederhole den Scan."; +"error.exit_status" = "smartctl meldet Status %d. Einzelne Daten fehlen möglicherweise oder enthalten Gesundheitswarnungen."; +"error.install" = "Installiere smartmontools mit „brew install smartmontools“ und aktualisiere danach."; +"error.invalid_json" = "Keine gültige SMART-JSON-Antwort. Prüfe smartmontools und den Laufwerkszugriff."; +"error.timeout" = "%@ hat nicht rechtzeitig geantwortet. Verbinde das Laufwerk erneut und wiederhole den Scan."; "gauge.lifespan" = "Lebensdauer"; "gauge.lifespan_unit" = "% verbraucht"; "gauge.media_errors" = "Medienfehler"; @@ -36,7 +47,6 @@ "label.health" = "Health"; "metric.media_errors_none" = "Fehlerfrei"; "metric.media_errors_some" = "Fehlerhaft"; -"metric.per_day" = "GB/Tag"; "metric.power_cycles" = "Power Cycles"; "metric.power_cycles_detail" = "Neustarts"; "metric.power_on_detail" = "Stunden"; @@ -45,20 +55,73 @@ "metric.unsafe_shutdowns" = "Unsafe Shutdowns"; "metric.unsafe_shutdowns_detail" = "Unsichere Trennungen"; "metric.written" = "Geschrieben"; -"nosmart.message" = "Der USB-Bridge-Chip dieses Gehäuses leitet keine SMART-Daten weiter. Das ist eine Hardware-Einschränkung des USB-Controllers."; +"nosmart.message" = "SMART-Daten konnten nicht gelesen werden. Laufwerk, Adapter oder Zugriffsrechte können den Zugriff verhindern. Beachte gegebenenfalls die Diagnose."; "nosmart.title" = "SMART nicht verfügbar"; "power_on_days" = "%lld Tage"; "power_on_years_days" = "%1$lld J. %2$lld T."; +"refresh.1m" = "Jede Minute"; +"refresh.30s" = "Alle 30 Sekunden"; +"refresh.5m" = "Alle 5 Minuten"; +"refresh.interval" = "Aktualisierung"; +"refresh.manual" = "Manuell"; +"refresh.updated" = "Letzter Scan"; "remaining_days" = "≈%lld Tage"; "remaining_years_days" = "≈%1$lld J. %2$lld T."; +"report.export" = "Bericht exportieren"; +"report.failed" = "Export fehlgeschlagen"; +"report.help" = "Bericht exportieren – speichert die aktuellen Laufwerksdaten als JSON-Datei, ohne Seriennummern."; +"scan.loading" = "Laufwerksdaten werden gelesen …"; +"search.empty" = "Keine passenden Laufwerke"; +"search.placeholder" = "Laufwerke suchen"; "section.drive_info" = "Laufwerks-Informationen"; "section.health" = "Gesundheit"; "section.life_prognosis" = "Lebensdauer-Prognose"; "section.usage" = "Nutzung"; "sidebar.subtitle" = "SSD- und HDD-Status"; "smartctl_error" = "smartctl Fehler: %@"; +"status.critical" = "Das Laufwerk meldet einen kritischen Zustand oder ausgeschöpfte Nenn-Ausdauer. Sichere wichtige Daten zeitnah."; +"status.healthy" = "SMART meldet aktuell keinen Ausfall. Regelmäßige Backups bleiben wichtig."; +"status.unknown" = "Es liegt kein zuverlässiges SMART-Gesamturteil vor. Verfügbare Messwerte stehen unten."; +"status.warning" = "Ein Verschleiß-, Temperatur- oder Fehlerwert ist auffällig. Prüfe die Werte und dein Backup."; "temp.hot" = "Heiß"; "temp.normal" = "Normal"; "temp.very_cool" = "Sehr kühl"; "temp.warm" = "Warm"; "time.ago" = "vor %@"; +"appearance.title" = "Darstellung"; +"appearance.system" = "System"; +"appearance.light" = "Hell"; +"appearance.dark" = "Dunkel"; +"appearance.help" = "Rechtsklick: Systemdarstellung verwenden"; +"power_on.help" = "Vom Laufwerk gemeldete Stunden. NVMe-Controller können Stromsparzeiten auslassen; der Wert entspricht nicht dem Kalenderalter des Laufwerks."; +"detail.written_per_smart_day" = "Geschrieben je 24 SMART-Stunden"; +"detail.written_per_smart_day.help" = "Gesamte Schreibmenge geteilt durch die vom Laufwerk gemeldeten Betriebsstunden, auf 24 Stunden normiert. Stromsparzeiten können fehlen; das ist deshalb nicht zwingend ein Kalendertagesdurchschnitt oder die aktuelle Schreibgeschwindigkeit."; + +// Daily write history +"history.title" = "Schreibvolumen pro Tag"; +"history.range" = "Zeitraum"; +"history.days" = "%d Tage"; +"history.day" = "Tag"; +"history.recorded" = "im Zeitraum erfasst"; +"history.empty" = "Aufzeichnung gestartet. Tageswerte erscheinen nach der nächsten erfolgreichen Aktualisierung."; +"history.unavailable" = "Keine verlässliche Laufwerkskennung oder kein Schreibzähler verfügbar."; +"history.error" = "Verlauf konnte nicht geladen oder gespeichert werden."; +"history.gaps" = "%.2f GB aus Messlücken, die diesen Zeitraum berühren, lassen sich keinem einzelnen Tag zuordnen."; +"history.note" = "Balken zeigen gemessene Schreibmengen, keine Hochrechnung. Leere Tage haben keine Messwerte; heute und teilweise erfasste Tage sind unvollständig. Kurze Intervalle über Mitternacht (bis 10 Minuten) werden anteilig verteilt. Die App zeichnet auf, solange sie geöffnet ist, und bewahrt 90 Tage auf. Balken auswählen für Wert und erfasste Stunden."; +"history.timezone" = "Kalenderzeitzone: %@"; +"history.today" = "Heute · bisher"; +"history.observed" = "%d / %d Tage erfasst"; +"history.demo" = "Demo-Verlauf"; +"history.details" = "Wie wird gemessen?"; +"refresh.segment.help" = "Automatisch aktualisieren oder mit Pause nur manuell scannen."; +"window.minimize" = "Minimieren"; +"history.range.help" = "Die letzten %d Kalendertage anzeigen"; +"history.range.general" = "Zeitraum wählen: 7, 30 oder 90 Kalendertage"; +"history.details.help" = "Erklärung zu Messlücken, Teilzeiträumen und Tagesgrenzen öffnen"; +"history.hours" = "%.1f Stunden erfasst"; +"history.estimated" = "Mitternachtsintervall anteilig verteilt"; +"history.no_measurement" = "Keine Messung an diesem Tag"; +"refresh.pause.help" = "Automatische Messung pausieren; manuelles Aktualisieren bleibt möglich"; +"refresh.now.help" = "Aktualisieren (⌘R) – liest die aktuellen Werte aller Laufwerke neu aus."; +"disclosure.expanded" = "Ausgeklappt"; +"disclosure.collapsed" = "Eingeklappt"; diff --git a/Sources/SMARTastic/Resources/en.lproj/Localizable.strings b/Sources/SMARTastic/Resources/en.lproj/Localizable.strings index 493b201..be73e4c 100644 --- a/Sources/SMARTastic/Resources/en.lproj/Localizable.strings +++ b/Sources/SMARTastic/Resources/en.lproj/Localizable.strings @@ -1,19 +1,30 @@ +"badge.smart_error" = "SMART ERROR"; "badge.smart_na" = "SMART n/a"; "badge.smart_na_card" = "SMART n/a"; "badge.smart_ok" = "SMART OK"; -"badge.smart_error" = "SMART ERROR"; "button.refresh.help" = "Refresh"; "button.retry" = "Retry"; "button.scan" = "Scan"; -"detail.avg_write_rate" = "Avg. Write Rate"; +"data.disclaimer" = "— means the drive did not report this value. SMART cannot guarantee that a drive will not fail."; +"demo.notice" = "Demo · Sample drives and illustrative data"; "detail.life_consumed" = "Life Used"; "detail.no_selection" = "Select a drive"; "detail.power_cycles" = "Power Cycles"; "detail.power_on" = "Power-On Time"; "detail.remaining_life" = "Est. Remaining Life"; +"diagnostics.title" = "Read diagnostics"; "disk_count_one" = "%lld drive"; "disk_count_other" = "%lld drives"; +"empty.help" = "Connect a drive, then scan again. SMART access depends on the drive and its controller."; "empty.no_drives" = "No drives found"; +"endurance.explanation" = "Remaining endurance is 100 minus the manufacturer’s wear indicator. It is not a failure prediction."; +"endurance.remaining" = "Endurance left"; +"endurance.title" = "About SSD endurance"; +"error.discovery" = "Drive discovery failed. Reconnect the drive and retry."; +"error.exit_status" = "smartctl returned status %d. Some data may be unavailable or contain health warnings."; +"error.install" = "Install smartmontools with “brew install smartmontools”, then refresh."; +"error.invalid_json" = "No valid SMART JSON response. Check smartmontools and drive access."; +"error.timeout" = "%@ did not respond in time. Reconnect the drive and retry."; "gauge.lifespan" = "Lifespan"; "gauge.lifespan_unit" = "% used"; "gauge.media_errors" = "Media Errors"; @@ -36,7 +47,6 @@ "label.health" = "Health"; "metric.media_errors_none" = "Error-free"; "metric.media_errors_some" = "Failing"; -"metric.per_day" = "GB/day"; "metric.power_cycles" = "Power Cycles"; "metric.power_cycles_detail" = "Restarts"; "metric.power_on_detail" = "hours"; @@ -45,20 +55,73 @@ "metric.unsafe_shutdowns" = "Unsafe Shutdowns"; "metric.unsafe_shutdowns_detail" = "Unsafe disconnects"; "metric.written" = "Written"; -"nosmart.message" = "The USB bridge chip of this enclosure does not forward SMART data. This is a hardware limitation of the USB controller."; +"nosmart.message" = "SMART data could not be read. The drive, adapter or access permissions may prevent it. See diagnostics when available."; "nosmart.title" = "SMART not available"; "power_on_days" = "%lld days"; "power_on_years_days" = "%1$lld yr %2$lld days"; +"refresh.1m" = "Every minute"; +"refresh.30s" = "Every 30 seconds"; +"refresh.5m" = "Every 5 minutes"; +"refresh.interval" = "Refresh"; +"refresh.manual" = "Manually"; +"refresh.updated" = "Last scan"; "remaining_days" = "≈%lld days"; "remaining_years_days" = "≈%1$lld yr %2$lld days"; +"report.export" = "Export report"; +"report.failed" = "Export failed"; +"report.help" = "Export report – save the current drive data as a JSON file, without serial numbers."; +"scan.loading" = "Reading drive information…"; +"search.empty" = "No matching drives"; +"search.placeholder" = "Search drives"; "section.drive_info" = "Drive Information"; "section.health" = "Health"; "section.life_prognosis" = "Lifespan Forecast"; "section.usage" = "Usage"; "sidebar.subtitle" = "SSD & HDD Status"; "smartctl_error" = "smartctl error: %@"; +"status.critical" = "The drive reports a critical condition or exhausted rated endurance. Back up important data promptly."; +"status.healthy" = "SMART reports no current failure. Keep regular backups."; +"status.unknown" = "No reliable overall SMART assessment was returned. Available measurements are shown below."; +"status.warning" = "A wear, temperature or error indicator needs attention. Review the values and check your backup."; "temp.hot" = "Hot"; "temp.normal" = "Normal"; "temp.very_cool" = "Very Cool"; "temp.warm" = "Warm"; "time.ago" = "%@ ago"; +"appearance.title" = "Appearance"; +"appearance.system" = "System"; +"appearance.light" = "Light"; +"appearance.dark" = "Dark"; +"appearance.help" = "Right-click to follow system appearance"; +"power_on.help" = "Hours reported by the drive. NVMe controllers may exclude time in low-power states; this is not the drive’s calendar age."; +"detail.written_per_smart_day" = "Written per 24 SMART hours"; +"detail.written_per_smart_day.help" = "Lifetime written data divided by drive-reported power-on hours, normalized to 24 hours. Low-power periods may be excluded, so this need not equal a calendar-day average or the current write speed."; + +// Daily write history +"history.title" = "Daily writes"; +"history.range" = "Period"; +"history.days" = "%d days"; +"history.day" = "Day"; +"history.recorded" = "recorded in this period"; +"history.empty" = "Recording started. Daily values appear after the next successful refresh."; +"history.unavailable" = "No reliable drive identity or write counter is available."; +"history.error" = "History could not be loaded or saved."; +"history.gaps" = "%.2f GB across measurement gaps touching this period could not be assigned to individual days."; +"history.note" = "Bars show measured writes, not projected daily totals. Empty days have no measurements; today and other partly observed days are incomplete. Short midnight intervals (up to 10 minutes) are split proportionally. Records are collected while the app is open and kept for 90 days. Select a bar for its value and observed hours."; +"history.timezone" = "Calendar time zone: %@"; +"history.today" = "Today · so far"; +"history.observed" = "%d / %d days observed"; +"history.demo" = "Demo history"; +"history.details" = "About these values"; +"refresh.segment.help" = "Refresh automatically, or pause for manual scans only."; +"window.minimize" = "Minimize"; +"history.range.help" = "Show the last %d calendar days"; +"history.range.general" = "Choose 7, 30 or 90 calendar days"; +"history.details.help" = "Explain measurement gaps, partial days and day boundaries"; +"history.hours" = "%.1f hours observed"; +"history.estimated" = "Midnight interval split proportionally"; +"history.no_measurement" = "No measurement for this day"; +"refresh.pause.help" = "Pause automatic scans; manual refresh remains available"; +"refresh.now.help" = "Refresh (⌘R) – read the latest values from all drives."; +"disclosure.expanded" = "Expanded"; +"disclosure.collapsed" = "Collapsed"; diff --git a/Sources/SMARTastic/Resources/es.lproj/Localizable.strings b/Sources/SMARTastic/Resources/es.lproj/Localizable.strings index 64d2e68..ce7b8de 100644 --- a/Sources/SMARTastic/Resources/es.lproj/Localizable.strings +++ b/Sources/SMARTastic/Resources/es.lproj/Localizable.strings @@ -1,19 +1,30 @@ +"badge.smart_error" = "SMART ERROR"; "badge.smart_na" = "SMART n/d"; "badge.smart_na_card" = "SMART n/d"; "badge.smart_ok" = "SMART OK"; -"badge.smart_error" = "SMART ERROR"; "button.refresh.help" = "Actualizar"; "button.retry" = "Reintentar"; "button.scan" = "Escanear"; -"detail.avg_write_rate" = "Tasa de escritura media"; +"data.disclaimer" = "— indica que la unidad no informó de este valor. SMART no garantiza la ausencia de fallos."; +"demo.notice" = "Demo · Unidades y datos ficticios"; "detail.life_consumed" = "Vida consumida"; "detail.no_selection" = "Selecciona una unidad"; "detail.power_cycles" = "Ciclos de encendido"; "detail.power_on" = "Tiempo encendido"; "detail.remaining_life" = "Vida restante estimada"; +"diagnostics.title" = "Diagnóstico de lectura"; "disk_count_one" = "%lld unidad"; "disk_count_other" = "%lld unidades"; +"empty.help" = "Conecta una unidad y vuelve a analizar. El acceso SMART depende de la unidad y del controlador."; "empty.no_drives" = "No se encontraron unidades"; +"endurance.explanation" = "La resistencia restante es 100 menos el desgaste indicado por el fabricante. No predice fallos."; +"endurance.remaining" = "Resistencia restante"; +"endurance.title" = "Acerca de la resistencia SSD"; +"error.discovery" = "Error al detectar unidades. Reconecta la unidad y reintenta."; +"error.exit_status" = "smartctl devolvió el estado %d. Puede faltar información o haber avisos de salud."; +"error.install" = "Instala smartmontools con «brew install smartmontools» y actualiza."; +"error.invalid_json" = "Respuesta JSON SMART no válida. Revisa smartmontools y el acceso."; +"error.timeout" = "%@ no respondió a tiempo. Reconecta la unidad y reintenta."; "gauge.lifespan" = "Vida útil"; "gauge.lifespan_unit" = "% usado"; "gauge.media_errors" = "Errores de medios"; @@ -36,7 +47,6 @@ "label.health" = "Salud"; "metric.media_errors_none" = "Sin errores"; "metric.media_errors_some" = "Con errores"; -"metric.per_day" = "GB/día"; "metric.power_cycles" = "Ciclos de encendido"; "metric.power_cycles_detail" = "Reinicios"; "metric.power_on_detail" = "horas"; @@ -45,20 +55,73 @@ "metric.unsafe_shutdowns" = "Apagados inseguros"; "metric.unsafe_shutdowns_detail" = "Desconexiones inseguras"; "metric.written" = "Escritura"; -"nosmart.message" = "El chip puente USB de este gabinete no transmite datos SMART. Esta es una limitación de hardware del controlador USB."; +"nosmart.message" = "No se pudieron leer los datos SMART. La unidad, el adaptador o los permisos pueden impedirlo. Consulta el diagnóstico si está disponible."; "nosmart.title" = "SMART no disponible"; "power_on_days" = "%lld días"; "power_on_years_days" = "%1$lld a %2$lld d"; +"refresh.1m" = "Cada minuto"; +"refresh.30s" = "Cada 30 segundos"; +"refresh.5m" = "Cada 5 minutos"; +"refresh.interval" = "Actualizar"; +"refresh.manual" = "Manualmente"; +"refresh.updated" = "Último análisis"; "remaining_days" = "≈%lld días"; "remaining_years_days" = "≈%1$lld a %2$lld d"; +"report.export" = "Exportar informe"; +"report.failed" = "Error al exportar"; +"report.help" = "Exportar informe – guardar los datos actuales de las unidades en un archivo JSON, sin números de serie."; +"scan.loading" = "Leyendo las unidades…"; +"search.empty" = "No hay coincidencias"; +"search.placeholder" = "Buscar unidades"; "section.drive_info" = "Información de la unidad"; "section.health" = "Salud"; "section.life_prognosis" = "Pronóstico de vida"; "section.usage" = "Uso"; "sidebar.subtitle" = "Estado de SSD y HDD"; "smartctl_error" = "Error de smartctl: %@"; +"status.critical" = "La unidad indica un estado crítico o resistencia nominal agotada. Guarda pronto los datos importantes."; +"status.healthy" = "SMART no informa de fallos actuales. Mantén copias de seguridad periódicas."; +"status.unknown" = "No se recibió una evaluación SMART global fiable. Se muestran las mediciones disponibles."; +"status.warning" = "Un indicador de desgaste, temperatura o errores requiere atención. Revisa los valores y tu copia de seguridad."; "temp.hot" = "Caliente"; "temp.normal" = "Normal"; "temp.very_cool" = "Muy frío"; "temp.warm" = "Cálido"; "time.ago" = "hace %@"; +"appearance.title" = "Apariencia"; +"appearance.system" = "Sistema"; +"appearance.light" = "Claro"; +"appearance.dark" = "Oscuro"; +"appearance.help" = "Clic derecho para usar la apariencia del sistema"; +"power_on.help" = "Horas indicadas por la unidad. Los controladores NVMe pueden excluir estados de bajo consumo; no equivalen a la edad natural de la unidad."; +"detail.written_per_smart_day" = "Escrito por 24 horas SMART"; +"detail.written_per_smart_day.help" = "Volumen total escrito dividido por las horas de funcionamiento indicadas por la unidad, normalizado a 24 horas. Puede excluir periodos de bajo consumo; no equivale necesariamente a una media por día natural ni a la velocidad actual."; + +// Daily write history +"history.title" = "Escrituras por día"; +"history.range" = "Periodo"; +"history.days" = "%d días"; +"history.day" = "Día"; +"history.recorded" = "registrados en el periodo"; +"history.empty" = "Registro iniciado. Los valores aparecerán tras la próxima actualización correcta."; +"history.unavailable" = "No hay identificador fiable de unidad o contador de escritura."; +"history.error" = "No se pudo cargar o guardar el historial."; +"history.gaps" = "%.2f GB de intervalos sin mediciones que afectan a este periodo no se pueden asignar a días concretos."; +"history.note" = "Las barras muestran escrituras medidas, sin extrapolación. Los días vacíos no tienen mediciones; hoy y los días parciales están incompletos. Los intervalos de medianoche de hasta 10 minutos se distribuyen proporcionalmente. Se registra con la app abierta y se conservan 90 días. Selecciona una barra para ver su valor y horas observadas."; +"history.timezone" = "Zona horaria del calendario: %@"; +"history.today" = "Hoy · hasta ahora"; +"history.observed" = "%d / %d días observados"; +"history.demo" = "Historial de ejemplo"; +"history.details" = "Acerca de estos valores"; +"refresh.segment.help" = "Actualizar automáticamente o pausar para escanear solo manualmente."; +"window.minimize" = "Minimizar"; +"history.range.help" = "Mostrar los últimos %d días naturales"; +"history.range.general" = "Elegir 7, 30 o 90 días naturales"; +"history.details.help" = "Explicar las lagunas, días parciales y límites de días"; +"history.hours" = "%.1f horas observadas"; +"history.estimated" = "Intervalo de medianoche repartido proporcionalmente"; +"history.no_measurement" = "Sin mediciones para este día"; +"refresh.pause.help" = "Pausar análisis automáticos; la actualización manual sigue disponible"; +"refresh.now.help" = "Actualizar (⌘R) – volver a leer los valores actuales de todas las unidades."; +"disclosure.expanded" = "Expandido"; +"disclosure.collapsed" = "Contraído"; diff --git a/Sources/SMARTastic/Resources/fr.lproj/Localizable.strings b/Sources/SMARTastic/Resources/fr.lproj/Localizable.strings index 166e6d3..c5cd668 100644 --- a/Sources/SMARTastic/Resources/fr.lproj/Localizable.strings +++ b/Sources/SMARTastic/Resources/fr.lproj/Localizable.strings @@ -1,19 +1,30 @@ +"badge.smart_error" = "SMART ERREUR"; "badge.smart_na" = "SMART n/d"; "badge.smart_na_card" = "SMART n/d"; "badge.smart_ok" = "SMART OK"; -"badge.smart_error" = "SMART ERREUR"; "button.refresh.help" = "Actualiser"; "button.retry" = "Réessayer"; "button.scan" = "Analyser"; -"detail.avg_write_rate" = "Taux d'écriture moyen"; +"data.disclaimer" = "— signifie que le disque n’a pas fourni cette valeur. SMART ne garantit pas l’absence de panne."; +"demo.notice" = "Démo · Disques et données fictifs"; "detail.life_consumed" = "Durée de vie utilisée"; "detail.no_selection" = "Sélectionnez un disque"; "detail.power_cycles" = "Cycles d'alimentation"; "detail.power_on" = "Temps de fonctionnement"; "detail.remaining_life" = "Durée de vie restante estimée"; +"diagnostics.title" = "Diagnostic de lecture"; "disk_count_one" = "%lld disque"; "disk_count_other" = "%lld disques"; +"empty.help" = "Connectez un disque et relancez une analyse. L’accès SMART dépend du disque et du contrôleur."; "empty.no_drives" = "Aucun disque trouvé"; +"endurance.explanation" = "L’endurance restante vaut 100 moins l’indicateur d’usure du fabricant. Elle ne prédit pas une panne."; +"endurance.remaining" = "Endurance restante"; +"endurance.title" = "À propos de l’endurance SSD"; +"error.discovery" = "La détection a échoué. Reconnectez le disque et réessayez."; +"error.exit_status" = "smartctl a renvoyé le statut %d. Certaines données peuvent manquer ou signaler un avertissement."; +"error.install" = "Installez smartmontools avec « brew install smartmontools », puis actualisez."; +"error.invalid_json" = "Réponse JSON SMART invalide. Vérifiez smartmontools et l’accès au disque."; +"error.timeout" = "%@ n’a pas répondu à temps. Reconnectez le disque et réessayez."; "gauge.lifespan" = "Durée de vie"; "gauge.lifespan_unit" = "% utilisé"; "gauge.media_errors" = "Erreurs de support"; @@ -36,7 +47,6 @@ "label.health" = "Santé"; "metric.media_errors_none" = "Sans erreur"; "metric.media_errors_some" = "Défectueux"; -"metric.per_day" = "GB/jour"; "metric.power_cycles" = "Cycles d'alimentation"; "metric.power_cycles_detail" = "Redémarrages"; "metric.power_on_detail" = "heures"; @@ -45,20 +55,73 @@ "metric.unsafe_shutdowns" = "Arrêts non sécurisés"; "metric.unsafe_shutdowns_detail" = "Déconnexions non sécurisées"; "metric.written" = "Écriture"; -"nosmart.message" = "Le pont USB de ce boîtier ne transmet pas les données SMART. Il s'agit d'une limitation matérielle du contrôleur USB."; +"nosmart.message" = "Les données SMART n’ont pas pu être lues. Le disque, l’adaptateur ou les autorisations peuvent bloquer l’accès. Consultez le diagnostic s’il est disponible."; "nosmart.title" = "SMART non disponible"; "power_on_days" = "%lld jours"; "power_on_years_days" = "%1$lld a %2$lld j"; +"refresh.1m" = "Chaque minute"; +"refresh.30s" = "Toutes les 30 secondes"; +"refresh.5m" = "Toutes les 5 minutes"; +"refresh.interval" = "Actualisation"; +"refresh.manual" = "Manuellement"; +"refresh.updated" = "Dernière analyse"; "remaining_days" = "≈%lld jours"; "remaining_years_days" = "≈%1$lld a %2$lld j"; +"report.export" = "Exporter le rapport"; +"report.failed" = "Échec de l’export"; +"report.help" = "Exporter le rapport – enregistrer les données actuelles des disques dans un fichier JSON, sans numéros de série."; +"scan.loading" = "Lecture des disques…"; +"search.empty" = "Aucun disque correspondant"; +"search.placeholder" = "Rechercher un disque"; "section.drive_info" = "Informations du disque"; "section.health" = "Santé"; "section.life_prognosis" = "Prévision de durée de vie"; "section.usage" = "Utilisation"; "sidebar.subtitle" = "État des SSD et HDD"; "smartctl_error" = "Erreur smartctl : %@"; +"status.critical" = "Le disque signale un état critique ou une endurance nominale épuisée. Sauvegardez rapidement les données importantes."; +"status.healthy" = "SMART ne signale aucune panne actuelle. Conservez des sauvegardes régulières."; +"status.unknown" = "Aucun bilan SMART global fiable n’a été reçu. Les mesures disponibles figurent ci-dessous."; +"status.warning" = "Un indicateur d’usure, de température ou d’erreur nécessite votre attention. Vérifiez les valeurs et vos sauvegardes."; "temp.hot" = "Très chaud"; "temp.normal" = "Normal"; "temp.very_cool" = "Très frais"; "temp.warm" = "Chaud"; "time.ago" = "il y a %@"; +"appearance.title" = "Apparence"; +"appearance.system" = "Système"; +"appearance.light" = "Clair"; +"appearance.dark" = "Sombre"; +"appearance.help" = "Clic droit pour suivre l’apparence du système"; +"power_on.help" = "Heures indiquées par le disque. Les contrôleurs NVMe peuvent exclure les états de faible consommation ; ce n’est pas l’âge calendaire du disque."; +"detail.written_per_smart_day" = "Écrit par 24 heures SMART"; +"detail.written_per_smart_day.help" = "Volume total écrit divisé par les heures de fonctionnement indiquées par le disque, ramené à 24 heures. Des périodes de faible consommation peuvent être exclues ; ce n’est pas nécessairement une moyenne par jour calendaire ni le débit actuel."; + +// Daily write history +"history.title" = "Écritures par jour"; +"history.range" = "Période"; +"history.days" = "%d jours"; +"history.day" = "Jour"; +"history.recorded" = "enregistrés sur la période"; +"history.empty" = "Enregistrement démarré. Les valeurs apparaîtront après la prochaine actualisation réussie."; +"history.unavailable" = "Identifiant fiable du disque ou compteur d’écriture indisponible."; +"history.error" = "Impossible de charger ou sauvegarder l’historique."; +"history.gaps" = "%.2f Go couvrant des lacunes de mesure touchant cette période ne peuvent être attribués à un jour précis."; +"history.note" = "Les barres montrent les écritures mesurées, sans extrapolation. Les jours vides sont sans mesure ; aujourd’hui et les jours partiels sont incomplets. Les intervalles à minuit de 10 minutes maximum sont répartis proportionnellement. Collecte lorsque l’app est ouverte, conservation de 90 jours. Sélectionnez une barre pour sa valeur et les heures observées."; +"history.timezone" = "Fuseau du calendrier : %@"; +"history.today" = "Aujourd’hui · à présent"; +"history.observed" = "%d / %d jours observés"; +"history.demo" = "Historique démo"; +"history.details" = "À propos des valeurs"; +"refresh.segment.help" = "Actualiser automatiquement ou mettre en pause pour des analyses manuelles."; +"window.minimize" = "Réduire"; +"history.range.help" = "Afficher les %d derniers jours calendaires"; +"history.range.general" = "Choisir 7, 30 ou 90 jours calendaires"; +"history.details.help" = "Expliquer les lacunes, jours partiels et limites des jours"; +"history.hours" = "%.1f heures observées"; +"history.estimated" = "Intervalle à minuit réparti proportionnellement"; +"history.no_measurement" = "Aucune mesure pour ce jour"; +"refresh.pause.help" = "Suspendre les analyses automatiques ; l’actualisation manuelle reste disponible"; +"refresh.now.help" = "Actualiser (⌘R) – relire les valeurs actuelles de tous les disques."; +"disclosure.expanded" = "Développé"; +"disclosure.collapsed" = "Réduit"; diff --git a/Sources/SMARTastic/Resources/logo.png b/Sources/SMARTastic/Resources/logo.png new file mode 100644 index 0000000..157279f Binary files /dev/null and b/Sources/SMARTastic/Resources/logo.png differ diff --git a/Sources/SMARTastic/Resources/zh-Hans.lproj/Localizable.strings b/Sources/SMARTastic/Resources/zh-Hans.lproj/Localizable.strings index 222868a..50eeaa3 100644 --- a/Sources/SMARTastic/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/SMARTastic/Resources/zh-Hans.lproj/Localizable.strings @@ -1,19 +1,30 @@ +"badge.smart_error" = "SMART 错误"; "badge.smart_na" = "SMART 不可用"; "badge.smart_na_card" = "SMART 不可用"; "badge.smart_ok" = "SMART OK"; -"badge.smart_error" = "SMART 错误"; "button.refresh.help" = "刷新"; "button.retry" = "重试"; "button.scan" = "扫描"; -"detail.avg_write_rate" = "平均写入速度"; +"data.disclaimer" = "— 表示磁盘未报告该数值。SMART 无法保证磁盘不会发生故障。"; +"demo.notice" = "演示 · 示例磁盘和模拟数据"; "detail.life_consumed" = "已用寿命"; "detail.no_selection" = "选择一个驱动器"; "detail.power_cycles" = "通电次数"; "detail.power_on" = "通电时间"; "detail.remaining_life" = "预计剩余寿命"; +"diagnostics.title" = "读取诊断"; "disk_count_one" = "%lld 个驱动器"; "disk_count_other" = "%lld 个驱动器"; +"empty.help" = "连接磁盘后重新扫描。SMART 访问取决于磁盘及其控制器。"; "empty.no_drives" = "未找到驱动器"; +"endurance.explanation" = "剩余耐久度为 100 减去厂商报告的磨损百分比,不能用于预测故障时间。"; +"endurance.remaining" = "剩余耐久度"; +"endurance.title" = "关于 SSD 耐久度"; +"error.discovery" = "磁盘发现失败。请重新连接磁盘后重试。"; +"error.exit_status" = "smartctl 返回状态 %d。部分数据可能不可用或包含健康警告。"; +"error.install" = "请运行“brew install smartmontools”安装后刷新。"; +"error.invalid_json" = "未收到有效的 SMART JSON 响应。请检查 smartmontools 和磁盘访问权限。"; +"error.timeout" = "%@ 未及时响应。请重新连接磁盘后重试。"; "gauge.lifespan" = "使用寿命"; "gauge.lifespan_unit" = "% 已用"; "gauge.media_errors" = "介质错误"; @@ -36,7 +47,6 @@ "label.health" = "健康"; "metric.media_errors_none" = "无错误"; "metric.media_errors_some" = "有错误"; -"metric.per_day" = "GB/天"; "metric.power_cycles" = "通电次数"; "metric.power_cycles_detail" = "重启"; "metric.power_on_detail" = "小时"; @@ -45,20 +55,73 @@ "metric.unsafe_shutdowns" = "不安全关机"; "metric.unsafe_shutdowns_detail" = "不安全断开"; "metric.written" = "写入"; -"nosmart.message" = "此机箱的 USB 桥接芯片不转发 SMART 数据。这是 USB 控制器的硬件限制。"; +"nosmart.message" = "无法读取 SMART 数据。磁盘、适配器或访问权限可能阻止读取。如有诊断信息,请查看。"; "nosmart.title" = "SMART 不可用"; "power_on_days" = "%lld 天"; "power_on_years_days" = "%1$lld 年 %2$lld 天"; +"refresh.1m" = "每分钟"; +"refresh.30s" = "每 30 秒"; +"refresh.5m" = "每 5 分钟"; +"refresh.interval" = "刷新"; +"refresh.manual" = "手动"; +"refresh.updated" = "上次扫描"; "remaining_days" = "≈%lld 天"; "remaining_years_days" = "≈%1$lld 年 %2$lld 天"; +"report.export" = "导出报告"; +"report.failed" = "导出失败"; +"report.help" = "导出报告 — 将当前驱动器数据保存为 JSON 文件,不包含序列号。"; +"scan.loading" = "正在读取磁盘信息…"; +"search.empty" = "没有匹配的磁盘"; +"search.placeholder" = "搜索磁盘"; "section.drive_info" = "驱动器信息"; "section.health" = "健康状态"; "section.life_prognosis" = "寿命预测"; "section.usage" = "使用情况"; "sidebar.subtitle" = "SSD 和 HDD 状态"; "smartctl_error" = "smartctl 错误:%@"; +"status.critical" = "磁盘报告严重状况或额定耐久度已耗尽。请尽快备份重要数据。"; +"status.healthy" = "SMART 当前未报告故障。请保持定期备份。"; +"status.unknown" = "未返回可靠的 SMART 整体评估。下方显示可用的测量数据。"; +"status.warning" = "磨损、温度或错误指标需要关注。请检查数值和备份。"; "temp.hot" = "热"; "temp.normal" = "正常"; "temp.very_cool" = "非常凉爽"; "temp.warm" = "温暖"; "time.ago" = "%@前"; +"appearance.title" = "外观"; +"appearance.system" = "系统"; +"appearance.light" = "浅色"; +"appearance.dark" = "深色"; +"appearance.help" = "右键单击以跟随系统外观"; +"power_on.help" = "磁盘报告的小时数。NVMe 控制器可能不计入低功耗状态的时间,因此该数值不代表磁盘的日历使用时长。"; +"detail.written_per_smart_day" = "每 24 个 SMART 小时写入量"; +"detail.written_per_smart_day.help" = "总写入量除以磁盘报告的通电小时数,再换算为 24 小时。低功耗时间可能未计入,因此不一定等于日历日平均值,也不表示当前写入速度。"; + +// Daily write history +"history.title" = "每日写入量"; +"history.range" = "时间范围"; +"history.days" = "%d 天"; +"history.day" = "日期"; +"history.recorded" = "此期间已记录"; +"history.empty" = "已开始记录。下次成功刷新后将显示每日数据。"; +"history.unavailable" = "没有可靠的驱动器标识或写入计数器。"; +"history.error" = "无法加载或保存历史记录。"; +"history.gaps" = "涉及此期间的测量间隔中有 %.2f GB 无法分配到具体日期。"; +"history.note" = "柱形显示实测写入量,不进行全天推算。空白日期没有测量值;今天和部分记录的日期均不完整。跨午夜且不超过 10 分钟的间隔按时间比例分配。仅在应用打开时记录,保留 90 天。选择柱形查看数值及观测小时数。"; +"history.timezone" = "日历时区:%@"; +"history.today" = "今天 · 至今"; +"history.observed" = "已观测 %d / %d 天"; +"history.demo" = "演示历史"; +"history.details" = "关于这些数值"; +"refresh.segment.help" = "自动刷新,或暂停后仅手动扫描。"; +"window.minimize" = "最小化"; +"history.range.help" = "显示最近 %d 个日历日"; +"history.range.general" = "选择 7、30 或 90 个日历日"; +"history.details.help" = "了解测量间隔、部分日期和日期边界"; +"history.hours" = "已观测 %.1f 小时"; +"history.estimated" = "跨午夜间隔按比例分配"; +"history.no_measurement" = "此日期没有测量数据"; +"refresh.pause.help" = "暂停自动扫描;仍可手动刷新"; +"refresh.now.help" = "刷新 (⌘R) — 重新读取所有驱动器的最新数据。"; +"disclosure.expanded" = "已展开"; +"disclosure.collapsed" = "已折叠"; diff --git a/Sources/SMARTastic/SMARTasticApp.swift b/Sources/SMARTastic/SMARTasticApp.swift index 786de92..b859f17 100644 --- a/Sources/SMARTastic/SMARTasticApp.swift +++ b/Sources/SMARTastic/SMARTasticApp.swift @@ -8,58 +8,108 @@ struct SMARTasticApp: App { Window("SMARTastic", id: "main") { ContentView() .environment(model) - .frame(minWidth: 900, minHeight: 640) + .onAppear { NSApplication.shared.appearance = model.appearance.nsAppearance } + .onChange(of: model.appearance) { _, appearance in + NSApplication.shared.appearance = appearance.nsAppearance + } + .frame(minWidth: 940, minHeight: 660) } .windowStyle(.hiddenTitleBar) + .defaultSize(width: 1120, height: 790) .windowResizability(.contentMinSize) + .commands { + CommandGroup(replacing: .saveItem) { + Button(loc("window.minimize")) { + (NSApp.keyWindow ?? NSApp.mainWindow)?.performMiniaturize(nil) + } + .keyboardShortcut("w", modifiers: .command) + } + CommandGroup(after: .newItem) { + Button(loc("button.refresh.help")) { model.refresh() } + .keyboardShortcut("r") + .disabled(model.isLoading || model.isDemo) + } + } } } -@Observable +enum AppAppearance: String, CaseIterable { + case system, light, dark + var colorScheme: ColorScheme? { + switch self { case .system: nil; case .light: .light; case .dark: .dark } + } + var nsAppearance: NSAppearance? { + switch self { + case .system: nil + case .light: NSAppearance(named: .aqua) + case .dark: NSAppearance(named: .darkAqua) + } + } + var label: String { loc("appearance." + rawValue) } +} + +@MainActor @Observable final class AppModel { + let writeHistory: WriteHistoryStore var disks: [DiskInfo] = [] var isLoading = false var error: String? var selectedDiskID: DiskInfo.ID? var lastRefreshed: Date? - + let isDemo: Bool + var appearance: AppAppearance { + didSet { UserDefaults.standard.set(appearance.rawValue, forKey: "appearance") } + } + var refreshInterval: Double { + didSet { + UserDefaults.standard.set(refreshInterval, forKey: "refreshInterval") + startAutoRefresh() + } + } private var timer: Timer? + private let scanner: @Sendable () async throws -> ScanResult - nonisolated init() {} - - var selectedDisk: DiskInfo? { - disks.first { $0.id == selectedDiskID } + init(isDemo: Bool = ProcessInfo.processInfo.arguments.contains("--demo"), + historyStore: WriteHistoryStore? = nil, + scanner: @escaping @Sendable () async throws -> ScanResult = { try await SmartCtlService.shared.scan() }) { + self.writeHistory = historyStore ?? WriteHistoryStore(url: isDemo ? nil : FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("SMARTastic/write-history.json")) + 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 + DemoData.seedHistory(writeHistory) + 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 + writeHistory.record(disks, at: lastRefreshed!) + 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..5ff517c 100644 --- a/Sources/SMARTastic/Services/SmartCtlService.swift +++ b/Sources/SMARTastic/Services/SmartCtlService.swift @@ -1,340 +1,95 @@ 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 { + // Identity, health and attributes include all displayed counters. + // -a additionally requests optional logs that Apple NVMe can reject. + let output = try CommandRunner.run(smartctl, ["-i", "-H", "-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/ActionTooltip.swift b/Sources/SMARTastic/Views/ActionTooltip.swift new file mode 100644 index 0000000..952cfc6 --- /dev/null +++ b/Sources/SMARTastic/Views/ActionTooltip.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// Titlebar overlays cannot rely on SwiftUI's delayed help bubble. Keep the +/// native button, and draw its explanation using AppKit pointer tracking. +struct ActionTooltip: ViewModifier { + let text: String + @State private var hovered = false + + func body(content: Content) -> some View { + content + .accessibilityHint(text) + .background { + PointerTracking { hovered = $0 } + } + .overlay(alignment: .topTrailing) { + if hovered { + Text(text) + .font(.system(size: 12)) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .frame(width: 260, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.1))) + .shadow(color: .black.opacity(0.16), radius: 10, y: 4) + .offset(y: 44) + .allowsHitTesting(false) + } + } + .onDisappear { hovered = false } + } +} + +private struct PointerTracking: NSViewRepresentable { + var onChange: (Bool) -> Void + func makeNSView(context: Context) -> TrackingView { + let view = TrackingView() + view.onChange = onChange + return view + } + func updateNSView(_ view: TrackingView, context: Context) { + view.onChange = onChange + } + final class TrackingView: NSView { + var onChange: (Bool) -> Void = { _ in } + private var area: NSTrackingArea? + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let area { removeTrackingArea(area) } + let area = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, userInfo: nil) + addTrackingArea(area) + self.area = area + } + override func mouseEntered(with event: NSEvent) { onChange(true) } + override func mouseExited(with event: NSEvent) { onChange(false) } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { onChange(false) } + } + } +} diff --git a/Sources/SMARTastic/Views/ContentView.swift b/Sources/SMARTastic/Views/ContentView.swift index 2916ac9..b43a8e6 100644 --- a/Sources/SMARTastic/Views/ContentView.swift +++ b/Sources/SMARTastic/Views/ContentView.swift @@ -1,195 +1,226 @@ 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) + .onExitCommand { 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() + } + VStack(alignment: .leading, spacing: 8) { + Label(loc("refresh.interval"), systemImage: "arrow.clockwise") + .font(.system(size: 12)).foregroundStyle(.secondary) + Picker(loc("refresh.interval"), selection: $model.refreshInterval) { + Image(systemName: "pause.fill").tag(0.0) + .accessibilityLabel(loc("refresh.manual")) + .help(loc("refresh.pause.help")) + Text("30 s").tag(30.0).help(loc("refresh.30s")) + Text("1 min").tag(60.0).help(loc("refresh.1m")) + Text("5 min").tag(300.0).help(loc("refresh.5m")) + } + .pickerStyle(.segmented).labelsHidden() + .help(loc("refresh.segment.help")) + .disabled(model.isDemo) + }.padding(.vertical, 4) + 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 - } - .onAppear { - if model.disks.isEmpty { - model.refresh() - } - model.startAutoRefresh() - } - .onDisappear { - model.stopAutoRefresh() + 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)) } - } - - // 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 + .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) } - .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")) + .fileExporter(isPresented: $exporting, document: report, contentType: .json, defaultFilename: "SMARTastic-report") { result in + if case .failure(let error) = result { exportError = error.localizedDescription } } - .padding(.horizontal, 16) - .padding(.vertical, 10) + .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() } } - - 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 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) + .modifier(NativeCircleAction()) + .modifier(ActionTooltip(text: loc("report.help"))) + Button { model.refresh() } label: { Label(loc("button.refresh.help"), systemImage: "arrow.clockwise") } + .disabled(model.isLoading || model.isDemo) + .modifier(NativeCircleAction()) + .modifier(ActionTooltip(text: loc("refresh.now.help"))) } } - - 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) } - - 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) + .help(appearance.label + " · " + loc("appearance.help")) + .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..907ad95 100644 --- a/Sources/SMARTastic/Views/DiskDetailView.swift +++ b/Sources/SMARTastic/Views/DiskDetailView.swift @@ -2,427 +2,154 @@ 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")) + .help(loc("power_on.help")) + MetricTile(label: loc("metric.power_cycles"), value: metric(disk.powerCycles), icon: "power") + } + } + WriteHistoryView(disk: disk) + 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 volume = disk.writtenGBPer24PowerOnHours { + Text(loc("detail.written_per_smart_day") + ": " + metric(volume, suffix: " GB", decimals: 1)) + .font(.system(size: 13)) + .help(loc("detail.written_per_smart_day.help")) + } + }.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) + }.disclosureGroupStyle(FullRowDisclosureStyle()) + .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/FullRowDisclosureStyle.swift b/Sources/SMARTastic/Views/FullRowDisclosureStyle.swift new file mode 100644 index 0000000..54ced03 --- /dev/null +++ b/Sources/SMARTastic/Views/FullRowDisclosureStyle.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Make the complete explanation header a keyboard-accessible target. +struct FullRowDisclosureStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.16)) { configuration.isExpanded.toggle() } + } label: { + HStack(spacing: 8) { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + configuration.label + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(Text(loc(configuration.isExpanded ? "disclosure.expanded" : "disclosure.collapsed"))) + if configuration.isExpanded { configuration.content } + } + } +} 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/Sources/SMARTastic/Views/WriteHistoryView.swift b/Sources/SMARTastic/Views/WriteHistoryView.swift new file mode 100644 index 0000000..ecb16b6 --- /dev/null +++ b/Sources/SMARTastic/Views/WriteHistoryView.swift @@ -0,0 +1,196 @@ +import SwiftUI +import Charts + +struct WriteHistoryView: View { + let disk: DiskInfo + @Environment(AppModel.self) private var model + @AppStorage("historyDays") private var range = 7 + @State private var selectedDate: Date? + @State private var hoveredDate: Date? + @State private var hoverPoint: CGPoint = .zero + @State private var showingDetails = false + private var calendar: Calendar { model.writeHistory.history.calendar } + private var count: Int { [7, 30, 90].contains(range) ? range : 7 } + private var today: Date { calendar.startOfDay(for: model.lastRefreshed ?? .now) } + private var start: Date { calendar.date(byAdding: .day, value: 1 - count, to: today)! } + private var end: Date { calendar.date(byAdding: .day, value: 1, to: today)! } + private var drive: WriteHistory.Drive? { model.writeHistory.drive(for: disk) } + private var days: [WriteHistory.Day] { drive?.days.filter { $0.date >= start && $0.date < end } ?? [] } + private var selected: WriteHistory.Day? { + guard let selectedDate else { return nil } + return days.first { calendar.isDate($0.date, inSameDayAs: selectedDate) } + } + private var highlighted: WriteHistory.Day? { + guard let date = hoveredDate ?? selectedDate else { return nil } + return days.first { calendar.isDate($0.date, inSameDayAs: date) } + } + private var gapsGB: Double { + drive?.gaps.filter { $0.end >= start && $0.start < end }.reduce(0) { $0 + $1.gb } ?? 0 + } + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Image(systemName: "chart.bar.xaxis") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.cyan) + .frame(width: 36, height: 36) + .background(.cyan.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + Text(loc("history.title")).font(.system(size: 15, weight: .semibold)) + Spacer() + Picker(loc("history.range"), selection: $range) { + ForEach([7, 30, 90], id: \.self) { Text(locf("history.days", $0)).tag($0).help(locf("history.range.help", $0)) } + }.pickerStyle(.segmented).labelsHidden().frame(width: 210) + .help(loc("history.range.general")) + } + if model.writeHistory.failed { + Label(loc("history.error"), systemImage: "exclamationmark.triangle").foregroundStyle(.orange) + } + if WriteHistory.key(for: disk) == nil || disk.dataWrittenTB == nil { + Text(loc("history.unavailable")).foregroundStyle(.secondary) + } else { + HStack(alignment: .firstTextBaseline) { + Text(metric(selected?.gb ?? (days.isEmpty ? nil : days.reduce(0) { $0 + $1.gb }), suffix: " GB", decimals: 2)) + .font(.system(size: 23, weight: .semibold)).monospacedDigit() + Text(selected.map { $0.date.formatted(Date.FormatStyle(date: .abbreviated, time: .omitted, calendar: calendar, timeZone: calendar.timeZone)) } ?? loc("history.recorded")) + .foregroundStyle(.secondary) + Spacer() + if selected == nil { + VStack(alignment: .trailing, spacing: 4) { + Label(loc("history.today"), systemImage: "sun.max") + .foregroundStyle(.secondary) + Text(metric(days.first { $0.date == today }?.gb, suffix: " GB", decimals: 2)) + .font(.system(size: 15, weight: .semibold)).monospacedDigit() + } + } + if let selected { + Text(String(format: "%.1f h", selected.seconds / 3600)).foregroundStyle(.secondary) + } + }.frame(height: 44) + Chart { + ForEach(days) { day in + BarMark(x: .value(loc("history.day"), day.date, unit: .day), y: .value("GB", day.gb)) + .foregroundStyle(LinearGradient(colors: day.date == today ? [.mint, .cyan] : [.cyan, .blue.opacity(0.7)], startPoint: .top, endPoint: .bottom)) + .opacity(highlighted == nil || highlighted?.date == day.date ? 1 : 0.35) + .cornerRadius(count == 90 ? 2 : 5) + .accessibilityLabel(day.date.formatted(Date.FormatStyle(date: .abbreviated, time: .omitted, calendar: calendar, timeZone: calendar.timeZone))) + .accessibilityValue(metric(day.gb, suffix: " GB", decimals: 2) + ", " + String(format: "%.1f h", day.seconds / 3600)) + if day.gb == 0 { + PointMark(x: .value(loc("history.day"), day.date, unit: .day), y: .value("GB", 0)) + .foregroundStyle(.cyan).symbolSize(15) + } + } + if let highlighted { + RuleMark(x: .value(loc("history.day"), highlighted.date, unit: .day)).foregroundStyle(.secondary.opacity(0.4)) + } + } + .chartXScale(domain: start...end) + .chartYScale(domain: 0...max(1, (days.map(\.gb).max() ?? 0) * 1.15)) + .chartXAxis { AxisMarks(values: .stride(by: .day, count: count == 7 ? 1 : count == 30 ? 5 : 15)) { _ in + AxisGridLine(); AxisValueLabel(format: .dateTime.day().month()) + } } + .chartYAxis { AxisMarks(position: .leading) } + .chartYAxisLabel("GB") + .chartXSelection(value: $selectedDate) + .chartOverlay { proxy in + GeometryReader { geometry in + Rectangle().fill(.clear).contentShape(Rectangle()) + .onTapGesture { location in + guard let frame = proxy.plotFrame else { return } + let plot = geometry[frame] + guard plot.contains(location) else { selectedDate = nil; return } + let date: Date? = proxy.value(atX: location.x - plot.minX) + if let date, let selectedDate, calendar.isDate(date, inSameDayAs: selectedDate) { + self.selectedDate = nil + } else { selectedDate = date } + } + .onContinuousHover { phase in + switch phase { + case .active(let location): + guard let frame = proxy.plotFrame, geometry[frame].contains(location) else { + hoveredDate = nil; return + } + hoverPoint = location + hoveredDate = proxy.value(atX: location.x - geometry[frame].minX) + case .ended: hoveredDate = nil + } + } + if let date = hoveredDate ?? selectedDate, let frame = proxy.plotFrame { + let plot = geometry[frame] + let dayStart = calendar.startOfDay(for: date) + let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart)! + let middle = dayStart.addingTimeInterval(nextDay.timeIntervalSince(dayStart) / 2) + let anchor = hoveredDate != nil ? hoverPoint.x : (proxy.position(forX: middle) ?? 0) + plot.minX + hoverCard(for: date) + .frame(width: 210) + .fixedSize(horizontal: false, vertical: true) + .position(x: min(max(anchor, plot.minX + 109), plot.maxX - 109), y: plot.minY + 48) + .allowsHitTesting(false) + } + } + } + .environment(\.calendar, calendar) + .environment(\.timeZone, calendar.timeZone) + .frame(height: 185) + .overlay { + if days.isEmpty { + Text(loc("history.empty")).multilineTextAlignment(.center).foregroundStyle(.secondary) + .padding(16).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)).padding(28) + } + } + if gapsGB > 0 { + Label(locf("history.gaps", gapsGB), systemImage: "calendar.badge.exclamationmark").foregroundStyle(.orange) + } + HStack { + Label(locf("history.observed", days.count, count), systemImage: "clock.badge.checkmark") + .foregroundStyle(.secondary) + Spacer() + if model.isDemo { + Text(loc("history.demo")).foregroundStyle(.cyan) + } + } + DisclosureGroup(loc("history.details"), isExpanded: $showingDetails) { + VStack(alignment: .leading, spacing: 6) { + Text(loc("history.note")) + Text(locf("history.timezone", calendar.timeZone.identifier)) + }.foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading).padding(.top, 6) + }.disclosureGroupStyle(FullRowDisclosureStyle()) + .foregroundStyle(.secondary) + .help(loc("history.details.help")) + } + } + .font(.system(size: 12)) + .padding(16) + .background { + RoundedRectangle(cornerRadius: 20).fill(Color(nsColor: .controlBackgroundColor)) + RoundedRectangle(cornerRadius: 20).fill(LinearGradient(colors: [.cyan.opacity(0.07), .clear, .blue.opacity(0.04)], startPoint: .topLeading, endPoint: .bottomTrailing)) + } + .overlay(RoundedRectangle(cornerRadius: 20).strokeBorder(.cyan.opacity(0.13))) + .onChange(of: range) { _, _ in selectedDate = nil; hoveredDate = nil } + .onChange(of: disk.id) { _, _ in selectedDate = nil; hoveredDate = nil } + } + private func hoverCard(for date: Date) -> some View { + let day = days.first { calendar.isDate($0.date, inSameDayAs: date) } + return VStack(alignment: .leading, spacing: 5) { + Text(date.formatted(Date.FormatStyle(date: .abbreviated, time: .omitted, calendar: calendar, timeZone: calendar.timeZone))) + .font(.system(size: 12, weight: .medium)).foregroundStyle(.secondary) + if let day { + HStack(alignment: .firstTextBaseline) { + Text(metric(day.gb, suffix: " GB", decimals: 2)) + .font(.system(size: 19, weight: .semibold)).monospacedDigit() + Spacer(minLength: 0) + Image(systemName: "arrow.down.to.line").foregroundStyle(.cyan) + } + Text(locf("history.hours", day.seconds / 3600)).foregroundStyle(.secondary) + if day.estimated { Text(loc("history.estimated")).foregroundStyle(.secondary) } + } else { + Text(loc("history.no_measurement")).font(.system(size: 13, weight: .medium)) + } + } + .font(.system(size: 11)) + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.08))) + .shadow(color: .black.opacity(0.15), radius: 10, y: 4) + .accessibilityElement(children: .combine) + } +} 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..674ef0c --- /dev/null +++ b/Tests/SMARTasticTests/SmartParserTests.swift @@ -0,0 +1,151 @@ +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(try XCTUnwrap(disk.writtenGBPer24PowerOnHours), 24.576, accuracy: 0.000001) + 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) + XCTAssertNil(disk.writtenGBPer24PowerOnHours) + } + 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) + XCTAssertEqual(disk.powerOnHours, 0) + XCTAssertNil(disk.writtenGBPer24PowerOnHours) + } + 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, historyStore: WriteHistoryStore(url: nil), 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, historyStore: WriteHistoryStore(url: nil), 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, historyStore: WriteHistoryStore(url: nil), 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, historyStore: WriteHistoryStore(url: nil), 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/Tests/SMARTasticTests/WriteHistoryTests.swift b/Tests/SMARTasticTests/WriteHistoryTests.swift new file mode 100644 index 0000000..576954f --- /dev/null +++ b/Tests/SMARTasticTests/WriteHistoryTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import SMARTastic + +final class WriteHistoryTests: XCTestCase { + private func disk(_ total: Double, serial: String? = "test") -> DiskInfo { + DiskInfo(id: "/dev/disk0", model: "Test", serial: serial, driveType: .ssd, interface: "NVMe", smartAvailable: true, dataWrittenTB: total) + } + private var base: Date { ISO8601DateFormatter().date(from: "2026-09-01T12:00:00Z")! } + private func history() -> WriteHistory { var h = WriteHistory(); h.timeZoneID = "GMT"; return h } + + func testBaselineAndRealElapsedTime() throws { + var h = history() + h.record(disk(115), at: base) + XCTAssertTrue(try XCTUnwrap(h.drives.values.first).days.isEmpty) + h.record(disk(115.002), at: base.addingTimeInterval(3600)) + let day = try XCTUnwrap(h.drives.values.first?.days.first) + XCTAssertEqual(day.gb, 2, accuracy: 0.000001) + XCTAssertEqual(day.seconds, 3600) + XCTAssertFalse(day.estimated) + } + func testMidnightSplitAndLongGap() throws { + var h = history() + let midnight = base.addingTimeInterval(12 * 3600) + h.record(disk(1), at: midnight.addingTimeInterval(-60)) + h.record(disk(1.002), at: midnight.addingTimeInterval(60)) + let days = try XCTUnwrap(h.drives.values.first).days + XCTAssertEqual(days.count, 2) + XCTAssertEqual(days[0].gb, 1, accuracy: 0.000001) + XCTAssertEqual(days[1].gb, 1, accuracy: 0.000001) + XCTAssertTrue(days.allSatisfy(\.estimated)) + h.record(disk(1.102), at: midnight.addingTimeInterval(3 * 86400)) + let drive = try XCTUnwrap(h.drives.values.first) + XCTAssertEqual(drive.days.count, 2) + XCTAssertEqual(drive.gaps.first!.gb, 100, accuracy: 0.000001) + } + func testResetAndStaleSamplesNeverCreateSpikes() throws { + var h = history() + h.record(disk(100), at: base) + h.record(disk(1), at: base.addingTimeInterval(60)) + h.record(disk(200), at: base) + h.record(disk(1.001), at: base.addingTimeInterval(120)) + XCTAssertEqual(try XCTUnwrap(h.drives.values.first?.days.first).gb, 1, accuracy: 0.000001) + } + func testIdentityAndMissingCounters() { + var h = history() + h.record(disk(1, serial: nil), at: base) + h.record(disk(.nan), at: base) + XCTAssertTrue(h.drives.isEmpty) + var moved = disk(1); moved = DiskInfo(id: "/dev/disk4", model: moved.model, serial: moved.serial, driveType: .ssd, interface: "NVMe") + XCTAssertEqual(WriteHistory.key(for: moved), WriteHistory.key(for: disk(1))) + XCTAssertNotEqual(WriteHistory.key(for: disk(1, serial: "other")), WriteHistory.key(for: disk(1))) + } + func testRetentionAndZeroMeasurements() throws { + var h = history() + h.record(disk(1), at: base) + h.record(disk(1), at: base.addingTimeInterval(60)) + XCTAssertEqual(h.drives.values.first?.days.first?.gb, 0) + h.record(disk(2), at: base.addingTimeInterval(91 * 86400)) + XCTAssertTrue(try XCTUnwrap(h.drives.values.first).days.isEmpty) + } + func testDSTDayUsesCalendarBoundaries() throws { + var h = history(); h.timeZoneID = "Europe/Berlin" + let start = ISO8601DateFormatter().date(from: "2026-03-28T23:00:00Z")! + h.record(disk(1), at: start) + h.record(disk(1.1), at: start.addingTimeInterval(23 * 3600 - 1)) + let day = try XCTUnwrap(h.drives.values.first?.days.first) + XCTAssertEqual(day.gb, 100, accuracy: 0.000001) + XCTAssertEqual(day.seconds, 23 * 3600 - 1) + } + @MainActor func testPersistenceAndCorruptFileProtection() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("history.json") + let store = WriteHistoryStore(url: url) + store.record([disk(1)], at: base) + let restored = WriteHistoryStore(url: url) + restored.record([disk(1.001)], at: base.addingTimeInterval(60)) + XCTAssertEqual(try XCTUnwrap(restored.drive(for: disk(1))?.days.first).gb, 1, accuracy: 0.000001) + let corrupt = Data("broken".utf8) + try corrupt.write(to: url) + let broken = WriteHistoryStore(url: url) + broken.record([disk(2)], at: base) + XCTAssertTrue(broken.failed) + XCTAssertEqual(try Data(contentsOf: url), corrupt) + } +} diff --git a/assets/AppIcon.icns b/assets/AppIcon.icns new file mode 100644 index 0000000..652b033 Binary files /dev/null and b/assets/AppIcon.icns differ diff --git a/assets/history-dark.png b/assets/history-dark.png new file mode 100644 index 0000000..2de53ff Binary files /dev/null and b/assets/history-dark.png differ diff --git a/assets/history-light.png b/assets/history-light.png new file mode 100644 index 0000000..ed9e8f1 Binary files /dev/null and b/assets/history-light.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..853b185 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..a499dd3 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/screenshot-dark.png b/assets/screenshot-dark.png new file mode 100644 index 0000000..7ede966 Binary files /dev/null and b/assets/screenshot-dark.png differ diff --git a/assets/screenshot-light.png b/assets/screenshot-light.png new file mode 100644 index 0000000..d9b3951 Binary files /dev/null and b/assets/screenshot-light.png differ diff --git a/assets/screenshot-warning.png b/assets/screenshot-warning.png new file mode 100644 index 0000000..fea14e3 Binary files /dev/null and b/assets/screenshot-warning.png differ diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..cc67698 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,113 @@ +# Releasing SMARTastic + +SMARTastic requires macOS 14 or later. The stable bundle identifier remains +`com.opencode.SMARTastic`. Releases use semantic versions; this overhaul is 1.1.0, +build 2. The release ZIP contains a Universal app (arm64 and x86_64). + +## Development and verification + +Use full Xcode 26.3 or later. If `xcode-select -p` selects Command Line Tools, +set `DEVELOPER_DIR` to the installed Xcode's `Contents/Developer` directory for +these commands. The standalone macOS 27 Command Line Tools currently lack the +SwiftUI macro plugin needed by that SDK; the full Xcode toolchain works. + +```sh +swift test +ARCHS="arm64 x86_64" ./scripts/make-app.sh +open .build/app/SMARTastic.app +``` + +The script defaults to a release build with an ad-hoc signature, which is only +for local development. `CONFIGURATION=debug` makes a debug bundle. The optional +first argument is an output directory. Existing bundles at that destination are +replaced only after the new bundle passes signature verification. + +The resource bundle lives inside `Contents/Resources`. AppResources resolves it +there before trying SwiftPM's development resolver. Test the app after copying +it outside the checkout; it must not depend on `.build` at runtime. + +## Signed, notarized release + +A valid Developer ID Application identity and an authenticated notarytool +keychain profile are required. Keep private keys and passwords out of this repo. + +```sh +VERSION=1.1.0 BUILD_NUMBER=2 \ +CODE_SIGN_IDENTITY='Developer ID Application: NAME (TEAMID)' \ +NOTARY_PROFILE='PROFILE_NAME' \ +./scripts/release.sh .build/releases +``` + +The script builds Universal, signs with Hardened Runtime and a secure timestamp, +submits to Apple, requires `Accepted`, staples the app, verifies its signature and +Gatekeeper assessment, then produces: + +- `SMARTastic-VERSION.zip` +- `SMARTastic-VERSION.zip.sha256` +- `Casks/smartastic.rb`, with the final archive's SHA-256 + +`CODE_SIGN_KEYCHAIN` and `NOTARY_KEYCHAIN` optionally select a temporary CI +keychain. No special entitlements, helper daemon, sudo, or bundled smartctl are +required. Homebrew installs smartmontools as a separate dependency. + +Submission ID, source commit, archive digest and the submitted bundle remain in +`.build/releases/.state-VERSION`. A timeout can be resumed from the same source +and state directory without another submission. If submission was interrupted +before its ID was saved, recover the matching ID with `notarytool history`; do +not submit again blindly. Invalid submissions require fixing the cause and a +fresh version/state directory. Never overwrite a published release archive. + +## GitHub Actions and Homebrew + +`build.yml` tests and builds both architectures without signing secrets. +`release.yml` is manually dispatched with a version, validates release/tap access, +runs tests, signs and notarizes, publishes the immutable GitHub release, and then +updates `localfoundry/homebrew-tap`. + +A rerun of an interrupted job restores the saved artifact for that run. A new +workflow dispatch can specify `resume_run_id` to restore an older submission; +use the same source commit. The original bundle build number is retained. +If the matching public release already exists, the workflow checks the tag's +source commit and archive checksum, then resumes directly at tap distribution. +Notary artifacts are retained for seven days; download them before expiry if +manual follow-up is needed. Resume stops if the original state is unavailable. + +Required secrets in the repository running the signing job: + +| Secret | Purpose | +| --- | --- | +| `CSC_LINK` | Base64 PKCS#12 containing Developer ID certificate and private key | +| `CSC_KEY_PASSWORD` | Password for the PKCS#12 | +| `APPLE_ID` | Notarization Apple ID | +| `APPLE_APP_SPECIFIC_PASSWORD` | Notarization app-specific password | +| `APPLE_TEAM_ID` | Apple Developer team | +| `TAP_GITHUB_TOKEN` | Write access to localfoundry/homebrew-tap | + +Secrets are imported after tests into a temporary keychain and cleaned up even +on failure. Local identities and profile names do not exist on a fresh runner. +A separate existing signing repository may run the same build against a pinned +SMARTastic commit and return only the artifacts; secrets must stay there. + +Publish only after all checks pass, verify the public download against its +checksum, then update the tap's cask and package list. The tap must reference the +versioned download URL, never `latest` or `sha256 :no_check`. Validate with: + +```sh +brew style localfoundry/tap/smartastic +brew audit --cask --strict --online localfoundry/tap/smartastic +brew install --cask localfoundry/tap/smartastic +codesign --verify --deep --strict /Applications/SMARTastic.app +xcrun stapler validate /Applications/SMARTastic.app +spctl --assess --type execute --verbose=4 /Applications/SMARTastic.app +``` + +Respect branch protection. A release with an unmerged tap update is not fully +shipped. Use GitHub noreply metadata for commits and tags. Intel execution needs +an Intel Mac; a Universal build and `lipo` check alone do not prove that runtime. + +## Artwork + +`assets/logo.svg` is the editable vector source. The PNG previews and ICNS app +icon are committed so normal builds need no image tooling. To regenerate after +editing the SVG, install the optional `librsvg` Homebrew package and run +`./scripts/make-icons.sh`. It uses the standard 16–1024 pixel macOS icon sizes. diff --git a/docs/REVIEW-1.1.0.md b/docs/REVIEW-1.1.0.md new file mode 100644 index 0000000..d6930e3 --- /dev/null +++ b/docs/REVIEW-1.1.0.md @@ -0,0 +1,123 @@ +# SMARTastic 1.1.0 review and verification + +Review baseline: `da2284f`, including the pre-existing local logo/icon changes. +Scope: the complete application, not just the initial two-file working diff. +Review perspectives: data correctness, process/error handling and concurrency, +UI semantics/accessibility, packaging/signing and release recovery. + +The first external-provider review attempts failed before execution. Three +independent native reviewers subsequently checked the implementation. Their +concrete findings were checked against code, tests and, where available, hardware. + +## Corrected defects + +| ID | Original behavior / reproducible scenario | Change and evidence | +| --- | --- | --- | +| R1 | ATA columns were parsed from the end of each text row, confusing normalized values and raw counters. | Parse smartctl JSON; tests assert 18,342 power-on hours and 654 cycles independently of normalized values. Temperature uses smartctl's decoded field. | +| R2 | Nonzero smartctl status caused all output to be discarded, including useful measurements returned with health warnings or failed optional commands. | Preserve valid JSON and surface diagnostics. Verified with the attached Apple SSD returning status 4 for an optional log failure. | +| R3 | A failing SSD with little reported wear could receive a healthy score. Missing data became zero wear/zero errors/full spare. | Optional metrics and explicit good/warning/critical/unknown states; failure, NVMe critical warning, low spare and exhausted endurance take precedence. | +| R4 | Numeric text parsing mixed thousands separators with bracketed capacity labels; raw NVMe units used the wrong byte multiplier. | Parse numeric JSON values and use exactly 512,000 bytes per NVMe data unit. Regression tests verify capacity and traffic quantities. | +| R5 | Apple models were always excluded. Disk enumeration stopped at disk20 and tested unrelated plist booleans. Missing serials could merge different drives. | Enumerate diskutil's physical WholeDisks with no fixed index limit or vendor exclusion; use device paths as identities. Real internal Apple and external NVMe drives are present. | +| R6 | Waiting for process exit before draining pipes could deadlock on large output; a stuck device had no timeout. | File-backed output, 12-second per-command deadlines and TERM/KILL escalation. Tests cover output larger than a pipe buffer and a timed-out process. | +| R7 | Repeated timer starts could leave multiple timers; selection was not reconciled after unplugging a drive. | MainActor model, idempotent timer setup and selection reconciliation. Tests cover disconnect, empty scan, concurrent refresh and preserving the last snapshot after errors. | +| R8 | During the overhaul, blindly adding ATA attribute 198 treated Host_Reads_GiB on some healthy SSDs as errors. | Use smartctl's named attribute semantics. Regression test covers the vendor read counter; confirmed against smartmontools' drive database. | +| R9 | Packaging copied debug output and put resources at the bundle root; there was no trusted release signature. | Versioned release build, embedded Resources bundle resolver, Universal binary, Developer ID signing with Hardened Runtime and timestamp. Both architectures checked. | +| R10 | The initial release workflow could not resume after Apple timeout or a later tap failure. | Restore the same submission and original build number; recover executable modes from submission ZIP. Existing public releases are matched to the exact source commit, verified and continued at the tap step. | +| R11 | Lifetime written bytes divided by SMART power-on hours and multiplied by 24 was labelled GB/day. NVMe power-on hours can exclude low-power states, making a calendar-day interpretation misleading. | Retained the normalized volume with the explicit label “Written per 24 SMART hours” and explanatory tooltips. Tests verify the normalization and unavailable/zero-hour cases. A true calendar-day average requires timestamped counter differences. Field semantics checked against the [libnvme SMART log documentation](https://manpages.debian.org/testing/libnvme-dev/nvme_smart_log.2.en.html). | + +## UI and documentation corrections + +- Native selectable sidebar rows replace mouse-only card gestures; search filters + by model or interface. First scan automatically selects a drive. +- Consistent system typography, compact metric cards and restrained status colours + replace multiple competing rings and gradients. Both four-metric sections use + one row when space permits and two columns in narrow windows. +- The title and opaque toolbar strip are removed. The scroll view and its scroll + indicator extend behind the transparent window chrome. Circular actions float + at the top right with equal 24-point margins; macOS 26 uses the native glass + button style, with native bordered buttons on macOS 14–15. +- The arbitrary HDD score and calendar failure-date forecast were removed. + Remaining rated SSD endurance is labelled and explained accurately. +- Unavailable values render as a dash, with an explanation. SMART-unavailable + guidance no longer asserts a USB hardware limitation without evidence. +- Warning indicators remain visible inside metric cards; selected sidebar text + has appropriate contrast. Singular drive counts use the correct translation. +- A sun/moon appearance switch in the sidebar replaces the dropdown and persists + across launches. System is the default and can be restored with a right-click. The screenshot-only `--light` argument + provides a transient demo override. +- New vector logo, complete macOS ICNS set and screenshots replace the old + artwork. Screenshots show labelled synthetic data, not private drive serials. +- JSON export omits serial-number fields, includes sample time and schema version, + and presents save failures. Diagnostic free text should be reviewed before sharing. +- README now matches the metrics, installation prerequisites, refresh intervals, + report export, languages, appearance controls and release status. + +## Executed verification + +- 17 Swift XCTest tests passed, including a separately enabled real hardware scan. +- Native app launch, drive selection, search, healthy/warning/unavailable states, + JSON export through the native save dialog and content verification. +- Appearance changed using the live sun/moon switch; selection persisted after restart. + Switching from Light back to System was verified to restore dark appearance + throughout the window. Window positioning during capture was corrected before + saving the final screenshots. +- Light/dark screenshots were inspected. Final README images are + `assets/screenshot-light.png`, `assets/screenshot-dark.png` and + `assets/screenshot-warning.png`. +- Universal arm64/x86_64 release build; local Developer ID signature verification. + Minimum binary target is macOS 14.0. The standalone macOS 27 Command Line Tools + failed due to a missing SwiftUI macro plugin; full Xcode succeeded. +- 11 offline Python regression tests cover release creation and recovery, including safe pre-build retries and refusing ambiguous submission state. +- Shell syntax and Python syntax checked. Pinned GitHub Action commits exist. +- GitHub Actions with Xcode 26.3 passed tests and built Universal. Its first + architecture-verification step exposed a lipo argument-order compatibility + issue. All scripts now verify the portable lipo -archs output through the + shared check-architectures.sh helper. + +## Limits and pending distribution + +- No directly readable ATA drive or Intel Mac was available. ATA semantics use + fixtures plus the smartmontools drive database; a Universal build is not an + Intel runtime test. +- The process timeout bounds each command, not the total duration of scanning + many slow drives. Scans preserve diskutil basic information on SMART failures. +- Developer ID identity exists and signs locally. The app was previously ad hoc + signed. Local notarization authentication currently returns HTTP 401. +- GitHub Actions release secrets are not configured in SMARTastic. A complete + existing signing setup is available in Locomni; use of that separate repository + is awaiting the user's choice. No notarization submission, public release or + Homebrew cask publication is claimed yet. +- CI signing/notary/tap recovery paths have been reviewed and prepared; an actual + successful end-to-end public release remains the acceptance gate. + +## Follow-up: calendar write history + +- Added a native Swift Charts daily-write view with 7/30/90-day segments, measured + period total, today's partial total and selectable daily values/observed hours. +- Daily amounts use counter differences and wall-clock timestamps; SMART power-on + hours never enter the calculation. Missing days are not fabricated as zero. +- Same-day differences are assigned directly. Midnight intervals up to ten minutes + are apportioned by elapsed time; longer cross-day gaps are listed separately. +- Local atomic JSON persistence retains 90 days, hashes model/serial identity, + handles counter decreases and stale samples, and preserves unreadable files. + Demo histories and test stores are isolated from real history. +- Added seven regression tests covering real deltas, gaps, midnight, DST, resets, + identity, missing counters, retention, persistence and corrupt-file protection. + All 24 Swift tests passed, including the explicitly enabled hardware test. +- Replaced the refresh dropdown with native pause/30-second/1-minute/5-minute + segments. Refined the chart with blue gradients, a mint today bar and a collapsible + explanation. Updated all five localizations and README history documentation. + +## Follow-up: optional logs, disclosure targets and search + +- Replaced `smartctl -a -j` with `-i -H -A -j`: the app needs drive identity, + health and attributes, not optional detailed error/self-test logs. On the Apple + SSD the old invocation returned status 4 with GetLogPage code 745; the targeted + invocation returned status 0 and the same set of NVMe health-counter fields. + Actual command failures remain visible; no diagnostic strings are suppressed. +- Explanation and diagnostic headers now have a full-width, 36-point button + target, keyboard activation and an accessible expanded/collapsed state. +- Escape clears the focused drive-search field. +- Window action help uses explicit AppKit pointer tracking and visible cards; + Robin confirmed that these cards appear in the running app. +- All 24 Swift tests passed with the hardware scan enabled after the command change. diff --git a/scripts/check-architectures.sh b/scripts/check-architectures.sh new file mode 100755 index 0000000..1d35e6c --- /dev/null +++ b/scripts/check-architectures.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail +: "${1:?Pass the Universal executable path}" +architectures="$(lipo -archs "$1")" +for expected in arm64 x86_64; do + case " $architectures " in + *" $expected "*) ;; + *) echo "Missing $expected in $1 (found: $architectures)" >&2; exit 1 ;; + esac +done diff --git a/scripts/generate-cask.py b/scripts/generate-cask.py new file mode 100755 index 0000000..be5cc38 --- /dev/null +++ b/scripts/generate-cask.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Generate a cask only from the final, verified release ZIP.""" +import argparse +import hashlib +import re +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--version', required=True) +parser.add_argument('--archive', type=Path, required=True) +parser.add_argument('--output', type=Path, required=True) +args = parser.parse_args() +if not re.fullmatch(r'\d+\.\d+\.\d+', args.version): + parser.error('Version must be x.y.z') +if args.archive.name != f'SMARTastic-{args.version}.zip': + parser.error('Archive name must match version') +sha = hashlib.sha256(args.archive.read_bytes()).hexdigest() +args.output.parent.mkdir(parents=True, exist_ok=True) +args.output.write_text(f'''cask "smartastic" do + version "{args.version}" + sha256 "{sha}" + + url "https://github.com/RobinBially/SMARTastic/releases/download/v#{{version}}/SMARTastic-#{{version}}.zip" + name "SMARTastic" + desc "Native SSD and HDD health monitor" + homepage "https://github.com/RobinBially/SMARTastic" + + depends_on formula: "smartmontools" + depends_on macos: ">= :sonoma" + + app "SMARTastic.app" +end +''') diff --git a/scripts/make-app.sh b/scripts/make-app.sh index 8905cf8..df84585 100755 --- a/scripts/make-app.sh +++ b/scripts/make-app.sh @@ -1,51 +1,57 @@ #!/bin/bash -set -e - -BUILD_DIR=".build/debug" -APP_NAME="SMARTastic" -APP_DIR="$APP_NAME.app" - -mkdir -p "$APP_DIR/Contents/MacOS" -mkdir -p "$APP_DIR/Contents/Resources" - -cp "$BUILD_DIR/$APP_NAME" "$APP_DIR/Contents/MacOS/$APP_NAME" -cp -R "$BUILD_DIR/${APP_NAME}_${APP_NAME}.bundle" "$APP_DIR/" - -cat > "$APP_DIR/Contents/Info.plist" <&2; exit 1; } +[[ "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo 'Invalid BUILD_NUMBER' >&2; exit 1; } +[[ "$CONFIGURATION" == release || "$CONFIGURATION" == debug ]] || exit 1 +OUTPUT="${1:-$PWD/.build/app}" +mkdir -p "$OUTPUT" +OUTPUT="$(cd "$OUTPUT" && pwd)" +STAGING="$(mktemp -d "$OUTPUT/.bundle.XXXXXX")" +trap 'rm -rf "$STAGING"' EXIT +APP="$STAGING/SMARTastic.app" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +binaries=() +for arch in $ARCHS; do + [[ "$arch" == arm64 || "$arch" == x86_64 ]] || exit 1 + swift build -c "$CONFIGURATION" --arch "$arch" + bin_dir="$(swift build -c "$CONFIGURATION" --arch "$arch" --show-bin-path)" + cp "$bin_dir/SMARTastic" "$STAGING/SMARTastic-$arch" + binaries+=("$STAGING/SMARTastic-$arch") + if [[ ! -d "$APP/Contents/Resources/SMARTastic_SMARTastic.bundle" ]]; then + ditto "$bin_dir/SMARTastic_SMARTastic.bundle" "$APP/Contents/Resources/SMARTastic_SMARTastic.bundle" + fi +done +lipo -create "${binaries[@]}" -output "$APP/Contents/MacOS/SMARTastic" +cp assets/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" +cat > "$APP/Contents/Info.plist" < - - - CFBundleExecutable - $APP_NAME - CFBundleIdentifier - com.opencode.$APP_NAME - CFBundleName - $APP_NAME - CFBundleVersion - 1 - CFBundleShortVersionString - 1.0 - CFBundlePackageType - APPL - LSMinimumSystemVersion - 14.0 - NSHighResolutionCapable - - LSUIElement - - CFBundleLocalizations - - de - en - es - fr - zh-Hans - - CFBundleDevelopmentRegion - de - - -EOF - -echo "✅ $APP_DIR created. Run with: open $APP_DIR" + +CFBundleExecutableSMARTastic +CFBundleIdentifiercom.opencode.SMARTastic +CFBundleNameSMARTastic +CFBundleIconFileAppIcon +CFBundlePackageTypeAPPL +CFBundleShortVersionString$VERSION +CFBundleVersion$BUILD_NUMBER +LSMinimumSystemVersion14.0 +NSHighResolutionCapable +CFBundleDevelopmentRegionen +CFBundleLocalizationsendeesfrzh-Hans + +PLIST +sign_options=(--force --sign "$IDENTITY") +if [[ "$IDENTITY" != - ]]; then sign_options+=(--options runtime --timestamp); fi +if [[ -n "${CODE_SIGN_KEYCHAIN:-}" ]]; then sign_options+=(--keychain "$CODE_SIGN_KEYCHAIN"); fi +codesign "${sign_options[@]}" "$APP" +codesign --verify --deep --strict --verbose=2 "$APP" +# Only replace the requested development bundle after all build/signing checks pass. +if [[ -e "$OUTPUT/SMARTastic.app" ]]; then mv "$OUTPUT/SMARTastic.app" "$STAGING/previous.app"; fi +mv "$APP" "$OUTPUT/SMARTastic.app" +echo "App ready: $OUTPUT/SMARTastic.app" diff --git a/scripts/make-icons.sh b/scripts/make-icons.sh new file mode 100755 index 0000000..0d4cb91 --- /dev/null +++ b/scripts/make-icons.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +command -v rsvg-convert >/dev/null || { echo 'Install the optional SVG renderer: brew install librsvg' >&2; exit 1; } +mkdir -p .build/AppIcon.iconset +rsvg-convert -w 1024 -h 1024 assets/logo.svg -o assets/logo.png +sips -z 256 256 assets/logo.png --out Sources/SMARTastic/Resources/logo.png >/dev/null +for size in 16 32 128 256 512; do + sips -z "$size" "$size" assets/logo.png --out ".build/AppIcon.iconset/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z "$double" "$double" assets/logo.png --out ".build/AppIcon.iconset/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns .build/AppIcon.iconset -o assets/AppIcon.icns diff --git a/scripts/prepare-release.py b/scripts/prepare-release.py new file mode 100644 index 0000000..ba91f3b --- /dev/null +++ b/scripts/prepare-release.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Resolve a new release, an interrupted submission, or a published release to finish.""" +import json +import os +import re +import subprocess +import urllib.error +import urllib.request +from pathlib import Path + +version = os.environ['VERSION'] +if not re.fullmatch(r'\d+\.\d+\.\d+', version): + raise SystemExit('Version must be x.y.z') +repository = os.environ['GITHUB_REPOSITORY'] +source = os.environ['GITHUB_SHA'] +output = Path('.build/releases') +output.mkdir(parents=True, exist_ok=True) + +def run(*args): + return subprocess.check_output(args, text=True).strip() + +request = urllib.request.Request(f'https://api.github.com/repos/{repository}/releases/tags/v{version}', + headers={'Authorization': f'Bearer {os.environ["GH_TOKEN"]}', 'Accept': 'application/vnd.github+json'}) +try: + with urllib.request.urlopen(request) as response: + release = json.load(response) +except urllib.error.HTTPError as error: + if error.code != 404: + raise + release = None + +refs = run('git', 'ls-remote', '--tags', 'origin', f'refs/tags/v{version}', f'refs/tags/v{version}^{{}}') +if refs: + subprocess.run(['git', 'fetch', 'origin', f'refs/tags/v{version}:refs/tags/v{version}'], check=True) + if run('git', 'rev-parse', f'v{version}^{{commit}}') != source: + raise SystemExit('Existing version tag belongs to another source commit.') + +mode = 'build' +if release: + if release['draft'] or not refs: + raise SystemExit('Existing release is a draft or has no public version tag; resolve it explicitly.') + subprocess.run(['gh', 'release', 'download', f'v{version}', '--repo', repository, + '--pattern', f'SMARTastic-{version}.zip*', '--dir', str(output)], check=True) + subprocess.run(['shasum', '-a', '256', '-c', f'SMARTastic-{version}.zip.sha256'], cwd=output, check=True) + subprocess.run(['python3', 'scripts/generate-cask.py', '--version', version, + '--archive', str(output / f'SMARTastic-{version}.zip'), + '--output', str(output / 'Casks/smartastic.rb')], check=True) + mode = 'tap' +else: + resume = os.environ.get('RESUME_RUN_ID', '') + if not resume and int(os.environ.get('GITHUB_RUN_ATTEMPT', '1')) > 1: + resume = os.environ['GITHUB_RUN_ID'] + if resume: + if not resume.isdecimal(): + raise SystemExit('Resume run ID must be numeric.') + previous = json.loads(run('gh', 'api', f'repos/{repository}/actions/runs/{resume}')) + if previous['head_sha'] != source: + raise SystemExit('Resume run belongs to another source commit.') + build = str(int(previous['run_number']) + 1) + artifacts = json.loads(run('gh', 'api', f'repos/{repository}/actions/runs/{resume}/artifacts'))['artifacts'] + artifact_exists = any(item['name'] == f'smartastic-notarization-{resume}' and not item['expired'] for item in artifacts) + if not artifact_exists: + jobs = json.loads(run('gh', 'api', f'repos/{repository}/actions/runs/{resume}/jobs?filter=all'))['jobs'] + build_steps = [step for job in jobs for step in job.get('steps', []) + if step['name'] == 'Build Universal app and notarize'] + if not build_steps or any(step.get('conclusion') != 'skipped' for step in build_steps): + raise SystemExit('No saved state after a possible submission. Recover the Apple submission manually.') + # Tests or credential setup failed before the build/submit step; a fresh build is safe. + else: + subprocess.run(['gh', 'run', 'download', resume, '--repo', repository, + '--name', f'smartastic-notarization-{resume}', '--dir', str(output)], check=True) + state = output / f'.state-{version}' + if (state / 'source-commit').read_text().strip() != source: + raise SystemExit('Saved notarization state belongs to another source commit.') + if (state / 'submission.zip').exists(): + # Artifact files lose executable modes; restore the bundle from its ZIP. + subprocess.run(['shasum', '-a', '256', '-c', 'submission.sha256'], cwd=state, check=True) + subprocess.run(['ditto', '-x', '-k', str(state / 'submission.zip'), str(state)], check=True) + import plistlib + with (state / 'SMARTastic.app/Contents/Info.plist').open('rb') as file: + plist = plistlib.load(file) + if plist['CFBundleShortVersionString'] != version: + raise SystemExit('Saved bundle version differs.') + build = plist['CFBundleVersion'] + elif (state / 'submission-started').exists() or (state / 'submission.json').exists(): + raise SystemExit('Submission state lacks its archive. Recover it before continuing.') + # A partial build with no submission marker can be safely rebuilt. + if (output / f'SMARTastic-{version}.zip').exists(): + subprocess.run(['shasum', '-a', '256', '-c', f'SMARTastic-{version}.zip.sha256'], cwd=output, check=True) + mode = 'publish' + else: + build = str(int(os.environ['GITHUB_RUN_NUMBER']) + 1) + with open(os.environ['GITHUB_ENV'], 'a') as file: + file.write(f'BUILD_NUMBER={build}\n') + +with open(os.environ['GITHUB_OUTPUT'], 'a') as file: + file.write(f'mode={mode}\n') diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..645618c --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +: "${VERSION:?Set VERSION (x.y.z)}" +: "${BUILD_NUMBER:?Set BUILD_NUMBER}" +: "${CODE_SIGN_IDENTITY:?Set a Developer ID Application identity}" +: "${NOTARY_PROFILE:?Set an existing notarytool profile}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 +[[ "$CODE_SIGN_IDENTITY" == 'Developer ID Application: '* ]] || exit 1 +OUTPUT="${1:-$PWD/.build/releases}" +mkdir -p "$OUTPUT" +OUTPUT="$(cd "$OUTPUT" && pwd)" +ARCHIVE="SMARTastic-$VERSION.zip" +[[ ! -e "$OUTPUT/$ARCHIVE" ]] || { echo 'Final archive already exists; do not overwrite a release.' >&2; exit 1; } +git diff --quiet HEAD || { echo 'Commit source changes before releasing.' >&2; exit 1; } +[[ -z "$(git ls-files --others --exclude-standard)" ]] || { echo 'Untracked source files: commit or ignore them before releasing.' >&2; exit 1; } +STATE="$OUTPUT/.state-$VERSION" +mkdir -p "$STATE" +SOURCE_COMMIT="$(git rev-parse HEAD)" +if [[ -e "$STATE/source-commit" ]]; then + [[ "$(cat "$STATE/source-commit")" == "$SOURCE_COMMIT" ]] || { echo 'Resume source differs from submission.' >&2; exit 1; } +else + printf '%s\n' "$SOURCE_COMMIT" > "$STATE/source-commit" +fi +APP="$STATE/SMARTastic.app" +if [[ ! -e "$STATE/submission.zip" ]]; then + ARCHS="arm64 x86_64" ./scripts/make-app.sh "$STATE" + ./scripts/check-architectures.sh "$APP/Contents/MacOS/SMARTastic" + ditto -c -k --keepParent "$APP" "$STATE/submission.zip" + (cd "$STATE" && shasum -a 256 submission.zip > submission.sha256) +fi +notary_options=(--keychain-profile "$NOTARY_PROFILE") +if [[ -n "${NOTARY_KEYCHAIN:-}" ]]; then notary_options+=(--keychain "$NOTARY_KEYCHAIN"); fi +[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" == "$VERSION" ]] || exit 1 +[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist")" == "$BUILD_NUMBER" ]] || { echo 'Resume build number differs.' >&2; exit 1; } +codesign --verify --deep --strict "$APP" +(cd "$STATE" && shasum -a 256 -c submission.sha256) +if [[ ! -s "$STATE/submission.json" ]]; then + [[ ! -e "$STATE/submission-started" ]] || { echo 'Submission interrupted: recover its ID from notarytool history before retrying.' >&2; exit 1; } + touch "$STATE/submission-started" + xcrun notarytool submit "$STATE/submission.zip" "${notary_options[@]}" --output-format json > "$STATE/submission.json" +fi +SUBMISSION_ID="$(plutil -extract id raw -o - "$STATE/submission.json")" +xcrun notarytool wait "$SUBMISSION_ID" "${notary_options[@]}" --timeout 30m --output-format json > "$STATE/status.json" +[[ "$(plutil -extract status raw -o - "$STATE/status.json")" == Accepted ]] || { + echo "Not accepted. Inspect notarytool log for $SUBMISSION_ID; submission state is preserved." >&2; exit 1; +} +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +codesign --verify --deep --strict --verbose=2 "$APP" +spctl --assess --type execute --verbose=4 "$APP" +ditto -c -k --keepParent "$APP" "$STATE/$ARCHIVE" +(cd "$STATE" && shasum -a 256 "$ARCHIVE" > "$ARCHIVE.sha256") +python3 scripts/generate-cask.py --version "$VERSION" --archive "$STATE/$ARCHIVE" --output "$OUTPUT/Casks/smartastic.rb" +mv "$STATE/$ARCHIVE" "$STATE/$ARCHIVE.sha256" "$OUTPUT/" +echo "Verified release: $OUTPUT/$ARCHIVE" diff --git a/scripts/update-tap.sh b/scripts/update-tap.sh new file mode 100755 index 0000000..1e027a6 --- /dev/null +++ b/scripts/update-tap.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +: "${VERSION:?Set VERSION}" +: "${GH_TOKEN:?Set a token authorized for the tap}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 +CASK="$PWD/.build/releases/Casks/smartastic.rb" +test -f "$CASK" +# This script runs on a disposable runner, after the release is public. +gh auth setup-git +gh repo clone localfoundry/homebrew-tap .build/tap +# Never let recovery of an old release downgrade the currently distributed app. +python3 - "$VERSION" "$CASK" <<'PYTHON' +import re, sys +from pathlib import Path +current=Path('.build/tap/Casks/smartastic.rb') +if current.exists(): + old=current.read_text() + match=re.search(r'version "([0-9]+\.[0-9]+\.[0-9]+)"', old) + if not match: raise SystemExit('Cannot safely compare the existing cask version.') + previous=tuple(map(int, match[1].split('.'))) + incoming=tuple(map(int, sys.argv[1].split('.'))) + if previous > incoming: + raise SystemExit('A newer SMARTastic release is already in the tap; refusing a downgrade.') + if previous == incoming: + new=Path(sys.argv[2]).read_text() + for field in ['sha256','url']: + pattern=field+r' "([^"\n]+)"' + if re.search(pattern, old).group(1) != re.search(pattern, new).group(1): + raise SystemExit('Existing version has different release bytes or URL; refusing replacement.') +PYTHON +cp "$CASK" .build/tap/Casks/smartastic.rb +python3 - <<'PY' +from pathlib import Path +p=Path('.build/tap/README.md') +s=p.read_text() +if '| `smartastic` |' not in s: + anchor='|---|---|---|' + assert anchor in s, 'Tap package table changed; update README explicitly.' + s=s.replace(anchor, anchor+'\n| `smartastic` | Cask · Native macOS drive health monitor | [RobinBially/SMARTastic](https://github.com/RobinBially/SMARTastic) |', 1) + s+='\n## Install SMARTastic\n\n```sh\nbrew install --cask localfoundry/tap/smartastic\n```\n\nRequires macOS 14+. The Universal app is signed and notarized. Homebrew also installs smartmontools.\n' +p.write_text(s) +PY +git -C .build/tap config user.name 'Robin Bially' +git -C .build/tap config user.email '7304732+RobinBially@users.noreply.github.com' +git -C .build/tap add Casks/smartastic.rb README.md +if ! git -C .build/tap diff --cached --quiet; then + git -C .build/tap commit -m "Release SMARTastic $VERSION" +fi + +brew tap localfoundry/tap "$PWD/.build/tap" +brew style localfoundry/tap/smartastic +brew audit --cask --strict --online localfoundry/tap/smartastic +brew install --cask localfoundry/tap/smartastic +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' /Applications/SMARTastic.app/Contents/Info.plist)" = "$VERSION" +./scripts/check-architectures.sh /Applications/SMARTastic.app/Contents/MacOS/SMARTastic +codesign --verify --deep --strict /Applications/SMARTastic.app +xcrun stapler validate /Applications/SMARTastic.app +spctl --assess --type execute --verbose=4 /Applications/SMARTastic.app + +git -C .build/tap push origin HEAD diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100755 index 0000000..355ce8e --- /dev/null +++ b/scripts/verify-release.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +: "${VERSION:?Set VERSION}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 +OUTPUT="$PWD/.build/releases" +(cd "$OUTPUT" && shasum -a 256 -c "SMARTastic-$VERSION.zip.sha256") +CHECK="$(mktemp -d "$PWD/.build/verify.XXXXXX")" +trap 'rm -rf "$CHECK"' EXIT +ditto -x -k "$OUTPUT/SMARTastic-$VERSION.zip" "$CHECK" +APP="$CHECK/SMARTastic.app" +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$VERSION" +./scripts/check-architectures.sh "$APP/Contents/MacOS/SMARTastic" +codesign --verify --deep --strict --verbose=2 "$APP" +xcrun stapler validate "$APP" +spctl --assess --type execute --verbose=4 "$APP" +python3 scripts/generate-cask.py --version "$VERSION" --archive "$OUTPUT/SMARTastic-$VERSION.zip" --output "$OUTPUT/Casks/smartastic.rb"