diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
new file mode 100644
index 0000000..d3c3d80
--- /dev/null
+++ b/.gitea/workflows/build.yml
@@ -0,0 +1,24 @@
+name: Build and test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ macos:
+ runs-on: macos-14
+ steps:
+ - uses: actions/checkout@v4
+ - name: Swift version
+ run: swift --version
+ - name: Build
+ run: swift build
+ - name: Test
+ run: swift test
+ - name: Package unsigned validation build
+ run: ./scripts/build-app.sh
+ - name: Build validation DMG
+ run: ./scripts/build-dmg.sh
+ - name: Verify packaged app and DMG
+ run: ./scripts/validate-package.sh
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..55d0790
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,87 @@
+name: Signed release
+
+on:
+ push:
+ tags: ['v*']
+
+jobs:
+ release:
+ runs-on: macos-14
+ steps:
+ - uses: actions/checkout@v4
+ - name: Import Developer ID certificate
+ env:
+ SIGNING_P12: ${{ secrets.DEVELOPER_ID_P12_BASE64 }}
+ SIGNING_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }}
+ run: |
+ KEYCHAIN="$RUNNER_TEMP/puter-signing.keychain-db"
+ security create-keychain -p runner "$KEYCHAIN"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN"
+ security unlock-keychain -p runner "$KEYCHAIN"
+ printf '%s' "$SIGNING_P12" | base64 --decode > "$RUNNER_TEMP/puter-signing.p12"
+ security import "$RUNNER_TEMP/puter-signing.p12" -k "$KEYCHAIN" -P "$SIGNING_PASSWORD" -T /usr/bin/codesign
+ security set-key-partition-list -S apple-tool:,apple: -s -k runner "$KEYCHAIN"
+ security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
+ - name: Prepare notarization key
+ env:
+ NOTARY_KEY: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }}
+ run: printf '%s' "$NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8"
+ - name: Build signed app and DMG
+ env:
+ PUTER_RELEASE_BUILD: '1'
+ PUTER_SIGN_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }}
+ PUTER_UPDATE_FEED_URL: ${{ secrets.SPARKLE_FEED_URL }}
+ PUTER_UPDATE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }}
+ run: |
+ export PUTER_VERSION="${GITHUB_REF_NAME#v}"
+ export PUTER_BUILD_NUMBER="$GITHUB_RUN_NUMBER"
+ ./scripts/build-app.sh
+ ./scripts/build-dmg.sh
+ ./scripts/validate-package.sh
+ - name: Notarize and staple
+ env:
+ PUTER_NOTARY_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
+ PUTER_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
+ PUTER_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
+ run: ./scripts/notarize-release.sh
+ - name: Validate notarized release
+ env:
+ PUTER_RELEASE_BUILD: '1'
+ run: |
+ ./scripts/validate-package.sh
+ xcrun stapler validate dist/puter.app
+ xcrun stapler validate dist/puter-macOS.dmg
+ - name: Generate signed appcast
+ env:
+ PUTER_UPDATE_DOWNLOAD_PREFIX: ${{ secrets.SPARKLE_DOWNLOAD_PREFIX }}
+ PUTER_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
+ run: |
+ mkdir -p dist/updates
+ ditto dist/puter-macOS.dmg "dist/updates/puter-${GITHUB_REF_NAME#v}.dmg"
+ ./scripts/generate-appcast.sh dist/updates
+ - name: Prepare release assets
+ run: |
+ ditto -c -k --sequesterRsrc --keepParent dist/puter.app "dist/puter-${GITHUB_REF_NAME#v}-macOS.zip"
+ shasum -a 256 \
+ dist/puter-macOS.dmg \
+ "dist/puter-${GITHUB_REF_NAME#v}-macOS.zip" \
+ dist/updates/appcast.xml > dist/SHA256SUMS
+ - name: Publish Gitea release
+ env:
+ RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ run: |
+ API_ROOT="${GITHUB_API_URL:-${GITEA_SERVER_URL}/api/v1}"
+ RELEASE_JSON="$(curl --fail-with-body -sS -X POST \
+ -H "Authorization: token $RELEASE_TOKEN" -H 'Content-Type: application/json' \
+ "$API_ROOT/repos/$GITHUB_REPOSITORY/releases" \
+ -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"puter $GITHUB_REF_NAME\",\"draft\":false,\"prerelease\":false}")"
+ RELEASE_ID="$(printf '%s' "$RELEASE_JSON" | jq -r .id)"
+ for ASSET in \
+ dist/puter-macOS.dmg \
+ "dist/puter-${GITHUB_REF_NAME#v}-macOS.zip" \
+ dist/updates/appcast.xml \
+ dist/SHA256SUMS; do
+ curl --fail-with-body -sS -X POST -H "Authorization: token $RELEASE_TOKEN" \
+ -H 'Content-Type: application/octet-stream' --data-binary "@$ASSET" \
+ "$API_ROOT/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$(basename "$ASSET")"
+ done
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..b6a14de
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "ecfe14dd009fc3311519d8c8b59cf31614faf281a9f44d45e4c0fc75f9911077",
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
+ "version" : "2.9.2"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index 5905914..d50ca0f 100644
--- a/Package.swift
+++ b/Package.swift
@@ -5,16 +5,33 @@ let package = Package(
name: "puter",
platforms: [.macOS(.v14)],
products: [
- .executable(name: "puter", targets: ["puter"])
+ .executable(name: "puter", targets: ["puter"]),
+ .executable(name: "puter-helper", targets: ["puter-helper"])
+ ],
+ dependencies: [
+ .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.2")
],
targets: [
+ .target(
+ name: "PuterHelperProtocol",
+ path: "Sources/PuterHelperProtocol"
+ ),
.executableTarget(
name: "puter",
+ dependencies: [
+ "PuterHelperProtocol",
+ .product(name: "Sparkle", package: "Sparkle")
+ ],
path: "Sources/puter"
),
+ .executableTarget(
+ name: "puter-helper",
+ dependencies: ["PuterHelperProtocol"],
+ path: "Sources/puter-helper"
+ ),
.testTarget(
name: "puterTests",
- dependencies: ["puter"],
+ dependencies: ["puter", "PuterHelperProtocol"],
path: "Tests/puterTests"
)
]
diff --git a/README.md b/README.md
index 7fb52a4..6046ca3 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Validated with automated tests, debug and release builds, packaged-app code-sign
- Cached native application icons throughout process groups, app history, startup apps, users, services, properties, and dependency views
- Search and sortable process columns, including live Disk and Network rates
- Drag-resizable, persisted Processes columns with memory hover details showing both resident bytes and physical-memory percentage
-- Column sorting automatically flattens process categories for a true global order; the header menu can restore Group by type
+- Process headers cycle through descending, ascending, and unsorted states; the third click restores Windows-style process categories
- Expandable application groups plus a resizable Details table with application icons, persisted optional columns, live selection actions, threads, open handles, architecture, priority/nice, parent PID, CPU time, and other diagnostics
- Collapsible Windows-style Apps, Background processes, and macOS processes categories
- Native process sampling reports from the Inspect submenu, with save-location memory and completion feedback
@@ -25,25 +25,36 @@ Validated with automated tests, debug and release builds, packaged-app code-sign
- Persistent per-app history with cumulative CPU time, network totals, download/upload breakdowns, and reset confirmation
- Disk, uptime, process, and thread statistics
- One live Performance entry per available removable volume, with capacity, availability, filesystem, mount point, and ejectable status; disconnected volumes are hidden
+- Physical-disk health inventory with one card per attached disk and macOS-reported SMART status, NVMe wear/life, spare capacity, temperature, TRIM, power hours/cycles, unsafe shutdowns, media errors, and lifetime reads/writes
- Detailed memory telemetry for active, cached, wired, compressed, and swap usage plus hardware type, manufacturer, and reported speed/frequency
+- Kernel-driven Normal/Warning/Critical memory-pressure status with plain-language guidance; reclaimable cache is no longer misreported as application memory in use
- A maintained M1-through-M5 Apple-silicon memory catalog, including core-count configuration variants; Intel Macs continue to use DIMM speeds reported directly by System Information
- Native disk read/write throughput, IOPS, active time, and response-time histories
- Live Apple GPU utilization with overall, renderer, and tiler histories plus GPU memory and core statistics
-- Hardware diagnostics for battery health, capacity, cycle life, temperature, charge state, live system watts, thermal pressure, USB-PD negotiation and advertised power profiles
+- Hardware diagnostics for battery health, capacity, cycle life, temperature, charge state, live SMC-backed system/battery/adapter watts, thermal pressure, USB-PD negotiation and advertised power profiles
+- Energy & Sleep dashboard with transparent CPU/disk/network-based process impact estimates, active power policy, sleep timers, and the processes currently preventing idle sleep
+- Event-driven lifecycle, application, mount, wake, thermal, memory-pressure, and power-source refreshes backed by independent adaptive telemetry, process-inventory, hardware, and service lanes; pages such as Hardware, Startup, Services, and Settings reuse cached processes and run only lightweight native system telemetry between process events
- USB-C, USB, and Thunderbolt port inventory with negotiated link speeds, connected-device power requirements, and an optional available-port map
-- User, detail, service, persistent app history, and startup views covering native login items plus LaunchAgents
+- User, detail, service, persistent app history, and startup views covering LaunchAgents immediately, with an explicit on-demand scan of the modern macOS background-task registry
- Estimated live startup impact with Low, Medium, and High classifications and detailed CPU/memory/disk evidence
- Combined user and system launchd Services registry with scope filtering, running/stopped state, safe Start/Stop/Restart controls, protected-system labeling, configuration reveal, Details, and Properties actions
- Process dependency inspector with relationship chains, network/local sockets, canonical framework dependencies, open files, and handle totals
+- On-demand process security inspector using Security.framework and Gatekeeper for signature validity, signing identity/team/authority, CDHash, hardened runtime, sandbox/debug entitlements, quarantine, and assessment status
- Persistent Resource values submenu for switching memory, disk, and network cells between values and percentages
- Signed-in Users sessions with active-state and aggregate resources plus Lock, home-folder, account-management, Copy, Details, and Properties actions
- Configurable 1/5/10-second diagnostic samples with remembered folders and optional automatic timestamped output
- Run New Task sheet and persistent update-speed settings
-- WinUI-style expanded and compact navigation rail controlled by the native title-bar sidebar button, with persisted state, balanced spacing, tooltips, and accessibility labels
+- Persistent expanded and compact navigation: one native macOS `NavigationSplitView` and sidebar `List` narrow in place, hiding only the brand and row labels while preserving selection, material, keyboard behavior, and the divider
- Native macOS menus, toolbar, light/dark mode, and accessibility behavior
- Per-resource top-process panels on Performance with application icons, owning users, and live CPU, memory, disk, or network values
- Native JSON export of performance, process, user, battery, power-delivery, thermal, and connected-hardware snapshots
+- Bounded telemetry session recording with start/stop controls and JSON or CSV export of CPU, memory pressure, disk, GPU, network, power, thermals, and process counts
+- Configurable sustained CPU, memory, disk, and thermal alerts with consecutive-sample gating, notification permission handling, cooldowns, foreground banners, and recent alert history
+- Persistent on-demand diagnostic captures with current system, memory-pressure, storage-health, and top-process evidence; select one for inspection or two for before/after delta comparison and JSON export
- Integrated SMC fan telemetry and safe in-app cooling controls with per-fan RPM ranges, manual targets, and one-click return to macOS automatic management
+- Optional menu-bar monitor for CPU, memory, network, or power that reuses the shared sampler rather than starting another polling loop
+- Command-limited `SMAppService` launch-daemon helper for fan writes, with same-Team-ID client validation, bounded fan/RPM inputs, explicit enable/disable status, and no arbitrary shell-command interface
+- Sparkle 2 automatic update support with EdDSA-verified appcasts; unsigned validation builds clearly show when release update credentials are not configured
## Run
@@ -67,4 +78,40 @@ To create a drag-to-install disk image containing the app and an Applications sh
./scripts/build-dmg.sh
```
-The app refreshes every two seconds. Some system-owned processes cannot be ended without elevated permissions.
+To run the same structural, signature, Hardened Runtime, icon, Sparkle, helper, and mounted-DMG checks used by CI:
+
+```sh
+./scripts/validate-package.sh
+```
+
+### Signed and notarized releases
+
+`build-app.sh` produces a Hardened Runtime validation build by default. For a public release, provide a Developer ID Application identity and a notarytool keychain profile:
+
+```sh
+export PUTER_SIGN_IDENTITY='Developer ID Application: Your Name (TEAMID)'
+export PUTER_NOTARY_PROFILE='puter-notary'
+export PUTER_UPDATE_FEED_URL='https://your-host.example/puter/appcast.xml'
+export PUTER_UPDATE_PUBLIC_KEY='SPARKLE_EDDSA_PUBLIC_KEY'
+./scripts/build-app.sh
+./scripts/build-dmg.sh
+./scripts/notarize-release.sh
+```
+
+The release runner must also provide the Stats-derived `smc` backend through `PUTER_SMC_SOURCE` or have Stats installed at `/Applications/Stats.app`. Release validation deliberately fails if live SMC power and fan support would be missing.
+
+The fan helper is embedded at `Contents/Resources/puter-helper` and its launchd property list at `Contents/Library/LaunchDaemons`. macOS will not register an ad-hoc helper; test registration from the Developer ID-signed app after placing it in Applications.
+
+To generate or update a signed Sparkle appcast from release archives:
+
+```sh
+export PUTER_UPDATE_DOWNLOAD_PREFIX='https://your-host.example/puter/releases/'
+export PUTER_SPARKLE_PRIVATE_KEY='SPARKLE_EDDSA_PRIVATE_KEY'
+./scripts/generate-appcast.sh dist/updates
+```
+
+Keep the Sparkle private key and notarization credentials outside the repository. The Gitea build workflow runs compile, unit, native-sampler performance, app-signature, mounted-DMG, helper, icon, and update-framework checks. Tag releases additionally require the Developer ID certificate, notary API key, Sparkle signing keys, release token, and SMC backend. A successful tag publishes the notarized DMG, stapled app ZIP, signed appcast, and SHA-256 checksum manifest.
+
+See [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for the credential inventory, signed workflow, independent-machine verification, and release acceptance criteria.
+
+Live CPU and rate metrics still require coordinated sampling, now using native Mach/libproc, virtual-memory, network-interface, and System Configuration APIs on the hot path. Foreground cadence follows the selected update speed where useful, while background, hardware, service, process-diagnostic, device, port, and persistence work runs independently at slower demand-aware intervals. Some system-owned processes cannot be ended without elevated permissions.
diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md
new file mode 100644
index 0000000..2cc6e67
--- /dev/null
+++ b/RELEASE_CHECKLIST.md
@@ -0,0 +1,57 @@
+# puter release checklist
+
+This checklist is the authoritative handoff for producing a public puter release. Run it from the repository root on a macOS runner.
+
+## Local quality gates
+
+- [x] `swift test --parallel` passes, including native full/lightweight sampler budgets, process sort semantics, memory pressure, storage, power parsing, helper input validation, security inspection, recording, alerts, and diagnostic serialization.
+- [x] `./scripts/build-app.sh` produces a Hardened Runtime app with the live Icon Composer asset, Sparkle framework, command-limited helper, launch-daemon property list, and SMC backend/license when available.
+- [x] `./scripts/build-dmg.sh` produces a drag-to-install DMG.
+- [x] `./scripts/validate-package.sh` validates signatures, nested code, runtime search paths, Sparkle linkage, bundle metadata, Icon Composer configuration, helper payload, SMC payload/license, DMG checksum, mounted app, and Applications shortcut.
+- [x] Packaged-app accessibility smoke tests cover sidebar hide/recovery, the descending/ascending/categorized sort cycle, process rows, and live Hardware values.
+- [x] Current sampler benchmark: full process telemetry 0.0529 seconds/iteration; lightweight system telemetry 0.0076 seconds/iteration.
+- [x] Current 20-second packaged Hardware-page benchmark: 0.83% average CPU, 9.7% one-second peak while hardware work completed.
+
+Benchmark numbers are evidence from the development Mac, not universal product guarantees. CI enforces the portable per-iteration budget rather than a machine-specific CPU percentage.
+
+## Required protected release inputs
+
+- [ ] `DEVELOPER_ID_P12_BASE64`: Developer ID Application certificate and private key in PKCS#12 form.
+- [ ] `DEVELOPER_ID_P12_PASSWORD`: PKCS#12 password.
+- [ ] `DEVELOPER_ID_IDENTITY`: exact `Developer ID Application: … (TEAMID)` identity.
+- [ ] `APPLE_NOTARY_KEY_BASE64`: App Store Connect API private key.
+- [ ] `APPLE_NOTARY_KEY_ID` and `APPLE_NOTARY_ISSUER_ID`.
+- [ ] `SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_KEY`, `SPARKLE_PRIVATE_KEY`, and `SPARKLE_DOWNLOAD_PREFIX`.
+- [ ] `RELEASE_TOKEN`: token allowed to create a release and upload assets to this Gitea repository.
+- [ ] Stats-derived `smc` executable through `PUTER_SMC_SOURCE`, or Stats installed at `/Applications/Stats.app` on the runner.
+
+Never commit certificates, API keys, Sparkle private keys, or release tokens.
+
+## Signed release procedure
+
+1. Confirm the working tree and intended tag are clean and reviewed.
+2. Confirm `security find-identity -v -p codesigning` lists the Developer ID Application identity.
+3. Run the local gates above.
+4. Push a semantic version tag such as `v1.0.0`.
+5. The `Signed release` workflow must:
+ - import the certificate into an ephemeral keychain;
+ - build with Hardened Runtime, library validation, update feed, and EdDSA public key;
+ - validate the signed app and DMG before submission;
+ - submit to Apple, staple and validate both app and DMG;
+ - validate the notarized package again;
+ - generate the EdDSA-signed appcast;
+ - publish the notarized DMG, stapled app ZIP, appcast, and `SHA256SUMS`.
+6. Download the published assets on a separate Mac and run:
+
+ ```sh
+ hdiutil verify puter-macOS.dmg
+ spctl --assess --type open --context context:primary-signature -v puter-macOS.dmg
+ xcrun stapler validate puter-macOS.dmg
+ ```
+
+7. Install into `/Applications`, launch it, approve the helper only when testing fan control, and verify monitoring works without helper approval.
+8. Exercise Check for Updates against the published appcast from the previous release before announcing the new release.
+
+## Release acceptance
+
+A release is complete only when Apple notarization is accepted, stapler and Gatekeeper validation pass, the Gitea assets are downloadable with matching SHA-256 values, Sparkle verifies the appcast, and the installed app passes the packaged smoke tests. An ad-hoc or Apple Development build is not a public release.
diff --git a/Resources/dev.soconnor.puter.helper.plist b/Resources/dev.soconnor.puter.helper.plist
new file mode 100644
index 0000000..c5f655b
--- /dev/null
+++ b/Resources/dev.soconnor.puter.helper.plist
@@ -0,0 +1,21 @@
+
+
+
+
+ Label
+ dev.soconnor.puter.helper
+ BundleProgram
+ Contents/Resources/puter-helper
+ MachServices
+
+ dev.soconnor.puter.helper
+
+
+ AssociatedBundleIdentifiers
+
+ dev.soconnor.puter
+
+ ProcessType
+ Interactive
+
+
diff --git a/Resources/puter-adhoc.entitlements b/Resources/puter-adhoc.entitlements
new file mode 100644
index 0000000..d8b1a82
--- /dev/null
+++ b/Resources/puter-adhoc.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/Resources/puter.entitlements b/Resources/puter.entitlements
new file mode 100644
index 0000000..0c67376
--- /dev/null
+++ b/Resources/puter.entitlements
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Sources/PuterHelperProtocol/PuterHelperProtocol.swift b/Sources/PuterHelperProtocol/PuterHelperProtocol.swift
new file mode 100644
index 0000000..87c5db7
--- /dev/null
+++ b/Sources/PuterHelperProtocol/PuterHelperProtocol.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+public enum PuterHelperConstants {
+ public static let machServiceName = "dev.soconnor.puter.helper"
+ public static let daemonPlistName = "dev.soconnor.puter.helper.plist"
+ public static let protocolVersion = 1
+}
+
+public enum PuterFanTargetValidator {
+ public static let validFanIDs = 0...15
+ public static let validRPM = 500...20_000
+
+ public static func validate(_ targets: [NSNumber: NSNumber]) -> [(fan: Int, rpm: Int)]? {
+ let values = targets.compactMap { key, value -> (fan: Int, rpm: Int)? in
+ let fan = key.intValue
+ let rpm = value.intValue
+ guard validFanIDs.contains(fan), validRPM.contains(rpm) else { return nil }
+ return (fan, rpm)
+ }
+ guard values.count == targets.count, !values.isEmpty else { return nil }
+ return values.sorted { $0.fan < $1.fan }
+ }
+}
+
+@objc public protocol PuterPrivilegedHelperProtocol {
+ func version(withReply reply: @escaping (Int) -> Void)
+ func setFanTargets(_ targets: [NSNumber: NSNumber], withReply reply: @escaping (Bool, String?) -> Void)
+ func restoreAutomaticFanControl(withReply reply: @escaping (Bool, String?) -> Void)
+}
diff --git a/Sources/puter-helper/main.swift b/Sources/puter-helper/main.swift
new file mode 100644
index 0000000..726e1d7
--- /dev/null
+++ b/Sources/puter-helper/main.swift
@@ -0,0 +1,114 @@
+import Foundation
+import PuterHelperProtocol
+import Security
+
+private final class HelperService: NSObject, PuterPrivilegedHelperProtocol {
+ func version(withReply reply: @escaping (Int) -> Void) {
+ reply(PuterHelperConstants.protocolVersion)
+ }
+
+ func setFanTargets(_ targets: [NSNumber: NSNumber], withReply reply: @escaping (Bool, String?) -> Void) {
+ guard let validated = PuterFanTargetValidator.validate(targets) else {
+ reply(false, "Fan targets failed validation.")
+ return
+ }
+ do {
+ for (fan, rpm) in validated {
+ try runSMC(["fan", "\(fan)", "-m", "1"])
+ try runSMC(["fan", "\(fan)", "-v", "\(rpm)"])
+ }
+ reply(true, nil)
+ } catch { reply(false, error.localizedDescription) }
+ }
+
+ func restoreAutomaticFanControl(withReply reply: @escaping (Bool, String?) -> Void) {
+ do {
+ try runSMC(["reset"])
+ reply(true, nil)
+ } catch { reply(false, error.localizedDescription) }
+ }
+
+ private func runSMC(_ arguments: [String]) throws {
+ let helperURL = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL
+ let toolURL = helperURL.deletingLastPathComponent().appendingPathComponent("smc")
+ guard FileManager.default.isExecutableFile(atPath: toolURL.path) else {
+ throw HelperError.smcUnavailable
+ }
+ let process = Process()
+ let errorPipe = Pipe()
+ process.executableURL = toolURL
+ process.arguments = arguments
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = errorPipe
+ try process.run()
+ let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ throw HelperError.commandFailed(String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+ }
+}
+
+private enum HelperError: LocalizedError {
+ case smcUnavailable
+ case commandFailed(String)
+ var errorDescription: String? {
+ switch self {
+ case .smcUnavailable: "The bundled SMC backend is unavailable."
+ case .commandFailed(let detail): detail.isEmpty ? "The SMC command failed." : detail
+ }
+ }
+}
+
+private final class HelperDelegate: NSObject, NSXPCListenerDelegate {
+ private let service = HelperService()
+
+ func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
+ guard ClientCodeValidator.isTrusted(pid: connection.processIdentifier) else { return false }
+ connection.exportedInterface = NSXPCInterface(with: PuterPrivilegedHelperProtocol.self)
+ connection.exportedObject = service
+ connection.resume()
+ return true
+ }
+}
+
+private enum ClientCodeValidator {
+ static func isTrusted(pid: pid_t) -> Bool {
+ var clientCode: SecCode?
+ let attributes = [kSecGuestAttributePid: NSNumber(value: pid)] as CFDictionary
+ guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &clientCode) == errSecSuccess,
+ let clientCode,
+ let clientInfo = signingInfo(for: clientCode),
+ clientInfo.identifier == "dev.soconnor.puter" else { return false }
+
+ var helperCode: SecCode?
+ guard SecCodeCopySelf([], &helperCode) == errSecSuccess,
+ let helperCode,
+ let helperInfo = signingInfo(for: helperCode),
+ let clientTeam = clientInfo.team,
+ let helperTeam = helperInfo.team,
+ clientTeam == helperTeam else { return false }
+
+ var requirement: SecRequirement?
+ let text = "anchor apple generic and identifier \"dev.soconnor.puter\""
+ guard SecRequirementCreateWithString(text as CFString, [], &requirement) == errSecSuccess,
+ let requirement else { return false }
+ return SecCodeCheckValidity(clientCode, [], requirement) == errSecSuccess
+ }
+
+ private static func signingInfo(for code: SecCode) -> (identifier: String, team: String?)? {
+ var staticCode: SecStaticCode?
+ guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { return nil }
+ var information: CFDictionary?
+ guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
+ let values = information as? [CFString: Any],
+ let identifier = values[kSecCodeInfoIdentifier] as? String else { return nil }
+ return (identifier, values[kSecCodeInfoTeamIdentifier] as? String)
+ }
+}
+
+private let delegate = HelperDelegate()
+private let listener = NSXPCListener(machServiceName: PuterHelperConstants.machServiceName)
+listener.delegate = delegate
+listener.resume()
+RunLoop.current.run()
diff --git a/Sources/puter/AppUtilitiesViews.swift b/Sources/puter/AppUtilitiesViews.swift
index 1e1d808..3ef47e8 100644
--- a/Sources/puter/AppUtilitiesViews.swift
+++ b/Sources/puter/AppUtilitiesViews.swift
@@ -74,11 +74,21 @@ struct NewTaskView: View {
struct SettingsView: View {
@Environment(SystemMonitor.self) private var monitor
+ @EnvironmentObject private var updates: UpdateController
@AppStorage("alwaysOnTop") private var alwaysOnTop = false
@AppStorage("defaultStartPage") private var defaultStartPage: TaskSection = .processes
@AppStorage("diagnosticReportDuration") private var diagnosticDuration = 5
@AppStorage("diagnosticAskEveryTime") private var diagnosticAskEveryTime = true
@AppStorage("diagnosticReportDirectory") private var diagnosticDirectory = ""
+ @AppStorage("resourceAlertsEnabled") private var resourceAlertsEnabled = false
+ @AppStorage("resourceAlertCPUThreshold") private var alertCPUThreshold = 90.0
+ @AppStorage("resourceAlertMemoryThreshold") private var alertMemoryThreshold = 85.0
+ @AppStorage("resourceAlertDiskThreshold") private var alertDiskThreshold = 90.0
+ @AppStorage("resourceAlertSustainedSamples") private var alertSustainedSamples = 3
+ @AppStorage("resourceAlertCooldownSeconds") private var alertCooldownSeconds = 300.0
+ @AppStorage("showMenuBarMonitor") private var showMenuBarMonitor = true
+ @AppStorage("menuBarMetric") private var menuBarMetric: MenuBarMetric = .cpu
+ @StateObject private var privilegedHelper = PrivilegedHelperManager()
var body: some View {
@Bindable var monitor = monitor
@@ -108,6 +118,111 @@ struct SettingsView: View {
LabeledContent("Memory units", value: "Automatic")
}
+ Section("Menu bar") {
+ Toggle("Show puter in the menu bar", isOn: $showMenuBarMonitor)
+ Picker("Displayed value", selection: $menuBarMetric) {
+ ForEach(MenuBarMetric.allCases) { Text($0.rawValue).tag($0) }
+ }
+ Text("The menu bar uses the shared adaptive sampler and slows to the background cadence when puter is inactive.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+
+ Section("Fan control helper") {
+ LabeledContent("Status", value: privilegedHelper.state.title)
+ Text(privilegedHelper.state.detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ HStack {
+ switch privilegedHelper.state {
+ case .enabled:
+ Button("Disable Helper", role: .destructive) { privilegedHelper.disable() }
+ case .requiresApproval:
+ Button("Open Login Items Settings") { privilegedHelper.openApprovalSettings() }
+ Button("Check Again") { privilegedHelper.refresh() }
+ case .unavailable, .requiresSignedInstallation:
+ EmptyView()
+ case .disabled, .failed:
+ Button("Enable Helper") { privilegedHelper.enable() }
+ }
+ if privilegedHelper.isWorking { ProgressView().controlSize(.small) }
+ }
+ Text("The helper accepts only validated fan targets and reset commands from a same-team signed copy of puter. It cannot run arbitrary commands.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Software updates") {
+ if updates.isConfigured {
+ Toggle("Check for updates automatically", isOn: Binding(
+ get: { updates.automaticallyChecksForUpdates },
+ set: { updates.automaticallyChecksForUpdates = $0 }
+ ))
+ Toggle("Download and install updates automatically", isOn: Binding(
+ get: { updates.automaticallyDownloadsUpdates },
+ set: { updates.automaticallyDownloadsUpdates = $0 }
+ ))
+ Button("Check for Updates…") { updates.checkForUpdates() }
+ .disabled(!updates.canCheckForUpdates)
+ } else {
+ LabeledContent("Status", value: "Not configured in this build")
+ Text("Release builds enable secure Sparkle updates when the appcast URL and EdDSA public key are supplied during packaging.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Resource alerts") {
+ Toggle("Notify when resources stay above a threshold", isOn: $resourceAlertsEnabled)
+ .onChange(of: resourceAlertsEnabled) { _, enabled in
+ guard enabled else { return }
+ Task {
+ if !(await monitor.requestAlertAuthorization()) {
+ resourceAlertsEnabled = false
+ monitor.errorMessage = "Notifications are disabled for puter. Enable them in System Settings to use resource alerts."
+ }
+ }
+ }
+ LabeledContent("CPU") {
+ thresholdControl(value: $alertCPUThreshold)
+ }
+ LabeledContent("Memory") {
+ thresholdControl(value: $alertMemoryThreshold)
+ }
+ LabeledContent("Disk activity") {
+ thresholdControl(value: $alertDiskThreshold)
+ }
+ Picker("Sustained for", selection: $alertSustainedSamples) {
+ Text("1 sample").tag(1)
+ Text("3 samples").tag(3)
+ Text("5 samples").tag(5)
+ Text("10 samples").tag(10)
+ }
+ Picker("Alert cooldown", selection: $alertCooldownSeconds) {
+ Text("1 minute").tag(60.0)
+ Text("5 minutes").tag(300.0)
+ Text("15 minutes").tag(900.0)
+ Text("1 hour").tag(3600.0)
+ }
+ Text("Serious and critical thermal pressure also trigger alerts. Thresholds require consecutive telemetry samples and respect the cooldown.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if !monitor.recentAlerts.isEmpty {
+ Section("Recent alerts") {
+ ForEach(monitor.recentAlerts.prefix(6)) { event in
+ LabeledContent {
+ Text(event.timestamp, style: .relative).foregroundStyle(.secondary)
+ } label: {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(event.resource).font(.callout.weight(.medium))
+ Text(event.message).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ }
+ Button("Clear alert history", role: .destructive) { monitor.clearAlerts() }
+ }
+ }
+
Section("Diagnostic reports") {
Picker("Sample duration", selection: $diagnosticDuration) {
Text("1 second").tag(1)
@@ -137,6 +252,7 @@ struct SettingsView: View {
}
.task {
NSApp.keyWindow?.level = alwaysOnTop ? .floating : .normal
+ privilegedHelper.refresh()
}
}
@@ -153,6 +269,16 @@ struct SettingsView: View {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Development"
}
+ private func thresholdControl(value: Binding) -> some View {
+ HStack(spacing: 10) {
+ Slider(value: value, in: 50...100, step: 5)
+ .frame(width: 180)
+ Text(Formatters.percent(value.wrappedValue))
+ .monospacedDigit()
+ .frame(width: 46, alignment: .trailing)
+ }
+ }
+
private func chooseDiagnosticDirectory() {
let panel = NSOpenPanel()
panel.title = "Choose diagnostic report folder"
diff --git a/Sources/puter/ContentView.swift b/Sources/puter/ContentView.swift
index d4f7fa5..cd4b3bf 100644
--- a/Sources/puter/ContentView.swift
+++ b/Sources/puter/ContentView.swift
@@ -11,7 +11,6 @@ struct ContentView: View {
@State private var searchText = ""
@State private var showingNewTask = false
@State private var detailSelection: Int32?
- @State private var columnVisibility: NavigationSplitViewVisibility = .all
@AppStorage("sidebarCompact") private var sidebarCompact = false
init() {
@@ -20,59 +19,25 @@ struct ContentView: View {
}
var body: some View {
- NavigationSplitView(columnVisibility: $columnVisibility) {
- Sidebar(selection: $selection, isCompact: sidebarCompact)
- .navigationSplitViewColumnWidth(
- min: sidebarCompact ? 68 : 190,
- ideal: sidebarCompact ? 68 : 210,
- max: sidebarCompact ? 68 : 250
- )
+ NavigationSplitView(columnVisibility: .constant(.all)) {
+ Sidebar(selection: $selection, compact: sidebarCompact)
+ .navigationSplitViewColumnWidth(
+ min: sidebarCompact ? 68 : 190,
+ ideal: sidebarCompact ? 68 : 210,
+ max: sidebarCompact ? 68 : 260
+ )
} detail: {
- Group {
- switch selection ?? .processes {
- case .processes:
- ProcessesView(searchText: searchText) { process in
- detailSelection = process.pid
- selection = .details
- }
- .searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
- case .performance:
- PerformanceView()
- case .history:
- AppHistoryView(searchText: searchText) { process in
- detailSelection = process.pid
- selection = .details
- }
- .searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
- case .startup:
- StartupAppsView()
- case .users:
- UsersView { process in
- detailSelection = process.pid
- selection = .details
- }
- case .details:
- DetailsView(searchText: searchText, selection: $detailSelection)
- .searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
- case .services:
- ServicesView(searchText: searchText) { process in
- detailSelection = process.pid
- selection = .details
- }
- .searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
- case .hardware:
- HardwareView()
- case .settings:
- SettingsView()
- }
- }
- .toolbar {
- TaskToolbarContent(showingNewTask: $showingNewTask)
- }
+ detailView
+ .toolbar(removing: .sidebarToggle)
+ }
+ .navigationSplitViewStyle(.balanced)
+ .toolbar(removing: .sidebarToggle)
+ .navigationTitle("")
+ .background(WindowToolbarCleaner())
+ .onChange(of: selection) { _, section in
+ searchText = ""
+ monitor.setActiveSection(section ?? .processes)
}
- .navigationTitle("puter")
- .animation(.snappy(duration: 0.22), value: sidebarCompact)
- .onChange(of: selection) { _, _ in searchText = "" }
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
.sheet(isPresented: $showingNewTask) {
NewTaskView(isPresented: $showingNewTask)
@@ -86,11 +51,120 @@ struct ContentView: View {
Text(monitor.errorMessage ?? "")
}
.onAppear {
+ monitor.setActiveSection(selection ?? .processes)
DispatchQueue.main.async {
NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal
}
}
}
+
+ @ViewBuilder
+ private var detailView: some View {
+ Group {
+ switch selection ?? .processes {
+ case .processes:
+ ProcessesView(searchText: searchText) { process in
+ detailSelection = process.pid
+ selection = .details
+ }
+ .searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
+ case .performance:
+ PerformanceView()
+ case .history:
+ AppHistoryView(searchText: searchText) { process in
+ detailSelection = process.pid
+ selection = .details
+ }
+ .searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
+ case .startup:
+ StartupAppsView()
+ case .users:
+ UsersView { process in
+ detailSelection = process.pid
+ selection = .details
+ }
+ case .details:
+ DetailsView(searchText: searchText, selection: $detailSelection)
+ .searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
+ case .services:
+ ServicesView(searchText: searchText) { process in
+ detailSelection = process.pid
+ selection = .details
+ }
+ .searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
+ case .energy:
+ EnergyView()
+ case .diagnostics:
+ DiagnosticsView()
+ case .hardware:
+ HardwareView()
+ case .settings:
+ SettingsView()
+ }
+ }
+ .toolbar {
+ TaskToolbarContent(showingNewTask: $showingNewTask)
+ }
+ }
+
+}
+
+private struct WindowToolbarCleaner: NSViewRepresentable {
+ func makeNSView(context: Context) -> CleanerView {
+ CleanerView()
+ }
+
+ func updateNSView(_ nsView: CleanerView, context: Context) {
+ nsView.removeAutomaticSidebarToggle()
+ nsView.installCenteredBrand()
+ }
+
+ final class CleanerView: NSView {
+ private weak var installedTitlebar: NSView?
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ removeAutomaticSidebarToggle()
+ installCenteredBrand()
+ DispatchQueue.main.async { [weak self] in
+ self?.removeAutomaticSidebarToggle()
+ self?.installCenteredBrand()
+ }
+ }
+
+ func installCenteredBrand() {
+ guard installedTitlebar == nil,
+ let closeButton = window?.standardWindowButton(.closeButton),
+ let titlebar = closeButton.superview else { return }
+ let brand = PassthroughHostingView(rootView: PuterBrand(compactTitlebar: true))
+ brand.translatesAutoresizingMaskIntoConstraints = false
+ titlebar.addSubview(brand)
+ NSLayoutConstraint.activate([
+ brand.centerXAnchor.constraint(equalTo: titlebar.centerXAnchor),
+ brand.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor)
+ ])
+ installedTitlebar = titlebar
+ }
+
+ func removeAutomaticSidebarToggle() {
+ guard let toolbar = window?.toolbar,
+ let index = toolbar.items.firstIndex(where: {
+ $0.itemIdentifier == .toggleSidebar
+ || $0.action == NSSelectorFromString("toggleSidebar:")
+ || $0.label == "Hide Sidebar"
+ || $0.label == "Show Sidebar"
+ || $0.toolTip == "Hide Sidebar"
+ || $0.toolTip == "Show Sidebar"
+ || $0.view?.accessibilityLabel() == "Hide Sidebar"
+ || $0.view?.accessibilityLabel() == "Show Sidebar"
+ }) else { return }
+ toolbar.removeItem(at: index)
+ }
+ }
+}
+
+private final class PassthroughHostingView: NSHostingView {
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
}
private struct TaskToolbarContent: ToolbarContent {
@@ -100,6 +174,16 @@ private struct TaskToolbarContent: ToolbarContent {
var body: some ToolbarContent {
@Bindable var monitor = monitor
+ ToolbarItem(placement: .navigation) {
+ Button {
+ sidebarCompact.toggle()
+ } label: {
+ Image(systemName: "sidebar.left")
+ }
+ .help(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
+ .accessibilityLabel(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
+ }
+
ToolbarItemGroup(placement: .primaryAction) {
Button {
showingNewTask = true
@@ -117,78 +201,71 @@ private struct TaskToolbarContent: ToolbarContent {
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
- Divider()
- Toggle("Compact Sidebar", isOn: $sidebarCompact)
} label: {
Image(systemName: "ellipsis")
}
.menuStyle(.borderlessButton)
}
+
+ }
+}
+
+private struct PuterBrand: View {
+ let compactTitlebar: Bool
+
+ var body: some View {
+ HStack(spacing: 9) {
+ Image(nsImage: NSApp.applicationIconImage)
+ .resizable()
+ .scaledToFit()
+ .frame(width: compactTitlebar ? 21 : 30, height: compactTitlebar ? 21 : 30)
+ Text("puter")
+ .font(compactTitlebar ? .headline : .title2.weight(.semibold))
+ }
+ .padding(.horizontal, compactTitlebar ? 8 : 14)
+ .frame(height: compactTitlebar ? 32 : 52)
+ .fixedSize()
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("puter")
+ .accessibilityAddTraits(.isHeader)
}
}
private struct Sidebar: View {
@Binding var selection: TaskSection?
- let isCompact: Bool
+ let compact: Bool
var body: some View {
- VStack(spacing: 0) {
- ScrollView {
- VStack(spacing: 4) {
- ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
- Button {
- selection = section
- } label: {
- sidebarLabel(section.rawValue, icon: section.icon)
- .padding(.horizontal, isCompact ? 0 : 8)
- .frame(minHeight: 40)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .frame(maxWidth: .infinity)
- .background(selection == section ? Color.accentColor.opacity(0.18) : .clear)
- .clipShape(RoundedRectangle(cornerRadius: 7))
- .help(section.rawValue)
- .accessibilityLabel(section.rawValue)
- .accessibilityAddTraits(selection == section ? .isSelected : [])
- }
+ List(selection: $selection) {
+ Section {
+ ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
+ sidebarRow(section)
}
- .padding(.horizontal, isCompact ? 8 : 10)
- .padding(.vertical, 8)
}
-
- Divider().padding(.horizontal, isCompact ? 12 : 16)
-
- Button {
- selection = .settings
- } label: {
- sidebarLabel("Settings", icon: "gearshape")
- .padding(.horizontal, isCompact ? 0 : 8)
- .frame(minHeight: 40)
- .contentShape(Rectangle())
+ Section {
+ sidebarRow(.settings)
}
- .buttonStyle(.plain)
- .font(.callout)
- .frame(maxWidth: .infinity)
- .background(selection == .settings ? Color.accentColor.opacity(0.18) : .clear)
- .clipShape(RoundedRectangle(cornerRadius: 7))
- .padding(.horizontal, isCompact ? 8 : 10)
- .padding(.vertical, 8)
- .foregroundStyle(selection == .settings ? Color.primary : Color.secondary)
- .help("Settings")
- .accessibilityLabel("Settings")
}
+ .listStyle(.sidebar)
+ .scrollIndicators(compact ? .hidden : .automatic)
+ .accessibilityLabel("Navigation")
}
- private func sidebarLabel(_ title: String, icon: String) -> some View {
+ private func sidebarRow(_ section: TaskSection) -> some View {
HStack(spacing: 10) {
- Image(systemName: icon)
+ Image(systemName: section.icon)
.frame(width: 22, height: 20)
- if !isCompact {
- Text(title).lineLimit(1)
+ if !compact {
+ Text(section.rawValue)
+ .lineLimit(1)
Spacer(minLength: 0)
}
}
- .frame(maxWidth: .infinity, alignment: isCompact ? .center : .leading)
+ .frame(maxWidth: .infinity, minHeight: compact ? 28 : nil, alignment: compact ? .center : .leading)
+ .contentShape(Rectangle())
+ .tag(section)
+ .help(section.rawValue)
+ .accessibilityLabel(section.rawValue)
+ .listRowInsets(compact ? EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) : nil)
}
}
diff --git a/Sources/puter/DiagnosticsView.swift b/Sources/puter/DiagnosticsView.swift
new file mode 100644
index 0000000..0f63a79
--- /dev/null
+++ b/Sources/puter/DiagnosticsView.swift
@@ -0,0 +1,393 @@
+import AppKit
+import Foundation
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct DiagnosticCapture: Identifiable, Codable, Hashable, Sendable {
+ let id: UUID
+ var name: String
+ let createdAt: Date
+ let system: DiagnosticSystemMetrics
+ let processes: [DiagnosticProcessMetrics]
+ let disks: [DiagnosticDiskMetrics]
+}
+
+struct DiagnosticSystemMetrics: Codable, Hashable, Sendable {
+ let cpuPercent: Double
+ let memoryUsedBytes: UInt64
+ let memoryTotalBytes: UInt64
+ let memoryPressure: String
+ let compressedBytes: UInt64
+ let swapUsedBytes: UInt64
+ let diskReadBytesPerSecond: Double
+ let diskWriteBytesPerSecond: Double
+ let diskActivePercent: Double
+ let gpuPercent: Double
+ let networkReceiveBytesPerSecond: Double
+ let networkSendBytesPerSecond: Double
+ let systemPowerWatts: Double
+ let thermalState: String
+ let processCount: Int
+ let threadCount: Int
+ let uptimeSeconds: Double
+}
+
+struct DiagnosticProcessMetrics: Identifiable, Codable, Hashable, Sendable {
+ let pid: Int32
+ let name: String
+ let executablePath: String
+ let user: String
+ let cpuPercent: Double
+ let residentBytes: UInt64
+ let diskBytesPerSecond: Double
+ let networkBytesPerSecond: Double
+ var id: Int32 { pid }
+}
+
+struct DiagnosticDiskMetrics: Identifiable, Codable, Hashable, Sendable {
+ let id: String
+ let name: String
+ let smartStatus: String
+ let remainingLifePercent: Double?
+ let temperatureCelsius: Double?
+ let mediaErrors: UInt64?
+ let unsafeShutdowns: UInt64?
+}
+
+@MainActor
+enum DiagnosticCaptureStore {
+ static var directory: URL {
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("puter/Diagnostics", isDirectory: true)
+ }
+
+ static func load() -> [DiagnosticCapture] {
+ guard let urls = try? FileManager.default.contentsOfDirectory(
+ at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
+ ) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return urls.filter { $0.pathExtension == "json" }.compactMap {
+ guard let data = try? Data(contentsOf: $0) else { return nil }
+ return try? decoder.decode(DiagnosticCapture.self, from: data)
+ }.sorted { $0.createdAt > $1.createdAt }
+ }
+
+ static func save(_ capture: DiagnosticCapture) throws {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ try encoder.encode(capture).write(to: url(for: capture.id), options: .atomic)
+ }
+
+ static func delete(_ capture: DiagnosticCapture) throws {
+ try FileManager.default.removeItem(at: url(for: capture.id))
+ }
+
+ static func url(for id: UUID) -> URL { directory.appendingPathComponent("\(id.uuidString).json") }
+}
+
+struct DiagnosticsView: View {
+ @Environment(SystemMonitor.self) private var monitor
+ @State private var captures = DiagnosticCaptureStore.load()
+ @State private var selectedIDs: Set = []
+ @State private var pendingDeletion: DiagnosticCapture?
+
+ private var selectedCaptures: [DiagnosticCapture] {
+ captures.filter { selectedIDs.contains($0.id) }.sorted { $0.createdAt < $1.createdAt }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ PageHeader(title: "Diagnostics", subtitle: "Capture system state and compare changes over time") {
+ HStack(spacing: 10) {
+ Button("Capture now", systemImage: "camera.metering.center.weighted") { captureNow() }
+ .buttonStyle(.borderedProminent)
+ Button("Open folder", systemImage: "folder") { openFolder() }
+ }
+ }
+ ResourceSummaryBar(snapshot: monitor.snapshot)
+ HSplitView {
+ captureList.frame(minWidth: 310, idealWidth: 360, maxWidth: 440)
+ comparison.frame(minWidth: 430, maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .confirmationDialog(
+ "Delete diagnostic capture?",
+ isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } })
+ ) {
+ Button("Delete", role: .destructive) { deletePendingCapture() }
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
+ } message: {
+ Text("This removes the saved capture from puter's Diagnostics folder.")
+ }
+ }
+
+ private var captureList: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Saved captures").font(.headline)
+ Spacer()
+ Text("Select two").font(.caption).foregroundStyle(.secondary)
+ }
+ .padding(14)
+ Divider()
+ if captures.isEmpty {
+ VStack(spacing: 10) {
+ Image(systemName: "waveform.badge.plus")
+ .font(.system(size: 30))
+ .foregroundStyle(.tertiary)
+ Text("No captures").font(.headline)
+ Text("Capture current system state to establish a baseline.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 260)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(20)
+ } else {
+ List(captures) { capture in
+ HStack(spacing: 10) {
+ Toggle("Compare \(capture.name)", isOn: selectionBinding(capture.id)).labelsHidden().toggleStyle(.checkbox)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(capture.name).font(.callout.weight(.medium)).lineLimit(1)
+ Text(capture.createdAt.formatted(date: .abbreviated, time: .standard))
+ .font(.caption).foregroundStyle(.secondary)
+ Text("CPU \(Formatters.percent(capture.system.cpuPercent)) • Memory \(Formatters.percent(memoryPercent(capture.system)))")
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Menu {
+ Button("Export…") { export(capture) }
+ Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([DiagnosticCaptureStore.url(for: capture.id)]) }
+ Divider()
+ Button("Delete", role: .destructive) { pendingDeletion = capture }
+ } label: { Image(systemName: "ellipsis.circle") }
+ .menuStyle(.borderlessButton)
+ }
+ .padding(.vertical, 4)
+ }
+ .listStyle(.inset)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var comparison: some View {
+ if selectedCaptures.count == 2 {
+ comparisonContent(from: selectedCaptures[0], to: selectedCaptures[1])
+ } else if selectedCaptures.count == 1 {
+ captureDetail(selectedCaptures[0])
+ } else {
+ ContentUnavailableView(
+ "Select captures to inspect",
+ systemImage: "arrow.left.and.right",
+ description: Text("Select one capture for details or two to compare deltas.")
+ )
+ }
+ }
+
+ private func captureDetail(_ capture: DiagnosticCapture) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ Text(capture.name).font(.title2.weight(.semibold))
+ metricGrid(capture.system)
+ topProcesses(capture.processes)
+ diskHealth(capture.disks)
+ }.padding(20)
+ }
+ }
+
+ private func comparisonContent(from baseline: DiagnosticCapture, to current: DiagnosticCapture) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ HStack {
+ VStack(alignment: .leading) {
+ Text("Comparison").font(.title2.weight(.semibold))
+ Text("\(baseline.name) → \(current.name)").foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(Formatters.duration(current.createdAt.timeIntervalSince(baseline.createdAt)))
+ .font(.callout.monospacedDigit()).foregroundStyle(.secondary)
+ }
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) {
+ deltaCard("CPU", baseline.system.cpuPercent, current.system.cpuPercent, suffix: " pp", lowerIsBetter: true)
+ deltaBytes("Memory used", baseline.system.memoryUsedBytes, current.system.memoryUsedBytes, lowerIsBetter: true)
+ deltaCard("Disk activity", baseline.system.diskActivePercent, current.system.diskActivePercent, suffix: " pp", lowerIsBetter: true)
+ deltaRate("Disk throughput", baseline.system.diskReadBytesPerSecond + baseline.system.diskWriteBytesPerSecond, current.system.diskReadBytesPerSecond + current.system.diskWriteBytesPerSecond)
+ deltaCard("GPU", baseline.system.gpuPercent, current.system.gpuPercent, suffix: " pp", lowerIsBetter: true)
+ deltaRate("Network", baseline.system.networkReceiveBytesPerSecond + baseline.system.networkSendBytesPerSecond, current.system.networkReceiveBytesPerSecond + current.system.networkSendBytesPerSecond)
+ deltaCard("System power", baseline.system.systemPowerWatts, current.system.systemPowerWatts, suffix: " W", lowerIsBetter: true)
+ deltaCard("Processes", Double(baseline.system.processCount), Double(current.system.processCount), suffix: "", lowerIsBetter: true)
+ }
+ Text("Current top processes").font(.headline)
+ topProcesses(current.processes)
+ }.padding(20)
+ }
+ }
+
+ private func metricGrid(_ metrics: DiagnosticSystemMetrics) -> some View {
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
+ valueCard("CPU", Formatters.percent(metrics.cpuPercent))
+ valueCard("Memory", Formatters.percent(memoryPercent(metrics)))
+ valueCard("Pressure", metrics.memoryPressure)
+ valueCard("Disk", Formatters.percent(metrics.diskActivePercent))
+ valueCard("GPU", Formatters.percent(metrics.gpuPercent))
+ valueCard("Power", metrics.systemPowerWatts > 0 ? String(format: "%.1f W", metrics.systemPowerWatts) : "Not reported")
+ valueCard("Thermals", metrics.thermalState)
+ valueCard("Processes", "\(metrics.processCount)")
+ }
+ }
+
+ private func topProcesses(_ processes: [DiagnosticProcessMetrics]) -> some View {
+ VStack(spacing: 0) {
+ ForEach(processes.prefix(12)) { process in
+ HStack {
+ ProcessIcon(name: process.name, path: process.executablePath, size: 20)
+ Text(process.name).lineLimit(1)
+ Spacer()
+ Text(Formatters.percent(process.cpuPercent)).monospacedDigit().frame(width: 60, alignment: .trailing)
+ Text(Formatters.bytes.string(fromByteCount: Int64(clamping: process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
+ }.font(.caption).padding(.vertical, 5)
+ if process.id != processes.prefix(12).last?.id { Divider() }
+ }
+ }
+ .padding(12)
+ .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
+ }
+
+ @ViewBuilder
+ private func diskHealth(_ disks: [DiagnosticDiskMetrics]) -> some View {
+ if !disks.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Physical storage health").font(.headline)
+ ForEach(disks) { disk in
+ HStack {
+ Label(disk.name, systemImage: "internaldrive")
+ Spacer()
+ Text(disk.smartStatus).foregroundStyle(disk.smartStatus == "Verified" ? .green : .orange)
+ if let life = disk.remainingLifePercent { Text("\(Formatters.percent(life)) life").monospacedDigit() }
+ }.font(.callout).padding(.vertical, 4)
+ }
+ }
+ }
+ }
+
+ private func selectionBinding(_ id: UUID) -> Binding {
+ Binding(get: { selectedIDs.contains(id) }, set: { selected in
+ if selected {
+ if selectedIDs.count == 2, let oldest = selectedCaptures.first { selectedIDs.remove(oldest.id) }
+ selectedIDs.insert(id)
+ } else { selectedIDs.remove(id) }
+ })
+ }
+
+ private func captureNow() {
+ let snapshot = monitor.snapshot
+ let metrics = DiagnosticSystemMetrics(
+ cpuPercent: snapshot.cpuPercent, memoryUsedBytes: snapshot.memoryUsed,
+ memoryTotalBytes: snapshot.memoryTotal, memoryPressure: snapshot.memoryPressure.rawValue,
+ compressedBytes: snapshot.memoryCompressed, swapUsedBytes: snapshot.swapUsed,
+ diskReadBytesPerSecond: snapshot.diskReadRate, diskWriteBytesPerSecond: snapshot.diskWriteRate,
+ diskActivePercent: snapshot.diskActivePercent, gpuPercent: snapshot.gpuPercent,
+ networkReceiveBytesPerSecond: snapshot.networkReceiveRate, networkSendBytesPerSecond: snapshot.networkSendRate,
+ systemPowerWatts: monitor.hardware.battery.systemPowerWatts, thermalState: monitor.hardware.thermalState,
+ processCount: snapshot.processCount, threadCount: snapshot.threadCount, uptimeSeconds: snapshot.uptime
+ )
+ let processes = monitor.processes.sorted {
+ ($0.cpu + $0.memoryPercent) > ($1.cpu + $1.memoryPercent)
+ }.prefix(30).map { process in
+ let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
+ return DiagnosticProcessMetrics(
+ pid: process.pid, name: process.displayName, executablePath: process.executablePath,
+ user: process.user, cpuPercent: process.cpu, residentBytes: process.residentBytes,
+ diskBytesPerSecond: activity.diskTotal, networkBytesPerSecond: activity.networkTotal
+ )
+ }
+ let disks = monitor.hardware.physicalDisks.map {
+ DiagnosticDiskMetrics(id: $0.id, name: $0.name, smartStatus: $0.smartStatus,
+ remainingLifePercent: $0.remainingLifePercent, temperatureCelsius: $0.temperatureCelsius,
+ mediaErrors: $0.mediaErrors, unsafeShutdowns: $0.unsafeShutdowns)
+ }
+ let now = Date()
+ let capture = DiagnosticCapture(
+ id: UUID(), name: "Capture \(now.formatted(date: .abbreviated, time: .shortened))",
+ createdAt: now, system: metrics, processes: processes, disks: disks
+ )
+ do {
+ try DiagnosticCaptureStore.save(capture)
+ captures.insert(capture, at: 0)
+ selectedIDs = [capture.id]
+ } catch { monitor.errorMessage = "Could not save diagnostic capture: \(error.localizedDescription)" }
+ }
+
+ private func deletePendingCapture() {
+ guard let capture = pendingDeletion else { return }
+ do {
+ try DiagnosticCaptureStore.delete(capture)
+ captures.removeAll { $0.id == capture.id }
+ selectedIDs.remove(capture.id)
+ } catch { monitor.errorMessage = "Could not delete diagnostic capture: \(error.localizedDescription)" }
+ pendingDeletion = nil
+ }
+
+ private func export(_ capture: DiagnosticCapture) {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [.json]
+ panel.nameFieldStringValue = "puter-diagnostic-\(capture.id.uuidString.prefix(8)).json"
+ panel.begin { response in
+ guard response == .OK, let destination = panel.url else { return }
+ do { try Data(contentsOf: DiagnosticCaptureStore.url(for: capture.id)).write(to: destination, options: .atomic) }
+ catch { monitor.errorMessage = "Could not export diagnostic capture: \(error.localizedDescription)" }
+ }
+ }
+
+ private func openFolder() {
+ try? FileManager.default.createDirectory(at: DiagnosticCaptureStore.directory, withIntermediateDirectories: true)
+ NSWorkspace.shared.open(DiagnosticCaptureStore.directory)
+ }
+
+ private func memoryPercent(_ metrics: DiagnosticSystemMetrics) -> Double {
+ metrics.memoryTotalBytes > 0 ? Double(metrics.memoryUsedBytes) / Double(metrics.memoryTotalBytes) * 100 : 0
+ }
+
+ private func valueCard(_ title: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 4) { Text(title).font(.caption).foregroundStyle(.secondary); Text(value).font(.title3.monospacedDigit()) }
+ .frame(maxWidth: .infinity, alignment: .leading).padding(12)
+ .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
+ }
+
+ private func deltaCard(_ title: String, _ old: Double, _ new: Double, suffix: String, lowerIsBetter: Bool) -> some View {
+ let delta = new - old
+ let favorable = lowerIsBetter ? delta <= 0 : delta >= 0
+ return VStack(alignment: .leading, spacing: 4) {
+ Text(title).font(.caption).foregroundStyle(.secondary)
+ Text(String(format: "%+.1f%@", delta, suffix)).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : (favorable ? Color.green : Color.orange))
+ Text(String(format: "%.1f → %.1f", old, new)).font(.caption2).foregroundStyle(.secondary)
+ }.frame(maxWidth: .infinity, alignment: .leading).padding(12)
+ .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
+ }
+
+ private func deltaBytes(_ title: String, _ old: UInt64, _ new: UInt64, lowerIsBetter: Bool) -> some View {
+ let delta = Int64(clamping: new) - Int64(clamping: old)
+ let text = (delta >= 0 ? "+" : "−") + Formatters.bytes.string(fromByteCount: abs(delta))
+ return VStack(alignment: .leading, spacing: 4) {
+ Text(title).font(.caption).foregroundStyle(.secondary)
+ Text(text).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : ((lowerIsBetter ? delta < 0 : delta > 0) ? Color.green : Color.orange))
+ Text("\(Formatters.bytes.string(fromByteCount: Int64(clamping: old))) → \(Formatters.bytes.string(fromByteCount: Int64(clamping: new)))").font(.caption2).foregroundStyle(.secondary)
+ }.frame(maxWidth: .infinity, alignment: .leading).padding(12)
+ .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
+ }
+
+ private func deltaRate(_ title: String, _ old: Double, _ new: Double) -> some View {
+ let delta = new - old
+ return VStack(alignment: .leading, spacing: 4) {
+ Text(title).font(.caption).foregroundStyle(.secondary)
+ Text((delta >= 0 ? "+" : "−") + Formatters.rate(abs(delta))).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : Color.orange)
+ Text("\(Formatters.rate(old)) → \(Formatters.rate(new))").font(.caption2).foregroundStyle(.secondary)
+ }.frame(maxWidth: .infinity, alignment: .leading).padding(12)
+ .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
+ }
+}
diff --git a/Sources/puter/EnergyView.swift b/Sources/puter/EnergyView.swift
new file mode 100644
index 0000000..55cdf16
--- /dev/null
+++ b/Sources/puter/EnergyView.swift
@@ -0,0 +1,210 @@
+import AppKit
+import SwiftUI
+
+struct EnergyView: View {
+ @Environment(SystemMonitor.self) private var monitor
+
+ private var power: PowerManagementSnapshot { monitor.hardware.powerManagement }
+ private var sleepBlockers: [SleepAssertionSnapshot] {
+ power.assertions.filter(\.preventsSleep)
+ }
+ private var rankedProcesses: [(process: ProcessRecord, score: Double)] {
+ monitor.processes.map { ($0, energyScore(for: $0)) }
+ .filter { $0.1 > 0.05 }
+ .sorted { $0.1 > $1.1 }
+ .prefix(12)
+ .map { $0 }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ PageHeader(
+ title: "Energy & Sleep",
+ subtitle: "Live energy estimates, power policy, and apps preventing sleep"
+ ) {
+ Button("Battery Settings", systemImage: "gear") {
+ NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
+ }
+ UpdateStatus()
+ }
+ ResourceSummaryBar(snapshot: monitor.snapshot)
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ summary
+ HStack(alignment: .top, spacing: 16) {
+ processSection
+ sleepSection
+ }
+ policySection
+ Label(
+ "Energy impact is an estimate based on sampled CPU, disk, and network activity. macOS does not expose Activity Monitor's proprietary per-process Energy Impact value.",
+ systemImage: "info.circle"
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(20)
+ }
+ }
+ }
+
+ private var summary: some View {
+ HStack(spacing: 12) {
+ summaryCard("Power source", power.source, "powerplug", .orange)
+ summaryCard("Power mode", power.mode, "gauge.with.dots.needle.50percent", .green)
+ summaryCard("System power", watts(monitor.hardware.battery.systemPowerWatts), "bolt.fill", .yellow)
+ summaryCard("Sleep blockers", "\(sleepBlockers.count)", "moon.zzz", sleepBlockers.isEmpty ? .green : .orange)
+ }
+ }
+
+ private var processSection: some View {
+ GroupBox("Estimated energy impact") {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Process")
+ Spacer()
+ Text("Impact")
+ }
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 6)
+ if rankedProcesses.isEmpty {
+ ContentUnavailableView("No active energy users", systemImage: "leaf", description: Text("No process activity was measured in this sample."))
+ .frame(minHeight: 220)
+ } else {
+ ForEach(rankedProcesses, id: \.process.pid) { item in
+ HStack(spacing: 8) {
+ ProcessIcon(name: item.process.displayName, path: item.process.executablePath, size: 22)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.process.displayName).lineLimit(1)
+ Text("CPU \(Formatters.percent(item.process.cpu))")
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(impactLabel(item.score))
+ .font(.caption.weight(.medium))
+ .foregroundStyle(impactColor(item.score))
+ .frame(width: 68, alignment: .trailing)
+ Text(String(format: "%.1f", item.score))
+ .monospacedDigit()
+ .frame(width: 44, alignment: .trailing)
+ }
+ .font(.callout)
+ .padding(.vertical, 5)
+ if item.process.pid != rankedProcesses.last?.process.pid { Divider() }
+ }
+ }
+ }
+ .padding(8)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private var sleepSection: some View {
+ GroupBox("Preventing sleep") {
+ VStack(spacing: 0) {
+ if sleepBlockers.isEmpty {
+ ContentUnavailableView("Nothing is preventing sleep", systemImage: "moon.zzz", description: Text("No active sleep assertions were reported by macOS."))
+ .frame(minHeight: 220)
+ } else {
+ ForEach(sleepBlockers) { assertion in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(assertion.processName).font(.callout.weight(.medium)).lineLimit(1)
+ Spacer()
+ Text(assertion.duration).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
+ }
+ Text(assertion.reason).font(.caption).foregroundStyle(.secondary).lineLimit(2)
+ Text("PID \(assertion.pid) • \(friendlyAssertionType(assertion.type))")
+ .font(.caption2).foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 7)
+ if assertion.id != sleepBlockers.last?.id { Divider() }
+ }
+ }
+ }
+ .padding(8)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private var policySection: some View {
+ GroupBox("Active power policy") {
+ HStack(spacing: 28) {
+ policyValue("System sleep", minuteText(power.systemSleepMinutes), "powersleep")
+ policyValue("Display sleep", minuteText(power.displaySleepMinutes), "display")
+ policyValue("Power Nap", power.powerNapEnabled ? "On" : "Off", "clock.arrow.circlepath")
+ policyValue("Wake for network", power.wakeOnNetworkEnabled ? "On" : "Off", "network")
+ Spacer()
+ }
+ .padding(10)
+ }
+ }
+
+ private func summaryCard(_ title: String, _ value: String, _ icon: String, _ color: Color) -> some View {
+ HStack(spacing: 10) {
+ Image(systemName: icon).font(.title2).foregroundStyle(color).frame(width: 30)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title).font(.caption).foregroundStyle(.secondary)
+ Text(value).font(.headline).lineLimit(1)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .background(Color(nsColor: .controlBackgroundColor).opacity(0.48))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .overlay { RoundedRectangle(cornerRadius: 10).stroke(.secondary.opacity(0.14)) }
+ }
+
+ private func policyValue(_ title: String, _ value: String, _ icon: String) -> some View {
+ Label {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title).font(.caption).foregroundStyle(.secondary)
+ Text(value).font(.callout.weight(.medium))
+ }
+ } icon: { Image(systemName: icon).foregroundStyle(.green) }
+ }
+
+ private func energyScore(for process: ProcessRecord) -> Double {
+ let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
+ let diskMB = activity.diskTotal / 1_048_576
+ let networkMB = activity.networkTotal / 1_048_576
+ return min(100, process.cpu + diskMB * 3.5 + networkMB * 1.5)
+ }
+
+ private func impactLabel(_ score: Double) -> String {
+ switch score {
+ case 30...: "Very high"
+ case 15..<30: "High"
+ case 5..<15: "Medium"
+ case 1..<5: "Low"
+ default: "Very low"
+ }
+ }
+
+ private func impactColor(_ score: Double) -> Color {
+ switch score {
+ case 30...: .red
+ case 15..<30: .orange
+ case 5..<15: .yellow
+ default: .green
+ }
+ }
+
+ private func minuteText(_ value: Int?) -> String {
+ guard let value else { return "Not reported" }
+ return value == 0 ? "Never" : "\(value) min"
+ }
+
+ private func friendlyAssertionType(_ value: String) -> String {
+ value.replacingOccurrences(of: "PreventUserIdle", with: "Prevent ")
+ .replacingOccurrences(of: "NoIdleSleepAssertion", with: "Prevent idle sleep")
+ .replacingOccurrences(of: "SystemSleep", with: "system sleep")
+ .replacingOccurrences(of: "DisplaySleep", with: "display sleep")
+ }
+
+ private func watts(_ value: Double) -> String {
+ value > 0 ? String(format: "%.1f W", value) : "Not reported"
+ }
+}
diff --git a/Sources/puter/FanControlService.swift b/Sources/puter/FanControlService.swift
index d9d4b4e..e3d5179 100644
--- a/Sources/puter/FanControlService.swift
+++ b/Sources/puter/FanControlService.swift
@@ -2,12 +2,21 @@ import Foundation
enum FanControlError: LocalizedError {
case backendUnavailable
+ case helperNotEnabled
+ case connectionFailed
+ case timedOut
case commandFailed(String)
var errorDescription: String? {
switch self {
case .backendUnavailable:
"The SMC control backend is unavailable on this Mac."
+ case .helperNotEnabled:
+ "Enable puter's privileged helper in Settings before changing fan control."
+ case .connectionFailed:
+ "puter could not connect to its privileged helper."
+ case .timedOut:
+ "The privileged helper did not respond in time."
case .commandFailed(let message):
message.isEmpty ? "The fan command did not complete." : message
}
@@ -29,39 +38,11 @@ enum FanControlService {
static func setManualTargets(_ targets: [Int: Double]) throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
- let commands = targets.sorted { $0.key < $1.key }.flatMap { id, speed in
- let safeID = max(0, id)
- let safeSpeed = max(0, Int(speed.rounded()))
- return ["\(quotedTool) fan \(safeID) -m 1", "\(quotedTool) fan \(safeID) -v \(safeSpeed)"]
- }
- try runPrivileged(commands.joined(separator: " && "))
+ try PrivilegedHelperClient.setFanTargets(targets)
}
static func restoreAutomatic() throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
- try runPrivileged("\(quotedTool) reset")
- }
-
- private static var quotedTool: String {
- "'" + toolPath.replacingOccurrences(of: "'", with: "'\\''") + "'"
- }
-
- private static func runPrivileged(_ shellCommand: String) throws {
- let escaped = shellCommand
- .replacingOccurrences(of: "\\", with: "\\\\")
- .replacingOccurrences(of: "\"", with: "\\\"")
- let script = "do shell script \"\(escaped)\" with administrator privileges"
- let process = Process()
- let errorPipe = Pipe()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
- process.arguments = ["-e", script]
- process.standardOutput = FileHandle.nullDevice
- process.standardError = errorPipe
- try process.run()
- let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
- process.waitUntilExit()
- guard process.terminationStatus == 0 else {
- throw FanControlError.commandFailed(String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
- }
+ try PrivilegedHelperClient.restoreAutomaticFanControl()
}
}
diff --git a/Sources/puter/HardwareView.swift b/Sources/puter/HardwareView.swift
index d62b52c..2948fba 100644
--- a/Sources/puter/HardwareView.swift
+++ b/Sources/puter/HardwareView.swift
@@ -33,6 +33,7 @@ struct HardwareView: View {
if battery.isPresent { batterySection }
if let adapter = battery.adapter { adapterSection(adapter) }
powerSection
+ if !monitor.hardware.physicalDisks.isEmpty { storageSection }
portsSection
coolingSection
topConsumersSection
@@ -151,6 +152,7 @@ struct HardwareView: View {
metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage))
metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent))
metric("Negotiated ceiling", watts(adapter.negotiatedWatts))
+ metric("Adapter input", watts(battery.adapterInputWatts))
metric("System draw", watts(battery.systemPowerWatts))
Spacer()
}
@@ -213,6 +215,60 @@ struct HardwareView: View {
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
+ private var storageSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Physical storage").font(.headline)
+ ForEach(monitor.hardware.physicalDisks) { disk in
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline) {
+ Label(disk.name, systemImage: disk.isSolidState ? "internaldrive.fill" : "internaldrive")
+ .font(.headline)
+ Text(disk.id).font(.caption.monospaced()).foregroundStyle(.secondary)
+ Spacer()
+ Label(disk.smartStatus, systemImage: disk.smartStatus == "Verified" ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+ .font(.callout.weight(.medium))
+ .foregroundStyle(disk.smartStatus == "Verified" ? .green : .orange)
+ }
+ HStack(spacing: 28) {
+ metric("Capacity", Formatters.bytes.string(fromByteCount: Int64(clamping: disk.capacity)))
+ metric("Connection", disk.protocolName)
+ metric("Media", disk.isSolidState ? "Solid-state" : "Rotational")
+ metric("Location", disk.isInternal ? "Internal" : (disk.isRemovable ? "Removable" : "External"))
+ if let remaining = disk.remainingLifePercent { metric("Estimated life", Formatters.percent(remaining)) }
+ Spacer()
+ }
+ Divider()
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 135), alignment: .leading)], alignment: .leading, spacing: 12) {
+ diskMetric("Temperature", disk.temperatureCelsius.map { String(format: "%.1f °C", $0) })
+ diskMetric("Available spare", disk.availableSparePercent.map(Formatters.percent))
+ diskMetric("TRIM", disk.trimEnabled.map { $0 ? "Enabled" : "Disabled" })
+ diskMetric("Power-on hours", disk.powerOnHours.map(String.init))
+ diskMetric("Power cycles", disk.powerCycles.map(String.init))
+ diskMetric("Unsafe shutdowns", disk.unsafeShutdowns.map(String.init))
+ diskMetric("Media errors", disk.mediaErrors.map(String.init))
+ diskMetric("Lifetime read", disk.bytesRead.map { Formatters.bytes.string(fromByteCount: Int64(clamping: $0)) })
+ diskMetric("Lifetime written", disk.bytesWritten.map { Formatters.bytes.string(fromByteCount: Int64(clamping: $0)) })
+ }
+ Text("Wear and lifetime counters are shown only when the drive exposes them through macOS SMART data.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ .padding(14)
+ .background(Color.secondary.opacity(0.045), in: RoundedRectangle(cornerRadius: 9))
+ .overlay { RoundedRectangle(cornerRadius: 9).stroke(Color.secondary.opacity(0.12)) }
+ }
+ }
+ .padding(16)
+ .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
+ .overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
+ }
+
+ private func diskMetric(_ title: String, _ value: String?) -> some View {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title).font(.caption).foregroundStyle(.secondary)
+ Text(value ?? "Not reported").font(.callout.monospacedDigit()).lineLimit(1)
+ }
+ }
+
private func portRow(_ port: HardwarePortSnapshot) -> some View {
VStack(alignment: .leading, spacing: 9) {
HStack {
diff --git a/Sources/puter/MenuBarMonitorView.swift b/Sources/puter/MenuBarMonitorView.swift
new file mode 100644
index 0000000..8a0a939
--- /dev/null
+++ b/Sources/puter/MenuBarMonitorView.swift
@@ -0,0 +1,96 @@
+import AppKit
+import SwiftUI
+
+struct MenuBarMonitorView: View {
+ @Environment(SystemMonitor.self) private var monitor
+ @Environment(\.openWindow) private var openWindow
+
+ private var topProcesses: [ProcessRecord] {
+ monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5).map { $0 }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 10) {
+ Image(nsImage: NSApp.applicationIconImage).resizable().scaledToFit().frame(width: 30, height: 30)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("puter").font(.headline)
+ if let updated = monitor.lastUpdated {
+ Text("Updated \(updated, style: .relative)").font(.caption).foregroundStyle(.secondary)
+ } else { Text("Collecting…").font(.caption).foregroundStyle(.secondary) }
+ }
+ Spacer()
+ Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 8, height: 8)
+ }
+
+ HStack(spacing: 8) {
+ metric("CPU", Formatters.percent(monitor.snapshot.cpuPercent), .blue)
+ metric("Memory", Formatters.percent(monitor.snapshot.memoryPercent), pressureColor)
+ }
+ HStack(spacing: 8) {
+ metric("Network", Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate), .teal)
+ metric("Power", powerText, .orange)
+ }
+
+ HStack {
+ Label("Memory pressure", systemImage: "memorychip")
+ Spacer()
+ Text(monitor.snapshot.memoryPressure.rawValue).foregroundStyle(pressureColor)
+ }
+ .font(.caption.weight(.medium))
+
+ Divider()
+ Text("Top CPU processes").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
+ if topProcesses.isEmpty {
+ Text("No process data yet").font(.caption).foregroundStyle(.secondary)
+ } else {
+ ForEach(topProcesses) { process in
+ HStack(spacing: 8) {
+ ProcessIcon(name: process.displayName, path: process.executablePath, size: 18)
+ Text(process.displayName).font(.caption).lineLimit(1)
+ Spacer()
+ Text(Formatters.percent(process.cpu)).font(.caption.monospacedDigit())
+ }
+ }
+ }
+
+ Divider()
+ HStack {
+ Button(monitor.isPaused ? "Resume" : "Pause") { monitor.isPaused.toggle() }
+ Button("Refresh") { monitor.refresh() }
+ Spacer()
+ Button("Open puter") {
+ openWindow(id: "main")
+ NSApp.activate()
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .controlSize(.small)
+ }
+ .padding(14)
+ .frame(width: 320)
+ }
+
+ private func metric(_ title: String, _ value: String, _ color: Color) -> some View {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title).font(.caption2).foregroundStyle(.secondary)
+ Text(value).font(.callout.weight(.semibold).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(9)
+ .background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8))
+ }
+
+ private var pressureColor: Color {
+ switch monitor.snapshot.memoryPressure {
+ case .normal: .green
+ case .warning: .orange
+ case .critical: .red
+ }
+ }
+
+ private var powerText: String {
+ let watts = monitor.hardware.battery.systemPowerWatts
+ return watts > 0 ? String(format: "%.1f W", watts) : "Not reported"
+ }
+}
diff --git a/Sources/puter/Models.swift b/Sources/puter/Models.swift
index 1d4888c..73d0c19 100644
--- a/Sources/puter/Models.swift
+++ b/Sources/puter/Models.swift
@@ -9,11 +9,22 @@ enum TaskSection: String, CaseIterable, Identifiable {
case users = "Users"
case details = "Details"
case services = "Services"
+ case energy = "Energy & Sleep"
+ case diagnostics = "Diagnostics"
case hardware = "Hardware"
case settings = "Settings"
var id: String { rawValue }
+ var usesLiveProcessInventory: Bool {
+ switch self {
+ case .processes, .performance, .history, .users, .details, .energy, .diagnostics:
+ true
+ case .startup, .services, .hardware, .settings:
+ false
+ }
+ }
+
var icon: String {
switch self {
case .processes: "square.stack.3d.up"
@@ -23,6 +34,8 @@ enum TaskSection: String, CaseIterable, Identifiable {
case .users: "person.2"
case .details: "list.bullet.rectangle"
case .services: "gearshape.2"
+ case .energy: "leaf"
+ case .diagnostics: "waveform.badge.magnifyingglass"
case .hardware: "bolt.horizontal.circle"
case .settings: "gearshape"
}
@@ -66,6 +79,8 @@ struct BatterySnapshot: Hashable, Sendable {
var serial = ""
var adapter: PowerAdapterSnapshot?
var systemPowerWatts = 0.0
+ var sensorBatteryPowerWatts = 0.0
+ var adapterInputWatts = 0.0
var healthPercent: Double {
guard designCapacityMAh > 0 else { return 0 }
@@ -73,6 +88,7 @@ struct BatterySnapshot: Hashable, Sendable {
}
var batteryPowerWatts: Double {
+ if sensorBatteryPowerWatts > 0 { return sensorBatteryPowerWatts }
let watts = abs(voltageVolts * currentAmps)
return watts.isFinite && watts <= 500 ? watts : 0
}
@@ -110,11 +126,59 @@ struct FanSnapshot: Identifiable, Hashable, Sendable {
var isAutomatic: Bool { mode.localizedCaseInsensitiveContains("automatic") }
}
+struct SleepAssertionSnapshot: Identifiable, Hashable, Sendable {
+ let id: String
+ let pid: Int32
+ let processName: String
+ let type: String
+ let reason: String
+ let duration: String
+
+ var preventsSleep: Bool {
+ type.localizedCaseInsensitiveContains("sleep") || type == "ExternalMedia"
+ }
+}
+
+struct PowerManagementSnapshot: Hashable, Sendable {
+ var source = "Unknown"
+ var mode = "Automatic"
+ var systemSleepMinutes: Int?
+ var displaySleepMinutes: Int?
+ var powerNapEnabled = false
+ var wakeOnNetworkEnabled = false
+ var assertions: [SleepAssertionSnapshot] = []
+}
+
+struct PhysicalDiskSnapshot: Identifiable, Hashable, Sendable {
+ let id: String
+ let name: String
+ let protocolName: String
+ let capacity: UInt64
+ let isInternal: Bool
+ let isRemovable: Bool
+ let isSolidState: Bool
+ let smartStatus: String
+ let trimEnabled: Bool?
+ let temperatureCelsius: Double?
+ let percentageUsed: Double?
+ let availableSparePercent: Double?
+ let powerOnHours: UInt64?
+ let powerCycles: UInt64?
+ let unsafeShutdowns: UInt64?
+ let mediaErrors: UInt64?
+ let bytesRead: UInt64?
+ let bytesWritten: UInt64?
+
+ var remainingLifePercent: Double? { percentageUsed.map { max(0, 100 - $0) } }
+}
+
struct HardwareSnapshot: Hashable, Sendable {
var battery = BatterySnapshot()
var ports: [HardwarePortSnapshot] = []
var fans: [FanSnapshot] = []
var thermalState = "Nominal"
+ var powerManagement = PowerManagementSnapshot()
+ var physicalDisks: [PhysicalDiskSnapshot] = []
var capturedAt = Date.distantPast
}
@@ -187,6 +251,14 @@ enum UpdateSpeed: String, CaseIterable, Identifiable {
}
}
+enum MenuBarMetric: String, CaseIterable, Identifiable {
+ case cpu = "CPU"
+ case memory = "Memory"
+ case network = "Network"
+ case power = "Power"
+ var id: String { rawValue }
+}
+
struct ProcessRecord: Identifiable, Hashable, Sendable {
let pid: Int32
let parentPID: Int32
@@ -324,6 +396,7 @@ struct SystemSnapshot {
var memoryType = "Unified"
var memorySpeed = "Not reported by macOS"
var memoryManufacturer = "Apple unified memory"
+ var memoryPressure = MemoryPressureLevel.normal
var diskFree: UInt64 = 0
var diskTotal: UInt64 = 0
var diskReadRate = 0.0
@@ -359,6 +432,74 @@ struct SystemSnapshot {
var diskThroughput: Double { diskReadRate + diskWriteRate }
}
+enum MemoryPressureLevel: String, Hashable, Sendable {
+ case normal = "Normal"
+ case warning = "Warning"
+ case critical = "Critical"
+}
+
+struct TelemetryRecordingSample: Codable, Hashable, Sendable {
+ let timestamp: Date
+ let cpuPercent: Double
+ let memoryUsedBytes: UInt64
+ let memoryTotalBytes: UInt64
+ let memoryPressure: String
+ let diskReadBytesPerSecond: Double
+ let diskWriteBytesPerSecond: Double
+ let gpuPercent: Double
+ let networkReceiveBytesPerSecond: Double
+ let networkSendBytesPerSecond: Double
+ let systemPowerWatts: Double
+ let thermalState: String
+ let processCount: Int
+}
+
+struct ResourceAlertEvent: Identifiable, Codable, Hashable, Sendable {
+ let id: UUID
+ let timestamp: Date
+ let resource: String
+ let value: Double
+ let threshold: Double
+ let message: String
+}
+
+struct ProcessSecuritySnapshot: Hashable, Sendable {
+ var signatureStatus = "Inspecting…"
+ var signingIdentifier = "Not reported"
+ var teamIdentifier = "Not reported"
+ var authority = "Not reported"
+ var hardenedRuntime = false
+ var appSandbox = false
+ var debuggerAllowed = false
+ var entitlementCount = 0
+ var codeDirectoryHash = "Not reported"
+ var gatekeeperStatus = "Not assessed"
+ var quarantineStatus = "Not quarantined"
+ var validationDetail = ""
+}
+
+enum ResourceAlertKind: String, CaseIterable, Sendable {
+ case cpu = "CPU"
+ case memory = "Memory"
+ case disk = "Disk activity"
+ case thermal = "Thermal pressure"
+}
+
+enum ResourceAlertPolicy {
+ static func shouldFire(
+ value: Double,
+ threshold: Double,
+ consecutiveSamples: Int,
+ requiredSamples: Int,
+ lastFired: Date?,
+ now: Date,
+ cooldown: TimeInterval
+ ) -> Bool {
+ guard value >= threshold, consecutiveSamples >= max(1, requiredSamples) else { return false }
+ return lastFired.map { now.timeIntervalSince($0) >= max(0, cooldown) } ?? true
+ }
+}
+
@MainActor
enum Formatters {
static let bytes: ByteCountFormatter = {
diff --git a/Sources/puter/PerformanceView.swift b/Sources/puter/PerformanceView.swift
index dac11e3..98de10c 100644
--- a/Sources/puter/PerformanceView.swift
+++ b/Sources/puter/PerformanceView.swift
@@ -38,6 +38,25 @@ struct PerformanceView: View {
VStack(spacing: 0) {
PageHeader(title: "Performance") {
HStack(spacing: 12) {
+ Button {
+ monitor.isRecording ? monitor.stopRecording() : monitor.startRecording()
+ } label: {
+ Label(monitor.isRecording ? "Stop recording" : "Record", systemImage: monitor.isRecording ? "stop.circle.fill" : "record.circle")
+ }
+ .tint(monitor.isRecording ? .red : nil)
+ if !monitor.recordingSamples.isEmpty {
+ Menu("Recording", systemImage: "waveform.path.ecg.rectangle") {
+ Text("\(monitor.recordingSamples.count) samples")
+ if let started = monitor.recordingStartDate {
+ Text("Started \(started, style: .relative)")
+ }
+ Divider()
+ Button("Export JSON…") { SessionRecordingExporter.exportJSON(monitor: monitor) }
+ Button("Export CSV…") { SessionRecordingExporter.exportCSV(monitor: monitor) }
+ Divider()
+ Button("Clear recording", role: .destructive) { monitor.clearRecording() }
+ }
+ }
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: selectedResourceName)
}
@@ -48,7 +67,7 @@ struct PerformanceView: View {
ScrollView {
VStack(spacing: 10) {
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors")
- metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)))")
+ metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal))) • \(monitor.snapshot.memoryPressure.rawValue) pressure")
metricCard(
.disk,
value: monitor.snapshot.diskActivePercent,
@@ -250,6 +269,7 @@ struct PerformanceView: View {
color: .purple,
value: monitor.snapshot.memoryPercent,
history: monitor.memoryHistory,
+ advisory: memoryAdvisory,
stats: [
("In use", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))),
("Available", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal - monitor.snapshot.memoryUsed))),
@@ -258,6 +278,7 @@ struct PerformanceView: View {
("Wired", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryWired))),
("Compressed", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCompressed))),
("Swap", "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapUsed))) / \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapTotal)))"),
+ ("Memory pressure", monitor.snapshot.memoryPressure.rawValue),
("Type", monitor.snapshot.memoryType),
("Frequency / speed", monitor.snapshot.memorySpeed),
("Manufacturer", monitor.snapshot.memoryManufacturer)
@@ -302,6 +323,32 @@ struct PerformanceView: View {
}
return String(decoding: buffer.prefix { $0 != 0 }.map(UInt8.init(bitPattern:)), as: UTF8.self)
}
+
+ private var memoryAdvisory: PerformanceAdvisory {
+ switch monitor.snapshot.memoryPressure {
+ case .normal:
+ PerformanceAdvisory(
+ icon: "checkmark.circle.fill",
+ title: "Memory pressure is normal",
+ message: "macOS uses otherwise-idle RAM for cache. A high allocation percentage alone does not mean memory is exhausted.",
+ color: .green
+ )
+ case .warning:
+ PerformanceAdvisory(
+ icon: "exclamationmark.triangle.fill",
+ title: "Memory pressure is elevated",
+ message: "Compression or swap demand is increasing. Closing memory-heavy apps may improve responsiveness.",
+ color: .orange
+ )
+ case .critical:
+ PerformanceAdvisory(
+ icon: "exclamationmark.octagon.fill",
+ title: "Memory pressure is critical",
+ message: "The kernel reports severe memory contention. Save work and close memory-heavy apps.",
+ color: .red
+ )
+ }
+ }
}
private struct RemovableDiskDetail: View {
@@ -843,6 +890,7 @@ private struct PerformanceDetail: View {
let color: Color
let value: Double
let history: [MetricSample]
+ var advisory: PerformanceAdvisory? = nil
let stats: [(String, String)]
var chartData: [MetricSample] {
@@ -862,6 +910,21 @@ private struct PerformanceDetail: View {
Text(Formatters.percent(value))
.font(.largeTitle.weight(.light).monospacedDigit())
}
+ if let advisory {
+ Label {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(advisory.title).font(.callout.weight(.semibold))
+ Text(advisory.message).font(.caption).foregroundStyle(.secondary)
+ }
+ } icon: {
+ Image(systemName: advisory.icon).foregroundStyle(advisory.color)
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(advisory.color.opacity(0.09))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .overlay { RoundedRectangle(cornerRadius: 10).stroke(advisory.color.opacity(0.2)) }
+ }
VStack(alignment: .leading, spacing: 8) {
Text("% Utilization").font(.caption).foregroundStyle(.secondary)
Chart(chartData) { sample in
@@ -892,3 +955,10 @@ private struct PerformanceDetail: View {
}
}
}
+
+private struct PerformanceAdvisory {
+ let icon: String
+ let title: String
+ let message: String
+ let color: Color
+}
diff --git a/Sources/puter/PhysicalDiskScanner.swift b/Sources/puter/PhysicalDiskScanner.swift
new file mode 100644
index 0000000..da9830b
--- /dev/null
+++ b/Sources/puter/PhysicalDiskScanner.swift
@@ -0,0 +1,81 @@
+import Foundation
+
+enum PhysicalDiskScanner {
+ static func capture() -> [PhysicalDiskSnapshot] {
+ let listData = run("/usr/sbin/diskutil", ["list", "-plist", "physical"])
+ guard let list = try? PropertyListSerialization.propertyList(from: listData, format: nil) as? [String: Any] else { return [] }
+ let identifiers = (list["WholeDisks"] as? [String])
+ ?? (list["AllDisksAndPartitions"] as? [[String: Any]])?.compactMap { $0["DeviceIdentifier"] as? String }
+ ?? []
+ let trimByDisk = storageTrimSupport()
+
+ return identifiers.compactMap { identifier in
+ let data = run("/usr/sbin/diskutil", ["info", "-plist", identifier])
+ guard let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil }
+ let smart = info["SMARTDeviceSpecificKeysMayVaryNotGuaranteed"] as? [String: Any] ?? [:]
+ func number(_ key: String) -> UInt64? { (smart[key] as? NSNumber)?.uint64Value }
+ func dataBytes(_ key: String) -> UInt64? {
+ guard let units = number(key) else { return nil }
+ let result = units.multipliedReportingOverflow(by: 512_000)
+ return result.overflow ? nil : result.partialValue
+ }
+ let temperature = number("TEMPERATURE").map {
+ let raw = Double($0)
+ return raw > 200 ? raw - 273.15 : raw
+ }
+ return PhysicalDiskSnapshot(
+ id: identifier,
+ name: info["MediaName"] as? String ?? identifier,
+ protocolName: info["BusProtocol"] as? String ?? "Not reported",
+ capacity: (info["TotalSize"] as? NSNumber)?.uint64Value ?? 0,
+ isInternal: info["Internal"] as? Bool ?? false,
+ isRemovable: info["Removable"] as? Bool ?? false,
+ isSolidState: info["SolidState"] as? Bool ?? false,
+ smartStatus: info["SMARTStatus"] as? String ?? "Not supported",
+ trimEnabled: trimByDisk[identifier],
+ temperatureCelsius: temperature,
+ percentageUsed: number("PERCENTAGE_USED").map { Double($0) },
+ availableSparePercent: number("AVAILABLE_SPARE").map { Double($0) },
+ powerOnHours: number("POWER_ON_HOURS_0"),
+ powerCycles: number("POWER_CYCLES_0"),
+ unsafeShutdowns: number("UNSAFE_SHUTDOWNS_0"),
+ mediaErrors: number("MEDIA_ERRORS_0"),
+ bytesRead: dataBytes("DATA_UNITS_READ_0"),
+ bytesWritten: dataBytes("DATA_UNITS_WRITTEN_0")
+ )
+ }
+ .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
+ }
+
+ private static func storageTrimSupport() -> [String: Bool] {
+ let data = run("/usr/sbin/system_profiler", ["SPNVMeDataType", "SPSerialATADataType", "-json", "-detailLevel", "mini"])
+ guard let root = try? JSONSerialization.jsonObject(with: data) else { return [:] }
+ var result: [String: Bool] = [:]
+ func visit(_ value: Any) {
+ if let dictionary = value as? [String: Any] {
+ if let identifier = dictionary["bsd_name"] as? String,
+ let trim = (dictionary["spnvme_trim_support"] as? String) ?? (dictionary["spsata_trim_support"] as? String) {
+ result[identifier] = trim.localizedCaseInsensitiveCompare("yes") == .orderedSame
+ }
+ dictionary.values.forEach(visit)
+ } else if let array = value as? [Any] { array.forEach(visit) }
+ }
+ visit(root)
+ return result
+ }
+
+ private static func run(_ executable: String, _ arguments: [String]) -> Data {
+ let process = Process()
+ let output = Pipe()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ process.standardOutput = output
+ process.standardError = FileHandle.nullDevice
+ do {
+ try process.run()
+ let data = output.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ return process.terminationStatus == 0 ? data : Data()
+ } catch { return Data() }
+ }
+}
diff --git a/Sources/puter/PrivilegedHelperManager.swift b/Sources/puter/PrivilegedHelperManager.swift
new file mode 100644
index 0000000..1134d5c
--- /dev/null
+++ b/Sources/puter/PrivilegedHelperManager.swift
@@ -0,0 +1,143 @@
+import AppKit
+import Foundation
+import PuterHelperProtocol
+import ServiceManagement
+
+@MainActor
+final class PrivilegedHelperManager: ObservableObject {
+ enum State: Equatable {
+ case enabled
+ case disabled
+ case requiresApproval
+ case unavailable
+ case requiresSignedInstallation
+ case failed(String)
+
+ var title: String {
+ switch self {
+ case .enabled: "Enabled"
+ case .disabled: "Not enabled"
+ case .requiresApproval: "Approval required"
+ case .unavailable: "Unavailable in this build"
+ case .requiresSignedInstallation: "Signed installation required"
+ case .failed: "Error"
+ }
+ }
+
+ var detail: String {
+ switch self {
+ case .enabled: "Fan changes use puter's signed, command-limited helper."
+ case .disabled: "Enable only if you want puter to change fan targets. Monitoring never requires it."
+ case .requiresApproval: "Allow puter under Login Items in System Settings, then return here."
+ case .unavailable: "The helper service is not included in this app bundle."
+ case .requiresSignedInstallation: "The helper is included, but macOS enables launch daemons only from a properly signed installed app."
+ case .failed(let message): message
+ }
+ }
+ }
+
+ @Published private(set) var state: State = .disabled
+ @Published private(set) var isWorking = false
+
+ private var service: SMAppService {
+ SMAppService.daemon(plistName: PuterHelperConstants.daemonPlistName)
+ }
+
+ init() { refresh() }
+
+ func refresh() {
+ switch service.status {
+ case .enabled: state = .enabled
+ case .notRegistered: state = .disabled
+ case .requiresApproval: state = .requiresApproval
+ case .notFound:
+ state = helperIsBundled ? .requiresSignedInstallation : .unavailable
+ @unknown default: state = .unavailable
+ }
+ }
+
+ private var helperIsBundled: Bool {
+ let contents = Bundle.main.bundleURL.appendingPathComponent("Contents")
+ return FileManager.default.fileExists(
+ atPath: contents.appendingPathComponent("Library/LaunchDaemons/\(PuterHelperConstants.daemonPlistName)").path
+ ) && FileManager.default.isExecutableFile(
+ atPath: contents.appendingPathComponent("Resources/puter-helper").path
+ )
+ }
+
+ func enable() {
+ isWorking = true
+ defer { isWorking = false }
+ do {
+ try service.register()
+ refresh()
+ } catch {
+ state = .failed(error.localizedDescription)
+ }
+ }
+
+ func disable() {
+ isWorking = true
+ service.unregister { [weak self] error in
+ Task { @MainActor in
+ guard let self else { return }
+ self.isWorking = false
+ if let error { self.state = .failed(error.localizedDescription) }
+ else { self.refresh() }
+ }
+ }
+ }
+
+ func openApprovalSettings() {
+ SMAppService.openSystemSettingsLoginItems()
+ }
+}
+
+enum PrivilegedHelperClient {
+ private final class ReplyBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var result: Result?
+
+ func set(_ value: Result) { lock.withLock { result = value } }
+ func get() -> Result? { lock.withLock { result } }
+ }
+
+ static func setFanTargets(_ targets: [Int: Double]) throws {
+ let boxed = Dictionary(uniqueKeysWithValues: targets.map { key, value in
+ (NSNumber(value: key), NSNumber(value: Int(value.rounded())))
+ })
+ try perform { proxy, reply in proxy.setFanTargets(boxed, withReply: reply) }
+ }
+
+ static func restoreAutomaticFanControl() throws {
+ try perform { proxy, reply in proxy.restoreAutomaticFanControl(withReply: reply) }
+ }
+
+ private static func perform(
+ _ operation: (PuterPrivilegedHelperProtocol, @escaping (Bool, String?) -> Void) -> Void
+ ) throws {
+ guard SMAppService.daemon(plistName: PuterHelperConstants.daemonPlistName).status == .enabled else {
+ throw FanControlError.helperNotEnabled
+ }
+ let connection = NSXPCConnection(machServiceName: PuterHelperConstants.machServiceName, options: .privileged)
+ connection.remoteObjectInterface = NSXPCInterface(with: PuterPrivilegedHelperProtocol.self)
+ connection.resume()
+ defer { connection.invalidate() }
+
+ let semaphore = DispatchSemaphore(value: 0)
+ let box = ReplyBox()
+ let errorHandler: (Error) -> Void = { error in
+ box.set(.failure(error))
+ semaphore.signal()
+ }
+ guard let proxy = connection.remoteObjectProxyWithErrorHandler(errorHandler) as? PuterPrivilegedHelperProtocol else {
+ throw FanControlError.connectionFailed
+ }
+ operation(proxy) { success, message in
+ box.set(success ? .success(()) : .failure(FanControlError.commandFailed(message ?? "")))
+ semaphore.signal()
+ }
+ guard semaphore.wait(timeout: .now() + 12) == .success else { throw FanControlError.timedOut }
+ try box.get()?.get()
+ }
+}
diff --git a/Sources/puter/ProcessActions.swift b/Sources/puter/ProcessActions.swift
index a9a4629..ff06e95 100644
--- a/Sources/puter/ProcessActions.swift
+++ b/Sources/puter/ProcessActions.swift
@@ -197,6 +197,7 @@ struct ProcessPropertiesView: View {
@Environment(\.dismiss) private var dismiss
@Environment(SystemMonitor.self) private var monitor
let process: ProcessRecord
+ @State private var security: ProcessSecuritySnapshot?
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
@@ -216,24 +217,33 @@ struct ProcessPropertiesView: View {
Divider()
- Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
- property("Executable", process.executablePath)
- property("User", process.user)
- property("Parent PID", "\(process.parentPID)")
- property("State", process.state)
- property("CPU", Formatters.percent(process.cpu))
- property("CPU time", Formatters.duration(process.cpuTime))
- property("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
- property("Threads", "\(process.threadCount)")
- property("Open handles", "\(process.openFileCount)")
- property("Architecture", process.architecture)
- property("Priority", "\(process.priority) (nice \(process.nice))")
- property("Disk read", Formatters.rate(activity.diskRead))
- property("Disk write", Formatters.rate(activity.diskWrite))
- property("Network", Formatters.rate(activity.networkTotal))
- property("Elapsed time", process.elapsed)
+ TabView {
+ ScrollView {
+ Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
+ property("Executable", process.executablePath)
+ property("User", process.user)
+ property("Parent PID", "\(process.parentPID)")
+ property("State", process.state)
+ property("CPU", Formatters.percent(process.cpu))
+ property("CPU time", Formatters.duration(process.cpuTime))
+ property("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
+ property("Threads", "\(process.threadCount)")
+ property("Open handles", "\(process.openFileCount)")
+ property("Architecture", process.architecture)
+ property("Priority", "\(process.priority) (nice \(process.nice))")
+ property("Disk read", Formatters.rate(activity.diskRead))
+ property("Disk write", Formatters.rate(activity.diskWrite))
+ property("Network", Formatters.rate(activity.networkTotal))
+ property("Elapsed time", process.elapsed)
+ }
+ .padding(20)
+ }
+ .tabItem { Label("Process", systemImage: "list.bullet.rectangle") }
+
+ securityContent
+ .tabItem { Label("Security", systemImage: "checkmark.shield") }
}
- .padding(20)
+ .padding(.horizontal, 12)
Divider()
HStack {
@@ -246,7 +256,57 @@ struct ProcessPropertiesView: View {
}
.padding(16)
}
- .frame(width: 520)
+ .frame(width: 680, height: 650)
+ .task(id: process.executablePath) {
+ security = await Task.detached(priority: .utility) {
+ ProcessSecurityInspector.inspect(executablePath: process.executablePath)
+ }.value
+ }
+ }
+
+ @ViewBuilder
+ private var securityContent: some View {
+ if let security {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ Label {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Code signature: \(security.signatureStatus)").font(.headline)
+ if !security.validationDetail.isEmpty {
+ Text(security.validationDetail).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ } icon: {
+ Image(systemName: security.signatureStatus == "Valid" ? "checkmark.shield.fill" : "exclamationmark.shield.fill")
+ .font(.title2)
+ .foregroundStyle(security.signatureStatus == "Valid" ? .green : .orange)
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background((security.signatureStatus == "Valid" ? Color.green : Color.orange).opacity(0.09))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+
+ Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
+ property("Identifier", security.signingIdentifier)
+ property("Team ID", security.teamIdentifier)
+ property("Authority", security.authority)
+ property("Gatekeeper", security.gatekeeperStatus)
+ property("Quarantine", security.quarantineStatus)
+ property("Hardened runtime", security.hardenedRuntime ? "Enabled" : "Not enabled")
+ property("App Sandbox", security.appSandbox ? "Enabled" : "Not enabled")
+ property("Debug entitlement", security.debuggerAllowed ? "Allowed" : "Not allowed")
+ property("Entitlements", "\(security.entitlementCount)")
+ property("CDHash", security.codeDirectoryHash)
+ }
+ Label("Security details are inspected on demand using Security.framework and Gatekeeper. They are not part of the recurring telemetry sampler.", systemImage: "info.circle")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ .padding(20)
+ }
+ } else {
+ ProgressView("Inspecting code signature and Gatekeeper status…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
}
private func property(_ name: String, _ value: String) -> some View {
diff --git a/Sources/puter/ProcessPreviewPane.swift b/Sources/puter/ProcessPreviewPane.swift
new file mode 100644
index 0000000..a29d6b6
--- /dev/null
+++ b/Sources/puter/ProcessPreviewPane.swift
@@ -0,0 +1,162 @@
+import SwiftUI
+
+struct ProcessPreviewPane: View {
+ @Environment(SystemMonitor.self) private var monitor
+ let process: ProcessRecord?
+ let onClose: () -> Void
+ var onShowDetails: ((ProcessRecord) -> Void)?
+ var onShowProperties: ((ProcessRecord) -> Void)?
+ var onEndTask: ((ProcessRecord) -> Void)?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Process preview")
+ .font(.headline)
+ Spacer()
+ Button(action: onClose) {
+ Image(systemName: "xmark")
+ }
+ .buttonStyle(.plain)
+ .help("Close preview")
+ .accessibilityLabel("Close process preview")
+ }
+ .padding(.horizontal, 16)
+ .frame(height: 46)
+
+ Divider()
+
+ if let process {
+ processContent(process)
+ } else {
+ ContentUnavailableView(
+ "No process selected",
+ systemImage: "sidebar.right",
+ description: Text("Select a process to see its live information here.")
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .background(.background)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Process preview")
+ }
+
+ private func processContent(_ process: ProcessRecord) -> some View {
+ VStack(spacing: 0) {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 12) {
+ ProcessIcon(name: process.displayName, path: process.executablePath)
+ .scaleEffect(1.45)
+ .frame(width: 48, height: 48)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(process.displayName)
+ .font(.title2.weight(.semibold))
+ .lineLimit(2)
+ Text("PID \(process.pid) • \(process.user)")
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ Spacer(minLength: 0)
+ }
+
+ informationGroup("Resources", systemImage: "gauge.with.dots.needle.67percent") {
+ infoRow("CPU", Formatters.percent(process.cpu))
+ infoRow("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
+ infoRow("Memory share", Formatters.percent(process.memoryPercent))
+ infoRow("Disk read", Formatters.rate(activity(for: process).diskRead))
+ infoRow("Disk write", Formatters.rate(activity(for: process).diskWrite))
+ infoRow("Network", Formatters.rate(activity(for: process).networkTotal))
+ }
+
+ informationGroup("Process", systemImage: "cpu") {
+ infoRow("Status", readableState(process.state))
+ infoRow("CPU time", Formatters.duration(process.cpuTime))
+ infoRow("Elapsed", process.elapsed)
+ infoRow("Threads", "\(process.threadCount)")
+ infoRow("Open handles", "\(process.openFileCount)")
+ infoRow("Architecture", process.architecture)
+ infoRow("Parent PID", "\(process.parentPID)")
+ infoRow("Priority", "\(process.priority) (nice \(process.nice))")
+ }
+
+ informationGroup("Executable", systemImage: "terminal") {
+ Text(process.executablePath)
+ .font(.callout)
+ .textSelection(.enabled)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .padding(16)
+ }
+
+ if onShowDetails != nil || onShowProperties != nil || onEndTask != nil {
+ Divider()
+ HStack(spacing: 8) {
+ if let onEndTask {
+ Button("End task", role: .destructive) { onEndTask(process) }
+ }
+ Spacer()
+ if let onShowDetails {
+ Button("Details") { onShowDetails(process) }
+ }
+ if let onShowProperties {
+ Button("Properties") { onShowProperties(process) }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding(12)
+ }
+ }
+ }
+
+ private func informationGroup(
+ _ title: String,
+ systemImage: String,
+ @ViewBuilder content: () -> Content
+ ) -> some View {
+ GroupBox {
+ VStack(alignment: .leading, spacing: 10) {
+ content()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 2)
+ } label: {
+ Label(title, systemImage: systemImage)
+ .font(.callout.weight(.semibold))
+ }
+ }
+
+ private func infoRow(_ label: String, _ value: String) -> some View {
+ ViewThatFits(in: .horizontal) {
+ LabeledContent(label) {
+ Text(value)
+ .monospacedDigit()
+ .textSelection(.enabled)
+ .multilineTextAlignment(.trailing)
+ }
+ VStack(alignment: .leading, spacing: 3) {
+ Text(label)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(value)
+ .monospacedDigit()
+ .textSelection(.enabled)
+ }
+ }
+ }
+
+ private func activity(for process: ProcessRecord) -> ProcessActivityRate {
+ monitor.processActivity[process.pid] ?? ProcessActivityRate()
+ }
+
+ private func readableState(_ state: String) -> String {
+ if state.hasPrefix("R") { return "Running" }
+ if state.hasPrefix("S") { return "Sleeping" }
+ if state.hasPrefix("Z") { return "Zombie" }
+ if state.hasPrefix("T") { return "Stopped" }
+ return state
+ }
+}
diff --git a/Sources/puter/ProcessSecurityInspector.swift b/Sources/puter/ProcessSecurityInspector.swift
new file mode 100644
index 0000000..25e4638
--- /dev/null
+++ b/Sources/puter/ProcessSecurityInspector.swift
@@ -0,0 +1,105 @@
+import Foundation
+import Security
+
+enum ProcessSecurityInspector {
+ static func inspect(executablePath: String) -> ProcessSecuritySnapshot {
+ guard executablePath.hasPrefix("/"), FileManager.default.fileExists(atPath: executablePath) else {
+ return ProcessSecuritySnapshot(signatureStatus: "Executable unavailable", validationDetail: "The process did not expose a readable executable path.")
+ }
+
+ var snapshot = ProcessSecuritySnapshot()
+ var staticCode: SecStaticCode?
+ let createStatus = SecStaticCodeCreateWithPath(URL(fileURLWithPath: executablePath) as CFURL, [], &staticCode)
+ guard createStatus == errSecSuccess, let staticCode else {
+ snapshot.signatureStatus = createStatus == errSecCSUnsigned ? "Unsigned" : "Inspection unavailable"
+ snapshot.validationDetail = SecCopyErrorMessageString(createStatus, nil) as String? ?? "Security.framework could not inspect this executable."
+ return snapshot
+ }
+
+ var validationError: Unmanaged?
+ let validationStatus = SecStaticCodeCheckValidityWithErrors(
+ staticCode,
+ SecCSFlags(rawValue: UInt32(kSecCSStrictValidate)),
+ nil,
+ &validationError
+ )
+ if validationStatus == errSecSuccess {
+ snapshot.signatureStatus = "Valid"
+ } else if validationStatus == errSecCSUnsigned {
+ snapshot.signatureStatus = "Unsigned"
+ } else {
+ snapshot.signatureStatus = "Invalid"
+ }
+ if let error = validationError?.takeRetainedValue() {
+ snapshot.validationDetail = CFErrorCopyDescription(error) as String
+ }
+
+ var information: CFDictionary?
+ if SecCodeCopySigningInformation(
+ staticCode,
+ SecCSFlags(rawValue: UInt32(kSecCSSigningInformation)),
+ &information
+ ) == errSecSuccess,
+ let info = information as? [CFString: Any] {
+ snapshot.signingIdentifier = info[kSecCodeInfoIdentifier] as? String ?? "Not reported"
+ snapshot.teamIdentifier = info[kSecCodeInfoTeamIdentifier] as? String ?? "Not reported"
+ let flags = (info[kSecCodeInfoFlags] as? NSNumber)?.uint32Value ?? 0
+ snapshot.hardenedRuntime = flags & 0x0001_0000 != 0
+ if let entitlements = info[kSecCodeInfoEntitlementsDict] as? [String: Any] {
+ snapshot.entitlementCount = entitlements.count
+ snapshot.appSandbox = entitlements["com.apple.security.app-sandbox"] as? Bool ?? false
+ snapshot.debuggerAllowed = entitlements["com.apple.security.get-task-allow"] as? Bool ?? false
+ }
+ if let certificates = info[kSecCodeInfoCertificates] as? [SecCertificate] {
+ snapshot.authority = certificates.compactMap {
+ SecCertificateCopySubjectSummary($0) as String?
+ }.first ?? "Ad hoc"
+ } else if snapshot.signatureStatus == "Valid" {
+ snapshot.authority = "Ad hoc"
+ }
+ if let unique = info[kSecCodeInfoUnique] as? Data {
+ snapshot.codeDirectoryHash = unique.map { String(format: "%02x", $0) }.joined()
+ }
+ }
+
+ let assessmentPath = enclosingApplicationPath(for: executablePath) ?? executablePath
+ let assessment = run("/usr/sbin/spctl", arguments: ["--assess", "--type", "execute", "--verbose=2", assessmentPath])
+ let assessmentText = (assessment.output + "\n" + assessment.error).trimmingCharacters(in: .whitespacesAndNewlines)
+ if assessment.status == 0 {
+ let origin = assessmentText.components(separatedBy: "origin=").dropFirst().first?.components(separatedBy: "\n").first
+ snapshot.gatekeeperStatus = origin.map { "Accepted • \($0)" } ?? "Accepted"
+ } else {
+ snapshot.gatekeeperStatus = assessmentText.isEmpty ? "Not accepted" : assessmentText.components(separatedBy: "\n").first ?? "Not accepted"
+ }
+
+ let quarantine = run("/usr/bin/xattr", arguments: ["-p", "com.apple.quarantine", assessmentPath])
+ snapshot.quarantineStatus = quarantine.status == 0 ? "Quarantined" : "Not quarantined"
+ return snapshot
+ }
+
+ private static func enclosingApplicationPath(for path: String) -> String? {
+ guard let range = path.range(of: ".app/", options: .caseInsensitive) else {
+ return path.lowercased().hasSuffix(".app") ? path : nil
+ }
+ return String(path[.. (output: String, error: String, status: Int32) {
+ let process = Process()
+ let output = Pipe()
+ let error = Pipe()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ process.standardOutput = output
+ process.standardError = error
+ do {
+ try process.run()
+ let outputData = output.fileHandleForReading.readDataToEndOfFile()
+ let errorData = error.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ return (String(decoding: outputData, as: UTF8.self), String(decoding: errorData, as: UTF8.self), process.terminationStatus)
+ } catch {
+ return ("", error.localizedDescription, -1)
+ }
+ }
+}
diff --git a/Sources/puter/ProcessViews.swift b/Sources/puter/ProcessViews.swift
index 269972b..d0ecae3 100644
--- a/Sources/puter/ProcessViews.swift
+++ b/Sources/puter/ProcessViews.swift
@@ -1,7 +1,7 @@
import AppKit
import SwiftUI
-private enum ProcessSort: String, CaseIterable {
+enum ProcessSort: String, CaseIterable {
case name = "Name"
case cpu = "CPU"
case memory = "Memory"
@@ -10,6 +10,27 @@ private enum ProcessSort: String, CaseIterable {
case pid = "PID"
}
+struct ProcessSortCycleState: Equatable {
+ let field: ProcessSort
+ let ascending: Bool
+ let grouped: Bool
+
+ static func next(currentField: ProcessSort, ascending: Bool, grouped: Bool, clicked: ProcessSort) -> Self {
+ if grouped || currentField != clicked {
+ return .init(field: clicked, ascending: false, grouped: false)
+ }
+ if !ascending {
+ return .init(field: clicked, ascending: true, grouped: false)
+ }
+ return .init(field: clicked, ascending: true, grouped: true)
+ }
+
+ func accessibilityValue(for header: ProcessSort) -> String {
+ guard !grouped, field == header else { return "Not sorted; grouped by category" }
+ return ascending ? "Sorted ascending" : "Sorted descending"
+ }
+}
+
private enum ResourceDisplayMode: String, CaseIterable {
case values = "Values"
case percentages = "Percentages"
@@ -76,9 +97,20 @@ struct ProcessesView: View {
@State private var terminationRequest: TerminationRequest?
@State private var groupTerminationRequest: ProcessGroup?
@State private var inspectedProcess: ProcessRecord?
+ @AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
private var grouped: [ProcessGroup] {
- ProcessGrouping.groups(from: monitor.processes, searchText: searchText).sorted { lhs, rhs in
+ let groups = ProcessGrouping.groups(from: monitor.processes, searchText: searchText)
+ if groupByType {
+ return groups.sorted {
+ let leftCategory = ProcessCategory.allCases.firstIndex(of: $0.category) ?? 0
+ let rightCategory = ProcessCategory.allCases.firstIndex(of: $1.category) ?? 0
+ if leftCategory != rightCategory { return leftCategory < rightCategory }
+ let comparison = $0.name.localizedCaseInsensitiveCompare($1.name)
+ return comparison == .orderedSame ? $0.primary.pid < $1.primary.pid : comparison == .orderedAscending
+ }
+ }
+ return groups.sorted { lhs, rhs in
let ordering: ComparisonResult
switch sort {
case .name:
@@ -108,12 +140,78 @@ struct ProcessesView: View {
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") {
- if monitor.isPaused {
- Label("Paused", systemImage: "pause.fill")
- .foregroundStyle(.orange)
+ HStack(spacing: 10) {
+ if monitor.isPaused {
+ Label("Paused", systemImage: "pause.fill")
+ .foregroundStyle(.orange)
+ }
+ Button {
+ previewPaneVisible.toggle()
+ } label: {
+ Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
+ }
+ .buttonStyle(.bordered)
+ .help(previewPaneVisible ? "Hide process preview" : "Show process preview")
}
}
ResourceSummaryBar(snapshot: monitor.snapshot)
+ processList
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .inspector(isPresented: $previewPaneVisible) {
+ ProcessPreviewPane(
+ process: previewProcess,
+ onClose: { previewPaneVisible = false },
+ onShowDetails: onShowDetails,
+ onShowProperties: { inspectedProcess = $0 },
+ onEndTask: { process in
+ if let group = grouped.first(where: { $0.id == selectedGroupID }) {
+ groupTerminationRequest = group
+ } else {
+ terminationRequest = .init(process: process, kind: .normal)
+ }
+ }
+ )
+ .inspectorColumnWidth(min: 250, ideal: 320, max: 480)
+ }
+ .confirmationDialog(
+ "\(terminationRequest?.kind.title ?? "End task"): \(terminationRequest?.process.displayName ?? "this task")?",
+ isPresented: Binding(get: { terminationRequest != nil }, set: { if !$0 { terminationRequest = nil } })
+ ) {
+ Button(terminationRequest?.kind.title ?? "End task", role: .destructive) {
+ if let request = terminationRequest {
+ switch request.kind {
+ case .normal: monitor.terminate(request.process)
+ case .force: monitor.terminate(request.process, force: true)
+ case .tree: monitor.terminateTree(request.process)
+ }
+ }
+ terminationRequest = nil
+ }
+ Button("Cancel", role: .cancel) { terminationRequest = nil }
+ } message: {
+ Text(terminationRequest?.kind == .tree ? "This will end the process and all of its child processes. Unsaved data may be lost." : "Unsaved data in this process may be lost.")
+ }
+ .confirmationDialog(
+ "End task: \(groupTerminationRequest?.name ?? "this application")?",
+ isPresented: Binding(get: { groupTerminationRequest != nil }, set: { if !$0 { groupTerminationRequest = nil } })
+ ) {
+ Button("End task", role: .destructive) {
+ if let groupTerminationRequest { monitor.terminateGroup(groupTerminationRequest.processes) }
+ groupTerminationRequest = nil
+ }
+ Button("Cancel", role: .cancel) { groupTerminationRequest = nil }
+ } message: {
+ Text("This will end all \(groupTerminationRequest?.processes.count ?? 0) processes in the application group. Unsaved data may be lost.")
+ }
+ .sheet(item: $inspectedProcess) { process in
+ ProcessPropertiesView(process: process)
+ }
+ .onAppear(perform: restoreColumnWidths)
+ }
+
+ private var processList: some View {
+ VStack(spacing: 0) {
ScrollView(.horizontal) {
VStack(spacing: 0) {
ProcessTableHeader(
@@ -187,40 +285,16 @@ struct ProcessesView: View {
}
.padding(12)
}
- .confirmationDialog(
- "\(terminationRequest?.kind.title ?? "End task"): \(terminationRequest?.process.displayName ?? "this task")?",
- isPresented: Binding(get: { terminationRequest != nil }, set: { if !$0 { terminationRequest = nil } })
- ) {
- Button(terminationRequest?.kind.title ?? "End task", role: .destructive) {
- if let request = terminationRequest {
- switch request.kind {
- case .normal: monitor.terminate(request.process)
- case .force: monitor.terminate(request.process, force: true)
- case .tree: monitor.terminateTree(request.process)
- }
- }
- terminationRequest = nil
- }
- Button("Cancel", role: .cancel) { terminationRequest = nil }
- } message: {
- Text(terminationRequest?.kind == .tree ? "This will end the process and all of its child processes. Unsaved data may be lost." : "Unsaved data in this process may be lost.")
+ }
+
+ private var previewProcess: ProcessRecord? {
+ if let selectedPID {
+ return monitor.processes.first { $0.pid == selectedPID }
}
- .confirmationDialog(
- "End task: \(groupTerminationRequest?.name ?? "this application")?",
- isPresented: Binding(get: { groupTerminationRequest != nil }, set: { if !$0 { groupTerminationRequest = nil } })
- ) {
- Button("End task", role: .destructive) {
- if let groupTerminationRequest { monitor.terminateGroup(groupTerminationRequest.processes) }
- groupTerminationRequest = nil
- }
- Button("Cancel", role: .cancel) { groupTerminationRequest = nil }
- } message: {
- Text("This will end all \(groupTerminationRequest?.processes.count ?? 0) processes in the application group. Unsaved data may be lost.")
+ if let selectedGroupID {
+ return grouped.first { $0.id == selectedGroupID }?.primary
}
- .sheet(item: $inspectedProcess) { process in
- ProcessPropertiesView(process: process)
- }
- .onAppear(perform: restoreColumnWidths)
+ return nil
}
private var processTableWidth: CGFloat {
@@ -530,7 +604,7 @@ private struct ProcessTableHeader: View {
HStack(spacing: 0) {
Text(title).lineLimit(1)
Spacer(minLength: 4)
- if sort == field {
+ if !groupByType && sort == field {
Color.clear.frame(width: 6, height: 0)
Image(systemName: ascending ? "chevron.up" : "chevron.down")
.frame(width: 10)
@@ -540,12 +614,19 @@ private struct ProcessTableHeader: View {
.frame(width: columnWidths[column] ?? column.defaultWidth)
.contentShape(Rectangle())
.onTapGesture {
- groupByType = false
- if sort == field { ascending.toggle() } else { sort = field; ascending = false }
+ let next = ProcessSortCycleState.next(
+ currentField: sort, ascending: ascending, grouped: groupByType, clicked: field
+ )
+ sort = next.field
+ ascending = next.ascending
+ groupByType = next.grouped
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(title)
- .accessibilityValue(sort == field ? (ascending ? "Sorted ascending" : "Sorted descending") : "Not sorted")
+ .accessibilityValue(
+ ProcessSortCycleState(field: sort, ascending: ascending, grouped: groupByType)
+ .accessibilityValue(for: field)
+ )
}
}
diff --git a/Sources/puter/PuterApp.swift b/Sources/puter/PuterApp.swift
index 6501365..d0c6dc3 100644
--- a/Sources/puter/PuterApp.swift
+++ b/Sources/puter/PuterApp.swift
@@ -1,20 +1,44 @@
import SwiftUI
+import UserNotifications
+
+final class PuterAppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ UNUserNotificationCenter.current().delegate = self
+ }
+
+ nonisolated func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ completionHandler([.banner, .sound])
+ }
+}
@main
struct PuterApp: App {
+ @NSApplicationDelegateAdaptor(PuterAppDelegate.self) private var appDelegate
@State private var monitor = SystemMonitor()
+ @StateObject private var updates = UpdateController()
@AppStorage("sidebarCompact") private var sidebarCompact = false
+ @AppStorage("processPreviewPaneVisible") private var processPreviewPaneVisible = false
+ @AppStorage("showMenuBarMonitor") private var showMenuBarMonitor = true
+ @AppStorage("menuBarMetric") private var menuBarMetric: MenuBarMetric = .cpu
var body: some Scene {
- WindowGroup("puter") {
+ WindowGroup("puter", id: "main") {
ContentView()
.environment(monitor)
+ .environmentObject(updates)
.frame(minWidth: 820, minHeight: 560)
.task { monitor.start() }
}
.defaultSize(width: 1180, height: 760)
- .windowStyle(.hiddenTitleBar)
.commands {
+ CommandGroup(after: .appInfo) {
+ Button("Check for Updates…") { updates.checkForUpdates() }
+ .disabled(!updates.canCheckForUpdates)
+ }
CommandGroup(replacing: .newItem) {
Button("Run New Task…") {
NotificationCenter.default.post(name: .runNewTask, object: nil)
@@ -32,7 +56,29 @@ struct PuterApp: App {
.keyboardShortcut("p", modifiers: .command)
Divider()
Toggle("Compact Sidebar", isOn: $sidebarCompact)
+ Toggle("Process Preview", isOn: $processPreviewPaneVisible)
+ .keyboardShortcut("i", modifiers: [.command, .option])
}
}
+
+ MenuBarExtra(isInserted: $showMenuBarMonitor) {
+ MenuBarMonitorView()
+ .environment(monitor)
+ .environmentObject(updates)
+ } label: {
+ Label(menuBarValue, systemImage: "gauge.with.dots.needle.50percent")
+ }
+ .menuBarExtraStyle(.window)
+ }
+
+ private var menuBarValue: String {
+ switch menuBarMetric {
+ case .cpu: Formatters.percent(monitor.snapshot.cpuPercent)
+ case .memory: Formatters.percent(monitor.snapshot.memoryPercent)
+ case .network: Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate)
+ case .power:
+ monitor.hardware.battery.systemPowerWatts > 0
+ ? String(format: "%.0f W", monitor.hardware.battery.systemPowerWatts) : "— W"
+ }
}
}
diff --git a/Sources/puter/SecondaryViews.swift b/Sources/puter/SecondaryViews.swift
index b5a062c..c0862ea 100644
--- a/Sources/puter/SecondaryViews.swift
+++ b/Sources/puter/SecondaryViews.swift
@@ -175,32 +175,45 @@ struct StartupAppsView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var items: [StartupItem] = []
@State private var isLoading = true
+ @State private var includesLoginItems = false
@State private var inspectedItem: StartupItem?
var body: some View {
VStack(spacing: 0) {
- PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") {
+ PageHeader(title: "Startup apps", subtitle: includesLoginItems
+ ? "Login items and launch agents with estimated live impact"
+ : "Launch agents with optional macOS login-item inventory") {
HStack {
Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() }
.disabled(isLoading)
- Button("Open Login Items") {
- openLoginItemsSettings()
+ Menu("Login Items", systemImage: "person.crop.circle.badge.checkmark") {
+ Button(includesLoginItems ? "Reload Login Items" : "Include Login Items") {
+ refreshItems(includeLoginItems: true)
+ }
+ .disabled(isLoading)
+ .help("Read macOS’s system-wide Background Task Management registry using the built-in sfltool diagnostic")
+ Divider()
+ Button("Open Login Items Settings") {
+ openLoginItemsSettings()
+ }
}
}
}
if isLoading && items.isEmpty {
VStack(spacing: 12) {
ProgressView()
- Text("Scanning login items…").foregroundStyle(.secondary)
+ Text("Scanning startup items…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .combine)
- .accessibilityLabel("Scanning login items")
+ .accessibilityLabel("Scanning startup items")
} else if items.isEmpty {
EmptyState(
icon: "rectangle.stack.badge.play",
title: "No startup items found",
- message: "Other login items can be managed in System Settings."
+ message: includesLoginItems
+ ? "No login items or launch agents were found."
+ : "No launch agents were found. Choose Include Login Items or manage them in System Settings."
)
} else {
Table(items) {
@@ -233,7 +246,7 @@ struct StartupAppsView: View {
.sheet(item: $inspectedItem) { item in
StartupItemPropertiesView(item: item, impact: startupImpact(for: item))
}
- .task { refreshItems() }
+ .task { refreshItems(includeLoginItems: false) }
}
private func openLoginItemsSettings() {
@@ -242,11 +255,15 @@ struct StartupAppsView: View {
}
}
- private func refreshItems() {
+ private func refreshItems(includeLoginItems requestedValue: Bool? = nil) {
guard !isLoading || items.isEmpty else { return }
+ let shouldIncludeLoginItems = requestedValue ?? includesLoginItems
+ includesLoginItems = shouldIncludeLoginItems
isLoading = true
Task {
- items = await Task.detached(priority: .utility) { StartupScanner.scan() }.value
+ items = await Task.detached(priority: .utility) {
+ StartupScanner.scan(includeLoginItems: shouldIncludeLoginItems)
+ }.value
isLoading = false
}
}
@@ -605,13 +622,86 @@ private struct StartupItem: Identifiable, Sendable {
var enabled: Bool
}
+struct BackgroundTaskLoginItem: Equatable, Sendable {
+ let name: String
+ let developer: String
+ let bundleIdentifier: String
+ let path: String
+ let enabled: Bool
+}
+
+enum BackgroundTaskRegistryParser {
+ static func parse(_ output: String) -> [BackgroundTaskLoginItem] {
+ var records: [[String]] = []
+ var current: [String] = []
+ for line in output.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("#"), trimmed.dropFirst().prefix(while: \Character.isNumber).isEmpty == false {
+ if !current.isEmpty { records.append(current) }
+ current = []
+ } else if !current.isEmpty || trimmed.hasPrefix("Name:") {
+ current.append(trimmed)
+ }
+ }
+ if !current.isEmpty { records.append(current) }
+
+ var byPath: [String: BackgroundTaskLoginItem] = [:]
+ for lines in records {
+ let type = value("Type", in: lines)
+ guard type.hasPrefix("app "),
+ let rawURL = optionalValue("URL", in: lines),
+ rawURL.hasPrefix("file://"),
+ let url = URL(string: rawURL), url.isFileURL else { continue }
+ let path = url.standardizedFileURL.path
+ guard path.hasPrefix("/"), !path.isEmpty else { continue }
+ let disposition = value("Disposition", in: lines)
+ let name = optionalValue("Name", in: lines)
+ ?? url.deletingPathExtension().lastPathComponent
+ let identifier = optionalValue("Bundle Identifier", in: lines)
+ ?? Bundle(url: url)?.bundleIdentifier
+ ?? path
+ let developer = optionalValue("Developer Name", in: lines) ?? "Unknown"
+ let enabled = disposition.contains("enabled")
+ && disposition.contains("allowed")
+ && !disposition.contains("disallowed")
+ let item = BackgroundTaskLoginItem(
+ name: name, developer: developer, bundleIdentifier: identifier,
+ path: path, enabled: enabled
+ )
+ if let existing = byPath[path] {
+ byPath[path] = BackgroundTaskLoginItem(
+ name: existing.name,
+ developer: existing.developer == "Unknown" ? item.developer : existing.developer,
+ bundleIdentifier: existing.bundleIdentifier,
+ path: path,
+ enabled: existing.enabled || item.enabled
+ )
+ } else {
+ byPath[path] = item
+ }
+ }
+ return byPath.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+
+ private static func value(_ key: String, in lines: [String]) -> String {
+ optionalValue(key, in: lines) ?? ""
+ }
+
+ private static func optionalValue(_ key: String, in lines: [String]) -> String? {
+ let prefix = "\(key):"
+ guard let line = lines.first(where: { $0.hasPrefix(prefix) }) else { return nil }
+ let value = line.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces)
+ return value.isEmpty || value == "(null)" ? nil : value
+ }
+}
+
private enum StartupScanner {
- static func scan() -> [StartupItem] {
+ static func scan(includeLoginItems: Bool) -> [StartupItem] {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let directories = ["\(home)/Library/LaunchAgents", "/Library/LaunchAgents"]
let disabled = disabledServices()
let agents = directories.flatMap { scanDirectory($0, disabled: disabled) }
- let loginItems = sessionLoginItems()
+ let loginItems = includeLoginItems ? sessionLoginItems() : []
return (loginItems + agents).sorted {
if $0.kind != $1.kind { return $0.kind == .loginItem }
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
@@ -642,42 +732,33 @@ private enum StartupScanner {
}
private static func sessionLoginItems() -> [StartupItem] {
- guard let unmanagedList = LSSharedFileListCreate(
- nil,
- "com.apple.LSSharedFileList.SessionLoginItems" as CFString,
- nil
- ) else { return [] }
- let list = unmanagedList.takeRetainedValue()
- guard let unmanagedSnapshot = LSSharedFileListCopySnapshot(list, nil) else { return [] }
- let snapshot = unmanagedSnapshot.takeRetainedValue()
- var seen: Set = []
- var items: [StartupItem] = []
- let flags = UInt32(kLSSharedFileListNoUserInteraction | kLSSharedFileListDoNotMountVolumes)
- for index in 0.. Set {
@@ -791,6 +872,7 @@ struct DetailsView: View {
@State private var columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
@AppStorage("detailSortColumn") private var sortColumn: DetailColumn = .pid
@AppStorage("detailSortAscending") private var ascending = true
+ @AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
private var visibleColumns: [DetailColumn] {
let stored = Set(visibleColumnStorage.split(separator: ",").map(String.init))
@@ -800,46 +882,70 @@ struct DetailsView: View {
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Details", subtitle: "Technical information for running processes") {
- Button("Properties", systemImage: "info.circle") {
- inspectedProcess = selectedProcess
- }
- .disabled(selectedProcess == nil)
- Menu("Columns", systemImage: "rectangle.split.3x1") {
- ForEach(DetailColumn.allCases) { column in
- Toggle(column.title, isOn: columnBinding(column))
- .disabled(column == .name || column == .pid)
+ HStack(spacing: 10) {
+ Button {
+ previewPaneVisible.toggle()
+ } label: {
+ Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
}
- Divider()
- Button("Reset columns") {
- visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
- columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
- persistColumnWidths()
+ .buttonStyle(.bordered)
+ Button("Properties", systemImage: "info.circle") {
+ inspectedProcess = selectedProcess
+ }
+ .disabled(selectedProcess == nil)
+ Menu("Columns", systemImage: "rectangle.split.3x1") {
+ ForEach(DetailColumn.allCases) { column in
+ Toggle(column.title, isOn: columnBinding(column))
+ .disabled(column == .name || column == .pid)
+ }
+ Divider()
+ Button("Reset columns") {
+ visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
+ columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
+ persistColumnWidths()
+ }
}
}
}
ResourceSummaryBar(snapshot: monitor.snapshot)
- GeometryReader { proxy in
- ScrollView([.horizontal, .vertical]) {
- LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
- Section {
- ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
- detailRow(process, index: index)
- }
- } header: {
- detailHeader
- }
- }
- .frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
- }
+ VStack(spacing: 0) {
+ detailsTable
+ selectionBar
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
- selectionBar
+ }
+ .inspector(isPresented: $previewPaneVisible) {
+ ProcessPreviewPane(
+ process: selectedProcess,
+ onClose: { previewPaneVisible = false },
+ onShowProperties: { inspectedProcess = $0 },
+ onEndTask: { terminationRequest = .init(process: $0, kind: .normal) }
+ )
+ .inspectorColumnWidth(min: 250, ideal: 320, max: 480)
}
.onAppear(perform: restoreColumnWidths)
.terminationConfirmation($terminationRequest)
.sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) }
}
+ private var detailsTable: some View {
+ GeometryReader { proxy in
+ ScrollView([.horizontal, .vertical]) {
+ LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
+ Section {
+ ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
+ detailRow(process, index: index)
+ }
+ } header: {
+ detailHeader
+ }
+ }
+ .frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
private var filtered: [ProcessRecord] {
monitor.processes.filter {
searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText)
diff --git a/Sources/puter/SessionRecordingExporter.swift b/Sources/puter/SessionRecordingExporter.swift
new file mode 100644
index 0000000..a5dbf52
--- /dev/null
+++ b/Sources/puter/SessionRecordingExporter.swift
@@ -0,0 +1,90 @@
+import AppKit
+import Foundation
+import UniformTypeIdentifiers
+
+@MainActor
+enum SessionRecordingExporter {
+ static func exportJSON(monitor: SystemMonitor) {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ do {
+ let document = RecordingDocument(
+ startedAt: monitor.recordingStartDate,
+ endedAt: monitor.recordingSamples.last?.timestamp,
+ samples: monitor.recordingSamples,
+ alerts: monitor.recentAlerts
+ )
+ try save(
+ data: encoder.encode(document),
+ name: "puter-session-\(filenameDate()).json",
+ type: .json,
+ message: "Export the recorded telemetry session and resource alerts as JSON.",
+ monitor: monitor
+ )
+ } catch {
+ monitor.errorMessage = "Could not encode the recording: \(error.localizedDescription)"
+ }
+ }
+
+ static func exportCSV(monitor: SystemMonitor) {
+ let header = "timestamp,cpu_percent,memory_used_bytes,memory_total_bytes,memory_pressure,disk_read_bytes_per_second,disk_write_bytes_per_second,gpu_percent,network_receive_bytes_per_second,network_send_bytes_per_second,system_power_watts,thermal_state,process_count"
+ let formatter = ISO8601DateFormatter()
+ let rows = monitor.recordingSamples.map { sample in
+ [
+ formatter.string(from: sample.timestamp),
+ number(sample.cpuPercent), String(sample.memoryUsedBytes), String(sample.memoryTotalBytes),
+ csv(sample.memoryPressure), number(sample.diskReadBytesPerSecond), number(sample.diskWriteBytesPerSecond),
+ number(sample.gpuPercent), number(sample.networkReceiveBytesPerSecond), number(sample.networkSendBytesPerSecond),
+ number(sample.systemPowerWatts), csv(sample.thermalState), String(sample.processCount)
+ ].joined(separator: ",")
+ }
+ let data = Data(([header] + rows).joined(separator: "\n").utf8)
+ save(
+ data: data,
+ name: "puter-session-\(filenameDate()).csv",
+ type: .commaSeparatedText,
+ message: "Export each recorded telemetry sample as a CSV row.",
+ monitor: monitor
+ )
+ }
+
+ private static func save(data: Data, name: String, type: UTType, message: String, monitor: SystemMonitor) {
+ let panel = NSSavePanel()
+ panel.title = "Export telemetry recording"
+ panel.message = message
+ panel.prompt = "Export"
+ panel.allowedContentTypes = [type]
+ panel.canCreateDirectories = true
+ panel.nameFieldStringValue = name
+ panel.begin { response in
+ guard response == .OK, let url = panel.url else { return }
+ do {
+ try data.write(to: url, options: .atomic)
+ } catch {
+ monitor.errorMessage = "Could not export the recording: \(error.localizedDescription)"
+ }
+ }
+ }
+
+ private static func filenameDate() -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd-HHmmss"
+ return formatter.string(from: Date())
+ }
+
+ private static func number(_ value: Double) -> String {
+ String(format: "%.4f", value.isFinite ? value : 0)
+ }
+
+ private static func csv(_ value: String) -> String {
+ "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
+ }
+
+ private struct RecordingDocument: Codable {
+ let startedAt: Date?
+ let endedAt: Date?
+ let samples: [TelemetryRecordingSample]
+ let alerts: [ResourceAlertEvent]
+ }
+}
diff --git a/Sources/puter/SystemMonitor.swift b/Sources/puter/SystemMonitor.swift
index 6c6f72c..129bf07 100644
--- a/Sources/puter/SystemMonitor.swift
+++ b/Sources/puter/SystemMonitor.swift
@@ -1,5 +1,9 @@
+import AppKit
import Foundation
+import IOKit.ps
import Observation
+import SystemConfiguration
+import UserNotifications
@MainActor
@Observable
@@ -29,14 +33,46 @@ final class SystemMonitor {
}
var lastUpdated: Date?
var errorMessage: String?
+ var isRecording = false
+ var recordingStartDate: Date?
+ var recordingSamples: [TelemetryRecordingSample] = []
+ var recentAlerts: [ResourceAlertEvent] = []
private var updateTask: Task?
+ private var hardwareUpdateTask: Task?
+ private var serviceUpdateTask: Task?
+ private var telemetryRefreshTask: Task?
+ private var hardwareRefreshTask: Task?
+ private var serviceRefreshTask: Task?
+ private var memoryPressureSource: DispatchSourceMemoryPressure?
+ private var powerSourceRunLoopSource: CFRunLoopSource?
+ private var telemetryRefreshPending = false
+ private var hardwareRefreshPending = false
+ private var hardwarePortRefreshPending = false
+ private var hardwarePowerRefreshPending = false
+ private var hardwareFanRefreshPending = false
+ private var hardwareSlowRefreshPending = false
+ private var serviceRefreshPending = false
+ private var eventObservers: [NSObjectProtocol] = []
+ private var activeSection: TaskSection = .processes
+ private var appIsActive = true
+ private var memoryPressureLevel = MemoryPressureLevel.normal
+ private var lastProcessDiagnosticsSample: Date?
+ private var lastProcessInventorySample: Date?
+ private var processInventoryRefreshRequested = true
+ private var lastDeviceMetricsSample: Date?
+ private var processDiagnostics: [Int32: ProcessDiagnostics] = [:]
+ private var lastAppHistorySave: Date?
+ private var alertConsecutiveSamples: [ResourceAlertKind: Int] = [:]
+ private var alertLastFired: [ResourceAlertKind: Date] = [:]
private var previousCoreTicks: [CoreTicks] = []
private var previousNetworkCounters: NetworkCounters?
private var previousNetworkDate: Date?
private var previousDiskCounters: DiskCounters?
private var previousDiskDate: Date?
private var previousProcessCPU: [Int32: TimeInterval] = [:]
+ private var previousProcessTelemetryCPU: [Int32: TimeInterval] = [:]
+ private var previousProcessTelemetryDate: Date?
private var previousProcessNetwork: [Int32: ProcessNetworkTotals] = [:]
private var previousProcessNetworkDate: Date?
private var previousProcessIO: [Int32: ProcessIOTotals] = [:]
@@ -44,6 +80,11 @@ final class SystemMonitor {
private var appNetworkTask: Task?
private var lastAppNetworkSample: Date?
private var lastPortSample: Date?
+ private var lastPowerSensorSample: Date?
+ private var lastFanSample: Date?
+ private var lastSlowHardwareSample: Date?
+ private var lastVolumeInventorySample: Date?
+ private var volumeInventoryRefreshRequested = true
init() {
updateSpeed = UpdateSpeed(rawValue: UserDefaults.standard.string(forKey: "updateSpeed") ?? "") ?? .normal
@@ -57,80 +98,433 @@ final class SystemMonitor {
func start() {
guard updateTask == nil else { return }
+ appIsActive = NSApp.isActive
+ installEventObservers()
+ installMemoryPressureSource()
+ installPowerSourceObserver()
refresh()
updateTask = Task { [weak self] in
while !Task.isCancelled {
- let interval = self?.updateSpeed.interval ?? .seconds(2)
+ let interval = self?.telemetryInterval ?? .seconds(2)
try? await Task.sleep(for: interval)
guard let self, !self.isPaused else { continue }
- self.refresh()
+ self.requestTelemetryRefresh()
+ }
+ }
+ hardwareUpdateTask = Task { [weak self] in
+ while !Task.isCancelled {
+ guard let self else { return }
+ let seconds = self.appIsActive
+ ? ([.hardware, .performance, .energy].contains(self.activeSection) ? 5 : 30)
+ : 60
+ try? await Task.sleep(for: .seconds(seconds))
+ guard !Task.isCancelled, !self.isPaused else { continue }
+ self.requestHardwareRefresh()
+ }
+ }
+ serviceUpdateTask = Task { [weak self] in
+ while !Task.isCancelled {
+ guard let self else { return }
+ let seconds = self.appIsActive ? (self.activeSection == .services ? 3 : 30) : 60
+ try? await Task.sleep(for: .seconds(seconds))
+ guard !Task.isCancelled, !self.isPaused else { continue }
+ self.requestServiceRefresh()
}
}
}
func refresh() {
- Task {
+ processInventoryRefreshRequested = true
+ volumeInventoryRefreshRequested = true
+ requestTelemetryRefresh()
+ requestHardwareRefresh(
+ forcePortRefresh: true,
+ forcePowerRefresh: true,
+ forceFanRefresh: true,
+ forceSlowRefresh: true
+ )
+ requestServiceRefresh()
+ }
+
+ func setActiveSection(_ section: TaskSection) {
+ guard activeSection != section else { return }
+ activeSection = section
+ if section.usesLiveProcessInventory {
+ processInventoryRefreshRequested = true
+ requestTelemetryRefresh()
+ }
+ if section == .performance { volumeInventoryRefreshRequested = true }
+ if section == .hardware || section == .performance || section == .energy { requestHardwareRefresh() }
+ if section == .services { requestServiceRefresh() }
+ }
+
+ private var telemetryInterval: Duration {
+ guard appIsActive else { return .seconds(10) }
+ switch activeSection {
+ case .processes, .performance, .history, .users, .details, .energy:
+ return updateSpeed.interval
+ case .startup, .services, .hardware, .diagnostics, .settings:
+ return .seconds(5)
+ }
+ }
+
+ private func requestTelemetryRefresh() {
+ guard telemetryRefreshTask == nil else {
+ telemetryRefreshPending = true
+ return
+ }
+ let now = Date()
+ let processInventoryInterval: TimeInterval = activeSection.usesLiveProcessInventory
+ ? 0
+ : (appIsActive ? 15 : 30)
+ let includeProcessInventory = processInventoryRefreshRequested
+ || processes.isEmpty
+ || lastProcessInventorySample.map { now.timeIntervalSince($0) >= processInventoryInterval } ?? true
+ if includeProcessInventory { processInventoryRefreshRequested = false }
+ let diagnosticInterval: TimeInterval = activeSection == .details ? 5 : 15
+ let includeDiagnostics = includeProcessInventory && (lastProcessDiagnosticsSample.map {
+ now.timeIntervalSince($0) >= diagnosticInterval
+ } ?? true)
+ let deviceMetricsInterval: TimeInterval = activeSection == .performance ? 3 : 20
+ let includeDeviceMetrics = lastDeviceMetricsSample.map {
+ Date().timeIntervalSince($0) >= deviceMetricsInterval
+ } ?? true
+ let includeProcessIO: Bool
+ switch activeSection {
+ case .processes, .performance, .history, .users, .details, .energy, .diagnostics:
+ includeProcessIO = includeProcessInventory && appIsActive
+ case .startup, .services, .hardware, .settings:
+ includeProcessIO = false
+ }
+ let includeVolumeInventory = volumeInventoryRefreshRequested
+ || (activeSection == .performance && (lastVolumeInventorySample.map { Date().timeIntervalSince($0) >= 10 } ?? true))
+ if includeVolumeInventory { volumeInventoryRefreshRequested = false }
+ let knownProcesses = includeProcessInventory
+ ? Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) })
+ : [:]
+ telemetryRefreshTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
- ProcessScanner.capture()
+ ProcessScanner.captureTelemetry(
+ includeProcessInventory: includeProcessInventory,
+ includeDiagnostics: includeDiagnostics,
+ includeDeviceMetrics: includeDeviceMetrics,
+ includeProcessIO: includeProcessIO,
+ includeVolumeInventory: includeVolumeInventory,
+ knownProcesses: knownProcesses
+ )
}.value
- let refreshPorts = lastPortSample.map { Date().timeIntervalSince($0) >= 10 } ?? true
- let existingPorts = hardware.ports
- let hardwareResult = await Task.detached(priority: .utility) {
- HardwareScanner.capture(existingPorts: existingPorts, refreshPorts: refreshPorts)
+ guard let self, !Task.isCancelled else { return }
+ self.applyTelemetry(result)
+ self.telemetryRefreshTask = nil
+ if self.telemetryRefreshPending {
+ self.telemetryRefreshPending = false
+ self.requestTelemetryRefresh()
+ }
+ }
+ }
+
+ private func requestHardwareRefresh(
+ forcePortRefresh: Bool = false,
+ forcePowerRefresh: Bool = false,
+ forceFanRefresh: Bool = false,
+ forceSlowRefresh: Bool = false
+ ) {
+ guard hardwareRefreshTask == nil else {
+ hardwareRefreshPending = true
+ hardwarePortRefreshPending = hardwarePortRefreshPending || forcePortRefresh
+ hardwarePowerRefreshPending = hardwarePowerRefreshPending || forcePowerRefresh
+ hardwareFanRefreshPending = hardwareFanRefreshPending || forceFanRefresh
+ hardwareSlowRefreshPending = hardwareSlowRefreshPending || forceSlowRefresh
+ return
+ }
+ let now = Date()
+ let refreshPorts = forcePortRefresh || lastPortSample.map { Date().timeIntervalSince($0) >= 60 } ?? true
+ let powerIsVisible = [.hardware, .performance, .energy].contains(activeSection)
+ || UserDefaults.standard.string(forKey: "menuBarMetric") == MenuBarMetric.power.rawValue
+ let powerInterval: TimeInterval = powerIsVisible ? 5 : 30
+ let refreshPower = forcePowerRefresh || lastPowerSensorSample.map { now.timeIntervalSince($0) >= powerInterval } ?? true
+ let fanInterval: TimeInterval = activeSection == .hardware ? 5 : 30
+ let refreshFans = forceFanRefresh || lastFanSample.map { now.timeIntervalSince($0) >= fanInterval } ?? true
+ let slowInterval: TimeInterval = [.hardware, .energy].contains(activeSection) ? 15 : 60
+ let refreshSlow = forceSlowRefresh || lastSlowHardwareSample.map { now.timeIntervalSince($0) >= slowInterval } ?? true
+ let existingHardware = hardware
+ hardwareRefreshTask = Task { [weak self] in
+ let result = await Task.detached(priority: .utility) {
+ HardwareScanner.capture(
+ existing: existingHardware,
+ refreshInventory: refreshPorts,
+ refreshPowerSensors: refreshPower,
+ refreshFans: refreshFans,
+ refreshSlowDiagnostics: refreshSlow
+ )
}.value
- processes = result.processes
- updateCPUHistory(with: result.processes)
- updateProcessDiskActivity(result.processIO, current: result.processes)
- services = result.services
- var refreshedSnapshot = result.snapshot
- refreshedSnapshot.corePercents = CoreCPUReader.capture(previous: &previousCoreTicks)
+ guard let self, !Task.isCancelled else { return }
+ self.hardware = result
+ if refreshPorts { self.lastPortSample = Date() }
+ if refreshPower { self.lastPowerSensorSample = Date() }
+ if refreshFans { self.lastFanSample = Date() }
+ if refreshSlow { self.lastSlowHardwareSample = Date() }
+ self.hardwareRefreshTask = nil
+ if self.hardwareRefreshPending {
+ let refreshPendingPorts = self.hardwarePortRefreshPending
+ let refreshPendingPower = self.hardwarePowerRefreshPending
+ let refreshPendingFans = self.hardwareFanRefreshPending
+ let refreshPendingSlow = self.hardwareSlowRefreshPending
+ self.hardwareRefreshPending = false
+ self.hardwarePortRefreshPending = false
+ self.hardwarePowerRefreshPending = false
+ self.hardwareFanRefreshPending = false
+ self.hardwareSlowRefreshPending = false
+ self.requestHardwareRefresh(
+ forcePortRefresh: refreshPendingPorts,
+ forcePowerRefresh: refreshPendingPower,
+ forceFanRefresh: refreshPendingFans,
+ forceSlowRefresh: refreshPendingSlow
+ )
+ }
+ }
+ }
+
+ private func requestServiceRefresh() {
+ guard serviceRefreshTask == nil else {
+ serviceRefreshPending = true
+ return
+ }
+ serviceRefreshTask = Task { [weak self] in
+ let result = await Task.detached(priority: .utility) {
+ ProcessScanner.captureServices()
+ }.value
+ guard let self, !Task.isCancelled else { return }
+ self.services = result
+ self.serviceRefreshTask = nil
+ if self.serviceRefreshPending {
+ self.serviceRefreshPending = false
+ self.requestServiceRefresh()
+ }
+ }
+ }
+
+ private func applyTelemetry(_ result: ProcessScanner.TelemetryResult) {
+ var currentProcesses = processes
+ if result.includesProcessInventory {
+ let sampledProcesses = processesWithCurrentCPU(result.processes)
+ if result.includesDiagnostics {
+ processDiagnostics = Dictionary(uniqueKeysWithValues: sampledProcesses.map {
+ ($0.pid, ProcessDiagnostics(threadCount: $0.threadCount, openFileCount: $0.openFileCount, architecture: $0.architecture))
+ })
+ lastProcessDiagnosticsSample = Date()
+ currentProcesses = sampledProcesses
+ } else {
+ currentProcesses = sampledProcesses.map { process in
+ guard let diagnostics = processDiagnostics[process.pid] else { return process }
+ return process.replacingDiagnostics(with: diagnostics)
+ }
+ }
+ let activePIDs = Set(currentProcesses.map(\.pid))
+ processDiagnostics = processDiagnostics.filter { activePIDs.contains($0.key) }
+ processes = currentProcesses
+ updateCPUHistory(with: currentProcesses)
+ updateProcessDiskActivity(result.processIO, current: currentProcesses)
+ lastProcessInventorySample = Date()
+ }
+ var refreshedSnapshot = result.snapshot
+ refreshedSnapshot.memoryPressure = memoryPressureLevel
+ if !result.includesProcessInventory {
+ refreshedSnapshot.processCount = snapshot.processCount
+ refreshedSnapshot.threadCount = snapshot.threadCount
+ } else if !result.includesDiagnostics {
+ refreshedSnapshot.threadCount = snapshot.threadCount
+ }
+ if !result.includesDeviceMetrics {
+ refreshedSnapshot.diskReadRate = snapshot.diskReadRate
+ refreshedSnapshot.diskWriteRate = snapshot.diskWriteRate
+ refreshedSnapshot.diskOperationsPerSecond = snapshot.diskOperationsPerSecond
+ refreshedSnapshot.diskLatencyMilliseconds = snapshot.diskLatencyMilliseconds
+ refreshedSnapshot.diskActivePercent = snapshot.diskActivePercent
+ refreshedSnapshot.gpuPercent = snapshot.gpuPercent
+ refreshedSnapshot.gpuRendererPercent = snapshot.gpuRendererPercent
+ refreshedSnapshot.gpuTilerPercent = snapshot.gpuTilerPercent
+ refreshedSnapshot.gpuMemoryBytes = snapshot.gpuMemoryBytes
+ refreshedSnapshot.gpuAllocatedBytes = snapshot.gpuAllocatedBytes
+ refreshedSnapshot.gpuCoreCount = snapshot.gpuCoreCount
+ }
+ if !result.includesVolumeInventory {
+ refreshedSnapshot.removableVolumes = snapshot.removableVolumes
+ } else {
+ lastVolumeInventorySample = Date()
+ }
+ refreshedSnapshot.corePercents = CoreCPUReader.capture(previous: &previousCoreTicks)
+ if !refreshedSnapshot.corePercents.isEmpty {
+ refreshedSnapshot.cpuPercent = refreshedSnapshot.corePercents.reduce(0, +)
+ / Double(refreshedSnapshot.corePercents.count)
+ } else {
+ refreshedSnapshot.cpuPercent = snapshot.cpuPercent
+ }
+ if let disk = result.disk {
let diskDate = Date()
if let previousDiskCounters, let previousDiskDate {
- let elapsed = max(0.001, diskDate.timeIntervalSince(previousDiskDate))
- let readBytes = result.disk.readBytes >= previousDiskCounters.readBytes
- ? result.disk.readBytes - previousDiskCounters.readBytes : 0
- let writeBytes = result.disk.writeBytes >= previousDiskCounters.writeBytes
- ? result.disk.writeBytes - previousDiskCounters.writeBytes : 0
- let readOperations = result.disk.readOperations >= previousDiskCounters.readOperations
- ? result.disk.readOperations - previousDiskCounters.readOperations : 0
- let writeOperations = result.disk.writeOperations >= previousDiskCounters.writeOperations
- ? result.disk.writeOperations - previousDiskCounters.writeOperations : 0
- let readTime = result.disk.readTime >= previousDiskCounters.readTime
- ? result.disk.readTime - previousDiskCounters.readTime : 0
- let writeTime = result.disk.writeTime >= previousDiskCounters.writeTime
- ? result.disk.writeTime - previousDiskCounters.writeTime : 0
- let operations = readOperations + writeOperations
- let serviceTime = readTime + writeTime
- refreshedSnapshot.diskReadRate = Double(readBytes) / elapsed
- refreshedSnapshot.diskWriteRate = Double(writeBytes) / elapsed
- refreshedSnapshot.diskOperationsPerSecond = Double(operations) / elapsed
- refreshedSnapshot.diskLatencyMilliseconds = operations > 0
- ? Double(serviceTime) / Double(operations) / 1_000_000 : 0
- refreshedSnapshot.diskActivePercent = min(100, Double(serviceTime) / (elapsed * 1_000_000_000) * 100)
+ let elapsed = max(0.001, diskDate.timeIntervalSince(previousDiskDate))
+ let readBytes = disk.readBytes >= previousDiskCounters.readBytes
+ ? disk.readBytes - previousDiskCounters.readBytes : 0
+ let writeBytes = disk.writeBytes >= previousDiskCounters.writeBytes
+ ? disk.writeBytes - previousDiskCounters.writeBytes : 0
+ let readOperations = disk.readOperations >= previousDiskCounters.readOperations
+ ? disk.readOperations - previousDiskCounters.readOperations : 0
+ let writeOperations = disk.writeOperations >= previousDiskCounters.writeOperations
+ ? disk.writeOperations - previousDiskCounters.writeOperations : 0
+ let readTime = disk.readTime >= previousDiskCounters.readTime
+ ? disk.readTime - previousDiskCounters.readTime : 0
+ let writeTime = disk.writeTime >= previousDiskCounters.writeTime
+ ? disk.writeTime - previousDiskCounters.writeTime : 0
+ let operations = readOperations + writeOperations
+ let serviceTime = readTime + writeTime
+ refreshedSnapshot.diskReadRate = Double(readBytes) / elapsed
+ refreshedSnapshot.diskWriteRate = Double(writeBytes) / elapsed
+ refreshedSnapshot.diskOperationsPerSecond = Double(operations) / elapsed
+ refreshedSnapshot.diskLatencyMilliseconds = operations > 0
+ ? Double(serviceTime) / Double(operations) / 1_000_000 : 0
+ refreshedSnapshot.diskActivePercent = min(100, Double(serviceTime) / (elapsed * 1_000_000_000) * 100)
}
- previousDiskCounters = result.disk
+ previousDiskCounters = disk
previousDiskDate = diskDate
- let networkDate = Date()
- if let previousNetworkCounters, let previousNetworkDate {
- let elapsed = max(0.001, networkDate.timeIntervalSince(previousNetworkDate))
- if result.network.received >= previousNetworkCounters.received {
- refreshedSnapshot.networkReceiveRate = Double(result.network.received - previousNetworkCounters.received) / elapsed
- }
- if result.network.sent >= previousNetworkCounters.sent {
- refreshedSnapshot.networkSendRate = Double(result.network.sent - previousNetworkCounters.sent) / elapsed
- }
- }
- refreshedSnapshot.networkInterface = result.network.interface
- previousNetworkCounters = result.network
- previousNetworkDate = networkDate
- snapshot = refreshedSnapshot
- hardware = hardwareResult
- if refreshPorts { lastPortSample = Date() }
- lastUpdated = Date()
- errorMessage = result.error
- appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent)
- sampleAppNetworkIfNeeded()
+ lastDeviceMetricsSample = diskDate
}
+ let networkDate = Date()
+ if let previousNetworkCounters, let previousNetworkDate {
+ let elapsed = max(0.001, networkDate.timeIntervalSince(previousNetworkDate))
+ if result.network.received >= previousNetworkCounters.received {
+ refreshedSnapshot.networkReceiveRate = Double(result.network.received - previousNetworkCounters.received) / elapsed
+ }
+ if result.network.sent >= previousNetworkCounters.sent {
+ refreshedSnapshot.networkSendRate = Double(result.network.sent - previousNetworkCounters.sent) / elapsed
+ }
+ }
+ refreshedSnapshot.networkInterface = result.network.interface
+ previousNetworkCounters = result.network
+ previousNetworkDate = networkDate
+ snapshot = refreshedSnapshot
+ lastUpdated = Date()
+ errorMessage = result.error
+ recordCurrentSampleIfNeeded()
+ evaluateResourceAlerts()
+ appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent)
+ sampleAppNetworkIfNeeded()
+ }
+
+ private func processesWithCurrentCPU(_ current: [ProcessRecord]) -> [ProcessRecord] {
+ let now = Date()
+ let elapsed = previousProcessTelemetryDate.map { max(0.001, now.timeIntervalSince($0)) }
+ let coreCount = Double(max(1, ProcessInfo.processInfo.activeProcessorCount))
+ let result = current.map { process in
+ guard let elapsed,
+ let previous = previousProcessTelemetryCPU[process.pid],
+ process.cpuTime >= previous else { return process.replacingCPU(with: 0) }
+ let percent = min(100, (process.cpuTime - previous) / elapsed / coreCount * 100)
+ return process.replacingCPU(with: percent.isFinite ? percent : 0)
+ }
+ previousProcessTelemetryCPU = Dictionary(uniqueKeysWithValues: current.map { ($0.pid, $0.cpuTime) })
+ previousProcessTelemetryDate = now
+ return result
+ }
+
+ private func installEventObservers() {
+ guard eventObservers.isEmpty else { return }
+ let center = NSWorkspace.shared.notificationCenter
+ for name in [
+ NSWorkspace.didLaunchApplicationNotification,
+ NSWorkspace.didTerminateApplicationNotification
+ ] {
+ eventObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
+ Task { @MainActor in
+ self?.processInventoryRefreshRequested = true
+ self?.requestTelemetryRefresh()
+ }
+ })
+ }
+ for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification] {
+ eventObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
+ Task { @MainActor in
+ self?.volumeInventoryRefreshRequested = true
+ self?.requestTelemetryRefresh()
+ self?.requestHardwareRefresh(forcePortRefresh: true)
+ }
+ })
+ }
+ eventObservers.append(center.addObserver(
+ forName: NSWorkspace.didWakeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in self?.refresh() }
+ })
+ eventObservers.append(NotificationCenter.default.addObserver(
+ forName: ProcessInfo.thermalStateDidChangeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in self?.requestHardwareRefresh() }
+ })
+ eventObservers.append(NotificationCenter.default.addObserver(
+ forName: NSApplication.didBecomeActiveNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in
+ self?.appIsActive = true
+ self?.requestTelemetryRefresh()
+ }
+ })
+ eventObservers.append(NotificationCenter.default.addObserver(
+ forName: NSApplication.didResignActiveNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in self?.appIsActive = false }
+ })
+ eventObservers.append(NotificationCenter.default.addObserver(
+ forName: NSApplication.willTerminateNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in self?.saveAppHistory(force: true) }
+ })
+ }
+
+ private func installMemoryPressureSource() {
+ guard memoryPressureSource == nil else { return }
+ let source = DispatchSource.makeMemoryPressureSource(
+ eventMask: [.normal, .warning, .critical],
+ queue: .main
+ )
+ source.setEventHandler { [weak self, weak source] in
+ guard let self, let event = source?.data else { return }
+ if event.contains(.critical) {
+ self.memoryPressureLevel = .critical
+ } else if event.contains(.warning) {
+ self.memoryPressureLevel = .warning
+ } else {
+ self.memoryPressureLevel = .normal
+ }
+ self.snapshot.memoryPressure = self.memoryPressureLevel
+ self.requestTelemetryRefresh()
+ }
+ source.resume()
+ memoryPressureSource = source
+ }
+
+ private func installPowerSourceObserver() {
+ guard powerSourceRunLoopSource == nil else { return }
+ let context = Unmanaged.passUnretained(self).toOpaque()
+ guard let unmanagedSource = IOPSNotificationCreateRunLoopSource({ context in
+ guard let context else { return }
+ let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ Task { @MainActor in
+ monitor.requestHardwareRefresh(forcePowerRefresh: true, forceSlowRefresh: true)
+ }
+ }, context) else { return }
+ let source = unmanagedSource.takeRetainedValue()
+ CFRunLoopAddSource(CFRunLoopGetMain(), source, .defaultMode)
+ powerSourceRunLoopSource = source
}
func resetAppHistory() {
@@ -138,7 +532,33 @@ final class SystemMonitor {
appHistoryStartDate = Date()
previousProcessCPU = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0.cpuTime) })
previousProcessNetwork = [:]
- saveAppHistory()
+ saveAppHistory(force: true)
+ }
+
+ func startRecording() {
+ recordingSamples.removeAll(keepingCapacity: true)
+ recordingStartDate = Date()
+ isRecording = true
+ recordCurrentSampleIfNeeded()
+ }
+
+ func stopRecording() {
+ isRecording = false
+ }
+
+ func clearRecording() {
+ isRecording = false
+ recordingStartDate = nil
+ recordingSamples = []
+ }
+
+ func clearAlerts() {
+ recentAlerts = []
+ alertConsecutiveSamples = [:]
+ }
+
+ func requestAlertAuthorization() async -> Bool {
+ await ResourceAlertNotifier.requestAuthorization()
}
func activeProcess(for usage: AppUsageRecord) -> ProcessRecord? {
@@ -375,6 +795,7 @@ final class SystemMonitor {
let now = Date()
let activePIDs = Set(current.map(\.pid))
processActivity = processActivity.filter { activePIDs.contains($0.key) }
+ guard !totals.isEmpty else { return }
if let previousProcessIODate {
let elapsed = max(0.001, now.timeIntervalSince(previousProcessIODate))
for (pid, total) in totals {
@@ -396,16 +817,164 @@ final class SystemMonitor {
appHistory = state.records
}
- private func saveAppHistory() {
+ private func saveAppHistory(force: Bool = false) {
+ let now = Date()
+ guard force || lastAppHistorySave.map({ now.timeIntervalSince($0) >= 30 }) ?? true else { return }
let state = AppHistoryPersistence.State(startDate: appHistoryStartDate, records: appHistory)
guard let data = try? JSONEncoder().encode(state) else { return }
do {
try FileManager.default.createDirectory(at: AppHistoryPersistence.directory, withIntermediateDirectories: true)
try data.write(to: AppHistoryPersistence.url, options: .atomic)
+ lastAppHistorySave = now
} catch {
errorMessage = "App history could not be saved: \(error.localizedDescription)"
}
}
+
+ private func recordCurrentSampleIfNeeded() {
+ guard isRecording else { return }
+ recordingSamples.append(TelemetryRecordingSample(
+ timestamp: Date(),
+ cpuPercent: snapshot.cpuPercent,
+ memoryUsedBytes: snapshot.memoryUsed,
+ memoryTotalBytes: snapshot.memoryTotal,
+ memoryPressure: snapshot.memoryPressure.rawValue,
+ diskReadBytesPerSecond: snapshot.diskReadRate,
+ diskWriteBytesPerSecond: snapshot.diskWriteRate,
+ gpuPercent: snapshot.gpuPercent,
+ networkReceiveBytesPerSecond: snapshot.networkReceiveRate,
+ networkSendBytesPerSecond: snapshot.networkSendRate,
+ systemPowerWatts: hardware.battery.systemPowerWatts,
+ thermalState: hardware.thermalState,
+ processCount: snapshot.processCount
+ ))
+ let maximumSamples = 18_000
+ if recordingSamples.count > maximumSamples {
+ recordingSamples.removeFirst(recordingSamples.count - maximumSamples)
+ }
+ }
+
+ private func evaluateResourceAlerts() {
+ let defaults = UserDefaults.standard
+ guard defaults.bool(forKey: "resourceAlertsEnabled") else {
+ alertConsecutiveSamples = [:]
+ return
+ }
+ let storedRequiredSamples = defaults.integer(forKey: "resourceAlertSustainedSamples")
+ let requiredSamples = storedRequiredSamples > 0 ? storedRequiredSamples : 3
+ let storedCooldown = defaults.double(forKey: "resourceAlertCooldownSeconds")
+ let cooldown = storedCooldown > 0 ? max(60, storedCooldown) : 300
+ let metrics: [(ResourceAlertKind, Double, Double, Bool)] = [
+ (.cpu, snapshot.cpuPercent, defaults.double(forKey: "resourceAlertCPUThreshold"), false),
+ (.memory, snapshot.memoryPercent, defaults.double(forKey: "resourceAlertMemoryThreshold"), false),
+ (.disk, snapshot.diskActivePercent, defaults.double(forKey: "resourceAlertDiskThreshold"), false),
+ (.thermal, hardware.thermalState == "Critical" ? 100 : (hardware.thermalState == "Serious" ? 75 : 0), 75, true)
+ ]
+ for (kind, value, storedThreshold, isThermal) in metrics {
+ let threshold = storedThreshold > 0 ? storedThreshold : defaultThreshold(for: kind)
+ guard value >= threshold else {
+ alertConsecutiveSamples[kind] = 0
+ continue
+ }
+ let count = (alertConsecutiveSamples[kind] ?? 0) + 1
+ alertConsecutiveSamples[kind] = count
+ let now = Date()
+ guard ResourceAlertPolicy.shouldFire(
+ value: value,
+ threshold: threshold,
+ consecutiveSamples: count,
+ requiredSamples: requiredSamples,
+ lastFired: alertLastFired[kind],
+ now: now,
+ cooldown: cooldown
+ ) else { continue }
+ let valueText = isThermal ? hardware.thermalState : Formatters.percent(value)
+ let message = "\(kind.rawValue) reached \(valueText) (threshold \(Formatters.percent(threshold)))."
+ let event = ResourceAlertEvent(
+ id: UUID(), timestamp: now, resource: kind.rawValue,
+ value: value, threshold: threshold, message: message
+ )
+ recentAlerts.insert(event, at: 0)
+ if recentAlerts.count > 100 { recentAlerts.removeLast(recentAlerts.count - 100) }
+ alertLastFired[kind] = event.timestamp
+ alertConsecutiveSamples[kind] = 0
+ ResourceAlertNotifier.deliver(event)
+ }
+ }
+
+ private func defaultThreshold(for kind: ResourceAlertKind) -> Double {
+ switch kind {
+ case .cpu: 90
+ case .memory: 85
+ case .disk: 90
+ case .thermal: 75
+ }
+ }
+}
+
+private enum ResourceAlertNotifier {
+ static func requestAuthorization() async -> Bool {
+ (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
+ }
+
+ static func deliver(_ event: ResourceAlertEvent) {
+ let content = UNMutableNotificationContent()
+ content.title = "puter resource alert"
+ content.body = event.message
+ content.sound = .default
+ let request = UNNotificationRequest(identifier: event.id.uuidString, content: content, trigger: nil)
+ UNUserNotificationCenter.current().add(request)
+ }
+}
+
+private struct ProcessDiagnostics {
+ let threadCount: Int
+ let openFileCount: Int
+ let architecture: String
+}
+
+private extension ProcessRecord {
+ func replacingCPU(with cpu: Double) -> ProcessRecord {
+ ProcessRecord(
+ pid: pid,
+ parentPID: parentPID,
+ name: name,
+ executablePath: executablePath,
+ user: user,
+ cpu: cpu,
+ memoryPercent: memoryPercent,
+ residentBytes: residentBytes,
+ state: state,
+ elapsed: elapsed,
+ cpuTime: cpuTime,
+ threadCount: threadCount,
+ openFileCount: openFileCount,
+ architecture: architecture,
+ priority: priority,
+ nice: nice
+ )
+ }
+
+ func replacingDiagnostics(with diagnostics: ProcessDiagnostics) -> ProcessRecord {
+ ProcessRecord(
+ pid: pid,
+ parentPID: parentPID,
+ name: name,
+ executablePath: executablePath,
+ user: user,
+ cpu: cpu,
+ memoryPercent: memoryPercent,
+ residentBytes: residentBytes,
+ state: state,
+ elapsed: elapsed,
+ cpuTime: cpuTime,
+ threadCount: diagnostics.threadCount,
+ openFileCount: diagnostics.openFileCount,
+ architecture: diagnostics.architecture,
+ priority: priority,
+ nice: nice
+ )
+ }
}
private enum AppHistoryPersistence {
@@ -494,6 +1063,16 @@ private struct DiskCounters: Sendable {
var writeTime: UInt64 = 0
}
+enum DiskMediaClassifier {
+ static func isPhysicalMediaName(_ name: String) -> Bool {
+ let normalized = name.lowercased()
+ return !normalized.isEmpty
+ && normalized.hasSuffix(" media")
+ && !normalized.contains("disk image")
+ && !normalized.contains("ram disk")
+ }
+}
+
private struct GPUCounters: Sendable {
var devicePercent = 0.0
var rendererPercent = 0.0
@@ -567,30 +1146,41 @@ private enum CoreCPUReader {
private enum ProcessScanner {
private static let memoryHardware = memoryHardwareInfo()
+ private static let machTimebase: mach_timebase_info_data_t = {
+ var value = mach_timebase_info_data_t()
+ mach_timebase_info(&value)
+ return value
+ }()
- struct Result: Sendable {
+ struct TelemetryResult: Sendable {
let processes: [ProcessRecord]
- let services: [ServiceRecord]
+ let includesProcessInventory: Bool
+ let includesDiagnostics: Bool
+ let includesDeviceMetrics: Bool
+ let includesVolumeInventory: Bool
let snapshot: SystemSnapshot
let network: NetworkCounters
- let disk: DiskCounters
+ let disk: DiskCounters?
let processIO: [Int32: ProcessIOTotals]
let error: String?
}
- static func capture() -> Result {
- let ps = run("/bin/ps", arguments: ["-axo", "pid=,ppid=,user=,%cpu=,%mem=,rss=,state=,etime=,time=,pri=,nice=,comm="])
- let threads = threadCounts()
- let records = parseProcesses(ps.output, threadCounts: threads)
- let userServices = parseServices(run("/bin/launchctl", arguments: ["list"]).output, domain: .user)
- let systemServices = parseSystemServices(run("/bin/launchctl", arguments: ["print", "system"]).output)
- let services = userServices + systemServices
- let cpu = min(100, records.reduce(0) { $0 + $1.cpu })
+ static func captureTelemetry(
+ includeProcessInventory: Bool = true,
+ includeDiagnostics: Bool,
+ includeDeviceMetrics: Bool,
+ includeProcessIO: Bool,
+ includeVolumeInventory: Bool,
+ knownProcesses: [Int32: ProcessRecord] = [:]
+ ) -> TelemetryResult {
+ let records = includeProcessInventory
+ ? nativeProcessRecords(includeDiagnostics: includeDiagnostics, knownProcesses: knownProcesses)
+ : []
let totalMemory = ProcessInfo.processInfo.physicalMemory
let memory = memoryCounters(total: totalMemory)
let disk = diskUsage()
var snapshot = SystemSnapshot()
- snapshot.cpuPercent = cpu
+ snapshot.cpuPercent = 0
snapshot.memoryUsed = memory.used
snapshot.memoryTotal = totalMemory
snapshot.memoryActive = memory.active
@@ -604,28 +1194,137 @@ private enum ProcessScanner {
snapshot.memoryManufacturer = memoryHardware.manufacturer
snapshot.diskFree = disk.free
snapshot.diskTotal = disk.total
- snapshot.removableVolumes = removableVolumes()
+ if includeVolumeInventory { snapshot.removableVolumes = removableVolumes() }
snapshot.processCount = records.count
- snapshot.threadCount = threads.values.reduce(0, +)
+ snapshot.threadCount = records.reduce(0) { $0 + $1.threadCount }
snapshot.uptime = ProcessInfo.processInfo.systemUptime
- let gpu = gpuCounters()
- snapshot.gpuPercent = gpu.devicePercent
- snapshot.gpuRendererPercent = gpu.rendererPercent
- snapshot.gpuTilerPercent = gpu.tilerPercent
- snapshot.gpuMemoryBytes = gpu.memoryBytes
- snapshot.gpuAllocatedBytes = gpu.allocatedBytes
- snapshot.gpuCoreCount = gpu.coreCount
- return Result(
+ if includeDeviceMetrics {
+ let gpu = gpuCounters()
+ snapshot.gpuPercent = gpu.devicePercent
+ snapshot.gpuRendererPercent = gpu.rendererPercent
+ snapshot.gpuTilerPercent = gpu.tilerPercent
+ snapshot.gpuMemoryBytes = gpu.memoryBytes
+ snapshot.gpuAllocatedBytes = gpu.allocatedBytes
+ snapshot.gpuCoreCount = gpu.coreCount
+ }
+ return TelemetryResult(
processes: records,
- services: services,
+ includesProcessInventory: includeProcessInventory,
+ includesDiagnostics: includeDiagnostics,
+ includesDeviceMetrics: includeDeviceMetrics,
+ includesVolumeInventory: includeVolumeInventory,
snapshot: snapshot,
network: networkCounters(),
- disk: diskCounters(),
- processIO: processIOTotals(records.map(\.pid)),
- error: ps.error
+ disk: includeDeviceMetrics ? diskCounters() : nil,
+ processIO: includeProcessIO ? processIOTotals(records.map(\.pid)) : [:],
+ error: nil
)
}
+ private static func nativeProcessRecords(
+ includeDiagnostics: Bool,
+ knownProcesses: [Int32: ProcessRecord]
+ ) -> [ProcessRecord] {
+ let expectedCount = max(1, Int(proc_listallpids(nil, 0)))
+ var pids = [pid_t](repeating: 0, count: expectedCount + 128)
+ let count = pids.withUnsafeMutableBytes { buffer in
+ Int(proc_listallpids(buffer.baseAddress, Int32(buffer.count)))
+ }
+ guard count > 0 else { return [] }
+ let totalMemory = Double(max(1, ProcessInfo.processInfo.physicalMemory))
+ let now = Date().timeIntervalSince1970
+
+ return pids.prefix(min(count, pids.count)).compactMap { pid in
+ guard pid > 0 else { return nil }
+ var info = proc_taskallinfo()
+ let copied = withUnsafeMutablePointer(to: &info) { pointer in
+ proc_pidinfo(pid, PROC_PIDTASKALLINFO, 0, pointer, Int32(MemoryLayout.stride))
+ }
+ guard copied == MemoryLayout.stride else { return nil }
+
+ let bsd = info.pbsd
+ let task = info.ptinfo
+ let metadata: (name: String, path: String, user: String)
+ if let known = knownProcesses[pid] {
+ metadata = (known.name, known.executablePath, known.user)
+ } else {
+ metadata = processMetadata(pid: pid, bsd: bsd)
+ }
+ let cpuTicks = task.pti_total_user + task.pti_total_system
+ let cpuTime = Double(cpuTicks) * Double(machTimebase.numer) / Double(max(1, machTimebase.denom)) / 1_000_000_000
+ let age = max(0, now - Double(bsd.pbi_start_tvsec))
+ return ProcessRecord(
+ pid: pid,
+ parentPID: Int32(bitPattern: bsd.pbi_ppid),
+ name: metadata.name,
+ executablePath: metadata.path,
+ user: metadata.user,
+ cpu: 0,
+ memoryPercent: Double(task.pti_resident_size) / totalMemory * 100,
+ residentBytes: task.pti_resident_size,
+ state: processState(bsd.pbi_status),
+ elapsed: elapsedDescription(age),
+ cpuTime: cpuTime,
+ threadCount: includeDiagnostics ? Int(task.pti_threadnum) : 0,
+ openFileCount: includeDiagnostics ? openFileCount(pid) : 0,
+ architecture: includeDiagnostics && !metadata.path.isEmpty
+ ? BinaryArchitectureReader.architecture(at: metadata.path)
+ : "Unknown",
+ priority: Int(task.pti_priority),
+ nice: Int(bsd.pbi_nice)
+ )
+ }
+ }
+
+ private static func processMetadata(pid: pid_t, bsd: proc_bsdinfo) -> (name: String, path: String, user: String) {
+ var pathBuffer = [CChar](repeating: 0, count: 4 * 1_024)
+ let pathLength = proc_pidpath(pid, &pathBuffer, UInt32(pathBuffer.count))
+ let path = pathLength > 0
+ ? String(decoding: pathBuffer.prefix(Int(pathLength)).map { UInt8(bitPattern: $0) }, as: UTF8.self)
+ : ""
+ let registeredName = withUnsafeBytes(of: bsd.pbi_name) { raw -> String in
+ guard let base = raw.bindMemory(to: CChar.self).baseAddress else { return "" }
+ return String(cString: base)
+ }
+ let commandName = withUnsafeBytes(of: bsd.pbi_comm) { raw -> String in
+ guard let base = raw.bindMemory(to: CChar.self).baseAddress else { return "" }
+ return String(cString: base)
+ }
+ let name = path.isEmpty
+ ? (registeredName.isEmpty ? commandName : registeredName)
+ : URL(fileURLWithPath: path).lastPathComponent
+ let user = getpwuid(bsd.pbi_uid).map { String(cString: $0.pointee.pw_name) } ?? String(bsd.pbi_uid)
+ return (name, path, user)
+ }
+
+ private static func processState(_ status: UInt32) -> String {
+ switch status {
+ case 1: "I"
+ case 2: "R"
+ case 3: "S"
+ case 4: "T"
+ case 5: "Z"
+ default: "?"
+ }
+ }
+
+ private static func elapsedDescription(_ seconds: TimeInterval) -> String {
+ let value = max(0, Int(seconds))
+ let days = value / 86_400
+ let hours = value / 3_600 % 24
+ let minutes = value / 60 % 60
+ let remainingSeconds = value % 60
+ if days > 0 { return String(format: "%d-%02d:%02d:%02d", days, hours, minutes, remainingSeconds) }
+ if hours > 0 { return String(format: "%02d:%02d:%02d", hours, minutes, remainingSeconds) }
+ return String(format: "%02d:%02d", minutes, remainingSeconds)
+ }
+
+ static func captureServices() -> [ServiceRecord] {
+ let userServices = parseServices(run("/bin/launchctl", arguments: ["list"]).output, domain: .user)
+ let systemServices = parseSystemServices(run("/bin/launchctl", arguments: ["print", "system"]).output)
+ return userServices + systemServices
+ }
+
private static func processIOTotals(_ pids: [Int32]) -> [Int32: ProcessIOTotals] {
var totals: [Int32: ProcessIOTotals] = [:]
for pid in pids {
@@ -653,6 +1352,9 @@ private enum ProcessScanner {
}
var result = DiskCounters()
for driver in drivers {
+ let childNames = (driver["IORegistryEntryChildren"] as? [[String: Any]] ?? [])
+ .compactMap { $0["IORegistryEntryName"] as? String }
+ guard childNames.contains(where: DiskMediaClassifier.isPhysicalMediaName) else { continue }
guard let statistics = driver["Statistics"] as? [String: Any] else { continue }
func value(_ key: String) -> UInt64 {
(statistics[key] as? NSNumber)?.uint64Value ?? 0
@@ -685,7 +1387,11 @@ private enum ProcessScanner {
)
}
- private static func parseProcesses(_ output: String, threadCounts: [Int32: Int]) -> [ProcessRecord] {
+ private static func parseProcesses(
+ _ output: String,
+ threadCounts: [Int32: Int],
+ includeDiagnostics: Bool
+ ) -> [ProcessRecord] {
output.split(separator: "\n").compactMap { line in
let fields = line.split(maxSplits: 11, whereSeparator: { $0 == " " || $0 == "\t" })
guard fields.count == 12,
@@ -711,9 +1417,9 @@ private enum ProcessScanner {
state: String(fields[6]),
elapsed: String(fields[7]),
cpuTime: parseCPUTime(String(fields[8])),
- threadCount: threadCounts[pid] ?? 0,
- openFileCount: openFileCount(pid),
- architecture: BinaryArchitectureReader.architecture(at: path),
+ threadCount: includeDiagnostics ? threadCounts[pid] ?? 0 : 0,
+ openFileCount: includeDiagnostics ? openFileCount(pid) : 0,
+ architecture: includeDiagnostics ? BinaryArchitectureReader.architecture(at: path) : "Unknown",
priority: priority,
nice: nice
)
@@ -765,21 +1471,32 @@ private enum ProcessScanner {
}
private static func memoryCounters(total: UInt64) -> MemoryCounters {
- let vm = run("/usr/bin/vm_stat", arguments: []).output
- let pageSize = UInt64(vm.firstMatch(#"page size of (\d+) bytes"#) ?? "4096") ?? 4096
- func pages(_ label: String) -> UInt64 {
- UInt64(vm.firstMatch("\(NSRegularExpression.escapedPattern(for: label)): +([0-9]+)") ?? "0") ?? 0
+ var statistics = vm_statistics64()
+ var count = mach_msg_type_number_t(
+ MemoryLayout.size / MemoryLayout.size
+ )
+ let result = withUnsafeMutablePointer(to: &statistics) { pointer in
+ pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
+ host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
+ }
}
- let free = (pages("Pages free") + pages("Pages speculative")) * pageSize
+ guard result == KERN_SUCCESS else { return MemoryCounters() }
+ var rawPageSize: vm_size_t = 0
+ guard host_page_size(mach_host_self(), &rawPageSize) == KERN_SUCCESS else { return MemoryCounters() }
+ let pageSize = UInt64(rawPageSize)
+ let active = UInt64(statistics.active_count) * pageSize
+ let wired = UInt64(statistics.wire_count) * pageSize
+ let compressed = UInt64(statistics.compressor_page_count) * pageSize
+ let used = min(total, active + wired + compressed)
var swap = xsw_usage()
var swapSize = MemoryLayout.size
_ = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
return MemoryCounters(
- used: total > free ? total - free : 0,
- active: pages("Pages active") * pageSize,
- wired: pages("Pages wired down") * pageSize,
- compressed: pages("Pages occupied by compressor") * pageSize,
- cached: (pages("Pages inactive") + pages("Pages purgeable")) * pageSize,
+ used: used,
+ active: active,
+ wired: wired,
+ compressed: compressed,
+ cached: (UInt64(statistics.inactive_count) + UInt64(statistics.purgeable_count)) * pageSize,
swapUsed: swap.xsu_used,
swapTotal: swap.xsu_total
)
@@ -855,30 +1572,26 @@ private enum ProcessScanner {
}
private static func networkCounters() -> NetworkCounters {
- let route = run("/sbin/route", arguments: ["-n", "get", "default"]).output
- let primary = route.firstMatch(#"interface: +([^\s]+)"#) ?? "Network"
- let netstat = run("/usr/sbin/netstat", arguments: ["-ibn"]).output
+ let global = SCDynamicStoreCopyValue(nil, "State:/Network/Global/IPv4" as CFString) as? [String: Any]
+ let primary = global?["PrimaryInterface"] as? String ?? "Network"
var received: UInt64 = 0
var sent: UInt64 = 0
- var matchedPrimary = false
-
- for line in netstat.split(separator: "\n") {
- let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
- guard fields.count >= 10,
- fields[2].hasPrefix("?
+ guard getifaddrs(&interfaces) == 0, let first = interfaces else {
return NetworkCounters(received: 0, sent: 0, interface: primary)
}
+ defer { freeifaddrs(interfaces) }
+ var cursor: UnsafeMutablePointer? = first
+ while let interface = cursor {
+ let item = interface.pointee
+ let name = String(cString: item.ifa_name)
+ if name == primary, let rawData = item.ifa_data {
+ let data = rawData.assumingMemoryBound(to: if_data.self).pointee
+ received += UInt64(data.ifi_ibytes)
+ sent += UInt64(data.ifi_obytes)
+ }
+ cursor = item.ifa_next
+ }
return NetworkCounters(received: received, sent: sent, interface: primary)
}
@@ -900,17 +1613,106 @@ private enum ProcessScanner {
}
}
+/// A narrow test hook for guarding the cost and completeness of the coordinated
+/// native telemetry path without exposing scanner implementation details to UI code.
+enum ProcessSamplerBenchmark {
+ struct Result: Sendable {
+ let iterations: Int
+ let processCount: Int
+ let elapsed: TimeInterval
+
+ var averageSeconds: TimeInterval {
+ iterations > 0 ? elapsed / Double(iterations) : 0
+ }
+ }
+
+ static func run(iterations: Int, includeProcessInventory: Bool = true) -> Result {
+ let count = max(1, iterations)
+ var knownProcesses: [Int32: ProcessRecord] = [:]
+ var processCount = 0
+ let started = ContinuousClock.now
+ for _ in 0.. SMCPowerSensorSnapshot {
+ func value(_ key: String) -> Double {
+ let raw = output.firstMatch("(?m)^\\[\(NSRegularExpression.escapedPattern(for: key))\\] +(-?[0-9.eE+-]+)")
+ .flatMap(Double.init) ?? 0
+ return raw.isFinite && raw >= 0 && raw <= 1_000 ? raw : 0
+ }
+ return SMCPowerSensorSnapshot(
+ systemWatts: value("PSTR"),
+ adapterWatts: value("PDTR"),
+ batteryWatts: value("PPBR")
+ )
+ }
+}
+
private enum HardwareScanner {
- static func capture(existingPorts: [HardwarePortSnapshot], refreshPorts: Bool) -> HardwareSnapshot {
- HardwareSnapshot(
- battery: batterySnapshot(),
- ports: refreshPorts ? portSnapshots() : existingPorts,
- fans: fanSnapshots(),
+ static func capture(
+ existing: HardwareSnapshot,
+ refreshInventory: Bool,
+ refreshPowerSensors: Bool,
+ refreshFans: Bool,
+ refreshSlowDiagnostics: Bool
+ ) -> HardwareSnapshot {
+ var battery = batterySnapshot()
+ if refreshPowerSensors {
+ let power = powerSensorSnapshot()
+ if power.systemWatts > 0 { battery.systemPowerWatts = power.systemWatts }
+ battery.sensorBatteryPowerWatts = power.batteryWatts
+ battery.adapterInputWatts = power.adapterWatts
+ } else {
+ battery.systemPowerWatts = existing.battery.systemPowerWatts
+ battery.sensorBatteryPowerWatts = existing.battery.sensorBatteryPowerWatts
+ battery.adapterInputWatts = existing.battery.adapterInputWatts
+ }
+ return HardwareSnapshot(
+ battery: battery,
+ ports: refreshInventory ? portSnapshots() : existing.ports,
+ fans: refreshFans ? fanSnapshots() : existing.fans,
thermalState: thermalState(),
+ powerManagement: refreshSlowDiagnostics
+ ? powerManagementSnapshot(onACPower: battery.externalPowerConnected)
+ : existing.powerManagement,
+ physicalDisks: refreshInventory ? physicalDiskSnapshots() : existing.physicalDisks,
capturedAt: Date()
)
}
+ private static func powerSensorSnapshot() -> SMCPowerSensorSnapshot {
+ let tool = FanControlService.toolPath
+ guard FileManager.default.isExecutableFile(atPath: tool) else { return .init() }
+ return SMCPowerSensorParser.parse(run(tool, arguments: ["list", "-p"]))
+ }
+
private static func fanSnapshots() -> [FanSnapshot] {
let tool = FanControlService.toolPath
guard FileManager.default.isExecutableFile(atPath: tool) else { return [] }
@@ -964,7 +1766,7 @@ private enum HardwareScanner {
let rawTime = Int(topNumber("TimeRemaining"))
let rawTemperature = topNumber("Temperature")
let temperature = rawTemperature > 1_000 ? rawTemperature / 10 - 273.15 : rawTemperature
- let systemPowerMW = Double(telemetryLine.firstMatch(#""SystemPowerIn"=([0-9]+)"#) ?? "0") ?? 0
+ let systemPowerMW = Double(telemetryLine.firstMatch(#""SystemLoad"=([0-9]+)"#) ?? "0") ?? 0
return BatterySnapshot(
isPresent: topBool("BatteryInstalled"),
@@ -1027,6 +1829,58 @@ private enum HardwareScanner {
}
}
+ private static func powerManagementSnapshot(onACPower: Bool) -> PowerManagementSnapshot {
+ let assertionsOutput = run("/usr/bin/pmset", arguments: ["-g", "assertions"])
+ let assertionPattern = #"(?m)^\s*pid ([0-9]+)\(([^)]+)\): \[[^]]+\] ([0-9:]+) ([^ ]+) named: \"([^\"]*)\""#
+ let regex = try? NSRegularExpression(pattern: assertionPattern)
+ let range = NSRange(assertionsOutput.startIndex.. SleepAssertionSnapshot? in
+ func value(_ index: Int) -> String {
+ guard let range = Range(match.range(at: index), in: assertionsOutput) else { return "" }
+ return String(assertionsOutput[range])
+ }
+ guard let pid = Int32(value(1)) else { return nil }
+ let type = value(4)
+ return SleepAssertionSnapshot(
+ id: "\(pid)-\(match.range.location)",
+ pid: pid,
+ processName: value(2),
+ type: type,
+ reason: value(5),
+ duration: value(3)
+ )
+ }
+
+ let source = onACPower ? "AC Power" : "Battery Power"
+ let customOutput = run("/usr/bin/pmset", arguments: ["-g", "custom"])
+ let section = customOutput.components(separatedBy: "\(source):").dropFirst().first?
+ .components(separatedBy: onACPower ? "Battery Power:" : "AC Power:").first ?? ""
+ var settings: [String: Int] = [:]
+ for line in section.split(separator: "\n") {
+ let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
+ guard fields.count >= 2, let value = Int(fields.last ?? "") else { continue }
+ settings[fields.dropLast().joined(separator: " ")] = value
+ }
+ let mode = switch settings["powermode"] ?? 0 {
+ case 1: "Low Power"
+ case 2: "High Power"
+ default: "Automatic"
+ }
+ return PowerManagementSnapshot(
+ source: source,
+ mode: mode,
+ systemSleepMinutes: settings["sleep"],
+ displaySleepMinutes: settings["displaysleep"],
+ powerNapEnabled: settings["powernap"] == 1,
+ wakeOnNetworkEnabled: settings["womp"] == 1,
+ assertions: assertions
+ )
+ }
+
+ private static func physicalDiskSnapshots() -> [PhysicalDiskSnapshot] {
+ PhysicalDiskScanner.capture()
+ }
+
private static func portSnapshots() -> [HardwarePortSnapshot] {
let data = runData(
"/usr/sbin/system_profiler",
diff --git a/Sources/puter/SystemReportExporter.swift b/Sources/puter/SystemReportExporter.swift
index 101ba35..dd082f4 100644
--- a/Sources/puter/SystemReportExporter.swift
+++ b/Sources/puter/SystemReportExporter.swift
@@ -48,6 +48,7 @@ enum SystemReportExporter {
"memoryType": snapshot.memoryType,
"memorySpeed": snapshot.memorySpeed,
"memoryManufacturer": snapshot.memoryManufacturer,
+ "memoryPressure": snapshot.memoryPressure.rawValue,
"diskReadBytesPerSecond": snapshot.diskReadRate,
"diskWriteBytesPerSecond": snapshot.diskWriteRate,
"diskActivePercent": snapshot.diskActivePercent,
@@ -63,11 +64,29 @@ enum SystemReportExporter {
"power": [
"thermalState": monitor.hardware.thermalState,
"systemPowerWatts": battery.systemPowerWatts,
+ "adapterInputWatts": battery.adapterInputWatts,
+ "batteryPowerWatts": battery.batteryPowerWatts,
"externalPowerConnected": battery.externalPowerConnected,
- "lowPowerMode": ProcessInfo.processInfo.isLowPowerModeEnabled
+ "lowPowerMode": ProcessInfo.processInfo.isLowPowerModeEnabled,
+ "source": monitor.hardware.powerManagement.source,
+ "mode": monitor.hardware.powerManagement.mode,
+ "systemSleepMinutes": monitor.hardware.powerManagement.systemSleepMinutes as Any,
+ "displaySleepMinutes": monitor.hardware.powerManagement.displaySleepMinutes as Any,
+ "powerNapEnabled": monitor.hardware.powerManagement.powerNapEnabled,
+ "wakeOnNetworkEnabled": monitor.hardware.powerManagement.wakeOnNetworkEnabled,
+ "sleepAssertions": monitor.hardware.powerManagement.assertions.map {
+ [
+ "pid": $0.pid,
+ "process": $0.processName,
+ "type": $0.type,
+ "reason": $0.reason,
+ "duration": $0.duration
+ ]
+ }
],
"battery": batteryReport(battery),
"ports": monitor.hardware.ports.map(portReport),
+ "physicalDisks": monitor.hardware.physicalDisks.map(diskReport),
"topProcesses": topProcesses(monitor),
"topUsers": topUsers(monitor)
]
@@ -84,6 +103,8 @@ enum SystemReportExporter {
"designCycleCount": battery.designCycleCount,
"temperatureCelsius": battery.temperatureCelsius,
"voltageVolts": battery.voltageVolts,
+ "batteryPowerWatts": battery.batteryPowerWatts,
+ "adapterInputWatts": battery.adapterInputWatts,
"fullChargeCapacityMAh": battery.fullChargeCapacityMAh,
"designCapacityMAh": battery.designCapacityMAh
]
@@ -119,6 +140,30 @@ enum SystemReportExporter {
]
}
+ private static func diskReport(_ disk: PhysicalDiskSnapshot) -> [String: Any] {
+ [
+ "identifier": disk.id,
+ "name": disk.name,
+ "protocol": disk.protocolName,
+ "capacityBytes": disk.capacity,
+ "internal": disk.isInternal,
+ "removable": disk.isRemovable,
+ "solidState": disk.isSolidState,
+ "smartStatus": disk.smartStatus,
+ "trimEnabled": disk.trimEnabled as Any,
+ "temperatureCelsius": disk.temperatureCelsius as Any,
+ "percentageUsed": disk.percentageUsed as Any,
+ "remainingLifePercent": disk.remainingLifePercent as Any,
+ "availableSparePercent": disk.availableSparePercent as Any,
+ "powerOnHours": disk.powerOnHours as Any,
+ "powerCycles": disk.powerCycles as Any,
+ "unsafeShutdowns": disk.unsafeShutdowns as Any,
+ "mediaErrors": disk.mediaErrors as Any,
+ "bytesRead": disk.bytesRead as Any,
+ "bytesWritten": disk.bytesWritten as Any
+ ]
+ }
+
private static func topProcesses(_ monitor: SystemMonitor) -> [[String: Any]] {
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(20).map { process in
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
diff --git a/Sources/puter/UpdateController.swift b/Sources/puter/UpdateController.swift
new file mode 100644
index 0000000..78f7775
--- /dev/null
+++ b/Sources/puter/UpdateController.swift
@@ -0,0 +1,37 @@
+import Foundation
+import Sparkle
+
+@MainActor
+final class UpdateController: ObservableObject {
+ let updaterController: SPUStandardUpdaterController?
+
+ init() {
+ let feed = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
+ let publicKey = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String
+ if let feed, !feed.isEmpty, let publicKey, !publicKey.isEmpty {
+ updaterController = SPUStandardUpdaterController(
+ startingUpdater: true,
+ updaterDelegate: nil,
+ userDriverDelegate: nil
+ )
+ } else {
+ updaterController = nil
+ }
+ }
+
+ var isConfigured: Bool { updaterController != nil }
+ var canCheckForUpdates: Bool { updaterController?.updater.canCheckForUpdates ?? false }
+ var automaticallyChecksForUpdates: Bool {
+ get { updaterController?.updater.automaticallyChecksForUpdates ?? false }
+ set { updaterController?.updater.automaticallyChecksForUpdates = newValue }
+ }
+ var automaticallyDownloadsUpdates: Bool {
+ get { updaterController?.updater.automaticallyDownloadsUpdates ?? false }
+ set { updaterController?.updater.automaticallyDownloadsUpdates = newValue }
+ }
+
+ func checkForUpdates() {
+ updaterController?.checkForUpdates(nil)
+ objectWillChange.send()
+ }
+}
diff --git a/Tests/puterTests/ModelTests.swift b/Tests/puterTests/ModelTests.swift
index 972c339..f8e8d54 100644
--- a/Tests/puterTests/ModelTests.swift
+++ b/Tests/puterTests/ModelTests.swift
@@ -1,7 +1,195 @@
import XCTest
+import PuterHelperProtocol
@testable import puter
final class ModelTests: XCTestCase {
+ func testNativeProcessSamplerPerformanceBudget() {
+ let result = ProcessSamplerBenchmark.run(iterations: 6)
+ XCTAssertGreaterThan(result.processCount, 20, "Native telemetry should return a credible process inventory")
+ XCTAssertLessThan(
+ result.averageSeconds,
+ 0.5,
+ "The native hot path should remain comfortably below the foreground sampling interval"
+ )
+ let lightweight = ProcessSamplerBenchmark.run(iterations: 12, includeProcessInventory: false)
+ print(
+ String(
+ format: "Sampler benchmark: full %.4f s, lightweight %.4f s per iteration",
+ result.averageSeconds,
+ lightweight.averageSeconds
+ )
+ )
+ XCTAssertEqual(lightweight.processCount, 0)
+ XCTAssertLessThan(
+ lightweight.averageSeconds,
+ result.averageSeconds,
+ "Lightweight system telemetry must remain cheaper than enumerating every process"
+ )
+ }
+
+ func testOnlyProcessConsumerPagesUseLiveInventory() {
+ XCTAssertTrue(TaskSection.processes.usesLiveProcessInventory)
+ XCTAssertTrue(TaskSection.performance.usesLiveProcessInventory)
+ XCTAssertTrue(TaskSection.energy.usesLiveProcessInventory)
+ XCTAssertTrue(TaskSection.diagnostics.usesLiveProcessInventory)
+ XCTAssertFalse(TaskSection.hardware.usesLiveProcessInventory)
+ XCTAssertFalse(TaskSection.startup.usesLiveProcessInventory)
+ XCTAssertFalse(TaskSection.services.usesLiveProcessInventory)
+ XCTAssertFalse(TaskSection.settings.usesLiveProcessInventory)
+ }
+
+ func testProcessHeaderCyclesDescendingAscendingThenGrouped() {
+ let descending = ProcessSortCycleState.next(
+ currentField: .name, ascending: true, grouped: true, clicked: .cpu
+ )
+ XCTAssertEqual(descending, .init(field: .cpu, ascending: false, grouped: false))
+ let ascending = ProcessSortCycleState.next(
+ currentField: descending.field, ascending: descending.ascending,
+ grouped: descending.grouped, clicked: .cpu
+ )
+ XCTAssertEqual(ascending, .init(field: .cpu, ascending: true, grouped: false))
+ let grouped = ProcessSortCycleState.next(
+ currentField: ascending.field, ascending: ascending.ascending,
+ grouped: ascending.grouped, clicked: .cpu
+ )
+ XCTAssertEqual(grouped, .init(field: .cpu, ascending: true, grouped: true))
+ XCTAssertEqual(descending.accessibilityValue(for: .cpu), "Sorted descending")
+ XCTAssertEqual(ascending.accessibilityValue(for: .cpu), "Sorted ascending")
+ XCTAssertEqual(grouped.accessibilityValue(for: .cpu), "Not sorted; grouped by category")
+ XCTAssertEqual(descending.accessibilityValue(for: .memory), "Not sorted; grouped by category")
+ }
+
+ func testPrivilegedHelperRejectsUnsafeFanTargets() {
+ XCTAssertEqual(PuterFanTargetValidator.validate([0: 2_000, 1: 4_500])?.count, 2)
+ XCTAssertNil(PuterFanTargetValidator.validate([16: 2_000]))
+ XCTAssertNil(PuterFanTargetValidator.validate([0: 20_001]))
+ XCTAssertNil(PuterFanTargetValidator.validate([:] as [NSNumber: NSNumber]))
+ }
+
+ func testBackgroundTaskRegistryParsesAndDeduplicatesLoginApps() {
+ let fixture = """
+ #1:
+ Name: Example App
+ Developer Name: Example Corp
+ Type: app (0x2)
+ Disposition: [disabled, allowed, notified] (0xa)
+ URL: file:///Applications/Example%20App.app/
+ Bundle Identifier: com.example.app
+
+ #2:
+ Name: Example App
+ Developer Name: Example Corp
+ Type: app (0x2)
+ Disposition: [enabled, allowed, notified] (0xb)
+ URL: file:///Applications/Example%20App.app/
+ Bundle Identifier: com.example.app
+
+ #3:
+ Name: Background Daemon
+ Type: daemon (0x10)
+ Disposition: [enabled, allowed, notified] (0xb)
+ URL: file:///Library/LaunchDaemons/com.example.daemon.plist
+ """
+ let items = BackgroundTaskRegistryParser.parse(fixture)
+ XCTAssertEqual(items.count, 1)
+ XCTAssertEqual(items[0].name, "Example App")
+ XCTAssertEqual(items[0].developer, "Example Corp")
+ XCTAssertEqual(items[0].bundleIdentifier, "com.example.app")
+ XCTAssertEqual(items[0].path, "/Applications/Example App.app")
+ XCTAssertTrue(items[0].enabled)
+ }
+
+ func testOnlySleepRelatedAssertionsAreBlockers() {
+ let blocker = SleepAssertionSnapshot(
+ id: "1", pid: 42, processName: "Video", type: "NoIdleSleepAssertion", reason: "Playback", duration: "00:01:00"
+ )
+ let activity = SleepAssertionSnapshot(
+ id: "2", pid: 43, processName: "WindowServer", type: "UserIsActive", reason: "Input", duration: "00:00:01"
+ )
+ XCTAssertTrue(blocker.preventsSleep)
+ XCTAssertFalse(activity.preventsSleep)
+ }
+
+ func testResourceAlertsRequireSustainedSamplesAndCooldown() {
+ let now = Date(timeIntervalSince1970: 1_000)
+ XCTAssertFalse(ResourceAlertPolicy.shouldFire(
+ value: 95, threshold: 90, consecutiveSamples: 2, requiredSamples: 3,
+ lastFired: nil, now: now, cooldown: 300
+ ))
+ XCTAssertTrue(ResourceAlertPolicy.shouldFire(
+ value: 95, threshold: 90, consecutiveSamples: 3, requiredSamples: 3,
+ lastFired: nil, now: now, cooldown: 300
+ ))
+ XCTAssertFalse(ResourceAlertPolicy.shouldFire(
+ value: 95, threshold: 90, consecutiveSamples: 4, requiredSamples: 3,
+ lastFired: now.addingTimeInterval(-299), now: now, cooldown: 300
+ ))
+ XCTAssertTrue(ResourceAlertPolicy.shouldFire(
+ value: 95, threshold: 90, consecutiveSamples: 4, requiredSamples: 3,
+ lastFired: now.addingTimeInterval(-300), now: now, cooldown: 300
+ ))
+ }
+
+ func testTelemetryRecordingSampleRoundTripsThroughJSON() throws {
+ let sample = TelemetryRecordingSample(
+ timestamp: Date(timeIntervalSince1970: 123), cpuPercent: 12.5,
+ memoryUsedBytes: 1_000, memoryTotalBytes: 2_000, memoryPressure: "Normal",
+ diskReadBytesPerSecond: 10, diskWriteBytesPerSecond: 20, gpuPercent: 30,
+ networkReceiveBytesPerSecond: 40, networkSendBytesPerSecond: 50,
+ systemPowerWatts: 6.5, thermalState: "Nominal", processCount: 100
+ )
+ XCTAssertEqual(try JSONDecoder().decode(TelemetryRecordingSample.self, from: JSONEncoder().encode(sample)), sample)
+ }
+
+ func testDiagnosticCaptureRoundTripsThroughJSON() throws {
+ let capture = DiagnosticCapture(
+ id: UUID(), name: "Baseline", createdAt: Date(timeIntervalSince1970: 100),
+ system: DiagnosticSystemMetrics(
+ cpuPercent: 10, memoryUsedBytes: 100, memoryTotalBytes: 200, memoryPressure: "Normal",
+ compressedBytes: 5, swapUsedBytes: 0, diskReadBytesPerSecond: 1,
+ diskWriteBytesPerSecond: 2, diskActivePercent: 3, gpuPercent: 4,
+ networkReceiveBytesPerSecond: 5, networkSendBytesPerSecond: 6,
+ systemPowerWatts: 7, thermalState: "Nominal", processCount: 8,
+ threadCount: 9, uptimeSeconds: 10
+ ),
+ processes: [], disks: []
+ )
+ XCTAssertEqual(try JSONDecoder().decode(DiagnosticCapture.self, from: JSONEncoder().encode(capture)), capture)
+ }
+
+ func testSystemExecutableHasAValidCodeSignature() {
+ let security = ProcessSecurityInspector.inspect(executablePath: "/bin/ls")
+ XCTAssertEqual(security.signatureStatus, "Valid")
+ XCTAssertNotEqual(security.signingIdentifier, "Not reported")
+ }
+
+ func testPhysicalDiskRemainingLifeIsDerivedFromPercentageUsed() {
+ let disk = PhysicalDiskSnapshot(
+ id: "disk0", name: "SSD", protocolName: "NVMe", capacity: 1_000,
+ isInternal: true, isRemovable: false, isSolidState: true, smartStatus: "Verified",
+ trimEnabled: true, temperatureCelsius: 39, percentageUsed: 5, availableSparePercent: 100,
+ powerOnHours: 10, powerCycles: 2, unsafeShutdowns: 0, mediaErrors: 0,
+ bytesRead: 100, bytesWritten: 200
+ )
+ XCTAssertEqual(disk.remainingLifePercent, 95)
+ }
+
+ func testDiskActivityExcludesVirtualDiskImages() {
+ XCTAssertTrue(DiskMediaClassifier.isPhysicalMediaName("APPLE SSD AP1024Z Media"))
+ XCTAssertTrue(DiskMediaClassifier.isPhysicalMediaName("External USB SSD Media"))
+ XCTAssertFalse(DiskMediaClassifier.isPhysicalMediaName("Apple Disk Image Media"))
+ XCTAssertFalse(DiskMediaClassifier.isPhysicalMediaName("RAM Disk Media"))
+ XCTAssertFalse(DiskMediaClassifier.isPhysicalMediaName(""))
+ }
+
+ func testPhysicalDiskScannerFindsAttachedStorage() {
+ let disks = PhysicalDiskScanner.capture()
+ XCTAssertFalse(disks.isEmpty)
+ XCTAssertTrue(disks.allSatisfy { !$0.id.isEmpty && $0.capacity > 0 })
+ XCTAssertTrue(disks.compactMap(\.percentageUsed).allSatisfy { $0 == $0.rounded() && (0...100).contains($0) })
+ XCTAssertTrue(disks.compactMap(\.availableSparePercent).allSatisfy { $0 == $0.rounded() && (0...100).contains($0) })
+ }
+
func testAppleSystemServicePublisherAndDomainIdentity() {
let system = ServiceRecord(label: "com.apple.accessoryd", pid: 7750, lastExitStatus: 0, domain: .system)
let user = ServiceRecord(label: "com.apple.accessoryd", pid: 731, lastExitStatus: 0, domain: .user)
@@ -64,6 +252,22 @@ final class ModelTests: XCTestCase {
XCTAssertEqual(corrupt.batteryPowerWatts, 0)
}
+ func testLiveSMCPowerSensorsOverrideCalculatedBatteryPower() {
+ let parsed = SMCPowerSensorParser.parse("""
+ [PDTR] 42.33818435668945
+ [PPBR] 23.479623794555664
+ [PSTR] 66.0309829711914
+ """)
+ XCTAssertEqual(parsed.systemWatts, 66.0309829711914, accuracy: 0.0001)
+ XCTAssertEqual(parsed.adapterWatts, 42.33818435668945, accuracy: 0.0001)
+ XCTAssertEqual(parsed.batteryWatts, 23.479623794555664, accuracy: 0.0001)
+ let battery = BatterySnapshot(
+ voltageVolts: 12, currentAmps: 1,
+ sensorBatteryPowerWatts: parsed.batteryWatts
+ )
+ XCTAssertEqual(battery.batteryPowerWatts, parsed.batteryWatts, accuracy: 0.0001)
+ }
+
func testUSBPDProfileAndNegotiatedPower() {
let profile = PowerProfile(voltageVolts: 20, currentAmps: 4.29)
let adapter = PowerAdapterSnapshot(
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
index 819d97b..d542b54 100755
--- a/scripts/build-app.sh
+++ b/scripts/build-app.sh
@@ -7,22 +7,48 @@ ICON_SOURCE="${PUTER_ICON_SOURCE:-$PROJECT_DIR/puter.icon}"
STAGING_ROOT="$(mktemp -d /tmp/puter-build.XXXXXX)"
STAGED_APP="$STAGING_ROOT/puter.app"
CONTENTS_DIR="$STAGED_APP/Contents"
+SIGN_IDENTITY="${PUTER_SIGN_IDENTITY:--}"
+APP_VERSION="${PUTER_VERSION:-1.0}"
+BUILD_NUMBER="${PUTER_BUILD_NUMBER:-1}"
trap '/bin/rm -rf -- "$STAGING_ROOT"' EXIT
cd "$PROJECT_DIR"
-swift build -c release
+swift build -c release --product puter
+swift build -c release --product puter-helper
-mkdir -p "$CONTENTS_DIR/MacOS" "$CONTENTS_DIR/Resources"
+mkdir -p "$CONTENTS_DIR/MacOS" "$CONTENTS_DIR/Resources" "$CONTENTS_DIR/Frameworks" "$CONTENTS_DIR/Library/LaunchDaemons"
cp "$PROJECT_DIR/.build/release/puter" "$CONTENTS_DIR/MacOS/puter"
+cp "$PROJECT_DIR/.build/release/puter-helper" "$CONTENTS_DIR/Resources/puter-helper"
+cp "$PROJECT_DIR/Resources/dev.soconnor.puter.helper.plist" "$CONTENTS_DIR/Library/LaunchDaemons/dev.soconnor.puter.helper.plist"
cp "$PROJECT_DIR/Resources/Info.plist" "$CONTENTS_DIR/Info.plist"
-chmod +x "$CONTENTS_DIR/MacOS/puter"
+ditto --norsrc "$PROJECT_DIR/.build/release/Sparkle.framework" "$CONTENTS_DIR/Frameworks/Sparkle.framework"
+chmod +x "$CONTENTS_DIR/MacOS/puter" "$CONTENTS_DIR/Resources/puter-helper"
+if ! otool -l "$CONTENTS_DIR/MacOS/puter" | grep -q '@executable_path/../Frameworks'; then
+ install_name_tool -add_rpath '@executable_path/../Frameworks' "$CONTENTS_DIR/MacOS/puter"
+fi
+plutil -lint "$CONTENTS_DIR/Info.plist" "$CONTENTS_DIR/Library/LaunchDaemons/dev.soconnor.puter.helper.plist" >/dev/null
+/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $APP_VERSION" "$CONTENTS_DIR/Info.plist"
+/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$CONTENTS_DIR/Info.plist"
-SMC_SOURCE="/Applications/Stats.app/Contents/Resources/smc"
+if [[ -n "${PUTER_UPDATE_FEED_URL:-}" && -n "${PUTER_UPDATE_PUBLIC_KEY:-}" ]]; then
+ /usr/libexec/PlistBuddy -c "Add :SUFeedURL string $PUTER_UPDATE_FEED_URL" "$CONTENTS_DIR/Info.plist"
+ /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string $PUTER_UPDATE_PUBLIC_KEY" "$CONTENTS_DIR/Info.plist"
+ /usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$CONTENTS_DIR/Info.plist"
+ /usr/libexec/PlistBuddy -c "Add :SUAllowsAutomaticUpdates bool true" "$CONTENTS_DIR/Info.plist"
+fi
+
+SMC_SOURCE="${PUTER_SMC_SOURCE:-$PROJECT_DIR/Resources/smc}"
+if [[ ! -x "$SMC_SOURCE" ]]; then
+ SMC_SOURCE="/Applications/Stats.app/Contents/Resources/smc"
+fi
if [[ -x "$SMC_SOURCE" ]]; then
ditto --norsrc "$SMC_SOURCE" "$CONTENTS_DIR/Resources/smc"
chmod +x "$CONTENTS_DIR/Resources/smc"
cp "$PROJECT_DIR/Resources/Stats-SMC-LICENSE.txt" "$CONTENTS_DIR/Resources/Stats-SMC-LICENSE.txt"
+elif [[ "${PUTER_RELEASE_BUILD:-0}" == "1" ]]; then
+ print -u2 "A release build requires PUTER_SMC_SOURCE or Resources/smc."
+ exit 2
fi
if [[ -d "$ICON_SOURCE" ]]; then
@@ -51,8 +77,40 @@ fi
# Finder and cloud-sync metadata can make an otherwise valid app bundle
# unsignable when rebuilding in place.
xattr -cr "$STAGED_APP"
-codesign --force --deep --sign - "$STAGED_APP" >/dev/null
+SIGN_ARGS=(--force --options runtime --sign "$SIGN_IDENTITY")
+if [[ "$SIGN_IDENTITY" != "-" ]]; then
+ SIGN_ARGS+=(--timestamp)
+fi
+
+# Sign every executable from the inside out. Avoid --deep, which can mask an
+# incorrectly signed nested helper and produces fragile release bundles.
+if [[ -x "$CONTENTS_DIR/Resources/smc" ]]; then
+ codesign "${SIGN_ARGS[@]}" --identifier dev.soconnor.puter.smc "$CONTENTS_DIR/Resources/smc" >/dev/null
+fi
+SPARKLE_CURRENT="$CONTENTS_DIR/Frameworks/Sparkle.framework/Versions/Current"
+codesign "${SIGN_ARGS[@]}" "$SPARKLE_CURRENT/Autoupdate" >/dev/null
+codesign "${SIGN_ARGS[@]}" "$SPARKLE_CURRENT/XPCServices/Downloader.xpc" >/dev/null
+codesign "${SIGN_ARGS[@]}" "$SPARKLE_CURRENT/XPCServices/Installer.xpc" >/dev/null
+codesign "${SIGN_ARGS[@]}" "$SPARKLE_CURRENT/Updater.app" >/dev/null
+codesign "${SIGN_ARGS[@]}" "$CONTENTS_DIR/Frameworks/Sparkle.framework" >/dev/null
+codesign "${SIGN_ARGS[@]}" --identifier dev.soconnor.puter.helper "$CONTENTS_DIR/Resources/puter-helper" >/dev/null
+APP_ENTITLEMENTS="$PROJECT_DIR/Resources/puter.entitlements"
+if [[ "$SIGN_IDENTITY" == "-" ]]; then
+ APP_ENTITLEMENTS="$PROJECT_DIR/Resources/puter-adhoc.entitlements"
+fi
+codesign "${SIGN_ARGS[@]}" --entitlements "$APP_ENTITLEMENTS" "$STAGED_APP" >/dev/null
codesign --verify --deep --strict "$STAGED_APP"
+if [[ "${PUTER_RELEASE_BUILD:-0}" == "1" ]]; then
+ [[ "$SIGN_IDENTITY" != "-" ]] || { print -u2 "Release builds cannot use an ad-hoc signature."; exit 2; }
+ codesign -dvv "$STAGED_APP" 2>&1 | grep -q '^Authority=Developer ID Application:' || {
+ print -u2 "Release build is not signed with a Developer ID Application certificate."
+ exit 2
+ }
+ [[ -n "${PUTER_UPDATE_FEED_URL:-}" && -n "${PUTER_UPDATE_PUBLIC_KEY:-}" ]] || {
+ print -u2 "Release builds require PUTER_UPDATE_FEED_URL and PUTER_UPDATE_PUBLIC_KEY."
+ exit 2
+ }
+fi
mkdir -p "$PROJECT_DIR/dist"
/bin/rm -rf -- "$APP_DIR"
@@ -61,6 +119,5 @@ ditto --norsrc "$STAGED_APP" "$APP_DIR"
# final copy. Clean and sign at the delivery path so verification reflects
# the bundle users actually launch.
xattr -cr "$APP_DIR"
-codesign --force --deep --sign - "$APP_DIR" >/dev/null
codesign --verify --deep --strict "$APP_DIR"
print "Built $APP_DIR"
diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh
index f2e5703..dacdc6d 100755
--- a/scripts/build-dmg.sh
+++ b/scripts/build-dmg.sh
@@ -4,6 +4,7 @@ set -euo pipefail
PROJECT_DIR="${0:A:h:h}"
APP_PATH="$PROJECT_DIR/dist/puter.app"
DMG_PATH="$PROJECT_DIR/dist/puter-macOS.dmg"
+SIGN_IDENTITY="${PUTER_SIGN_IDENTITY:--}"
STAGING_DIR="$(mktemp -d /tmp/puter-dmg.XXXXXX)"
cleanup() {
@@ -28,4 +29,9 @@ hdiutil create \
-ov \
"$DMG_PATH"
+if [[ "$SIGN_IDENTITY" != "-" ]]; then
+ codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH"
+ codesign --verify --strict "$DMG_PATH"
+fi
+
print "Built $DMG_PATH"
diff --git a/scripts/generate-appcast.sh b/scripts/generate-appcast.sh
new file mode 100755
index 0000000..1d9fef6
--- /dev/null
+++ b/scripts/generate-appcast.sh
@@ -0,0 +1,22 @@
+#!/bin/zsh
+set -euo pipefail
+
+PROJECT_DIR="${0:A:h:h}"
+ARCHIVES_DIR="${1:-$PROJECT_DIR/dist/updates}"
+DOWNLOAD_PREFIX="${PUTER_UPDATE_DOWNLOAD_PREFIX:-}"
+PRIVATE_KEY="${PUTER_SPARKLE_PRIVATE_KEY:-}"
+GENERATOR="$PROJECT_DIR/.build/artifacts/sparkle/Sparkle/bin/generate_appcast"
+
+if [[ -z "$DOWNLOAD_PREFIX" || -z "$PRIVATE_KEY" ]]; then
+ print -u2 "Set PUTER_UPDATE_DOWNLOAD_PREFIX and PUTER_SPARKLE_PRIVATE_KEY."
+ exit 2
+fi
+if [[ ! -x "$GENERATOR" ]]; then
+ swift package resolve --package-path "$PROJECT_DIR"
+fi
+mkdir -p "$ARCHIVES_DIR"
+print -rn -- "$PRIVATE_KEY" | "$GENERATOR" \
+ --ed-key-file - \
+ --download-url-prefix "$DOWNLOAD_PREFIX" \
+ "$ARCHIVES_DIR"
+print "Generated $ARCHIVES_DIR/appcast.xml"
diff --git a/scripts/notarize-release.sh b/scripts/notarize-release.sh
new file mode 100755
index 0000000..b37d156
--- /dev/null
+++ b/scripts/notarize-release.sh
@@ -0,0 +1,35 @@
+#!/bin/zsh
+set -euo pipefail
+
+PROJECT_DIR="${0:A:h:h}"
+DMG_PATH="${1:-$PROJECT_DIR/dist/puter-macOS.dmg}"
+APP_PATH="$PROJECT_DIR/dist/puter.app"
+NOTARY_PROFILE="${PUTER_NOTARY_PROFILE:-}"
+NOTARY_KEY_PATH="${PUTER_NOTARY_KEY_PATH:-}"
+NOTARY_KEY_ID="${PUTER_NOTARY_KEY_ID:-}"
+NOTARY_ISSUER="${PUTER_NOTARY_ISSUER:-}"
+
+if [[ -z "$NOTARY_PROFILE" && ( -z "$NOTARY_KEY_PATH" || -z "$NOTARY_KEY_ID" || -z "$NOTARY_ISSUER" ) ]]; then
+ print -u2 "Set PUTER_NOTARY_PROFILE or the PUTER_NOTARY_KEY_PATH, PUTER_NOTARY_KEY_ID, and PUTER_NOTARY_ISSUER variables."
+ exit 2
+fi
+if [[ ! -f "$DMG_PATH" ]]; then
+ print -u2 "DMG not found: $DMG_PATH"
+ exit 2
+fi
+
+codesign --verify --strict "$DMG_PATH"
+if [[ -n "$NOTARY_PROFILE" ]]; then
+ xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
+else
+ xcrun notarytool submit "$DMG_PATH" \
+ --key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" --wait
+fi
+xcrun stapler staple "$DMG_PATH"
+xcrun stapler validate "$DMG_PATH"
+if [[ -d "$APP_PATH" ]]; then
+ xcrun stapler staple "$APP_PATH"
+ xcrun stapler validate "$APP_PATH"
+fi
+spctl --assess --type open --context context:primary-signature -v "$DMG_PATH"
+print "Notarized and stapled $DMG_PATH"
diff --git a/scripts/validate-package.sh b/scripts/validate-package.sh
new file mode 100755
index 0000000..c14db98
--- /dev/null
+++ b/scripts/validate-package.sh
@@ -0,0 +1,103 @@
+#!/bin/zsh
+set -euo pipefail
+
+PROJECT_DIR="${0:A:h:h}"
+APP_PATH="${1:-$PROJECT_DIR/dist/puter.app}"
+DMG_PATH="${2:-$PROJECT_DIR/dist/puter-macOS.dmg}"
+RELEASE_VALIDATION="${PUTER_RELEASE_BUILD:-0}"
+MOUNT_DIR=""
+MOUNT_DEVICE=""
+
+cleanup() {
+ if [[ -n "$MOUNT_DEVICE" ]]; then
+ hdiutil detach "$MOUNT_DEVICE" >/dev/null 2>&1 || true
+ fi
+ if [[ -n "$MOUNT_DIR" && -d "$MOUNT_DIR" ]]; then
+ rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
+ fi
+}
+trap cleanup EXIT
+
+fail() {
+ print -u2 "Package validation failed: $1"
+ exit 2
+}
+
+validate_app() {
+ local app="$1"
+ local contents="$app/Contents"
+ local info="$contents/Info.plist"
+
+ [[ -d "$app" ]] || fail "app bundle not found at $app"
+ [[ -x "$contents/MacOS/puter" ]] || fail "main executable is missing"
+ [[ -x "$contents/Resources/puter-helper" ]] || fail "privileged helper is missing"
+ if [[ "$RELEASE_VALIDATION" == "1" ]]; then
+ [[ -x "$contents/Resources/smc" ]] || fail "release SMC backend is missing"
+ [[ -f "$contents/Resources/Stats-SMC-LICENSE.txt" ]] || fail "release SMC license is missing"
+ elif [[ -e "$contents/Resources/smc" ]]; then
+ [[ -x "$contents/Resources/smc" ]] || fail "bundled SMC backend is not executable"
+ [[ -f "$contents/Resources/Stats-SMC-LICENSE.txt" ]] || fail "bundled SMC license is missing"
+ fi
+ [[ -f "$contents/Library/LaunchDaemons/dev.soconnor.puter.helper.plist" ]] || fail "helper launch daemon plist is missing"
+ [[ -d "$contents/Frameworks/Sparkle.framework" ]] || fail "Sparkle framework is missing"
+ [[ -f "$contents/Resources/Assets.car" ]] || fail "Icon Composer asset catalog is missing"
+
+ plutil -lint "$info" "$contents/Library/LaunchDaemons/dev.soconnor.puter.helper.plist" >/dev/null
+ [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$info")" == "dev.soconnor.puter" ]] \
+ || fail "unexpected bundle identifier"
+ [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$info")" == "puter" ]] \
+ || fail "Icon Composer asset is not configured"
+ [[ "$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$info")" == "14.0" ]] \
+ || fail "unexpected deployment target"
+ if /usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$info" >/dev/null 2>&1; then
+ fail "legacy CFBundleIconFile overrides the Icon Composer asset"
+ fi
+
+ [[ "$(otool -l "$contents/MacOS/puter")" == *'@executable_path/../Frameworks'* ]] \
+ || fail "framework runtime search path is missing"
+ [[ "$(otool -L "$contents/MacOS/puter")" == *'Sparkle.framework'* ]] \
+ || fail "main executable is not linked to Sparkle"
+ codesign --verify --deep --strict "$app"
+ local signature
+ signature="$(codesign -dvv "$app" 2>&1)"
+ [[ "$signature" == *'runtime'* ]] || fail "Hardened Runtime is missing"
+
+ if [[ "$RELEASE_VALIDATION" == "1" ]]; then
+ [[ "$signature" == *'Authority=Developer ID Application:'* ]] \
+ || fail "release app is not Developer ID Application signed"
+ [[ -n "$(print -r -- "$signature" | sed -n 's/^TeamIdentifier=//p')" ]] \
+ || fail "release app has no Team Identifier"
+ /usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$info" >/dev/null 2>&1 \
+ || fail "release app has no Sparkle feed"
+ /usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$info" >/dev/null 2>&1 \
+ || fail "release app has no Sparkle public key"
+ local entitlements
+ entitlements="$(codesign -d --entitlements - "$app" 2>/dev/null || true)"
+ [[ "$entitlements" != *'com.apple.security.get-task-allow'* ]] \
+ || fail "release app contains get-task-allow"
+ [[ "$entitlements" != *'com.apple.security.cs.disable-library-validation'* ]] \
+ || fail "release app disables library validation"
+ fi
+}
+
+validate_app "$APP_PATH"
+
+if [[ -f "$DMG_PATH" ]]; then
+ hdiutil verify "$DMG_PATH" >/dev/null
+ if [[ "$RELEASE_VALIDATION" == "1" ]]; then
+ codesign --verify --strict "$DMG_PATH"
+ fi
+ MOUNT_DIR="$(mktemp -d /tmp/puter-package.XXXXXX)"
+ MOUNT_DEVICE="$(hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT_DIR" "$DMG_PATH" | awk '/Apple_APFS/ {print $1; exit}')"
+ [[ -n "$MOUNT_DEVICE" ]] || fail "DMG did not mount"
+ [[ -L "$MOUNT_DIR/Applications" ]] || fail "DMG Applications shortcut is missing"
+ [[ "$(readlink "$MOUNT_DIR/Applications")" == "/Applications" ]] \
+ || fail "DMG Applications shortcut has the wrong target"
+ validate_app "$MOUNT_DIR/puter.app"
+fi
+
+if [[ -f "$DMG_PATH" ]]; then
+ print "Validated $APP_PATH and $DMG_PATH"
+else
+ print "Validated $APP_PATH"
+fi