Add advanced monitoring and release infrastructure
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,32 +175,45 @@ struct StartupAppsView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@State private var items: [StartupItem] = []
|
||||
@State private var isLoading = true
|
||||
@State private var includesLoginItems = false
|
||||
@State private var inspectedItem: StartupItem?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") {
|
||||
PageHeader(title: "Startup apps", subtitle: includesLoginItems
|
||||
? "Login items and launch agents with estimated live impact"
|
||||
: "Launch agents with optional macOS login-item inventory") {
|
||||
HStack {
|
||||
Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() }
|
||||
.disabled(isLoading)
|
||||
Button("Open Login Items") {
|
||||
openLoginItemsSettings()
|
||||
Menu("Login Items", systemImage: "person.crop.circle.badge.checkmark") {
|
||||
Button(includesLoginItems ? "Reload Login Items" : "Include Login Items") {
|
||||
refreshItems(includeLoginItems: true)
|
||||
}
|
||||
.disabled(isLoading)
|
||||
.help("Read macOS’s system-wide Background Task Management registry using the built-in sfltool diagnostic")
|
||||
Divider()
|
||||
Button("Open Login Items Settings") {
|
||||
openLoginItemsSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isLoading && items.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Scanning login items…").foregroundStyle(.secondary)
|
||||
Text("Scanning startup items…").foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Scanning login items")
|
||||
.accessibilityLabel("Scanning startup items")
|
||||
} else if items.isEmpty {
|
||||
EmptyState(
|
||||
icon: "rectangle.stack.badge.play",
|
||||
title: "No startup items found",
|
||||
message: "Other login items can be managed in System Settings."
|
||||
message: includesLoginItems
|
||||
? "No login items or launch agents were found."
|
||||
: "No launch agents were found. Choose Include Login Items or manage them in System Settings."
|
||||
)
|
||||
} else {
|
||||
Table(items) {
|
||||
@@ -233,7 +246,7 @@ struct StartupAppsView: View {
|
||||
.sheet(item: $inspectedItem) { item in
|
||||
StartupItemPropertiesView(item: item, impact: startupImpact(for: item))
|
||||
}
|
||||
.task { refreshItems() }
|
||||
.task { refreshItems(includeLoginItems: false) }
|
||||
}
|
||||
|
||||
private func openLoginItemsSettings() {
|
||||
@@ -242,11 +255,15 @@ struct StartupAppsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshItems() {
|
||||
private func refreshItems(includeLoginItems requestedValue: Bool? = nil) {
|
||||
guard !isLoading || items.isEmpty else { return }
|
||||
let shouldIncludeLoginItems = requestedValue ?? includesLoginItems
|
||||
includesLoginItems = shouldIncludeLoginItems
|
||||
isLoading = true
|
||||
Task {
|
||||
items = await Task.detached(priority: .utility) { StartupScanner.scan() }.value
|
||||
items = await Task.detached(priority: .utility) {
|
||||
StartupScanner.scan(includeLoginItems: shouldIncludeLoginItems)
|
||||
}.value
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -605,13 +622,86 @@ private struct StartupItem: Identifiable, Sendable {
|
||||
var enabled: Bool
|
||||
}
|
||||
|
||||
struct BackgroundTaskLoginItem: Equatable, Sendable {
|
||||
let name: String
|
||||
let developer: String
|
||||
let bundleIdentifier: String
|
||||
let path: String
|
||||
let enabled: Bool
|
||||
}
|
||||
|
||||
enum BackgroundTaskRegistryParser {
|
||||
static func parse(_ output: String) -> [BackgroundTaskLoginItem] {
|
||||
var records: [[String]] = []
|
||||
var current: [String] = []
|
||||
for line in output.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("#"), trimmed.dropFirst().prefix(while: \Character.isNumber).isEmpty == false {
|
||||
if !current.isEmpty { records.append(current) }
|
||||
current = []
|
||||
} else if !current.isEmpty || trimmed.hasPrefix("Name:") {
|
||||
current.append(trimmed)
|
||||
}
|
||||
}
|
||||
if !current.isEmpty { records.append(current) }
|
||||
|
||||
var byPath: [String: BackgroundTaskLoginItem] = [:]
|
||||
for lines in records {
|
||||
let type = value("Type", in: lines)
|
||||
guard type.hasPrefix("app "),
|
||||
let rawURL = optionalValue("URL", in: lines),
|
||||
rawURL.hasPrefix("file://"),
|
||||
let url = URL(string: rawURL), url.isFileURL else { continue }
|
||||
let path = url.standardizedFileURL.path
|
||||
guard path.hasPrefix("/"), !path.isEmpty else { continue }
|
||||
let disposition = value("Disposition", in: lines)
|
||||
let name = optionalValue("Name", in: lines)
|
||||
?? url.deletingPathExtension().lastPathComponent
|
||||
let identifier = optionalValue("Bundle Identifier", in: lines)
|
||||
?? Bundle(url: url)?.bundleIdentifier
|
||||
?? path
|
||||
let developer = optionalValue("Developer Name", in: lines) ?? "Unknown"
|
||||
let enabled = disposition.contains("enabled")
|
||||
&& disposition.contains("allowed")
|
||||
&& !disposition.contains("disallowed")
|
||||
let item = BackgroundTaskLoginItem(
|
||||
name: name, developer: developer, bundleIdentifier: identifier,
|
||||
path: path, enabled: enabled
|
||||
)
|
||||
if let existing = byPath[path] {
|
||||
byPath[path] = BackgroundTaskLoginItem(
|
||||
name: existing.name,
|
||||
developer: existing.developer == "Unknown" ? item.developer : existing.developer,
|
||||
bundleIdentifier: existing.bundleIdentifier,
|
||||
path: path,
|
||||
enabled: existing.enabled || item.enabled
|
||||
)
|
||||
} else {
|
||||
byPath[path] = item
|
||||
}
|
||||
}
|
||||
return byPath.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private static func value(_ key: String, in lines: [String]) -> String {
|
||||
optionalValue(key, in: lines) ?? ""
|
||||
}
|
||||
|
||||
private static func optionalValue(_ key: String, in lines: [String]) -> String? {
|
||||
let prefix = "\(key):"
|
||||
guard let line = lines.first(where: { $0.hasPrefix(prefix) }) else { return nil }
|
||||
let value = line.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces)
|
||||
return value.isEmpty || value == "(null)" ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
private enum StartupScanner {
|
||||
static func scan() -> [StartupItem] {
|
||||
static func scan(includeLoginItems: Bool) -> [StartupItem] {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let directories = ["\(home)/Library/LaunchAgents", "/Library/LaunchAgents"]
|
||||
let disabled = disabledServices()
|
||||
let agents = directories.flatMap { scanDirectory($0, disabled: disabled) }
|
||||
let loginItems = sessionLoginItems()
|
||||
let loginItems = includeLoginItems ? sessionLoginItems() : []
|
||||
return (loginItems + agents).sorted {
|
||||
if $0.kind != $1.kind { return $0.kind == .loginItem }
|
||||
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
@@ -642,42 +732,33 @@ private enum StartupScanner {
|
||||
}
|
||||
|
||||
private static func sessionLoginItems() -> [StartupItem] {
|
||||
guard let unmanagedList = LSSharedFileListCreate(
|
||||
nil,
|
||||
"com.apple.LSSharedFileList.SessionLoginItems" as CFString,
|
||||
nil
|
||||
) else { return [] }
|
||||
let list = unmanagedList.takeRetainedValue()
|
||||
guard let unmanagedSnapshot = LSSharedFileListCopySnapshot(list, nil) else { return [] }
|
||||
let snapshot = unmanagedSnapshot.takeRetainedValue()
|
||||
var seen: Set<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]
|
||||
}
|
||||
}
|
||||
+978
-124
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+22
@@ -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"
|
||||
Executable
+35
@@ -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"
|
||||
Executable
+103
@@ -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
|
||||
Reference in New Issue
Block a user