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