Add advanced monitoring and release infrastructure
Build and test / macos (push) Canceled after 0s
Signed release / release (push) Canceled after 0s

This commit is contained in:
2026-08-15 18:23:35 -04:00
parent a67bb5fdf3
commit 0da94715be
38 changed files with 4219 additions and 406 deletions
+24
View File
@@ -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
+87
View File
@@ -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
+15
View File
@@ -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
}
+19 -2
View File
@@ -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"
)
]
+52 -5
View File
@@ -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.
+57
View File
@@ -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.
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.soconnor.puter.helper</string>
<key>BundleProgram</key>
<string>Contents/Resources/puter-helper</string>
<key>MachServices</key>
<dict>
<key>dev.soconnor.puter.helper</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>dev.soconnor.puter</string>
</array>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Ad-hoc signatures do not carry a Team ID. Production Developer ID
builds use puter.entitlements and retain library validation. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
@@ -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)
}
+114
View File
@@ -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()
+126
View File
@@ -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<Double>) -> 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"
+179 -102
View File
@@ -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<Content: View>: NSHostingView<Content> {
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)
}
}
+393
View File
@@ -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<UUID> = []
@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<Bool> {
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))
}
}
+210
View File
@@ -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"
}
}
+11 -30
View File
@@ -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()
}
}
+56
View File
@@ -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 {
+96
View File
@@ -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"
}
}
+141
View File
@@ -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 = {
+71 -1
View File
@@ -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
}
+81
View File
@@ -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() }
}
}
+143
View File
@@ -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<Void, Error>?
func set(_ value: Result<Void, Error>) { lock.withLock { result = value } }
func get() -> Result<Void, Error>? { 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()
}
}
+78 -18
View File
@@ -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 {
+162
View File
@@ -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<Content: View>(
_ 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
}
}
@@ -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<CFError>?
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[..<range.lowerBound]) + ".app"
}
private static func run(_ executable: String, arguments: [String]) -> (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)
}
}
}
+122 -41
View File
@@ -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)
)
}
}
+48 -2
View File
@@ -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"
}
}
}
+180 -74
View File
@@ -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 macOSs 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<String> = []
var items: [StartupItem] = []
let flags = UInt32(kLSSharedFileListNoUserInteraction | kLSSharedFileListDoNotMountVolumes)
for index in 0..<CFArrayGetCount(snapshot) {
let raw = CFArrayGetValueAtIndex(snapshot, index)
let item = unsafeBitCast(raw, to: LSSharedFileListItem.self)
guard let unmanagedURL = LSSharedFileListItemCopyResolvedURL(item, flags, nil) else { continue }
let url = unmanagedURL.takeRetainedValue() as URL
let path = url.standardizedFileURL.path
guard seen.insert(path).inserted else { continue }
let bundle = Bundle(url: url)
let identifier = bundle?.bundleIdentifier ?? path
let name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String)
?? url.deletingPathExtension().lastPathComponent
items.append(StartupItem(
id: "login:\(path)",
name: name,
publisher: publisherName(label: identifier, command: path),
sourcePath: path,
executablePath: path,
serviceIdentifier: identifier,
kind: .loginItem,
isDirectlyManageable: false,
enabled: true
))
}
return items
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sfltool")
process.arguments = ["dumpbtm"]
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
let output = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
process.waitUntilExit()
guard process.terminationStatus == 0 else { return [] }
return BackgroundTaskRegistryParser.parse(output).map { item in
StartupItem(
id: "login:\(item.path)",
name: item.name,
publisher: item.developer == "Unknown"
? publisherName(label: item.bundleIdentifier, command: item.path)
: item.developer,
sourcePath: item.path,
executablePath: item.path,
serviceIdentifier: item.bundleIdentifier,
kind: .loginItem,
isDirectlyManageable: false,
enabled: item.enabled
)
}
} catch { return [] }
}
private static func disabledServices() -> Set<String> {
@@ -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)
@@ -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]
}
}
File diff suppressed because it is too large Load Diff
+46 -1
View File
@@ -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()
+37
View File
@@ -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()
}
}
+204
View File
@@ -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(
+63 -6
View File
@@ -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"
+6
View File
@@ -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"
+22
View File
@@ -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"
+35
View File
@@ -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"
+103
View File
@@ -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