3 Commits
Author SHA1 Message Date
soconnor 0da94715be Add advanced monitoring and release infrastructure
Signed release / release (push) Canceled after 0s
Build and test / macos (push) Canceled after 0s
2026-08-15 18:23:35 -04:00
soconnor a67bb5fdf3 Use lowercase puter branding 2026-08-15 00:05:24 -04:00
soconnor 053bb71da2 Rename app and repository to Puter 2026-08-14 23:55:16 -04:00
53 changed files with 7261 additions and 1946 deletions
+24
View File
@@ -0,0 +1,24 @@
name: Build and test
on:
push:
branches: [main]
pull_request:
jobs:
macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Swift version
run: swift --version
- name: Build
run: swift build
- name: Test
run: swift test
- name: Package unsigned validation build
run: ./scripts/build-app.sh
- name: Build validation DMG
run: ./scripts/build-dmg.sh
- name: Verify packaged app and DMG
run: ./scripts/validate-package.sh
+87
View File
@@ -0,0 +1,87 @@
name: Signed release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Import Developer ID certificate
env:
SIGNING_P12: ${{ secrets.DEVELOPER_ID_P12_BASE64 }}
SIGNING_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/puter-signing.keychain-db"
security create-keychain -p runner "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p runner "$KEYCHAIN"
printf '%s' "$SIGNING_P12" | base64 --decode > "$RUNNER_TEMP/puter-signing.p12"
security import "$RUNNER_TEMP/puter-signing.p12" -k "$KEYCHAIN" -P "$SIGNING_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k runner "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
- name: Prepare notarization key
env:
NOTARY_KEY: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }}
run: printf '%s' "$NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8"
- name: Build signed app and DMG
env:
PUTER_RELEASE_BUILD: '1'
PUTER_SIGN_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }}
PUTER_UPDATE_FEED_URL: ${{ secrets.SPARKLE_FEED_URL }}
PUTER_UPDATE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }}
run: |
export PUTER_VERSION="${GITHUB_REF_NAME#v}"
export PUTER_BUILD_NUMBER="$GITHUB_RUN_NUMBER"
./scripts/build-app.sh
./scripts/build-dmg.sh
./scripts/validate-package.sh
- name: Notarize and staple
env:
PUTER_NOTARY_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
PUTER_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
PUTER_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
run: ./scripts/notarize-release.sh
- name: Validate notarized release
env:
PUTER_RELEASE_BUILD: '1'
run: |
./scripts/validate-package.sh
xcrun stapler validate dist/puter.app
xcrun stapler validate dist/puter-macOS.dmg
- name: Generate signed appcast
env:
PUTER_UPDATE_DOWNLOAD_PREFIX: ${{ secrets.SPARKLE_DOWNLOAD_PREFIX }}
PUTER_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
mkdir -p dist/updates
ditto dist/puter-macOS.dmg "dist/updates/puter-${GITHUB_REF_NAME#v}.dmg"
./scripts/generate-appcast.sh dist/updates
- name: Prepare release assets
run: |
ditto -c -k --sequesterRsrc --keepParent dist/puter.app "dist/puter-${GITHUB_REF_NAME#v}-macOS.zip"
shasum -a 256 \
dist/puter-macOS.dmg \
"dist/puter-${GITHUB_REF_NAME#v}-macOS.zip" \
dist/updates/appcast.xml > dist/SHA256SUMS
- name: Publish Gitea release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
API_ROOT="${GITHUB_API_URL:-${GITEA_SERVER_URL}/api/v1}"
RELEASE_JSON="$(curl --fail-with-body -sS -X POST \
-H "Authorization: token $RELEASE_TOKEN" -H 'Content-Type: application/json' \
"$API_ROOT/repos/$GITHUB_REPOSITORY/releases" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"puter $GITHUB_REF_NAME\",\"draft\":false,\"prerelease\":false}")"
RELEASE_ID="$(printf '%s' "$RELEASE_JSON" | jq -r .id)"
for ASSET in \
dist/puter-macOS.dmg \
"dist/puter-${GITHUB_REF_NAME#v}-macOS.zip" \
dist/updates/appcast.xml \
dist/SHA256SUMS; do
curl --fail-with-body -sS -X POST -H "Authorization: token $RELEASE_TOKEN" \
-H 'Content-Type: application/octet-stream' --data-binary "@$ASSET" \
"$API_ROOT/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$(basename "$ASSET")"
done
+15
View File
@@ -0,0 +1,15 @@
{
"originHash" : "ecfe14dd009fc3311519d8c8b59cf31614faf281a9f44d45e4c0fc75f9911077",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
}
}
],
"version" : 3
}
+24 -7
View File
@@ -2,20 +2,37 @@
import PackageDescription
let package = Package(
name: "MacTaskManager",
name: "puter",
platforms: [.macOS(.v14)],
products: [
.executable(name: "MacTaskManager", targets: ["MacTaskManager"])
.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: "MacTaskManager",
path: "Sources/MacTaskManager"
name: "puter",
dependencies: [
"PuterHelperProtocol",
.product(name: "Sparkle", package: "Sparkle")
],
path: "Sources/puter"
),
.executableTarget(
name: "puter-helper",
dependencies: ["PuterHelperProtocol"],
path: "Sources/puter-helper"
),
.testTarget(
name: "MacTaskManagerTests",
dependencies: ["MacTaskManager"],
path: "Tests/MacTaskManagerTests"
name: "puterTests",
dependencies: ["puter", "PuterHelperProtocol"],
path: "Tests/puterTests"
)
]
)
+60 -7
View File
@@ -1,4 +1,4 @@
# Mac Task Manager
# puter
A native macOS system monitor written in SwiftUI and inspired by Windows 11 Task Manager.
@@ -10,8 +10,8 @@ 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
- Expandable application groups plus an advanced Details table with persisted optional columns for threads, open handles, architecture, priority/nice, parent PID, CPU time, and other diagnostics
- 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
- Persistent preferences for the default start page, Performance selection and CPU graph mode, update speed, always-on-top behavior, and process/detail table layouts
@@ -25,19 +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
- User, detail, service, persistent app history, and startup views covering native login items plus LaunchAgents
- 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 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
@@ -51,7 +68,7 @@ To create a standard double-clickable app bundle:
```sh
./scripts/build-app.sh
open "dist/Task Manager.app"
open "dist/puter.app"
```
To create a drag-to-install disk image containing the app and an Applications shortcut:
@@ -61,4 +78,40 @@ To create a drag-to-install disk image containing the app and an Applications sh
./scripts/build-dmg.sh
```
The app refreshes every two seconds. Some system-owned processes cannot be ended without elevated permissions.
To run the same structural, signature, Hardened Runtime, icon, Sparkle, helper, and mounted-DMG checks used by CI:
```sh
./scripts/validate-package.sh
```
### Signed and notarized releases
`build-app.sh` produces a Hardened Runtime validation build by default. For a public release, provide a Developer ID Application identity and a notarytool keychain profile:
```sh
export PUTER_SIGN_IDENTITY='Developer ID Application: Your Name (TEAMID)'
export PUTER_NOTARY_PROFILE='puter-notary'
export PUTER_UPDATE_FEED_URL='https://your-host.example/puter/appcast.xml'
export PUTER_UPDATE_PUBLIC_KEY='SPARKLE_EDDSA_PUBLIC_KEY'
./scripts/build-app.sh
./scripts/build-dmg.sh
./scripts/notarize-release.sh
```
The release runner must also provide the Stats-derived `smc` backend through `PUTER_SMC_SOURCE` or have Stats installed at `/Applications/Stats.app`. Release validation deliberately fails if live SMC power and fan support would be missing.
The fan helper is embedded at `Contents/Resources/puter-helper` and its launchd property list at `Contents/Library/LaunchDaemons`. macOS will not register an ad-hoc helper; test registration from the Developer ID-signed app after placing it in Applications.
To generate or update a signed Sparkle appcast from release archives:
```sh
export PUTER_UPDATE_DOWNLOAD_PREFIX='https://your-host.example/puter/releases/'
export PUTER_SPARKLE_PRIVATE_KEY='SPARKLE_EDDSA_PRIVATE_KEY'
./scripts/generate-appcast.sh dist/updates
```
Keep the Sparkle private key and notarization credentials outside the repository. The Gitea build workflow runs compile, unit, native-sampler performance, app-signature, mounted-DMG, helper, icon, and update-framework checks. Tag releases additionally require the Developer ID certificate, notary API key, Sparkle signing keys, release token, and SMC backend. A successful tag publishes the notarized DMG, stapled app ZIP, signed appcast, and SHA-256 checksum manifest.
See [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for the credential inventory, signed workflow, independent-machine verification, and release acceptance criteria.
Live CPU and rate metrics still require coordinated sampling, now using native Mach/libproc, virtual-memory, network-interface, and System Configuration APIs on the hot path. Foreground cadence follows the selected update speed where useful, while background, hardware, service, process-diagnostic, device, port, and persistence work runs independently at slower demand-aware intervals. Some system-owned processes cannot be ended without elevated permissions.
+57
View File
@@ -0,0 +1,57 @@
# puter release checklist
This checklist is the authoritative handoff for producing a public puter release. Run it from the repository root on a macOS runner.
## Local quality gates
- [x] `swift test --parallel` passes, including native full/lightweight sampler budgets, process sort semantics, memory pressure, storage, power parsing, helper input validation, security inspection, recording, alerts, and diagnostic serialization.
- [x] `./scripts/build-app.sh` produces a Hardened Runtime app with the live Icon Composer asset, Sparkle framework, command-limited helper, launch-daemon property list, and SMC backend/license when available.
- [x] `./scripts/build-dmg.sh` produces a drag-to-install DMG.
- [x] `./scripts/validate-package.sh` validates signatures, nested code, runtime search paths, Sparkle linkage, bundle metadata, Icon Composer configuration, helper payload, SMC payload/license, DMG checksum, mounted app, and Applications shortcut.
- [x] Packaged-app accessibility smoke tests cover sidebar hide/recovery, the descending/ascending/categorized sort cycle, process rows, and live Hardware values.
- [x] Current sampler benchmark: full process telemetry 0.0529 seconds/iteration; lightweight system telemetry 0.0076 seconds/iteration.
- [x] Current 20-second packaged Hardware-page benchmark: 0.83% average CPU, 9.7% one-second peak while hardware work completed.
Benchmark numbers are evidence from the development Mac, not universal product guarantees. CI enforces the portable per-iteration budget rather than a machine-specific CPU percentage.
## Required protected release inputs
- [ ] `DEVELOPER_ID_P12_BASE64`: Developer ID Application certificate and private key in PKCS#12 form.
- [ ] `DEVELOPER_ID_P12_PASSWORD`: PKCS#12 password.
- [ ] `DEVELOPER_ID_IDENTITY`: exact `Developer ID Application: … (TEAMID)` identity.
- [ ] `APPLE_NOTARY_KEY_BASE64`: App Store Connect API private key.
- [ ] `APPLE_NOTARY_KEY_ID` and `APPLE_NOTARY_ISSUER_ID`.
- [ ] `SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_KEY`, `SPARKLE_PRIVATE_KEY`, and `SPARKLE_DOWNLOAD_PREFIX`.
- [ ] `RELEASE_TOKEN`: token allowed to create a release and upload assets to this Gitea repository.
- [ ] Stats-derived `smc` executable through `PUTER_SMC_SOURCE`, or Stats installed at `/Applications/Stats.app` on the runner.
Never commit certificates, API keys, Sparkle private keys, or release tokens.
## Signed release procedure
1. Confirm the working tree and intended tag are clean and reviewed.
2. Confirm `security find-identity -v -p codesigning` lists the Developer ID Application identity.
3. Run the local gates above.
4. Push a semantic version tag such as `v1.0.0`.
5. The `Signed release` workflow must:
- import the certificate into an ephemeral keychain;
- build with Hardened Runtime, library validation, update feed, and EdDSA public key;
- validate the signed app and DMG before submission;
- submit to Apple, staple and validate both app and DMG;
- validate the notarized package again;
- generate the EdDSA-signed appcast;
- publish the notarized DMG, stapled app ZIP, appcast, and `SHA256SUMS`.
6. Download the published assets on a separate Mac and run:
```sh
hdiutil verify puter-macOS.dmg
spctl --assess --type open --context context:primary-signature -v puter-macOS.dmg
xcrun stapler validate puter-macOS.dmg
```
7. Install into `/Applications`, launch it, approve the helper only when testing fan control, and verify monitoring works without helper approval.
8. Exercise Check for Updates against the published appcast from the previous release before announcing the new release.
## Release acceptance
A release is complete only when Apple notarization is accepted, stapler and Gatekeeper validation pass, the Gitea assets are downloadable with matching SHA-256 values, Sparkle verifies the appcast, and the installed app passes the packaged smoke tests. An ad-hoc or Apple Development build is not a public release.
+5 -7
View File
@@ -5,19 +5,17 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Task Manager</string>
<string>puter</string>
<key>CFBundleExecutable</key>
<string>MacTaskManager</string>
<string>puter</string>
<key>CFBundleIdentifier</key>
<string>local.mactaskmanager.app</string>
<key>CFBundleIconFile</key>
<string>mactaskmanager.icns</string>
<string>dev.soconnor.puter</string>
<key>CFBundleIconName</key>
<string>mactaskmanager</string>
<string>puter</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Task Manager</string>
<string>puter</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Serhiy Mytrovtsiy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.soconnor.puter.helper</string>
<key>BundleProgram</key>
<string>Contents/Resources/puter-helper</string>
<key>MachServices</key>
<dict>
<key>dev.soconnor.puter.helper</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>dev.soconnor.puter</string>
</array>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Ad-hoc signatures do not carry a Team ID. Production Developer ID
builds use puter.entitlements and retain library validation. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
@@ -1,166 +0,0 @@
import AppKit
import SwiftUI
struct NewTaskView: View {
@Binding var isPresented: Bool
@State private var path = ""
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 14) {
Image(systemName: "plus.square.dashed")
.font(.system(size: 32))
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
Text("Run new task").font(.title2.weight(.semibold))
Text("Open an application, document, or executable.").foregroundStyle(.secondary)
}
}
HStack {
TextField("Application or executable path", text: $path)
.textFieldStyle(.roundedBorder)
.onSubmit { launch() }
Button("Browse…") { browse() }
}
if let errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.red)
}
HStack {
Text("Tip: drag an app or file from Finder into the path field.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Cancel") { isPresented = false }
.keyboardShortcut(.cancelAction)
Button("Run") { launch() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(width: 560)
}
private func browse() {
let panel = NSOpenPanel()
panel.title = "Choose an application, executable, or document"
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url { path = url.path }
}
private func launch() {
let cleaned = (path as NSString).expandingTildeInPath.trimmingCharacters(in: .whitespacesAndNewlines)
let url = URL(fileURLWithPath: cleaned)
guard FileManager.default.fileExists(atPath: cleaned) else {
errorMessage = "That file could not be found."
return
}
if NSWorkspace.shared.open(url) {
isPresented = false
} else {
errorMessage = "macOS could not open this item."
}
}
}
struct SettingsView: View {
@Environment(SystemMonitor.self) private var monitor
@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 = ""
var body: some View {
@Bindable var monitor = monitor
VStack(spacing: 0) {
PageHeader(title: "Settings", subtitle: "Customize Task Manager")
Form {
Section("General") {
Picker("Default start page", selection: $defaultStartPage) {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
Text(section.rawValue).tag(section)
}
}
Picker("Real-time update speed", selection: $monitor.updateSpeed) {
ForEach(UpdateSpeed.allCases) { speed in
Text(speed.rawValue).tag(speed)
}
}
Toggle("Always on top", isOn: $alwaysOnTop)
.onChange(of: alwaysOnTop) { _, enabled in
NSApp.keyWindow?.level = enabled ? .floating : .normal
}
}
Section("Process data") {
LabeledContent("Refresh interval", value: intervalLabel)
LabeledContent("Process source", value: "macOS process table")
LabeledContent("Memory units", value: "Automatic")
}
Section("Diagnostic reports") {
Picker("Sample duration", selection: $diagnosticDuration) {
Text("1 second").tag(1)
Text("5 seconds").tag(5)
Text("10 seconds").tag(10)
}
Toggle("Ask where to save each report", isOn: $diagnosticAskEveryTime)
LabeledContent("Output folder") {
HStack(spacing: 10) {
Text(diagnosticDirectory.isEmpty ? "Not selected" : URL(fileURLWithPath: diagnosticDirectory).lastPathComponent)
.foregroundStyle(.secondary)
Button("Choose…") { chooseDiagnosticDirectory() }
}
}
}
Section("About") {
LabeledContent("Application", value: "Task Manager for macOS")
LabeledContent("Version", value: "1.0")
LabeledContent("Framework", value: "Native SwiftUI")
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.frame(maxWidth: 720)
.frame(maxWidth: .infinity, alignment: .leading)
}
.task {
NSApp.keyWindow?.level = alwaysOnTop ? .floating : .normal
}
}
private var intervalLabel: String {
switch monitor.updateSpeed {
case .high: "Every second"
case .normal: "Every 2 seconds"
case .low: "Every 5 seconds"
case .paused: "Paused"
}
}
private func chooseDiagnosticDirectory() {
let panel = NSOpenPanel()
panel.title = "Choose diagnostic report folder"
panel.prompt = "Choose"
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if !diagnosticDirectory.isEmpty {
panel.directoryURL = URL(fileURLWithPath: diagnosticDirectory, isDirectory: true)
}
if panel.runModal() == .OK, let url = panel.url {
diagnosticDirectory = url.path
}
}
}
-196
View File
@@ -1,196 +0,0 @@
import AppKit
import SwiftUI
struct ContentView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var selection: TaskSection?
@State private var searchText = ""
@State private var showingAbout = false
@State private var showingNewTask = false
@State private var detailSelection: Int32?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@AppStorage("sidebarCompact") private var sidebarCompact = false
init() {
let stored = UserDefaults.standard.string(forKey: "defaultStartPage") ?? TaskSection.processes.rawValue
_selection = State(initialValue: TaskSection(rawValue: stored) ?? .processes)
}
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
)
} detail: {
Group {
switch selection ?? .processes {
case .processes:
ProcessesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
case .performance:
PerformanceView()
case .history:
AppHistoryView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
case .startup:
StartupAppsView()
case .users:
UsersView { process in
detailSelection = process.pid
selection = .details
}
case .details:
DetailsView(searchText: searchText, selection: $detailSelection)
case .services:
ServicesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
case .settings:
SettingsView()
}
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Type a name, PID, or user")
.toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask, showingAbout: $showingAbout)
}
}
.navigationTitle("Task Manager")
.animation(.snappy(duration: 0.22), value: sidebarCompact)
.onChange(of: columnVisibility) { _, visibility in
guard visibility == .detailOnly else { return }
columnVisibility = .all
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
withAnimation(.snappy(duration: 0.22)) { sidebarCompact.toggle() }
}
}
.sheet(isPresented: $showingNewTask) {
NewTaskView(isPresented: $showingNewTask)
}
.alert("Task Manager", isPresented: Binding(
get: { monitor.errorMessage != nil },
set: { if !$0 { monitor.errorMessage = nil } }
)) {
Button("OK", role: .cancel) { monitor.errorMessage = nil }
} message: {
Text(monitor.errorMessage ?? "")
}
.alert("Task Manager for macOS", isPresented: $showingAbout) {
Button("OK", role: .cancel) { }
} message: {
Text("A native SwiftUI system monitor inspired by Windows Task Manager.")
}
.onAppear {
DispatchQueue.main.async {
NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal
}
}
}
}
private struct TaskToolbarContent: ToolbarContent {
@Environment(SystemMonitor.self) private var monitor
@Binding var showingNewTask: Bool
@Binding var showingAbout: Bool
var body: some ToolbarContent {
@Bindable var monitor = monitor
ToolbarItemGroup(placement: .primaryAction) {
Button {
showingNewTask = true
} label: {
Label("Run new task", systemImage: "plus.square")
}
.help("Launch an application or executable")
Button {
monitor.refresh()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.help("Refresh now (⌘R)")
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
Divider()
Button("About Task Manager") { showingAbout = true }
} label: {
Image(systemName: "ellipsis")
}
.menuStyle(.borderlessButton)
}
}
}
private struct Sidebar: View {
@Binding var selection: TaskSection?
let isCompact: 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(height: 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 : [])
}
}
.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(height: 40)
.contentShape(Rectangle())
}
.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")
}
}
private func sidebarLabel(_ title: String, icon: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.frame(width: 22, height: 20)
if !isCompact {
Text(title).lineLimit(1)
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, alignment: isCompact ? .center : .leading)
}
}
@@ -1,48 +0,0 @@
import AppKit
import SwiftUI
@MainActor
private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillFinishLaunching(_ notification: Notification) {
applyApplicationIcon()
}
func applicationDidFinishLaunching(_ notification: Notification) {
applyApplicationIcon()
}
private func applyApplicationIcon() {
guard let iconURL = Bundle.main.url(forResource: "mactaskmanager", withExtension: "icns"),
let icon = NSImage(contentsOf: iconURL) else { return }
NSApplication.shared.applicationIconImage = icon
}
}
@main
struct MacTaskManagerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var monitor = SystemMonitor()
var body: some Scene {
WindowGroup("Task Manager") {
ContentView()
.environment(monitor)
.frame(minWidth: 980, minHeight: 640)
.task { monitor.start() }
}
.defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(replacing: .newItem) { }
CommandMenu("Options") {
Button("Refresh now") { monitor.refresh() }
.keyboardShortcut("r", modifiers: .command)
Divider()
Button(monitor.isPaused ? "Resume updates" : "Pause updates") {
monitor.isPaused.toggle()
}
.keyboardShortcut("p", modifiers: .command)
}
}
}
}
-253
View File
@@ -1,253 +0,0 @@
import Foundation
import SwiftUI
enum TaskSection: String, CaseIterable, Identifiable {
case processes = "Processes"
case performance = "Performance"
case history = "App history"
case startup = "Startup apps"
case users = "Users"
case details = "Details"
case services = "Services"
case settings = "Settings"
var id: String { rawValue }
var icon: String {
switch self {
case .processes: "square.stack.3d.up"
case .performance: "waveform.path.ecg"
case .history: "clock.arrow.circlepath"
case .startup: "gauge.open.with.lines.needle.33percent"
case .users: "person.2"
case .details: "list.bullet.rectangle"
case .services: "gearshape.2"
case .settings: "gearshape"
}
}
}
enum UpdateSpeed: String, CaseIterable, Identifiable {
case high = "High"
case normal = "Normal"
case low = "Low"
case paused = "Paused"
var id: String { rawValue }
var interval: Duration {
switch self {
case .high: .seconds(1)
case .normal: .seconds(2)
case .low: .seconds(5)
case .paused: .seconds(2)
}
}
}
struct ProcessRecord: Identifiable, Hashable, Sendable {
let pid: Int32
let parentPID: Int32
let name: String
let executablePath: String
let user: String
let cpu: Double
let memoryPercent: Double
let residentBytes: UInt64
let state: String
let elapsed: String
let cpuTime: TimeInterval
let threadCount: Int
let openFileCount: Int
let architecture: String
let priority: Int
let nice: Int
var id: Int32 { pid }
var displayName: String {
let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.isEmpty ? "Process \(pid)" : cleaned
}
}
struct AppUsageRecord: Identifiable, Codable, Hashable, Sendable {
let id: String
var name: String
var executablePath: String
var user: String
var cpuSeconds: TimeInterval
var networkReceivedBytes: UInt64
var networkSentBytes: UInt64
var lastSeen: Date
var networkTotalBytes: UInt64 { networkReceivedBytes + networkSentBytes }
}
struct ProcessActivityRate: Hashable, Sendable {
var diskRead = 0.0
var diskWrite = 0.0
var networkReceive = 0.0
var networkSend = 0.0
var diskTotal: Double { diskRead + diskWrite }
var networkTotal: Double { networkReceive + networkSend }
}
enum ServiceDomain: String, CaseIterable, Identifiable, Sendable {
case user = "User"
case system = "System"
var id: String { rawValue }
var launchctlPrefix: String { self == .system ? "system" : "gui/\(getuid())" }
}
struct ServiceRecord: Identifiable, Hashable, Sendable {
let label: String
let pid: Int32?
let lastExitStatus: Int32
let domain: ServiceDomain
var id: String { "\(domain.rawValue):\(label)" }
var isRunning: Bool { pid != nil }
var isControllable: Bool { domain == .user }
var configurationPath: String? {
let roots = domain == .system
? ["/Library/LaunchDaemons", "/System/Library/LaunchDaemons"]
: [NSHomeDirectory() + "/Library/LaunchAgents", "/Library/LaunchAgents", "/System/Library/LaunchAgents"]
return roots.map { "\($0)/\(label).plist" }.first { FileManager.default.fileExists(atPath: $0) }
}
var displayName: String {
let parts = label.split(separator: ".").map(String.init)
let meaningful = parts.filter { part in
let isNumber = part.allSatisfy(\.isNumber)
let isUUID = UUID(uuidString: part) != nil
return !isNumber && !isUUID
}
guard let last = meaningful.last else { return label }
let generic = ["agent", "helper", "service", "xpcservice", "app"]
if generic.contains(last.lowercased()), meaningful.count > 1 {
return "\(meaningful[meaningful.count - 2]) \(last)"
}
return last
}
var publisher: String {
let parts = label.split(separator: ".").map(String.init)
guard let first = parts.first else { return "Unknown" }
if label.hasPrefix("com.apple") { return "Apple" }
if first == "application", parts.count > 2 {
let vendorIndex = ["com", "org", "net", "io"].contains(parts[1].lowercased()) ? 2 : 1
let vendor = parts[vendorIndex]
return vendor.prefix(1).uppercased() + vendor.dropFirst()
}
if ["com", "org", "net", "io", "us"].contains(first.lowercased()), parts.count > 1 {
return parts[1].prefix(1).uppercased() + parts[1].dropFirst()
}
return first.prefix(1).uppercased() + first.dropFirst()
}
}
struct MetricSample: Identifiable {
let id = UUID()
let date: Date
let value: Double
}
struct VolumeSnapshot: Identifiable, Hashable, Sendable {
let id: String
let name: String
let mountPath: String
let fileSystem: String
let capacity: UInt64
let available: UInt64
let isEjectable: Bool
var used: UInt64 { capacity > available ? capacity - available : 0 }
var usedPercent: Double { capacity > 0 ? Double(used) / Double(capacity) * 100 : 0 }
}
struct SystemSnapshot {
var cpuPercent = 0.0
var memoryUsed: UInt64 = 0
var memoryTotal: UInt64 = ProcessInfo.processInfo.physicalMemory
var memoryActive: UInt64 = 0
var memoryWired: UInt64 = 0
var memoryCompressed: UInt64 = 0
var memoryCached: UInt64 = 0
var swapUsed: UInt64 = 0
var swapTotal: UInt64 = 0
var memoryType = "Unified"
var memorySpeed = "Not reported by macOS"
var memoryManufacturer = "Apple unified memory"
var diskFree: UInt64 = 0
var diskTotal: UInt64 = 0
var diskReadRate = 0.0
var diskWriteRate = 0.0
var diskOperationsPerSecond = 0.0
var diskLatencyMilliseconds = 0.0
var diskActivePercent = 0.0
var gpuPercent = 0.0
var gpuRendererPercent = 0.0
var gpuTilerPercent = 0.0
var gpuMemoryBytes: UInt64 = 0
var gpuAllocatedBytes: UInt64 = 0
var gpuCoreCount = 0
var processCount = 0
var threadCount = 0
var uptime: TimeInterval = ProcessInfo.processInfo.systemUptime
var corePercents: [Double] = []
var networkReceiveRate = 0.0
var networkSendRate = 0.0
var networkInterface = "Network"
var removableVolumes: [VolumeSnapshot] = []
var memoryPercent: Double {
guard memoryTotal > 0 else { return 0 }
return Double(memoryUsed) / Double(memoryTotal) * 100
}
var diskPercent: Double {
guard diskTotal > 0 else { return 0 }
return Double(diskTotal - diskFree) / Double(diskTotal) * 100
}
var diskThroughput: Double { diskReadRate + diskWriteRate }
}
@MainActor
enum Formatters {
static let bytes: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.countStyle = .memory
formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]
return formatter
}()
static func percent(_ value: Double) -> String {
value < 10 ? String(format: "%.1f%%", value) : String(format: "%.0f%%", value)
}
static func uptime(_ interval: TimeInterval) -> String {
let total = Int(interval)
let days = total / 86_400
let hours = (total % 86_400) / 3_600
let minutes = (total % 3_600) / 60
return days > 0 ? "\(days)d \(hours)h \(minutes)m" : "\(hours)h \(minutes)m"
}
static func rate(_ bytesPerSecond: Double) -> String {
"\(bytes.string(fromByteCount: Int64(max(0, bytesPerSecond))))/s"
}
static func duration(_ interval: TimeInterval) -> String {
let total = max(0, Int(interval.rounded()))
let hours = total / 3_600
let minutes = (total % 3_600) / 60
let seconds = total % 60
return hours > 0
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
: String(format: "%02d:%02d", minutes, seconds)
}
}
-943
View File
@@ -1,943 +0,0 @@
import Foundation
import Observation
@MainActor
@Observable
final class SystemMonitor {
var processes: [ProcessRecord] = []
var services: [ServiceRecord] = []
var appHistory: [AppUsageRecord] = []
var appHistoryStartDate = Date()
var processActivity: [Int32: ProcessActivityRate] = [:]
var snapshot = SystemSnapshot()
var cpuHistory: [MetricSample] = []
var coreHistories: [[MetricSample]] = []
var memoryHistory: [MetricSample] = []
var diskReadHistory: [MetricSample] = []
var diskWriteHistory: [MetricSample] = []
var diskLatencyHistory: [MetricSample] = []
var diskActiveHistory: [MetricSample] = []
var gpuHistory: [MetricSample] = []
var gpuRendererHistory: [MetricSample] = []
var gpuTilerHistory: [MetricSample] = []
var networkReceiveHistory: [MetricSample] = []
var networkSendHistory: [MetricSample] = []
var updateSpeed: UpdateSpeed {
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") }
}
var lastUpdated: Date?
var errorMessage: String?
private var updateTask: Task<Void, Never>?
private var previousCoreTicks: [CoreTicks] = []
private var previousNetworkCounters: NetworkCounters?
private var previousNetworkDate: Date?
private var previousDiskCounters: DiskCounters?
private var previousDiskDate: Date?
private var previousProcessCPU: [Int32: TimeInterval] = [:]
private var previousProcessNetwork: [Int32: ProcessNetworkTotals] = [:]
private var previousProcessNetworkDate: Date?
private var previousProcessIO: [Int32: ProcessIOTotals] = [:]
private var previousProcessIODate: Date?
private var appNetworkTask: Task<Void, Never>?
private var lastAppNetworkSample: Date?
init() {
updateSpeed = UpdateSpeed(rawValue: UserDefaults.standard.string(forKey: "updateSpeed") ?? "") ?? .normal
loadAppHistory()
}
var isPaused: Bool {
get { updateSpeed == .paused }
set { updateSpeed = newValue ? .paused : .normal }
}
func start() {
guard updateTask == nil else { return }
refresh()
updateTask = Task { [weak self] in
while !Task.isCancelled {
let interval = self?.updateSpeed.interval ?? .seconds(2)
try? await Task.sleep(for: interval)
guard let self, !self.isPaused else { continue }
self.refresh()
}
}
}
func refresh() {
Task {
let result = await Task.detached(priority: .userInitiated) {
ProcessScanner.capture()
}.value
processes = result.processes
updateCPUHistory(with: result.processes)
updateProcessDiskActivity(result.processIO, current: result.processes)
services = result.services
var refreshedSnapshot = result.snapshot
refreshedSnapshot.corePercents = CoreCPUReader.capture(previous: &previousCoreTicks)
let diskDate = Date()
if let previousDiskCounters, let previousDiskDate {
let elapsed = max(0.001, diskDate.timeIntervalSince(previousDiskDate))
let readBytes = result.disk.readBytes >= previousDiskCounters.readBytes
? result.disk.readBytes - previousDiskCounters.readBytes : 0
let writeBytes = result.disk.writeBytes >= previousDiskCounters.writeBytes
? result.disk.writeBytes - previousDiskCounters.writeBytes : 0
let readOperations = result.disk.readOperations >= previousDiskCounters.readOperations
? result.disk.readOperations - previousDiskCounters.readOperations : 0
let writeOperations = result.disk.writeOperations >= previousDiskCounters.writeOperations
? result.disk.writeOperations - previousDiskCounters.writeOperations : 0
let readTime = result.disk.readTime >= previousDiskCounters.readTime
? result.disk.readTime - previousDiskCounters.readTime : 0
let writeTime = result.disk.writeTime >= previousDiskCounters.writeTime
? result.disk.writeTime - previousDiskCounters.writeTime : 0
let operations = readOperations + writeOperations
let serviceTime = readTime + writeTime
refreshedSnapshot.diskReadRate = Double(readBytes) / elapsed
refreshedSnapshot.diskWriteRate = Double(writeBytes) / elapsed
refreshedSnapshot.diskOperationsPerSecond = Double(operations) / elapsed
refreshedSnapshot.diskLatencyMilliseconds = operations > 0
? Double(serviceTime) / Double(operations) / 1_000_000 : 0
refreshedSnapshot.diskActivePercent = min(100, Double(serviceTime) / (elapsed * 1_000_000_000) * 100)
}
previousDiskCounters = result.disk
previousDiskDate = diskDate
let networkDate = Date()
if let previousNetworkCounters, let previousNetworkDate {
let elapsed = max(0.001, networkDate.timeIntervalSince(previousNetworkDate))
if result.network.received >= previousNetworkCounters.received {
refreshedSnapshot.networkReceiveRate = Double(result.network.received - previousNetworkCounters.received) / elapsed
}
if result.network.sent >= previousNetworkCounters.sent {
refreshedSnapshot.networkSendRate = Double(result.network.sent - previousNetworkCounters.sent) / elapsed
}
}
refreshedSnapshot.networkInterface = result.network.interface
previousNetworkCounters = result.network
previousNetworkDate = networkDate
snapshot = refreshedSnapshot
lastUpdated = Date()
errorMessage = result.error
appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent)
sampleAppNetworkIfNeeded()
}
}
func resetAppHistory() {
appHistory = []
appHistoryStartDate = Date()
previousProcessCPU = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0.cpuTime) })
previousProcessNetwork = [:]
saveAppHistory()
}
func activeProcess(for usage: AppUsageRecord) -> ProcessRecord? {
processes.first { AppIdentity.forProcess($0).id == usage.id }
}
func terminate(_ process: ProcessRecord, force: Bool = false) {
guard process.pid != getpid() else {
errorMessage = "Task Manager cannot end itself."
return
}
let signal = force ? SIGKILL : SIGTERM
if kill(process.pid, signal) == 0 {
processes.removeAll { $0.pid == process.pid }
Task {
try? await Task.sleep(for: .milliseconds(400))
refresh()
}
} else {
errorMessage = "Could not end \(process.displayName). You may not have permission."
}
}
func terminateTree(_ process: ProcessRecord) {
let descendants = descendantPIDs(of: process.pid)
for pid in descendants.reversed() { _ = kill(pid, SIGTERM) }
terminate(process)
}
func terminateGroup(_ group: [ProcessRecord]) {
guard !group.contains(where: { $0.pid == getpid() }) else {
errorMessage = "Task Manager cannot end an application group that contains itself."
return
}
let pids = Set(group.map(\.pid))
var failed = false
for process in group {
if kill(process.pid, SIGTERM) != 0 { failed = true }
}
processes.removeAll { pids.contains($0.pid) }
if failed { errorMessage = "Some processes could not be ended. You may not have permission." }
Task {
try? await Task.sleep(for: .milliseconds(500))
refresh()
}
}
func sendSignal(_ signal: Int32, to process: ProcessRecord, successMessage: String? = nil) {
guard process.pid != getpid() else {
errorMessage = "Task Manager cannot control itself."
return
}
if kill(process.pid, signal) != 0 {
errorMessage = "Could not control \(process.displayName). You may not have permission."
} else if let successMessage {
errorMessage = successMessage
}
Task {
try? await Task.sleep(for: .milliseconds(300))
refresh()
}
}
func setPriority(_ priority: Int, for process: ProcessRecord) {
let command = Process()
command.executableURL = URL(fileURLWithPath: "/usr/bin/renice")
command.arguments = ["-n", "\(priority)", "-p", "\(process.pid)"]
command.standardOutput = FileHandle.nullDevice
command.standardError = FileHandle.nullDevice
do {
try command.run()
command.waitUntilExit()
if command.terminationStatus != 0 {
errorMessage = "Could not change priority for \(process.displayName). Higher priorities may require administrator access."
}
} catch {
errorMessage = error.localizedDescription
}
}
func startService(_ service: ServiceRecord) {
runServiceCommand(["kickstart", serviceTarget(service)], action: "start", service: service)
}
func stopService(_ service: ServiceRecord) {
runServiceCommand(["kill", "SIGTERM", serviceTarget(service)], action: "stop", service: service)
}
func restartService(_ service: ServiceRecord) {
runServiceCommand(["kickstart", "-k", serviceTarget(service)], action: "restart", service: service)
}
private func serviceTarget(_ service: ServiceRecord) -> String {
"\(service.domain.launchctlPrefix)/\(service.label)"
}
private func runServiceCommand(_ arguments: [String], action: String, service: ServiceRecord) {
let command = Process()
let errorPipe = Pipe()
command.executableURL = URL(fileURLWithPath: "/bin/launchctl")
command.arguments = arguments
command.standardOutput = FileHandle.nullDevice
command.standardError = errorPipe
do {
try command.run()
command.waitUntilExit()
if command.terminationStatus != 0 {
let message = String(decoding: errorPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
errorMessage = message.isEmpty ? "Could not \(action) \(service.displayName)." : message
}
Task {
try? await Task.sleep(for: .milliseconds(500))
refresh()
}
} catch {
errorMessage = error.localizedDescription
}
}
private func descendantPIDs(of parentPID: Int32) -> [Int32] {
let children = processes.filter { $0.parentPID == parentPID }.map(\.pid)
return children + children.flatMap { descendantPIDs(of: $0) }
}
private func appendHistory(cpu: Double, memory: Double) {
let now = Date()
cpuHistory.append(MetricSample(date: now, value: cpu))
memoryHistory.append(MetricSample(date: now, value: memory))
networkReceiveHistory.append(MetricSample(date: now, value: snapshot.networkReceiveRate))
networkSendHistory.append(MetricSample(date: now, value: snapshot.networkSendRate))
diskReadHistory.append(MetricSample(date: now, value: snapshot.diskReadRate))
diskWriteHistory.append(MetricSample(date: now, value: snapshot.diskWriteRate))
diskLatencyHistory.append(MetricSample(date: now, value: snapshot.diskLatencyMilliseconds))
diskActiveHistory.append(MetricSample(date: now, value: snapshot.diskActivePercent))
gpuHistory.append(MetricSample(date: now, value: snapshot.gpuPercent))
gpuRendererHistory.append(MetricSample(date: now, value: snapshot.gpuRendererPercent))
gpuTilerHistory.append(MetricSample(date: now, value: snapshot.gpuTilerPercent))
if coreHistories.count != snapshot.corePercents.count {
coreHistories = Array(repeating: [], count: snapshot.corePercents.count)
}
for index in snapshot.corePercents.indices {
coreHistories[index].append(MetricSample(date: now, value: snapshot.corePercents[index]))
}
let cutoff = now.addingTimeInterval(-60)
cpuHistory.removeAll { $0.date < cutoff }
memoryHistory.removeAll { $0.date < cutoff }
networkReceiveHistory.removeAll { $0.date < cutoff }
networkSendHistory.removeAll { $0.date < cutoff }
diskReadHistory.removeAll { $0.date < cutoff }
diskWriteHistory.removeAll { $0.date < cutoff }
diskLatencyHistory.removeAll { $0.date < cutoff }
diskActiveHistory.removeAll { $0.date < cutoff }
gpuHistory.removeAll { $0.date < cutoff }
gpuRendererHistory.removeAll { $0.date < cutoff }
gpuTilerHistory.removeAll { $0.date < cutoff }
for index in coreHistories.indices {
coreHistories[index].removeAll { $0.date < cutoff }
}
}
private func updateCPUHistory(with current: [ProcessRecord]) {
var records = Dictionary(uniqueKeysWithValues: appHistory.map { ($0.id, $0) })
let now = Date()
for process in current where process.user == NSUserName() {
let identity = AppIdentity.forProcess(process)
var usage = records[identity.id] ?? AppUsageRecord(
id: identity.id,
name: identity.name,
executablePath: identity.path,
user: process.user,
cpuSeconds: 0,
networkReceivedBytes: 0,
networkSentBytes: 0,
lastSeen: now
)
if let old = previousProcessCPU[process.pid], process.cpuTime >= old {
usage.cpuSeconds += process.cpuTime - old
}
usage.lastSeen = now
records[identity.id] = usage
}
previousProcessCPU = Dictionary(uniqueKeysWithValues: current.map { ($0.pid, $0.cpuTime) })
appHistory = Array(records.values)
saveAppHistory()
}
private func sampleAppNetworkIfNeeded() {
guard appNetworkTask == nil,
lastAppNetworkSample.map({ Date().timeIntervalSince($0) >= 10 }) ?? true else { return }
lastAppNetworkSample = Date()
appNetworkTask = Task { [weak self] in
let totals = await Task.detached(priority: .utility) { AppNetworkScanner.capture() }.value
guard let self else { return }
self.mergeNetworkHistory(totals)
self.appNetworkTask = nil
}
}
private func mergeNetworkHistory(_ totals: [Int32: ProcessNetworkTotals]) {
var records = Dictionary(uniqueKeysWithValues: appHistory.map { ($0.id, $0) })
let processByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) })
let now = Date()
let elapsed = previousProcessNetworkDate.map { max(0.001, now.timeIntervalSince($0)) }
for (pid, total) in totals {
guard let process = processByPID[pid], process.user == NSUserName() else { continue }
let identity = AppIdentity.forProcess(process)
guard var usage = records[identity.id] else { continue }
if let old = previousProcessNetwork[pid] {
if total.received >= old.received { usage.networkReceivedBytes += total.received - old.received }
if total.sent >= old.sent { usage.networkSentBytes += total.sent - old.sent }
}
records[identity.id] = usage
}
if let elapsed {
for (pid, total) in totals {
guard let old = previousProcessNetwork[pid] else { continue }
var activity = processActivity[pid] ?? ProcessActivityRate()
activity.networkReceive = total.received >= old.received ? Double(total.received - old.received) / elapsed : 0
activity.networkSend = total.sent >= old.sent ? Double(total.sent - old.sent) / elapsed : 0
processActivity[pid] = activity
}
}
previousProcessNetwork = totals
previousProcessNetworkDate = now
appHistory = Array(records.values)
saveAppHistory()
}
private func updateProcessDiskActivity(_ totals: [Int32: ProcessIOTotals], current: [ProcessRecord]) {
let now = Date()
let activePIDs = Set(current.map(\.pid))
processActivity = processActivity.filter { activePIDs.contains($0.key) }
if let previousProcessIODate {
let elapsed = max(0.001, now.timeIntervalSince(previousProcessIODate))
for (pid, total) in totals {
guard let old = previousProcessIO[pid] else { continue }
var activity = processActivity[pid] ?? ProcessActivityRate()
activity.diskRead = total.read >= old.read ? Double(total.read - old.read) / elapsed : 0
activity.diskWrite = total.written >= old.written ? Double(total.written - old.written) / elapsed : 0
processActivity[pid] = activity
}
}
previousProcessIO = totals
previousProcessIODate = now
}
private func loadAppHistory() {
guard let data = try? Data(contentsOf: AppHistoryPersistence.url),
let state = try? JSONDecoder().decode(AppHistoryPersistence.State.self, from: data) else { return }
appHistoryStartDate = state.startDate
appHistory = state.records
}
private func saveAppHistory() {
let state = AppHistoryPersistence.State(startDate: appHistoryStartDate, records: appHistory)
guard let data = try? JSONEncoder().encode(state) else { return }
do {
try FileManager.default.createDirectory(at: AppHistoryPersistence.directory, withIntermediateDirectories: true)
try data.write(to: AppHistoryPersistence.url, options: .atomic)
} catch {
errorMessage = "App history could not be saved: \(error.localizedDescription)"
}
}
}
private enum AppHistoryPersistence {
struct State: Codable {
let startDate: Date
let records: [AppUsageRecord]
}
static let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MacTaskManager", isDirectory: true)
static let url = directory.appendingPathComponent("app-history.json")
}
private struct AppIdentity {
let id: String
let name: String
let path: String
static func forProcess(_ process: ProcessRecord) -> AppIdentity {
let path = process.executablePath
if let range = path.range(of: ".app/Contents/", options: .caseInsensitive) {
let appPath = String(path[..<range.lowerBound]) + ".app"
let filename = URL(fileURLWithPath: appPath).deletingPathExtension().lastPathComponent
return AppIdentity(id: appPath, name: filename, path: appPath)
}
return AppIdentity(id: "\(process.user)|\(path)", name: process.displayName, path: path)
}
}
private struct ProcessNetworkTotals: Sendable {
let received: UInt64
let sent: UInt64
}
private struct ProcessIOTotals: Sendable {
let read: UInt64
let written: UInt64
}
private enum AppNetworkScanner {
static func capture() -> [Int32: ProcessNetworkTotals] {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop")
process.arguments = ["-P", "-x", "-l", "1", "-J", "bytes_in,bytes_out"]
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return [:] }
return parse(String(decoding: data, as: UTF8.self))
} catch {
return [:]
}
}
private static func parse(_ output: String) -> [Int32: ProcessNetworkTotals] {
var result: [Int32: ProcessNetworkTotals] = [:]
for line in output.split(separator: "\n").dropFirst() {
let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
guard fields.count >= 3,
let received = UInt64(fields[fields.count - 2]),
let sent = UInt64(fields[fields.count - 1]) else { continue }
let identity = fields.dropLast(2).joined(separator: " ")
guard let dot = identity.lastIndex(of: "."), let pid = Int32(identity[identity.index(after: dot)...]) else { continue }
result[pid] = ProcessNetworkTotals(received: received, sent: sent)
}
return result
}
}
private struct NetworkCounters: Sendable {
let received: UInt64
let sent: UInt64
let interface: String
}
private struct DiskCounters: Sendable {
var readBytes: UInt64 = 0
var writeBytes: UInt64 = 0
var readOperations: UInt64 = 0
var writeOperations: UInt64 = 0
var readTime: UInt64 = 0
var writeTime: UInt64 = 0
}
private struct GPUCounters: Sendable {
var devicePercent = 0.0
var rendererPercent = 0.0
var tilerPercent = 0.0
var memoryBytes: UInt64 = 0
var allocatedBytes: UInt64 = 0
var coreCount = 0
}
private struct MemoryCounters: Sendable {
var used: UInt64 = 0
var active: UInt64 = 0
var wired: UInt64 = 0
var compressed: UInt64 = 0
var cached: UInt64 = 0
var swapUsed: UInt64 = 0
var swapTotal: UInt64 = 0
}
private struct MemoryHardwareInfo: Sendable {
var type = "Unified"
var speed = "Not reported by macOS"
var manufacturer = "Apple unified memory"
}
private struct CoreTicks {
let active: UInt64
let idle: UInt64
}
private enum CoreCPUReader {
static func capture(previous: inout [CoreTicks]) -> [Double] {
var processorCount: natural_t = 0
var info: processor_info_array_t?
var infoCount: mach_msg_type_number_t = 0
let result = host_processor_info(
mach_host_self(),
PROCESSOR_CPU_LOAD_INFO,
&processorCount,
&info,
&infoCount
)
guard result == KERN_SUCCESS, let info else { return [] }
defer {
vm_deallocate(
mach_task_self_,
vm_address_t(UInt(bitPattern: info)),
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride)
)
}
let current = (0..<Int(processorCount)).map { core -> CoreTicks in
let offset = core * Int(CPU_STATE_MAX)
let user = UInt64(info[offset + Int(CPU_STATE_USER)])
let system = UInt64(info[offset + Int(CPU_STATE_SYSTEM)])
let nice = UInt64(info[offset + Int(CPU_STATE_NICE)])
let idle = UInt64(info[offset + Int(CPU_STATE_IDLE)])
return CoreTicks(active: user + system + nice, idle: idle)
}
defer { previous = current }
guard previous.count == current.count else { return Array(repeating: 0, count: current.count) }
return zip(current, previous).map { current, old in
let activeDelta = current.active >= old.active ? current.active - old.active : 0
let idleDelta = current.idle >= old.idle ? current.idle - old.idle : 0
let total = activeDelta + idleDelta
return total > 0 ? min(100, Double(activeDelta) / Double(total) * 100) : 0
}
}
}
private enum ProcessScanner {
private static let memoryHardware = memoryHardwareInfo()
struct Result: Sendable {
let processes: [ProcessRecord]
let services: [ServiceRecord]
let snapshot: SystemSnapshot
let network: NetworkCounters
let disk: DiskCounters
let processIO: [Int32: ProcessIOTotals]
let error: String?
}
static func capture() -> Result {
let ps = run("/bin/ps", arguments: ["-axo", "pid=,ppid=,user=,%cpu=,%mem=,rss=,state=,etime=,time=,pri=,nice=,comm="])
let threads = threadCounts()
let records = parseProcesses(ps.output, threadCounts: threads)
let userServices = parseServices(run("/bin/launchctl", arguments: ["list"]).output, domain: .user)
let systemServices = parseSystemServices(run("/bin/launchctl", arguments: ["print", "system"]).output)
let services = userServices + systemServices
let cpu = min(100, records.reduce(0) { $0 + $1.cpu })
let totalMemory = ProcessInfo.processInfo.physicalMemory
let memory = memoryCounters(total: totalMemory)
let disk = diskUsage()
var snapshot = SystemSnapshot()
snapshot.cpuPercent = cpu
snapshot.memoryUsed = memory.used
snapshot.memoryTotal = totalMemory
snapshot.memoryActive = memory.active
snapshot.memoryWired = memory.wired
snapshot.memoryCompressed = memory.compressed
snapshot.memoryCached = memory.cached
snapshot.swapUsed = memory.swapUsed
snapshot.swapTotal = memory.swapTotal
snapshot.memoryType = memoryHardware.type
snapshot.memorySpeed = memoryHardware.speed
snapshot.memoryManufacturer = memoryHardware.manufacturer
snapshot.diskFree = disk.free
snapshot.diskTotal = disk.total
snapshot.removableVolumes = removableVolumes()
snapshot.processCount = records.count
snapshot.threadCount = threads.values.reduce(0, +)
snapshot.uptime = ProcessInfo.processInfo.systemUptime
let gpu = gpuCounters()
snapshot.gpuPercent = gpu.devicePercent
snapshot.gpuRendererPercent = gpu.rendererPercent
snapshot.gpuTilerPercent = gpu.tilerPercent
snapshot.gpuMemoryBytes = gpu.memoryBytes
snapshot.gpuAllocatedBytes = gpu.allocatedBytes
snapshot.gpuCoreCount = gpu.coreCount
return Result(
processes: records,
services: services,
snapshot: snapshot,
network: networkCounters(),
disk: diskCounters(),
processIO: processIOTotals(records.map(\.pid)),
error: ps.error
)
}
private static func processIOTotals(_ pids: [Int32]) -> [Int32: ProcessIOTotals] {
var totals: [Int32: ProcessIOTotals] = [:]
for pid in pids {
var usage = rusage_info_v2()
let result = withUnsafeMutablePointer(to: &usage) { pointer in
pointer.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
proc_pid_rusage(pid, RUSAGE_INFO_V2, $0)
}
}
if result == 0 {
totals[pid] = ProcessIOTotals(read: usage.ri_diskio_bytesread, written: usage.ri_diskio_byteswritten)
}
}
return totals
}
private static func diskCounters() -> DiskCounters {
let output = run(
"/usr/sbin/ioreg",
arguments: ["-r", "-c", "IOBlockStorageDriver", "-k", "Statistics", "-a"]
).output
guard let data = output.data(using: .utf8),
let drivers = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] else {
return DiskCounters()
}
var result = DiskCounters()
for driver in drivers {
guard let statistics = driver["Statistics"] as? [String: Any] else { continue }
func value(_ key: String) -> UInt64 {
(statistics[key] as? NSNumber)?.uint64Value ?? 0
}
result.readBytes += value("Bytes (Read)")
result.writeBytes += value("Bytes (Write)")
result.readOperations += value("Operations (Read)")
result.writeOperations += value("Operations (Write)")
result.readTime += value("Total Time (Read)")
result.writeTime += value("Total Time (Write)")
}
return result
}
private static func gpuCounters() -> GPUCounters {
let output = run(
"/usr/sbin/ioreg",
arguments: ["-r", "-c", "AGXAccelerator", "-l"]
).output
func number(_ key: String) -> UInt64 {
UInt64(output.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\" *= *(\\d+)") ?? "0") ?? 0
}
return GPUCounters(
devicePercent: Double(number("Device Utilization %")),
rendererPercent: Double(number("Renderer Utilization %")),
tilerPercent: Double(number("Tiler Utilization %")),
memoryBytes: number("In use system memory"),
allocatedBytes: number("Alloc system memory"),
coreCount: Int(number("gpu-core-count"))
)
}
private static func parseProcesses(_ output: String, threadCounts: [Int32: Int]) -> [ProcessRecord] {
output.split(separator: "\n").compactMap { line in
let fields = line.split(maxSplits: 11, whereSeparator: { $0 == " " || $0 == "\t" })
guard fields.count == 12,
let pid = Int32(fields[0]),
let ppid = Int32(fields[1]),
let cpu = Double(fields[3]),
let memory = Double(fields[4]),
let rssKB = UInt64(fields[5]),
let priority = Int(fields[9]),
let nice = Int(fields[10]) else { return nil }
let path = String(fields[11])
let name = URL(fileURLWithPath: path).lastPathComponent
let normalizedCPU = cpu / Double(max(1, ProcessInfo.processInfo.activeProcessorCount))
return ProcessRecord(
pid: pid,
parentPID: ppid,
name: name,
executablePath: path,
user: String(fields[2]),
cpu: normalizedCPU,
memoryPercent: memory,
residentBytes: rssKB * 1024,
state: String(fields[6]),
elapsed: String(fields[7]),
cpuTime: parseCPUTime(String(fields[8])),
threadCount: threadCounts[pid] ?? 0,
openFileCount: openFileCount(pid),
architecture: BinaryArchitectureReader.architecture(at: path),
priority: priority,
nice: nice
)
}
}
private static func parseCPUTime(_ value: String) -> TimeInterval {
let dayParts = value.split(separator: "-", maxSplits: 1).map(String.init)
let days = dayParts.count == 2 ? Double(dayParts[0]) ?? 0 : 0
let clock = (dayParts.count == 2 ? dayParts[1] : dayParts[0]).split(separator: ":").compactMap { Double($0) }
guard !clock.isEmpty else { return days * 86_400 }
var multiplier = 1.0
var seconds = 0.0
for component in clock.reversed() {
seconds += component * multiplier
multiplier *= 60
}
return days * 86_400 + seconds
}
private static func parseServices(_ output: String, domain: ServiceDomain) -> [ServiceRecord] {
output.split(separator: "\n").dropFirst().compactMap { line in
let fields = line.split(maxSplits: 2, whereSeparator: { $0 == " " || $0 == "\t" })
guard fields.count == 3, let status = Int32(fields[1]) else { return nil }
let pid = fields[0] == "-" ? nil : Int32(fields[0])
let label = String(fields[2]).trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty else { return nil }
return ServiceRecord(label: label, pid: pid, lastExitStatus: status, domain: domain)
}
}
private static func parseSystemServices(_ output: String) -> [ServiceRecord] {
guard let start = output.range(of: "\n\tservices = {\n") else { return [] }
let body = output[start.upperBound...]
guard let end = body.range(of: "\n\t}") else { return [] }
return body[..<end.lowerBound].split(separator: "\n").compactMap { line in
let fields = line.split(maxSplits: 2, whereSeparator: { $0 == " " || $0 == "\t" })
guard fields.count == 3, let rawPID = Int32(fields[0]) else { return nil }
let status = Int32(fields[1]) ?? 0
let label = String(fields[2]).trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty else { return nil }
return ServiceRecord(
label: label,
pid: rawPID > 0 ? rawPID : nil,
lastExitStatus: status,
domain: .system
)
}
}
private static func memoryCounters(total: UInt64) -> MemoryCounters {
let vm = run("/usr/bin/vm_stat", arguments: []).output
let pageSize = UInt64(vm.firstMatch(#"page size of (\d+) bytes"#) ?? "4096") ?? 4096
func pages(_ label: String) -> UInt64 {
UInt64(vm.firstMatch("\(NSRegularExpression.escapedPattern(for: label)): +([0-9]+)") ?? "0") ?? 0
}
let free = (pages("Pages free") + pages("Pages speculative")) * pageSize
var swap = xsw_usage()
var swapSize = MemoryLayout<xsw_usage>.size
_ = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
return MemoryCounters(
used: total > free ? total - free : 0,
active: pages("Pages active") * pageSize,
wired: pages("Pages wired down") * pageSize,
compressed: pages("Pages occupied by compressor") * pageSize,
cached: (pages("Pages inactive") + pages("Pages purgeable")) * pageSize,
swapUsed: swap.xsu_used,
swapTotal: swap.xsu_total
)
}
private static func memoryHardwareInfo() -> MemoryHardwareInfo {
let output = run("/usr/sbin/system_profiler", arguments: ["SPMemoryDataType", "-detailLevel", "mini"]).output
return MemoryHardwareInfo(
type: output.firstMatch(#"Type: +([^\n]+)"#) ?? "Unified",
speed: output.firstMatch(#"Speed: +([^\n]+)"#) ?? "Not reported by macOS",
manufacturer: output.firstMatch(#"Manufacturer: +([^\n]+)"#) ?? "Apple unified memory"
)
}
private static func removableVolumes() -> [VolumeSnapshot] {
let keys: [URLResourceKey] = [
.volumeNameKey,
.volumeLocalizedFormatDescriptionKey,
.volumeIsRemovableKey,
.volumeIsEjectableKey,
.volumeTotalCapacityKey,
.volumeAvailableCapacityForImportantUsageKey
]
let urls = FileManager.default.mountedVolumeURLs(
includingResourceValuesForKeys: keys,
options: [.skipHiddenVolumes]
) ?? []
return urls.compactMap { url in
guard let values = try? url.resourceValues(forKeys: Set(keys)),
values.volumeIsRemovable == true || values.volumeIsEjectable == true else { return nil }
let capacity = UInt64(max(0, values.volumeTotalCapacity ?? 0))
let available = UInt64(max(0, values.volumeAvailableCapacityForImportantUsage ?? 0))
return VolumeSnapshot(
id: url.path,
name: values.volumeName ?? url.lastPathComponent,
mountPath: url.path,
fileSystem: values.volumeLocalizedFormatDescription ?? "Removable storage",
capacity: capacity,
available: available,
isEjectable: values.volumeIsEjectable ?? false
)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
private static func diskUsage() -> (free: UInt64, total: UInt64) {
guard let values = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [
.volumeAvailableCapacityForImportantUsageKey,
.volumeTotalCapacityKey
]) else { return (0, 0) }
return (UInt64(max(0, values.volumeAvailableCapacityForImportantUsage ?? 0)), UInt64(max(0, values.volumeTotalCapacity ?? 0)))
}
private static func threadCounts() -> [Int32: Int] {
let result = run("/bin/ps", arguments: ["-M", "-axo", "pid="]).output
let rawCounts = result.split(separator: "\n").reduce(into: [Int32: Int]()) { counts, line in
guard let last = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).last,
let pid = Int32(last) else { return }
counts[pid, default: 0] += 1
}
return rawCounts.mapValues { max(1, $0 - 1) }
}
private static func openFileCount(_ pid: Int32) -> Int {
let bytes = Int(proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0))
return bytes > 0 ? bytes / MemoryLayout<proc_fdinfo>.stride : 0
}
private static func networkCounters() -> NetworkCounters {
let route = run("/sbin/route", arguments: ["-n", "get", "default"]).output
let primary = route.firstMatch(#"interface: +([^\s]+)"#) ?? "Network"
let netstat = run("/usr/sbin/netstat", arguments: ["-ibn"]).output
var received: UInt64 = 0
var sent: UInt64 = 0
var matchedPrimary = false
for line in netstat.split(separator: "\n") {
let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
guard fields.count >= 10,
fields[2].hasPrefix("<Link#"),
fields[0] != "lo0",
!fields[0].hasSuffix("*") else { continue }
if primary != "Network", fields[0] != primary { continue }
let counters = Array(fields.suffix(7))
guard let inputBytes = UInt64(counters[2]), let outputBytes = UInt64(counters[5]) else { continue }
received += inputBytes
sent += outputBytes
matchedPrimary = true
}
if !matchedPrimary, primary != "Network" {
return NetworkCounters(received: 0, sent: 0, interface: primary)
}
return NetworkCounters(received: received, sent: sent, interface: primary)
}
private static func run(_ path: String, arguments: [String]) -> (output: String, error: String?) {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = arguments
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return (String(decoding: data, as: UTF8.self), process.terminationStatus == 0 ? nil : "System data is temporarily unavailable.")
} catch {
return ("", error.localizedDescription)
}
}
}
private enum BinaryArchitectureReader {
private static let machO64: UInt32 = 0xfeedfacf
private static let machO64Swapped: UInt32 = 0xcffaedfe
private static let fat32: UInt32 = 0xcafebabe
private static let fat32Swapped: UInt32 = 0xbebafeca
private static let fat64: UInt32 = 0xcafebabf
private static let fat64Swapped: UInt32 = 0xbfbafeca
private static let arm64: UInt32 = 0x0100000c
private static let x86_64: UInt32 = 0x01000007
static func architecture(at path: String) -> String {
guard path.hasPrefix("/"),
let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { return "Unknown" }
defer { try? handle.close() }
guard let data = try? handle.read(upToCount: 512), data.count >= 8 else { return "Unknown" }
let magicBE = read(data, offset: 0, bigEndian: true)
let magicLE = read(data, offset: 0, bigEndian: false)
if magicBE == fat32 || magicBE == fat64 || magicBE == fat32Swapped || magicBE == fat64Swapped {
let bigEndian = magicBE == fat32 || magicBE == fat64
let is64 = magicBE == fat64 || magicBE == fat64Swapped
let count = min(Int(read(data, offset: 4, bigEndian: bigEndian)), 16)
let stride = is64 ? 32 : 20
var architectures: Set<UInt32> = []
for index in 0..<count {
let offset = 8 + index * stride
guard offset + 4 <= data.count else { break }
architectures.insert(read(data, offset: offset, bigEndian: bigEndian))
}
if architectures.contains(arm64) { return "ARM64" }
if architectures.contains(x86_64) { return "x86_64" }
return "Universal"
}
if magicLE == machO64 || magicBE == machO64Swapped {
let type = read(data, offset: 4, bigEndian: false)
if type == arm64 { return "ARM64" }
if type == x86_64 { return "x86_64" }
}
return "Unknown"
}
private static func read(_ data: Data, offset: Int, bigEndian: Bool) -> UInt32 {
guard offset + 4 <= data.count else { return 0 }
let bytes = data[offset..<(offset + 4)]
if bigEndian {
return bytes.reduce(0) { ($0 << 8) | UInt32($1) }
}
return bytes.reversed().reduce(0) { ($0 << 8) | UInt32($1) }
}
}
private extension String {
func firstMatch(_ pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: self, range: NSRange(startIndex..., in: self)),
match.numberOfRanges > 1,
let range = Range(match.range(at: 1), in: self) else { return nil }
return String(self[range])
}
}
@@ -0,0 +1,29 @@
import Foundation
public enum PuterHelperConstants {
public static let machServiceName = "dev.soconnor.puter.helper"
public static let daemonPlistName = "dev.soconnor.puter.helper.plist"
public static let protocolVersion = 1
}
public enum PuterFanTargetValidator {
public static let validFanIDs = 0...15
public static let validRPM = 500...20_000
public static func validate(_ targets: [NSNumber: NSNumber]) -> [(fan: Int, rpm: Int)]? {
let values = targets.compactMap { key, value -> (fan: Int, rpm: Int)? in
let fan = key.intValue
let rpm = value.intValue
guard validFanIDs.contains(fan), validRPM.contains(rpm) else { return nil }
return (fan, rpm)
}
guard values.count == targets.count, !values.isEmpty else { return nil }
return values.sorted { $0.fan < $1.fan }
}
}
@objc public protocol PuterPrivilegedHelperProtocol {
func version(withReply reply: @escaping (Int) -> Void)
func setFanTargets(_ targets: [NSNumber: NSNumber], withReply reply: @escaping (Bool, String?) -> Void)
func restoreAutomaticFanControl(withReply reply: @escaping (Bool, String?) -> Void)
}
+114
View File
@@ -0,0 +1,114 @@
import Foundation
import PuterHelperProtocol
import Security
private final class HelperService: NSObject, PuterPrivilegedHelperProtocol {
func version(withReply reply: @escaping (Int) -> Void) {
reply(PuterHelperConstants.protocolVersion)
}
func setFanTargets(_ targets: [NSNumber: NSNumber], withReply reply: @escaping (Bool, String?) -> Void) {
guard let validated = PuterFanTargetValidator.validate(targets) else {
reply(false, "Fan targets failed validation.")
return
}
do {
for (fan, rpm) in validated {
try runSMC(["fan", "\(fan)", "-m", "1"])
try runSMC(["fan", "\(fan)", "-v", "\(rpm)"])
}
reply(true, nil)
} catch { reply(false, error.localizedDescription) }
}
func restoreAutomaticFanControl(withReply reply: @escaping (Bool, String?) -> Void) {
do {
try runSMC(["reset"])
reply(true, nil)
} catch { reply(false, error.localizedDescription) }
}
private func runSMC(_ arguments: [String]) throws {
let helperURL = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL
let toolURL = helperURL.deletingLastPathComponent().appendingPathComponent("smc")
guard FileManager.default.isExecutableFile(atPath: toolURL.path) else {
throw HelperError.smcUnavailable
}
let process = Process()
let errorPipe = Pipe()
process.executableURL = toolURL
process.arguments = arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = errorPipe
try process.run()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw HelperError.commandFailed(String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
}
}
}
private enum HelperError: LocalizedError {
case smcUnavailable
case commandFailed(String)
var errorDescription: String? {
switch self {
case .smcUnavailable: "The bundled SMC backend is unavailable."
case .commandFailed(let detail): detail.isEmpty ? "The SMC command failed." : detail
}
}
}
private final class HelperDelegate: NSObject, NSXPCListenerDelegate {
private let service = HelperService()
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
guard ClientCodeValidator.isTrusted(pid: connection.processIdentifier) else { return false }
connection.exportedInterface = NSXPCInterface(with: PuterPrivilegedHelperProtocol.self)
connection.exportedObject = service
connection.resume()
return true
}
}
private enum ClientCodeValidator {
static func isTrusted(pid: pid_t) -> Bool {
var clientCode: SecCode?
let attributes = [kSecGuestAttributePid: NSNumber(value: pid)] as CFDictionary
guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &clientCode) == errSecSuccess,
let clientCode,
let clientInfo = signingInfo(for: clientCode),
clientInfo.identifier == "dev.soconnor.puter" else { return false }
var helperCode: SecCode?
guard SecCodeCopySelf([], &helperCode) == errSecSuccess,
let helperCode,
let helperInfo = signingInfo(for: helperCode),
let clientTeam = clientInfo.team,
let helperTeam = helperInfo.team,
clientTeam == helperTeam else { return false }
var requirement: SecRequirement?
let text = "anchor apple generic and identifier \"dev.soconnor.puter\""
guard SecRequirementCreateWithString(text as CFString, [], &requirement) == errSecSuccess,
let requirement else { return false }
return SecCodeCheckValidity(clientCode, [], requirement) == errSecSuccess
}
private static func signingInfo(for code: SecCode) -> (identifier: String, team: String?)? {
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { return nil }
var information: CFDictionary?
guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
let values = information as? [CFString: Any],
let identifier = values[kSecCodeInfoIdentifier] as? String else { return nil }
return (identifier, values[kSecCodeInfoTeamIdentifier] as? String)
}
}
private let delegate = HelperDelegate()
private let listener = NSXPCListener(machServiceName: PuterHelperConstants.machServiceName)
listener.delegate = delegate
listener.resume()
RunLoop.current.run()
+296
View File
@@ -0,0 +1,296 @@
import AppKit
import SwiftUI
struct NewTaskView: View {
@Binding var isPresented: Bool
@State private var path = ""
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 14) {
Image(systemName: "plus.square.dashed")
.font(.system(size: 32))
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
Text("Run new task").font(.title2.weight(.semibold))
Text("Open an application, document, or executable.").foregroundStyle(.secondary)
}
}
HStack {
TextField("Application or executable path", text: $path)
.textFieldStyle(.roundedBorder)
.onSubmit { launch() }
Button("Browse…") { browse() }
}
if let errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.red)
}
HStack {
Text("Tip: drag an app or file from Finder into the path field.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Cancel") { isPresented = false }
.keyboardShortcut(.cancelAction)
Button("Run") { launch() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(width: 560)
}
private func browse() {
let panel = NSOpenPanel()
panel.title = "Choose an application, executable, or document"
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url { path = url.path }
}
private func launch() {
let cleaned = (path as NSString).expandingTildeInPath.trimmingCharacters(in: .whitespacesAndNewlines)
let url = URL(fileURLWithPath: cleaned)
guard FileManager.default.fileExists(atPath: cleaned) else {
errorMessage = "That file could not be found."
return
}
if NSWorkspace.shared.open(url) {
isPresented = false
} else {
errorMessage = "macOS could not open this item."
}
}
}
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
VStack(spacing: 0) {
PageHeader(title: "Settings", subtitle: "Customize puter")
Form {
Section("General") {
Picker("Default start page", selection: $defaultStartPage) {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
Text(section.rawValue).tag(section)
}
}
Picker("Real-time update speed", selection: $monitor.updateSpeed) {
ForEach(UpdateSpeed.allCases) { speed in
Text(speed.rawValue).tag(speed)
}
}
Toggle("Always on top", isOn: $alwaysOnTop)
.onChange(of: alwaysOnTop) { _, enabled in
NSApp.keyWindow?.level = enabled ? .floating : .normal
}
}
Section("Process data") {
LabeledContent("Refresh interval", value: intervalLabel)
LabeledContent("Process source", value: "macOS process table")
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)
Text("5 seconds").tag(5)
Text("10 seconds").tag(10)
}
Toggle("Ask where to save each report", isOn: $diagnosticAskEveryTime)
LabeledContent("Output folder") {
HStack(spacing: 10) {
Text(diagnosticDirectory.isEmpty ? "Not selected" : URL(fileURLWithPath: diagnosticDirectory).lastPathComponent)
.foregroundStyle(.secondary)
Button("Choose…") { chooseDiagnosticDirectory() }
}
}
}
Section("About") {
LabeledContent("Application", value: "puter")
LabeledContent("Version", value: appVersion)
LabeledContent("Framework", value: "Native SwiftUI")
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.frame(maxWidth: 720)
.frame(maxWidth: .infinity, alignment: .leading)
}
.task {
NSApp.keyWindow?.level = alwaysOnTop ? .floating : .normal
privilegedHelper.refresh()
}
}
private var intervalLabel: String {
switch monitor.updateSpeed {
case .high: "Every second"
case .normal: "Every 2 seconds"
case .low: "Every 5 seconds"
case .paused: "Paused"
}
}
private var appVersion: String {
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"
panel.prompt = "Choose"
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if !diagnosticDirectory.isEmpty {
panel.directoryURL = URL(fileURLWithPath: diagnosticDirectory, isDirectory: true)
}
if panel.runModal() == .OK, let url = panel.url {
diagnosticDirectory = url.path
}
}
}
@@ -14,7 +14,7 @@ struct PageHeader<Trailing: View>: View {
var body: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 28, weight: .semibold))
Text(title).font(.title.weight(.semibold))
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
}
Spacer()
@@ -33,7 +33,7 @@ struct ResourceSummaryBar: View {
HStack(spacing: 22) {
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu")
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip")
Label("\(Formatters.percent(snapshot.diskPercent)) Disk", systemImage: "internaldrive")
Label("\(Formatters.percent(snapshot.diskActivePercent)) Disk activity", systemImage: "internaldrive")
Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
Spacer()
}
@@ -54,7 +54,11 @@ struct UpdateStatus: View {
if monitor.isPaused {
Text("Updates paused")
} else if let date = monitor.lastUpdated {
Text("Updated \(date, style: .relative)")
if Date().timeIntervalSince(date) < 1.5 {
Text("Updated now")
} else {
Text("Updated \(date, style: .relative)")
}
} else {
Text("Collecting system data…")
}
+271
View File
@@ -0,0 +1,271 @@
import AppKit
import SwiftUI
extension Notification.Name {
static let runNewTask = Notification.Name("puter.runNewTask")
}
struct ContentView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var selection: TaskSection?
@State private var searchText = ""
@State private var showingNewTask = false
@State private var detailSelection: Int32?
@AppStorage("sidebarCompact") private var sidebarCompact = false
init() {
let stored = UserDefaults.standard.string(forKey: "defaultStartPage") ?? TaskSection.processes.rawValue
_selection = State(initialValue: TaskSection(rawValue: stored) ?? .processes)
}
var body: some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
Sidebar(selection: $selection, compact: sidebarCompact)
.navigationSplitViewColumnWidth(
min: sidebarCompact ? 68 : 190,
ideal: sidebarCompact ? 68 : 210,
max: sidebarCompact ? 68 : 260
)
} detail: {
detailView
.toolbar(removing: .sidebarToggle)
}
.navigationSplitViewStyle(.balanced)
.toolbar(removing: .sidebarToggle)
.navigationTitle("")
.background(WindowToolbarCleaner())
.onChange(of: selection) { _, section in
searchText = ""
monitor.setActiveSection(section ?? .processes)
}
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
.sheet(isPresented: $showingNewTask) {
NewTaskView(isPresented: $showingNewTask)
}
.alert("puter", isPresented: Binding(
get: { monitor.errorMessage != nil },
set: { if !$0 { monitor.errorMessage = nil } }
)) {
Button("OK", role: .cancel) { monitor.errorMessage = nil }
} message: {
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 {
@Environment(SystemMonitor.self) private var monitor
@Binding var showingNewTask: Bool
@AppStorage("sidebarCompact") private var sidebarCompact = false
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
} label: {
Label("Run new task", systemImage: "plus.square")
}
.help("Launch an application or executable")
Button {
monitor.refresh()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.help("Refresh now (⌘R)")
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
} 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 compact: Bool
var body: some View {
List(selection: $selection) {
Section {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
sidebarRow(section)
}
}
Section {
sidebarRow(.settings)
}
}
.listStyle(.sidebar)
.scrollIndicators(compact ? .hidden : .automatic)
.accessibilityLabel("Navigation")
}
private func sidebarRow(_ section: TaskSection) -> some View {
HStack(spacing: 10) {
Image(systemName: section.icon)
.frame(width: 22, height: 20)
if !compact {
Text(section.rawValue)
.lineLimit(1)
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, minHeight: compact ? 28 : nil, alignment: compact ? .center : .leading)
.contentShape(Rectangle())
.tag(section)
.help(section.rawValue)
.accessibilityLabel(section.rawValue)
.listRowInsets(compact ? EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) : nil)
}
}
+393
View File
@@ -0,0 +1,393 @@
import AppKit
import Foundation
import SwiftUI
import UniformTypeIdentifiers
struct DiagnosticCapture: Identifiable, Codable, Hashable, Sendable {
let id: UUID
var name: String
let createdAt: Date
let system: DiagnosticSystemMetrics
let processes: [DiagnosticProcessMetrics]
let disks: [DiagnosticDiskMetrics]
}
struct DiagnosticSystemMetrics: Codable, Hashable, Sendable {
let cpuPercent: Double
let memoryUsedBytes: UInt64
let memoryTotalBytes: UInt64
let memoryPressure: String
let compressedBytes: UInt64
let swapUsedBytes: UInt64
let diskReadBytesPerSecond: Double
let diskWriteBytesPerSecond: Double
let diskActivePercent: Double
let gpuPercent: Double
let networkReceiveBytesPerSecond: Double
let networkSendBytesPerSecond: Double
let systemPowerWatts: Double
let thermalState: String
let processCount: Int
let threadCount: Int
let uptimeSeconds: Double
}
struct DiagnosticProcessMetrics: Identifiable, Codable, Hashable, Sendable {
let pid: Int32
let name: String
let executablePath: String
let user: String
let cpuPercent: Double
let residentBytes: UInt64
let diskBytesPerSecond: Double
let networkBytesPerSecond: Double
var id: Int32 { pid }
}
struct DiagnosticDiskMetrics: Identifiable, Codable, Hashable, Sendable {
let id: String
let name: String
let smartStatus: String
let remainingLifePercent: Double?
let temperatureCelsius: Double?
let mediaErrors: UInt64?
let unsafeShutdowns: UInt64?
}
@MainActor
enum DiagnosticCaptureStore {
static var directory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("puter/Diagnostics", isDirectory: true)
}
static func load() -> [DiagnosticCapture] {
guard let urls = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return urls.filter { $0.pathExtension == "json" }.compactMap {
guard let data = try? Data(contentsOf: $0) else { return nil }
return try? decoder.decode(DiagnosticCapture.self, from: data)
}.sorted { $0.createdAt > $1.createdAt }
}
static func save(_ capture: DiagnosticCapture) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(capture).write(to: url(for: capture.id), options: .atomic)
}
static func delete(_ capture: DiagnosticCapture) throws {
try FileManager.default.removeItem(at: url(for: capture.id))
}
static func url(for id: UUID) -> URL { directory.appendingPathComponent("\(id.uuidString).json") }
}
struct DiagnosticsView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var captures = DiagnosticCaptureStore.load()
@State private var selectedIDs: Set<UUID> = []
@State private var pendingDeletion: DiagnosticCapture?
private var selectedCaptures: [DiagnosticCapture] {
captures.filter { selectedIDs.contains($0.id) }.sorted { $0.createdAt < $1.createdAt }
}
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Diagnostics", subtitle: "Capture system state and compare changes over time") {
HStack(spacing: 10) {
Button("Capture now", systemImage: "camera.metering.center.weighted") { captureNow() }
.buttonStyle(.borderedProminent)
Button("Open folder", systemImage: "folder") { openFolder() }
}
}
ResourceSummaryBar(snapshot: monitor.snapshot)
HSplitView {
captureList.frame(minWidth: 310, idealWidth: 360, maxWidth: 440)
comparison.frame(minWidth: 430, maxWidth: .infinity, maxHeight: .infinity)
}
}
.confirmationDialog(
"Delete diagnostic capture?",
isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } })
) {
Button("Delete", role: .destructive) { deletePendingCapture() }
Button("Cancel", role: .cancel) { pendingDeletion = nil }
} message: {
Text("This removes the saved capture from puter's Diagnostics folder.")
}
}
private var captureList: some View {
VStack(spacing: 0) {
HStack {
Text("Saved captures").font(.headline)
Spacer()
Text("Select two").font(.caption).foregroundStyle(.secondary)
}
.padding(14)
Divider()
if captures.isEmpty {
VStack(spacing: 10) {
Image(systemName: "waveform.badge.plus")
.font(.system(size: 30))
.foregroundStyle(.tertiary)
Text("No captures").font(.headline)
Text("Capture current system state to establish a baseline.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 260)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(20)
} else {
List(captures) { capture in
HStack(spacing: 10) {
Toggle("Compare \(capture.name)", isOn: selectionBinding(capture.id)).labelsHidden().toggleStyle(.checkbox)
VStack(alignment: .leading, spacing: 3) {
Text(capture.name).font(.callout.weight(.medium)).lineLimit(1)
Text(capture.createdAt.formatted(date: .abbreviated, time: .standard))
.font(.caption).foregroundStyle(.secondary)
Text("CPU \(Formatters.percent(capture.system.cpuPercent)) • Memory \(Formatters.percent(memoryPercent(capture.system)))")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Menu {
Button("Export…") { export(capture) }
Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([DiagnosticCaptureStore.url(for: capture.id)]) }
Divider()
Button("Delete", role: .destructive) { pendingDeletion = capture }
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton)
}
.padding(.vertical, 4)
}
.listStyle(.inset)
}
}
}
@ViewBuilder
private var comparison: some View {
if selectedCaptures.count == 2 {
comparisonContent(from: selectedCaptures[0], to: selectedCaptures[1])
} else if selectedCaptures.count == 1 {
captureDetail(selectedCaptures[0])
} else {
ContentUnavailableView(
"Select captures to inspect",
systemImage: "arrow.left.and.right",
description: Text("Select one capture for details or two to compare deltas.")
)
}
}
private func captureDetail(_ capture: DiagnosticCapture) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Text(capture.name).font(.title2.weight(.semibold))
metricGrid(capture.system)
topProcesses(capture.processes)
diskHealth(capture.disks)
}.padding(20)
}
}
private func comparisonContent(from baseline: DiagnosticCapture, to current: DiagnosticCapture) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading) {
Text("Comparison").font(.title2.weight(.semibold))
Text("\(baseline.name)\(current.name)").foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.duration(current.createdAt.timeIntervalSince(baseline.createdAt)))
.font(.callout.monospacedDigit()).foregroundStyle(.secondary)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) {
deltaCard("CPU", baseline.system.cpuPercent, current.system.cpuPercent, suffix: " pp", lowerIsBetter: true)
deltaBytes("Memory used", baseline.system.memoryUsedBytes, current.system.memoryUsedBytes, lowerIsBetter: true)
deltaCard("Disk activity", baseline.system.diskActivePercent, current.system.diskActivePercent, suffix: " pp", lowerIsBetter: true)
deltaRate("Disk throughput", baseline.system.diskReadBytesPerSecond + baseline.system.diskWriteBytesPerSecond, current.system.diskReadBytesPerSecond + current.system.diskWriteBytesPerSecond)
deltaCard("GPU", baseline.system.gpuPercent, current.system.gpuPercent, suffix: " pp", lowerIsBetter: true)
deltaRate("Network", baseline.system.networkReceiveBytesPerSecond + baseline.system.networkSendBytesPerSecond, current.system.networkReceiveBytesPerSecond + current.system.networkSendBytesPerSecond)
deltaCard("System power", baseline.system.systemPowerWatts, current.system.systemPowerWatts, suffix: " W", lowerIsBetter: true)
deltaCard("Processes", Double(baseline.system.processCount), Double(current.system.processCount), suffix: "", lowerIsBetter: true)
}
Text("Current top processes").font(.headline)
topProcesses(current.processes)
}.padding(20)
}
}
private func metricGrid(_ metrics: DiagnosticSystemMetrics) -> some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
valueCard("CPU", Formatters.percent(metrics.cpuPercent))
valueCard("Memory", Formatters.percent(memoryPercent(metrics)))
valueCard("Pressure", metrics.memoryPressure)
valueCard("Disk", Formatters.percent(metrics.diskActivePercent))
valueCard("GPU", Formatters.percent(metrics.gpuPercent))
valueCard("Power", metrics.systemPowerWatts > 0 ? String(format: "%.1f W", metrics.systemPowerWatts) : "Not reported")
valueCard("Thermals", metrics.thermalState)
valueCard("Processes", "\(metrics.processCount)")
}
}
private func topProcesses(_ processes: [DiagnosticProcessMetrics]) -> some View {
VStack(spacing: 0) {
ForEach(processes.prefix(12)) { process in
HStack {
ProcessIcon(name: process.name, path: process.executablePath, size: 20)
Text(process.name).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpuPercent)).monospacedDigit().frame(width: 60, alignment: .trailing)
Text(Formatters.bytes.string(fromByteCount: Int64(clamping: process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
}.font(.caption).padding(.vertical, 5)
if process.id != processes.prefix(12).last?.id { Divider() }
}
}
.padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func diskHealth(_ disks: [DiagnosticDiskMetrics]) -> some View {
if !disks.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Physical storage health").font(.headline)
ForEach(disks) { disk in
HStack {
Label(disk.name, systemImage: "internaldrive")
Spacer()
Text(disk.smartStatus).foregroundStyle(disk.smartStatus == "Verified" ? .green : .orange)
if let life = disk.remainingLifePercent { Text("\(Formatters.percent(life)) life").monospacedDigit() }
}.font(.callout).padding(.vertical, 4)
}
}
}
}
private func selectionBinding(_ id: UUID) -> Binding<Bool> {
Binding(get: { selectedIDs.contains(id) }, set: { selected in
if selected {
if selectedIDs.count == 2, let oldest = selectedCaptures.first { selectedIDs.remove(oldest.id) }
selectedIDs.insert(id)
} else { selectedIDs.remove(id) }
})
}
private func captureNow() {
let snapshot = monitor.snapshot
let metrics = DiagnosticSystemMetrics(
cpuPercent: snapshot.cpuPercent, memoryUsedBytes: snapshot.memoryUsed,
memoryTotalBytes: snapshot.memoryTotal, memoryPressure: snapshot.memoryPressure.rawValue,
compressedBytes: snapshot.memoryCompressed, swapUsedBytes: snapshot.swapUsed,
diskReadBytesPerSecond: snapshot.diskReadRate, diskWriteBytesPerSecond: snapshot.diskWriteRate,
diskActivePercent: snapshot.diskActivePercent, gpuPercent: snapshot.gpuPercent,
networkReceiveBytesPerSecond: snapshot.networkReceiveRate, networkSendBytesPerSecond: snapshot.networkSendRate,
systemPowerWatts: monitor.hardware.battery.systemPowerWatts, thermalState: monitor.hardware.thermalState,
processCount: snapshot.processCount, threadCount: snapshot.threadCount, uptimeSeconds: snapshot.uptime
)
let processes = monitor.processes.sorted {
($0.cpu + $0.memoryPercent) > ($1.cpu + $1.memoryPercent)
}.prefix(30).map { process in
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
return DiagnosticProcessMetrics(
pid: process.pid, name: process.displayName, executablePath: process.executablePath,
user: process.user, cpuPercent: process.cpu, residentBytes: process.residentBytes,
diskBytesPerSecond: activity.diskTotal, networkBytesPerSecond: activity.networkTotal
)
}
let disks = monitor.hardware.physicalDisks.map {
DiagnosticDiskMetrics(id: $0.id, name: $0.name, smartStatus: $0.smartStatus,
remainingLifePercent: $0.remainingLifePercent, temperatureCelsius: $0.temperatureCelsius,
mediaErrors: $0.mediaErrors, unsafeShutdowns: $0.unsafeShutdowns)
}
let now = Date()
let capture = DiagnosticCapture(
id: UUID(), name: "Capture \(now.formatted(date: .abbreviated, time: .shortened))",
createdAt: now, system: metrics, processes: processes, disks: disks
)
do {
try DiagnosticCaptureStore.save(capture)
captures.insert(capture, at: 0)
selectedIDs = [capture.id]
} catch { monitor.errorMessage = "Could not save diagnostic capture: \(error.localizedDescription)" }
}
private func deletePendingCapture() {
guard let capture = pendingDeletion else { return }
do {
try DiagnosticCaptureStore.delete(capture)
captures.removeAll { $0.id == capture.id }
selectedIDs.remove(capture.id)
} catch { monitor.errorMessage = "Could not delete diagnostic capture: \(error.localizedDescription)" }
pendingDeletion = nil
}
private func export(_ capture: DiagnosticCapture) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "puter-diagnostic-\(capture.id.uuidString.prefix(8)).json"
panel.begin { response in
guard response == .OK, let destination = panel.url else { return }
do { try Data(contentsOf: DiagnosticCaptureStore.url(for: capture.id)).write(to: destination, options: .atomic) }
catch { monitor.errorMessage = "Could not export diagnostic capture: \(error.localizedDescription)" }
}
}
private func openFolder() {
try? FileManager.default.createDirectory(at: DiagnosticCaptureStore.directory, withIntermediateDirectories: true)
NSWorkspace.shared.open(DiagnosticCaptureStore.directory)
}
private func memoryPercent(_ metrics: DiagnosticSystemMetrics) -> Double {
metrics.memoryTotalBytes > 0 ? Double(metrics.memoryUsedBytes) / Double(metrics.memoryTotalBytes) * 100 : 0
}
private func valueCard(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 4) { Text(title).font(.caption).foregroundStyle(.secondary); Text(value).font(.title3.monospacedDigit()) }
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
}
private func deltaCard(_ title: String, _ old: Double, _ new: Double, suffix: String, lowerIsBetter: Bool) -> some View {
let delta = new - old
let favorable = lowerIsBetter ? delta <= 0 : delta >= 0
return VStack(alignment: .leading, spacing: 4) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(String(format: "%+.1f%@", delta, suffix)).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : (favorable ? Color.green : Color.orange))
Text(String(format: "%.1f → %.1f", old, new)).font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
}
private func deltaBytes(_ title: String, _ old: UInt64, _ new: UInt64, lowerIsBetter: Bool) -> some View {
let delta = Int64(clamping: new) - Int64(clamping: old)
let text = (delta >= 0 ? "+" : "") + Formatters.bytes.string(fromByteCount: abs(delta))
return VStack(alignment: .leading, spacing: 4) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(text).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : ((lowerIsBetter ? delta < 0 : delta > 0) ? Color.green : Color.orange))
Text("\(Formatters.bytes.string(fromByteCount: Int64(clamping: old)))\(Formatters.bytes.string(fromByteCount: Int64(clamping: new)))").font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
}
private func deltaRate(_ title: String, _ old: Double, _ new: Double) -> some View {
let delta = new - old
return VStack(alignment: .leading, spacing: 4) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text((delta >= 0 ? "+" : "") + Formatters.rate(abs(delta))).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : Color.orange)
Text("\(Formatters.rate(old))\(Formatters.rate(new))").font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
}
}
+210
View File
@@ -0,0 +1,210 @@
import AppKit
import SwiftUI
struct EnergyView: View {
@Environment(SystemMonitor.self) private var monitor
private var power: PowerManagementSnapshot { monitor.hardware.powerManagement }
private var sleepBlockers: [SleepAssertionSnapshot] {
power.assertions.filter(\.preventsSleep)
}
private var rankedProcesses: [(process: ProcessRecord, score: Double)] {
monitor.processes.map { ($0, energyScore(for: $0)) }
.filter { $0.1 > 0.05 }
.sorted { $0.1 > $1.1 }
.prefix(12)
.map { $0 }
}
var body: some View {
VStack(spacing: 0) {
PageHeader(
title: "Energy & Sleep",
subtitle: "Live energy estimates, power policy, and apps preventing sleep"
) {
Button("Battery Settings", systemImage: "gear") {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
}
UpdateStatus()
}
ResourceSummaryBar(snapshot: monitor.snapshot)
ScrollView {
VStack(alignment: .leading, spacing: 18) {
summary
HStack(alignment: .top, spacing: 16) {
processSection
sleepSection
}
policySection
Label(
"Energy impact is an estimate based on sampled CPU, disk, and network activity. macOS does not expose Activity Monitor's proprietary per-process Energy Impact value.",
systemImage: "info.circle"
)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(20)
}
}
}
private var summary: some View {
HStack(spacing: 12) {
summaryCard("Power source", power.source, "powerplug", .orange)
summaryCard("Power mode", power.mode, "gauge.with.dots.needle.50percent", .green)
summaryCard("System power", watts(monitor.hardware.battery.systemPowerWatts), "bolt.fill", .yellow)
summaryCard("Sleep blockers", "\(sleepBlockers.count)", "moon.zzz", sleepBlockers.isEmpty ? .green : .orange)
}
}
private var processSection: some View {
GroupBox("Estimated energy impact") {
VStack(spacing: 0) {
HStack {
Text("Process")
Spacer()
Text("Impact")
}
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.bottom, 6)
if rankedProcesses.isEmpty {
ContentUnavailableView("No active energy users", systemImage: "leaf", description: Text("No process activity was measured in this sample."))
.frame(minHeight: 220)
} else {
ForEach(rankedProcesses, id: \.process.pid) { item in
HStack(spacing: 8) {
ProcessIcon(name: item.process.displayName, path: item.process.executablePath, size: 22)
VStack(alignment: .leading, spacing: 2) {
Text(item.process.displayName).lineLimit(1)
Text("CPU \(Formatters.percent(item.process.cpu))")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Text(impactLabel(item.score))
.font(.caption.weight(.medium))
.foregroundStyle(impactColor(item.score))
.frame(width: 68, alignment: .trailing)
Text(String(format: "%.1f", item.score))
.monospacedDigit()
.frame(width: 44, alignment: .trailing)
}
.font(.callout)
.padding(.vertical, 5)
if item.process.pid != rankedProcesses.last?.process.pid { Divider() }
}
}
}
.padding(8)
}
.frame(maxWidth: .infinity)
}
private var sleepSection: some View {
GroupBox("Preventing sleep") {
VStack(spacing: 0) {
if sleepBlockers.isEmpty {
ContentUnavailableView("Nothing is preventing sleep", systemImage: "moon.zzz", description: Text("No active sleep assertions were reported by macOS."))
.frame(minHeight: 220)
} else {
ForEach(sleepBlockers) { assertion in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(assertion.processName).font(.callout.weight(.medium)).lineLimit(1)
Spacer()
Text(assertion.duration).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
Text(assertion.reason).font(.caption).foregroundStyle(.secondary).lineLimit(2)
Text("PID \(assertion.pid)\(friendlyAssertionType(assertion.type))")
.font(.caption2).foregroundStyle(.tertiary)
}
.padding(.vertical, 7)
if assertion.id != sleepBlockers.last?.id { Divider() }
}
}
}
.padding(8)
}
.frame(maxWidth: .infinity)
}
private var policySection: some View {
GroupBox("Active power policy") {
HStack(spacing: 28) {
policyValue("System sleep", minuteText(power.systemSleepMinutes), "powersleep")
policyValue("Display sleep", minuteText(power.displaySleepMinutes), "display")
policyValue("Power Nap", power.powerNapEnabled ? "On" : "Off", "clock.arrow.circlepath")
policyValue("Wake for network", power.wakeOnNetworkEnabled ? "On" : "Off", "network")
Spacer()
}
.padding(10)
}
}
private func summaryCard(_ title: String, _ value: String, _ icon: String, _ color: Color) -> some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.title2).foregroundStyle(color).frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.headline).lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.48))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(.secondary.opacity(0.14)) }
}
private func policyValue(_ title: String, _ value: String, _ icon: String) -> some View {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.medium))
}
} icon: { Image(systemName: icon).foregroundStyle(.green) }
}
private func energyScore(for process: ProcessRecord) -> Double {
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
let diskMB = activity.diskTotal / 1_048_576
let networkMB = activity.networkTotal / 1_048_576
return min(100, process.cpu + diskMB * 3.5 + networkMB * 1.5)
}
private func impactLabel(_ score: Double) -> String {
switch score {
case 30...: "Very high"
case 15..<30: "High"
case 5..<15: "Medium"
case 1..<5: "Low"
default: "Very low"
}
}
private func impactColor(_ score: Double) -> Color {
switch score {
case 30...: .red
case 15..<30: .orange
case 5..<15: .yellow
default: .green
}
}
private func minuteText(_ value: Int?) -> String {
guard let value else { return "Not reported" }
return value == 0 ? "Never" : "\(value) min"
}
private func friendlyAssertionType(_ value: String) -> String {
value.replacingOccurrences(of: "PreventUserIdle", with: "Prevent ")
.replacingOccurrences(of: "NoIdleSleepAssertion", with: "Prevent idle sleep")
.replacingOccurrences(of: "SystemSleep", with: "system sleep")
.replacingOccurrences(of: "DisplaySleep", with: "display sleep")
}
private func watts(_ value: Double) -> String {
value > 0 ? String(format: "%.1f W", value) : "Not reported"
}
}
+48
View File
@@ -0,0 +1,48 @@
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
}
}
}
enum FanControlService {
static var toolPath: String {
if let bundled = Bundle.main.url(forResource: "smc", withExtension: nil)?.path,
FileManager.default.isExecutableFile(atPath: bundled) {
return bundled
}
return "/Applications/Stats.app/Contents/Resources/smc"
}
static var isAvailable: Bool {
FileManager.default.isExecutableFile(atPath: toolPath)
}
static func setManualTargets(_ targets: [Int: Double]) throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
try PrivilegedHelperClient.setFanTargets(targets)
}
static func restoreAutomatic() throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
try PrivilegedHelperClient.restoreAutomaticFanControl()
}
}
+524
View File
@@ -0,0 +1,524 @@
import AppKit
import Charts
import SwiftUI
struct HardwareView: View {
@Environment(SystemMonitor.self) private var monitor
@AppStorage("showEmptyHardwarePorts") private var showEmptyPorts = true
@State private var fanTargets: [Int: Double] = [:]
@State private var fanCommandInProgress = false
@State private var fanError: String?
@State private var fanStatus: String?
@State private var showingFanConfirmation = false
private var battery: BatterySnapshot { monitor.hardware.battery }
private var visiblePorts: [HardwarePortSnapshot] {
showEmptyPorts ? monitor.hardware.ports : monitor.hardware.ports.filter(\.isConnected)
}
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Hardware", subtitle: "Power, battery, thermals, ports, and connection diagnostics") {
HStack(spacing: 10) {
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: "Hardware")
}
Button("Power Settings", systemImage: "gear") { openPowerSettings() }
}
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
summaryGrid
verdictCard
if battery.isPresent { batterySection }
if let adapter = battery.adapter { adapterSection(adapter) }
powerSection
if !monitor.hardware.physicalDisks.isEmpty { storageSection }
portsSection
coolingSection
topConsumersSection
}
.padding(20)
}
}
.onChange(of: monitor.hardware.fans) { _, fans in
seedFanTargets(fans)
}
.onAppear { seedFanTargets(monitor.hardware.fans) }
.confirmationDialog("Apply manual fan targets?", isPresented: $showingFanConfirmation) {
Button("Apply Targets") { updateFans(manual: true) }
Button("Cancel", role: .cancel) { }
} message: {
Text("\(fanTargetSummary). macOS automatic fan control will be disabled until you choose Automatic.")
}
.alert("Fan control failed", isPresented: Binding(
get: { fanError != nil },
set: { if !$0 { fanError = nil } }
)) {
Button("OK", role: .cancel) { fanError = nil }
} message: {
Text(fanError ?? "")
}
}
private var summaryGrid: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 10)], spacing: 10) {
summaryCard("CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), detail: "\(monitor.snapshot.corePercents.count) logical processors", icon: "cpu", color: .blue)
summaryCard("Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), detail: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed)), icon: "memorychip", color: .purple)
summaryCard("System power", value: watts(battery.systemPowerWatts), detail: battery.externalPowerConnected ? "External power" : "Battery power", icon: "bolt.fill", color: .orange)
summaryCard("Thermals", value: monitor.hardware.thermalState, detail: thermalDetail, icon: "thermometer.medium", color: thermalColor)
}
}
private func summaryCard(_ title: String, value: String, detail: String, icon: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(color)
Text(value).font(.title2.weight(.semibold)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
.frame(maxWidth: .infinity, minHeight: 86, alignment: .leading)
.padding(13)
.background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.18)) }
}
private var verdictCard: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: verdictIcon).font(.title2).foregroundStyle(verdictColor).frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(verdictTitle).font(.headline)
Text(verdictDetail).font(.callout).foregroundStyle(.secondary)
}
Spacer()
}
.padding(15)
.background(verdictColor.opacity(0.09), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(verdictColor.opacity(0.22)) }
}
private var batterySection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Battery").font(.headline)
HStack(alignment: .firstTextBaseline) {
metric("Charge", Formatters.percent(battery.chargePercent))
Spacer()
metric("Condition", battery.healthPercent > 80 ? "Normal" : "Service recommended")
Spacer()
metric("Health", Formatters.percent(battery.healthPercent))
}
HStack(alignment: .firstTextBaseline) {
metric("Cycles", cycleText)
Spacer()
metric("Temperature", String(format: "%.1f °C", battery.temperatureCelsius))
Spacer()
metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
}
HStack(alignment: .firstTextBaseline) {
metric("Battery power", watts(battery.batteryPowerWatts))
Spacer()
metric("Full capacity", capacity(battery.fullChargeCapacityMAh))
Spacer()
metric("Design capacity", capacity(battery.designCapacityMAh))
Spacer()
metric("Time remaining", timeRemaining)
}
Divider()
HStack {
Label(powerSourceText, systemImage: battery.externalPowerConnected ? "powerplug.fill" : "battery.75percent")
Spacer()
Text(batteryStateText).foregroundStyle(.secondary)
}
.font(.callout.weight(.medium))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func adapterSection(_ adapter: PowerAdapterSnapshot) -> some View {
VStack(alignment: .leading, spacing: 13) {
Text("Charger and USB Power Delivery").font(.headline)
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(adapter.name).font(.headline)
Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
}
Divider()
HStack(spacing: 24) {
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()
}
if !adapter.profiles.isEmpty {
VStack(alignment: .leading, spacing: 7) {
Text("Advertised power profiles").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
HStack(spacing: 7) {
ForEach(adapter.profiles) { profile in
Text(String(format: "%.0f V × %.2f A · %.0f W", profile.voltageVolts, profile.currentAmps, profile.watts))
.font(.caption.monospacedDigit())
.padding(.horizontal, 9).padding(.vertical, 5)
.background(Color.accentColor.opacity(profile.voltageVolts == adapter.negotiatedVoltage ? 0.18 : 0.07), in: Capsule())
}
}
}
}
Label(cableEvidence(adapter), systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var powerSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Power history — 60 seconds").font(.headline)
Chart(monitor.systemPowerHistory) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
.foregroundStyle(.orange.opacity(0.13))
LineMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
.foregroundStyle(.orange).lineStyle(.init(lineWidth: 2))
}
.chartYAxisLabel("W")
.chartXAxis(.hidden)
.frame(height: 150)
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var portsSection: some View {
VStack(spacing: 0) {
HStack {
Text("USB-C, USB, and Thunderbolt").font(.headline)
Spacer()
Toggle("Show available ports", isOn: $showEmptyPorts).toggleStyle(.switch).controlSize(.small)
}
.padding(8)
if !visiblePorts.isEmpty { Divider() }
ForEach(visiblePorts) { port in
portRow(port)
if port.id != visiblePorts.last?.id { Divider() }
}
}
.padding(8)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.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 {
Image(systemName: port.isConnected ? "cable.connector" : "bolt.horizontal.circle")
.foregroundStyle(port.isConnected ? Color.green : Color.secondary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(port.name).font(.callout.weight(.semibold))
Text("\(port.transport)\(port.maximumSpeed)").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(port.status).font(.caption.weight(.medium)).foregroundStyle(port.isConnected ? Color.green : Color.secondary)
}
ForEach(port.devices) { device in
HStack(spacing: 8) {
Image(systemName: "arrow.turn.down.right").foregroundStyle(.tertiary)
VStack(alignment: .leading, spacing: 2) {
Text(device.name).lineLimit(1)
Text(deviceDetail(device)).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
.padding(.leading, CGFloat(device.depth * 18 + 30))
}
}
.padding(10)
}
private var topConsumersSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top live consumers").font(.headline)
VStack(spacing: 0) {
ForEach(Array(monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5))) { process in
HStack {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpu)).monospacedDigit().frame(width: 64, alignment: .trailing)
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
}
.padding(.vertical, 6)
}
}
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var coolingSection: some View {
VStack(alignment: .leading, spacing: 13) {
HStack {
Label("Cooling and fan control", systemImage: "fan")
.font(.headline)
Spacer()
Text(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? "Automatic" : "Manual")
.font(.caption.weight(.medium))
.foregroundStyle(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? .green : .orange)
}
Divider()
if monitor.hardware.fans.isEmpty {
HStack(spacing: 12) {
Image(systemName: "fan.slash").font(.title2).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 3) {
Text("Fan telemetry unavailable").font(.callout.weight(.medium))
Text("This Mac is not publishing compatible SMC fan data.")
.font(.caption).foregroundStyle(.secondary)
}
}
} else {
ForEach(monitor.hardware.fans) { fan in
fanRow(fan)
if fan.id != monitor.hardware.fans.last?.id { Divider().opacity(0.55) }
}
}
HStack(spacing: 28) {
metric("Thermal pressure", monitor.hardware.thermalState)
metric("Low Power Mode", ProcessInfo.processInfo.isLowPowerModeEnabled ? "On" : "Off")
metric("Control backend", FanControlService.isAvailable ? "Ready" : "Unavailable")
Spacer()
}
Text("puter reads fan sensors directly through the Mac's SMC interface. Manual targets are clamped to each fan's reported safe range; Automatic returns control to macOS.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button("Apply targets", systemImage: "speedometer") {
showingFanConfirmation = true
}
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
Button("Automatic", systemImage: "arrow.counterclockwise") { updateFans(manual: false) }
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
Button("Reduce monitor refresh", systemImage: "tortoise") {
monitor.updateSpeed = .low
}
Spacer()
if fanCommandInProgress {
ProgressView().controlSize(.small).accessibilityLabel("Applying fan settings")
} else if let fanStatus {
Label(fanStatus, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func fanRow(_ fan: FanSnapshot) -> some View {
let target = Binding(
get: { safeTarget(for: fan) },
set: { fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, $0)) }
)
return HStack(spacing: 16) {
ZStack {
Circle().fill(Color.cyan.opacity(0.12)).frame(width: 40, height: 40)
Image(systemName: "fan.fill").foregroundStyle(.cyan)
}
VStack(alignment: .leading, spacing: 3) {
Text(fan.name).font(.callout.weight(.semibold))
Text("\(Int(fan.actualRPM.rounded())) RPM now · \(Int(fan.minimumRPM))\(Int(fan.maximumRPM)) RPM")
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
}
.frame(width: 220, alignment: .leading)
Slider(value: target, in: fan.minimumRPM...max(fan.minimumRPM + 1, fan.maximumRPM), step: 25)
.accessibilityLabel("\(fan.name) target speed")
.accessibilityValue("\(Int(target.wrappedValue.rounded())) RPM")
.accessibilityHint("Adjusts from \(Int(fan.minimumRPM)) to \(Int(fan.maximumRPM)) RPM")
Text("\(Int(target.wrappedValue.rounded())) RPM")
.font(.callout.monospacedDigit()).frame(width: 82, alignment: .trailing)
Text(fan.isAutomatic ? "Auto" : "Manual")
.font(.caption.weight(.medium))
.foregroundStyle(fan.isAutomatic ? .green : .orange)
.frame(width: 52, alignment: .trailing)
}
}
private func updateFans(manual: Bool) {
fanCommandInProgress = true
fanStatus = nil
let targets = Dictionary(uniqueKeysWithValues: monitor.hardware.fans.map { fan in
(fan.id, safeTarget(for: fan))
})
Task {
do {
try await Task.detached {
if manual { try FanControlService.setManualTargets(targets) }
else { try FanControlService.restoreAutomatic() }
}.value
fanStatus = manual ? "Targets applied" : "Automatic control restored"
monitor.refresh()
} catch {
fanError = error.localizedDescription
}
fanCommandInProgress = false
}
}
private var fanTargetSummary: String {
monitor.hardware.fans.map { fan in
let target = safeTarget(for: fan)
return "\(fan.name): \(Int(target.rounded())) RPM"
}.joined(separator: ", ")
}
private func seedFanTargets(_ fans: [FanSnapshot]) {
for fan in fans where fanTargets[fan.id] == nil {
let reported = fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM
fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, reported))
}
}
private func safeTarget(for fan: FanSnapshot) -> Double {
let reported = fanTargets[fan.id] ?? (fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM)
return min(fan.maximumRPM, max(fan.minimumRPM, reported))
}
private func metric(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.medium)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
}
}
private var verdictTitle: String {
if !battery.isPresent { return "Power telemetry available for desktop hardware" }
if battery.isFullyCharged && battery.externalPowerConnected { return "Battery full — running from external power" }
if battery.isCharging { return "Battery charging normally" }
if battery.externalPowerConnected { return "External power connected — battery is not charging" }
return "Running on battery"
}
private var verdictDetail: String {
guard battery.isPresent else { return "Battery-specific fields are hidden because this Mac reports no internal battery." }
if let adapter = battery.adapter {
return "macOS reports a \(Int(adapter.ratedWatts.rounded())) W adapter and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Current system input is \(watts(battery.systemPowerWatts))."
}
return "Current battery draw is \(watts(battery.batteryPowerWatts)). Charger and cable capability are shown only when macOS publishes them."
}
private var verdictIcon: String {
battery.externalPowerConnected ? "checkmark.circle.fill" : "battery.75percent"
}
private var verdictColor: Color {
monitor.hardware.thermalState == "Serious" || monitor.hardware.thermalState == "Critical" ? .red : .green
}
private var thermalColor: Color {
switch monitor.hardware.thermalState {
case "Critical", "Serious": .red
case "Fair": .orange
default: .green
}
}
private var thermalDetail: String {
switch monitor.hardware.thermalState {
case "Critical": "Performance is heavily constrained"
case "Serious": "Performance may be reduced"
case "Fair": "Elevated thermal pressure"
default: "No thermal pressure"
}
}
private var batteryColor: Color { battery.chargePercent < 20 ? .red : (battery.isCharging ? .green : .accentColor) }
private var cycleText: String { battery.designCycleCount > 0 ? "\(battery.cycleCount) of \(battery.designCycleCount)" : "\(battery.cycleCount)" }
private var timeRemaining: String {
guard let minutes = battery.timeRemainingMinutes else { return battery.isFullyCharged ? "Full" : "Calculating" }
return "\(minutes / 60)h \(minutes % 60)m"
}
private var powerSourceText: String { battery.externalPowerConnected ? "Power adapter" : "Internal battery" }
private var batteryStateText: String { battery.isFullyCharged ? "Fully charged" : (battery.isCharging ? "Charging" : "Not charging") }
private func cableEvidence(_ adapter: PowerAdapterSnapshot) -> String {
"Observed USB-PD contract: \(String(format: "%.1f V at %.2f A", adapter.negotiatedVoltage, adapter.negotiatedCurrent)). Cable e-marker identity is not claimed unless macOS exposes it."
}
private func deviceDetail(_ device: ConnectedHardwareDevice) -> String {
var parts = [device.vendor, device.speed].filter { $0 != "Not reported" }
if let required = device.currentRequiredMA { parts.append("requests \(required) mA") }
if let available = device.currentAvailableMA { parts.append("\(available) mA available") }
return parts.isEmpty ? "Technical details not reported by macOS" : parts.joined(separator: "")
}
private func watts(_ value: Double) -> String { value > 0 ? String(format: "%.1f W", value) : "" }
private func capacity(_ value: Double) -> String { value > 0 ? String(format: "%.0f mAh", value) : "Not reported" }
private func openPowerSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.battery") { NSWorkspace.shared.open(url) }
}
}
+96
View File
@@ -0,0 +1,96 @@
import AppKit
import SwiftUI
struct MenuBarMonitorView: View {
@Environment(SystemMonitor.self) private var monitor
@Environment(\.openWindow) private var openWindow
private var topProcesses: [ProcessRecord] {
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5).map { $0 }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(nsImage: NSApp.applicationIconImage).resizable().scaledToFit().frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 2) {
Text("puter").font(.headline)
if let updated = monitor.lastUpdated {
Text("Updated \(updated, style: .relative)").font(.caption).foregroundStyle(.secondary)
} else { Text("Collecting…").font(.caption).foregroundStyle(.secondary) }
}
Spacer()
Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 8, height: 8)
}
HStack(spacing: 8) {
metric("CPU", Formatters.percent(monitor.snapshot.cpuPercent), .blue)
metric("Memory", Formatters.percent(monitor.snapshot.memoryPercent), pressureColor)
}
HStack(spacing: 8) {
metric("Network", Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate), .teal)
metric("Power", powerText, .orange)
}
HStack {
Label("Memory pressure", systemImage: "memorychip")
Spacer()
Text(monitor.snapshot.memoryPressure.rawValue).foregroundStyle(pressureColor)
}
.font(.caption.weight(.medium))
Divider()
Text("Top CPU processes").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
if topProcesses.isEmpty {
Text("No process data yet").font(.caption).foregroundStyle(.secondary)
} else {
ForEach(topProcesses) { process in
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath, size: 18)
Text(process.displayName).font(.caption).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpu)).font(.caption.monospacedDigit())
}
}
}
Divider()
HStack {
Button(monitor.isPaused ? "Resume" : "Pause") { monitor.isPaused.toggle() }
Button("Refresh") { monitor.refresh() }
Spacer()
Button("Open puter") {
openWindow(id: "main")
NSApp.activate()
}
.buttonStyle(.borderedProminent)
}
.controlSize(.small)
}
.padding(14)
.frame(width: 320)
}
private func metric(_ title: String, _ value: String, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.caption2).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.semibold).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(9)
.background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8))
}
private var pressureColor: Color {
switch monitor.snapshot.memoryPressure {
case .normal: .green
case .warning: .orange
case .critical: .red
}
}
private var powerText: String {
let watts = monitor.hardware.battery.systemPowerWatts
return watts > 0 ? String(format: "%.1f W", watts) : "Not reported"
}
}
+537
View File
@@ -0,0 +1,537 @@
import Foundation
import SwiftUI
enum TaskSection: String, CaseIterable, Identifiable {
case processes = "Processes"
case performance = "Performance"
case history = "App history"
case startup = "Startup apps"
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"
case .performance: "waveform.path.ecg"
case .history: "clock.arrow.circlepath"
case .startup: "gauge.open.with.lines.needle.33percent"
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"
}
}
}
struct PowerProfile: Identifiable, Hashable, Sendable {
let voltageVolts: Double
let currentAmps: Double
var id: String { "\(voltageVolts)-\(currentAmps)" }
var watts: Double { voltageVolts * currentAmps }
}
struct PowerAdapterSnapshot: Hashable, Sendable {
var name = "Power adapter"
var manufacturer = "Not reported"
var serial = ""
var ratedWatts = 0.0
var negotiatedVoltage = 0.0
var negotiatedCurrent = 0.0
var profiles: [PowerProfile] = []
var negotiatedWatts: Double { negotiatedVoltage * negotiatedCurrent }
}
struct BatterySnapshot: Hashable, Sendable {
var isPresent = false
var chargePercent = 0.0
var isCharging = false
var isFullyCharged = false
var externalPowerConnected = false
var cycleCount = 0
var designCycleCount = 0
var currentCapacityMAh = 0.0
var fullChargeCapacityMAh = 0.0
var designCapacityMAh = 0.0
var voltageVolts = 0.0
var currentAmps = 0.0
var temperatureCelsius = 0.0
var timeRemainingMinutes: Int?
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 }
return min(100, fullChargeCapacityMAh / designCapacityMAh * 100)
}
var batteryPowerWatts: Double {
if sensorBatteryPowerWatts > 0 { return sensorBatteryPowerWatts }
let watts = abs(voltageVolts * currentAmps)
return watts.isFinite && watts <= 500 ? watts : 0
}
}
struct ConnectedHardwareDevice: Identifiable, Hashable, Sendable {
let id: String
let name: String
let vendor: String
let speed: String
let currentAvailableMA: Int?
let currentRequiredMA: Int?
let depth: Int
}
struct HardwarePortSnapshot: Identifiable, Hashable, Sendable {
let id: String
let name: String
let transport: String
let maximumSpeed: String
let isConnected: Bool
let status: String
let devices: [ConnectedHardwareDevice]
}
struct FanSnapshot: Identifiable, Hashable, Sendable {
let id: Int
let name: String
let actualRPM: Double
let minimumRPM: Double
let maximumRPM: Double
let targetRPM: Double
let mode: String
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
}
struct MacMemorySpecification: Hashable, Sendable {
let chip: String
let cpuCoreCount: Int?
let bandwidthGBps: Double
let isMaximum: Bool
var displayValue: String {
let bandwidth = bandwidthGBps.rounded() == bandwidthGBps
? String(format: "%.0f", bandwidthGBps)
: String(format: "%.2f", bandwidthGBps)
return "\(isMaximum ? "Up to " : "")\(bandwidth) GB/s bandwidth (Apple specification)"
}
}
enum MemoryBandwidthCatalog {
// Ordered from most-specific to broadest. Core-count entries distinguish
// configurations where Apple ships one chip name with multiple memory buses.
static let specifications: [MacMemorySpecification] = [
.init(chip: "M5 Max", cpuCoreCount: nil, bandwidthGBps: 614, isMaximum: true),
.init(chip: "M5 Pro", cpuCoreCount: nil, bandwidthGBps: 307, isMaximum: true),
.init(chip: "M5", cpuCoreCount: nil, bandwidthGBps: 153, isMaximum: false),
.init(chip: "M4 Max", cpuCoreCount: 16, bandwidthGBps: 546, isMaximum: false),
.init(chip: "M4 Max", cpuCoreCount: 14, bandwidthGBps: 410, isMaximum: false),
.init(chip: "M4 Pro", cpuCoreCount: nil, bandwidthGBps: 273, isMaximum: false),
.init(chip: "M4", cpuCoreCount: nil, bandwidthGBps: 120, isMaximum: false),
.init(chip: "M3 Ultra", cpuCoreCount: nil, bandwidthGBps: 819, isMaximum: false),
.init(chip: "M3 Max", cpuCoreCount: 16, bandwidthGBps: 400, isMaximum: false),
.init(chip: "M3 Max", cpuCoreCount: 14, bandwidthGBps: 300, isMaximum: false),
.init(chip: "M3 Pro", cpuCoreCount: nil, bandwidthGBps: 150, isMaximum: false),
.init(chip: "M3", cpuCoreCount: nil, bandwidthGBps: 100, isMaximum: false),
.init(chip: "M2 Ultra", cpuCoreCount: nil, bandwidthGBps: 800, isMaximum: false),
.init(chip: "M2 Max", cpuCoreCount: nil, bandwidthGBps: 400, isMaximum: false),
.init(chip: "M2 Pro", cpuCoreCount: nil, bandwidthGBps: 200, isMaximum: false),
.init(chip: "M2", cpuCoreCount: nil, bandwidthGBps: 100, isMaximum: false),
.init(chip: "M1 Ultra", cpuCoreCount: nil, bandwidthGBps: 800, isMaximum: false),
.init(chip: "M1 Max", cpuCoreCount: nil, bandwidthGBps: 400, isMaximum: false),
.init(chip: "M1 Pro", cpuCoreCount: nil, bandwidthGBps: 200, isMaximum: false),
.init(chip: "M1", cpuCoreCount: nil, bandwidthGBps: 68.25, isMaximum: false)
]
static func specification(for processor: String, cpuCoreCount: Int = ProcessInfo.processInfo.activeProcessorCount) -> MacMemorySpecification? {
let normalized = processor.replacingOccurrences(of: "Apple ", with: "")
return specifications.first {
normalized == $0.chip && ($0.cpuCoreCount == nil || $0.cpuCoreCount == cpuCoreCount)
}
}
static func description(for processor: String, cpuCoreCount: Int = ProcessInfo.processInfo.activeProcessorCount) -> String {
specification(for: processor, cpuCoreCount: cpuCoreCount)?.displayValue ?? "Clock not published by macOS"
}
}
enum UpdateSpeed: String, CaseIterable, Identifiable {
case high = "High"
case normal = "Normal"
case low = "Low"
case paused = "Paused"
var id: String { rawValue }
var interval: Duration {
switch self {
case .high: .seconds(1)
case .normal: .seconds(2)
case .low: .seconds(5)
case .paused: .seconds(2)
}
}
}
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
let name: String
let executablePath: String
let user: String
let cpu: Double
let memoryPercent: Double
let residentBytes: UInt64
let state: String
let elapsed: String
let cpuTime: TimeInterval
let threadCount: Int
let openFileCount: Int
let architecture: String
let priority: Int
let nice: Int
var id: Int32 { pid }
var displayName: String {
let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.isEmpty ? "Process \(pid)" : cleaned
}
}
struct AppUsageRecord: Identifiable, Codable, Hashable, Sendable {
let id: String
var name: String
var executablePath: String
var user: String
var cpuSeconds: TimeInterval
var networkReceivedBytes: UInt64
var networkSentBytes: UInt64
var lastSeen: Date
var networkTotalBytes: UInt64 { networkReceivedBytes + networkSentBytes }
}
struct ProcessActivityRate: Hashable, Sendable {
var diskRead = 0.0
var diskWrite = 0.0
var networkReceive = 0.0
var networkSend = 0.0
var diskTotal: Double { diskRead + diskWrite }
var networkTotal: Double { networkReceive + networkSend }
}
enum ServiceDomain: String, CaseIterable, Identifiable, Sendable {
case user = "User"
case system = "System"
var id: String { rawValue }
var launchctlPrefix: String { self == .system ? "system" : "gui/\(getuid())" }
}
struct ServiceRecord: Identifiable, Hashable, Sendable {
let label: String
let pid: Int32?
let lastExitStatus: Int32
let domain: ServiceDomain
var id: String { "\(domain.rawValue):\(label)" }
var isRunning: Bool { pid != nil }
var isControllable: Bool { domain == .user }
var configurationPath: String? {
let roots = domain == .system
? ["/Library/LaunchDaemons", "/System/Library/LaunchDaemons"]
: [NSHomeDirectory() + "/Library/LaunchAgents", "/Library/LaunchAgents", "/System/Library/LaunchAgents"]
return roots.map { "\($0)/\(label).plist" }.first { FileManager.default.fileExists(atPath: $0) }
}
var displayName: String {
let parts = label.split(separator: ".").map(String.init)
let meaningful = parts.filter { part in
let isNumber = part.allSatisfy(\.isNumber)
let isUUID = UUID(uuidString: part) != nil
return !isNumber && !isUUID
}
guard let last = meaningful.last else { return label }
let generic = ["agent", "helper", "service", "xpcservice", "app"]
if generic.contains(last.lowercased()), meaningful.count > 1 {
return "\(meaningful[meaningful.count - 2]) \(last)"
}
return last
}
var publisher: String {
let parts = label.split(separator: ".").map(String.init)
guard let first = parts.first else { return "Unknown" }
if label.hasPrefix("com.apple") { return "Apple" }
if first == "application", parts.count > 2 {
let vendorIndex = ["com", "org", "net", "io"].contains(parts[1].lowercased()) ? 2 : 1
let vendor = parts[vendorIndex]
return vendor.prefix(1).uppercased() + vendor.dropFirst()
}
if ["com", "org", "net", "io", "us"].contains(first.lowercased()), parts.count > 1 {
return parts[1].prefix(1).uppercased() + parts[1].dropFirst()
}
return first.prefix(1).uppercased() + first.dropFirst()
}
}
struct MetricSample: Identifiable {
let id = UUID()
let date: Date
let value: Double
}
struct VolumeSnapshot: Identifiable, Hashable, Sendable {
let id: String
let name: String
let mountPath: String
let fileSystem: String
let capacity: UInt64
let available: UInt64
let isEjectable: Bool
var used: UInt64 { capacity > available ? capacity - available : 0 }
var usedPercent: Double { capacity > 0 ? Double(used) / Double(capacity) * 100 : 0 }
}
struct SystemSnapshot {
var cpuPercent = 0.0
var memoryUsed: UInt64 = 0
var memoryTotal: UInt64 = ProcessInfo.processInfo.physicalMemory
var memoryActive: UInt64 = 0
var memoryWired: UInt64 = 0
var memoryCompressed: UInt64 = 0
var memoryCached: UInt64 = 0
var swapUsed: UInt64 = 0
var swapTotal: UInt64 = 0
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
var diskWriteRate = 0.0
var diskOperationsPerSecond = 0.0
var diskLatencyMilliseconds = 0.0
var diskActivePercent = 0.0
var gpuPercent = 0.0
var gpuRendererPercent = 0.0
var gpuTilerPercent = 0.0
var gpuMemoryBytes: UInt64 = 0
var gpuAllocatedBytes: UInt64 = 0
var gpuCoreCount = 0
var processCount = 0
var threadCount = 0
var uptime: TimeInterval = ProcessInfo.processInfo.systemUptime
var corePercents: [Double] = []
var networkReceiveRate = 0.0
var networkSendRate = 0.0
var networkInterface = "Network"
var removableVolumes: [VolumeSnapshot] = []
var memoryPercent: Double {
guard memoryTotal > 0 else { return 0 }
return Double(memoryUsed) / Double(memoryTotal) * 100
}
var diskPercent: Double {
guard diskTotal > 0 else { return 0 }
return Double(diskTotal - diskFree) / Double(diskTotal) * 100
}
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 = {
let formatter = ByteCountFormatter()
formatter.countStyle = .memory
formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]
return formatter
}()
static func percent(_ value: Double) -> String {
value < 10 ? String(format: "%.1f%%", value) : String(format: "%.0f%%", value)
}
static func uptime(_ interval: TimeInterval) -> String {
let total = Int(interval)
let days = total / 86_400
let hours = (total % 86_400) / 3_600
let minutes = (total % 3_600) / 60
return days > 0 ? "\(days)d \(hours)h \(minutes)m" : "\(hours)h \(minutes)m"
}
static func rate(_ bytesPerSecond: Double) -> String {
"\(bytes.string(fromByteCount: Int64(max(0, bytesPerSecond))))/s"
}
static func duration(_ interval: TimeInterval) -> String {
let total = max(0, Int(interval.rounded()))
let hours = total / 3_600
let minutes = (total % 3_600) / 60
let seconds = total % 60
return hours > 0
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
: String(format: "%02d:%02d", minutes, seconds)
}
}
@@ -37,13 +37,37 @@ struct PerformanceView: View {
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Performance") {
UpdateStatus()
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)
}
UpdateStatus()
}
}
HStack(spacing: 0) {
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,
@@ -67,14 +91,94 @@ struct PerformanceView: View {
}
.padding(14)
}
.frame(width: 245)
.frame(minWidth: 190, idealWidth: 245, maxWidth: 245)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
Divider()
detail
VStack(spacing: 0) {
detail
Divider()
topConsumers
}
}
}
}
private var selectedResourceName: String {
if let selectedVolumeID,
let volume = monitor.snapshot.removableVolumes.first(where: { $0.id == selectedVolumeID }) {
return volume.name
}
return selectedMetric.rawValue
}
private var activeConsumerMetric: PerformanceMetric {
selectedVolumeID == nil ? selectedMetric : .disk
}
private var rankedConsumers: [ProcessRecord] {
guard activeConsumerMetric != .gpu else { return [] }
return monitor.processes
.filter { consumerValue($0) > 0 }
.sorted { consumerValue($0) > consumerValue($1) }
.prefix(4)
.map { $0 }
}
private var topConsumers: some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text("Top processes — \(selectedResourceName)").font(.callout.weight(.semibold))
Spacer()
Text("User").frame(width: 110, alignment: .leading)
Text(activeConsumerMetric.rawValue).frame(width: 92, alignment: .trailing)
}
.font(.caption)
.foregroundStyle(.secondary)
if activeConsumerMetric == .gpu {
Label("Per-process GPU attribution is not exposed by the current macOS telemetry.", systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 12)
} else if rankedConsumers.isEmpty {
Text("No active consumers in this sample.").font(.caption).foregroundStyle(.secondary).padding(.vertical, 12)
} else {
ForEach(rankedConsumers) { process in
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath, size: 20)
Text(process.displayName).lineLimit(1)
Spacer()
Text(process.user).lineLimit(1).frame(width: 110, alignment: .leading)
Text(consumerValueText(process)).monospacedDigit().frame(width: 92, alignment: .trailing)
}
.font(.caption)
.padding(.vertical, 2)
}
}
}
.padding(.horizontal, 18)
.padding(.vertical, 10)
.frame(height: 146, alignment: .top)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.35))
}
private func consumerValue(_ process: ProcessRecord) -> Double {
switch activeConsumerMetric {
case .cpu: process.cpu
case .memory: Double(process.residentBytes)
case .disk: monitor.processActivity[process.pid]?.diskTotal ?? 0
case .network: monitor.processActivity[process.pid]?.networkTotal ?? 0
case .gpu: 0
}
}
private func consumerValueText(_ process: ProcessRecord) -> String {
switch activeConsumerMetric {
case .cpu: Formatters.percent(process.cpu)
case .memory: Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))
case .disk, .network: Formatters.rate(consumerValue(process))
case .gpu: ""
}
}
private func metricCard(_ metric: PerformanceMetric, value: Double, valueText: String? = nil, detail: String) -> some View {
Button {
selectedVolumeID = nil
@@ -165,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))),
@@ -173,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)
@@ -217,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 {
@@ -226,7 +358,7 @@ private struct RemovableDiskDetail: View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text(volume.name).font(.system(size: 30, weight: .semibold))
Text(volume.name).font(.title.weight(.semibold))
Text("\(volume.fileSystem)\(volume.mountPath)")
.foregroundStyle(.secondary)
}
@@ -236,7 +368,7 @@ private struct RemovableDiskDetail: View {
.foregroundStyle(.orange)
Spacer()
Text(Formatters.percent(volume.usedPercent))
.font(.system(size: 34, weight: .light).monospacedDigit())
.font(.largeTitle.weight(.light).monospacedDigit())
}
ProgressView(value: volume.usedPercent, total: 100)
@@ -266,12 +398,12 @@ private struct GPUPerformanceDetail: View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("GPU").font(.system(size: 30, weight: .semibold))
Text("GPU").font(.title.weight(.semibold))
Text(name).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(snapshot.gpuPercent))
.font(.system(size: 34, weight: .light).monospacedDigit())
.font(.largeTitle.weight(.light).monospacedDigit())
}
VStack(alignment: .leading, spacing: 8) {
@@ -366,13 +498,13 @@ private struct DiskPerformanceDetail: View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Disk 0").font(.system(size: 30, weight: .semibold))
Text("Disk 0").font(.title.weight(.semibold))
Text("System volume • APFS • Solid-state").foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(Formatters.rate(snapshot.diskThroughput))
.font(.system(size: 30, weight: .light).monospacedDigit())
.font(.title.weight(.light).monospacedDigit())
Text("\(Formatters.percent(snapshot.diskActivePercent)) active")
.font(.caption).foregroundStyle(.secondary)
}
@@ -499,12 +631,12 @@ private struct NetworkPerformanceDetail: View {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Network").font(.system(size: 30, weight: .semibold))
Text("Network").font(.title.weight(.semibold))
Text(interface).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.rate(receiveRate + sendRate))
.font(.system(size: 30, weight: .light).monospacedDigit())
.font(.title.weight(.light).monospacedDigit())
}
VStack(alignment: .leading, spacing: 8) {
@@ -584,12 +716,12 @@ private struct CPUPerformanceDetail: View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("CPU").font(.system(size: 30, weight: .semibold))
Text("CPU").font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(value))
.font(.system(size: 34, weight: .light).monospacedDigit())
.font(.largeTitle.weight(.light).monospacedDigit())
}
cpuGraph
@@ -620,9 +752,24 @@ private struct CPUPerformanceDetail: View {
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
.font(.caption)
.foregroundStyle(.secondary)
Menu {
Button {
displayMode = .summary
} label: {
Label("Summary view", systemImage: displayMode == .summary ? "checkmark" : "chart.xyaxis.line")
}
Button {
displayMode = .logicalProcessors
} label: {
Label("Logical processors", systemImage: displayMode == .logicalProcessors ? "checkmark" : "square.grid.3x3")
}
} label: {
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
.font(.caption)
}
.menuStyle(.borderlessButton)
.fixedSize()
.accessibilityLabel("CPU graph layout")
}
if displayMode == .summary {
@@ -743,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] {
@@ -755,12 +903,27 @@ private struct PerformanceDetail: View {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.system(size: 30, weight: .semibold))
Text(title).font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(value))
.font(.system(size: 34, weight: .light).monospacedDigit())
.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)
@@ -792,3 +955,10 @@ private struct PerformanceDetail: View {
}
}
}
private struct PerformanceAdvisory {
let icon: String
let title: String
let message: String
let color: Color
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
enum PhysicalDiskScanner {
static func capture() -> [PhysicalDiskSnapshot] {
let listData = run("/usr/sbin/diskutil", ["list", "-plist", "physical"])
guard let list = try? PropertyListSerialization.propertyList(from: listData, format: nil) as? [String: Any] else { return [] }
let identifiers = (list["WholeDisks"] as? [String])
?? (list["AllDisksAndPartitions"] as? [[String: Any]])?.compactMap { $0["DeviceIdentifier"] as? String }
?? []
let trimByDisk = storageTrimSupport()
return identifiers.compactMap { identifier in
let data = run("/usr/sbin/diskutil", ["info", "-plist", identifier])
guard let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil }
let smart = info["SMARTDeviceSpecificKeysMayVaryNotGuaranteed"] as? [String: Any] ?? [:]
func number(_ key: String) -> UInt64? { (smart[key] as? NSNumber)?.uint64Value }
func dataBytes(_ key: String) -> UInt64? {
guard let units = number(key) else { return nil }
let result = units.multipliedReportingOverflow(by: 512_000)
return result.overflow ? nil : result.partialValue
}
let temperature = number("TEMPERATURE").map {
let raw = Double($0)
return raw > 200 ? raw - 273.15 : raw
}
return PhysicalDiskSnapshot(
id: identifier,
name: info["MediaName"] as? String ?? identifier,
protocolName: info["BusProtocol"] as? String ?? "Not reported",
capacity: (info["TotalSize"] as? NSNumber)?.uint64Value ?? 0,
isInternal: info["Internal"] as? Bool ?? false,
isRemovable: info["Removable"] as? Bool ?? false,
isSolidState: info["SolidState"] as? Bool ?? false,
smartStatus: info["SMARTStatus"] as? String ?? "Not supported",
trimEnabled: trimByDisk[identifier],
temperatureCelsius: temperature,
percentageUsed: number("PERCENTAGE_USED").map { Double($0) },
availableSparePercent: number("AVAILABLE_SPARE").map { Double($0) },
powerOnHours: number("POWER_ON_HOURS_0"),
powerCycles: number("POWER_CYCLES_0"),
unsafeShutdowns: number("UNSAFE_SHUTDOWNS_0"),
mediaErrors: number("MEDIA_ERRORS_0"),
bytesRead: dataBytes("DATA_UNITS_READ_0"),
bytesWritten: dataBytes("DATA_UNITS_WRITTEN_0")
)
}
.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
}
private static func storageTrimSupport() -> [String: Bool] {
let data = run("/usr/sbin/system_profiler", ["SPNVMeDataType", "SPSerialATADataType", "-json", "-detailLevel", "mini"])
guard let root = try? JSONSerialization.jsonObject(with: data) else { return [:] }
var result: [String: Bool] = [:]
func visit(_ value: Any) {
if let dictionary = value as? [String: Any] {
if let identifier = dictionary["bsd_name"] as? String,
let trim = (dictionary["spnvme_trim_support"] as? String) ?? (dictionary["spsata_trim_support"] as? String) {
result[identifier] = trim.localizedCaseInsensitiveCompare("yes") == .orderedSame
}
dictionary.values.forEach(visit)
} else if let array = value as? [Any] { array.forEach(visit) }
}
visit(root)
return result
}
private static func run(_ executable: String, _ arguments: [String]) -> Data {
let process = Process()
let output = Pipe()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.standardOutput = output
process.standardError = FileHandle.nullDevice
do {
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return process.terminationStatus == 0 ? data : Data()
} catch { return Data() }
}
}
+143
View File
@@ -0,0 +1,143 @@
import AppKit
import Foundation
import PuterHelperProtocol
import ServiceManagement
@MainActor
final class PrivilegedHelperManager: ObservableObject {
enum State: Equatable {
case enabled
case disabled
case requiresApproval
case unavailable
case requiresSignedInstallation
case failed(String)
var title: String {
switch self {
case .enabled: "Enabled"
case .disabled: "Not enabled"
case .requiresApproval: "Approval required"
case .unavailable: "Unavailable in this build"
case .requiresSignedInstallation: "Signed installation required"
case .failed: "Error"
}
}
var detail: String {
switch self {
case .enabled: "Fan changes use puter's signed, command-limited helper."
case .disabled: "Enable only if you want puter to change fan targets. Monitoring never requires it."
case .requiresApproval: "Allow puter under Login Items in System Settings, then return here."
case .unavailable: "The helper service is not included in this app bundle."
case .requiresSignedInstallation: "The helper is included, but macOS enables launch daemons only from a properly signed installed app."
case .failed(let message): message
}
}
}
@Published private(set) var state: State = .disabled
@Published private(set) var isWorking = false
private var service: SMAppService {
SMAppService.daemon(plistName: PuterHelperConstants.daemonPlistName)
}
init() { refresh() }
func refresh() {
switch service.status {
case .enabled: state = .enabled
case .notRegistered: state = .disabled
case .requiresApproval: state = .requiresApproval
case .notFound:
state = helperIsBundled ? .requiresSignedInstallation : .unavailable
@unknown default: state = .unavailable
}
}
private var helperIsBundled: Bool {
let contents = Bundle.main.bundleURL.appendingPathComponent("Contents")
return FileManager.default.fileExists(
atPath: contents.appendingPathComponent("Library/LaunchDaemons/\(PuterHelperConstants.daemonPlistName)").path
) && FileManager.default.isExecutableFile(
atPath: contents.appendingPathComponent("Resources/puter-helper").path
)
}
func enable() {
isWorking = true
defer { isWorking = false }
do {
try service.register()
refresh()
} catch {
state = .failed(error.localizedDescription)
}
}
func disable() {
isWorking = true
service.unregister { [weak self] error in
Task { @MainActor in
guard let self else { return }
self.isWorking = false
if let error { self.state = .failed(error.localizedDescription) }
else { self.refresh() }
}
}
}
func openApprovalSettings() {
SMAppService.openSystemSettingsLoginItems()
}
}
enum PrivilegedHelperClient {
private final class ReplyBox: @unchecked Sendable {
private let lock = NSLock()
private var result: Result<Void, Error>?
func set(_ value: Result<Void, Error>) { lock.withLock { result = value } }
func get() -> Result<Void, Error>? { lock.withLock { result } }
}
static func setFanTargets(_ targets: [Int: Double]) throws {
let boxed = Dictionary(uniqueKeysWithValues: targets.map { key, value in
(NSNumber(value: key), NSNumber(value: Int(value.rounded())))
})
try perform { proxy, reply in proxy.setFanTargets(boxed, withReply: reply) }
}
static func restoreAutomaticFanControl() throws {
try perform { proxy, reply in proxy.restoreAutomaticFanControl(withReply: reply) }
}
private static func perform(
_ operation: (PuterPrivilegedHelperProtocol, @escaping (Bool, String?) -> Void) -> Void
) throws {
guard SMAppService.daemon(plistName: PuterHelperConstants.daemonPlistName).status == .enabled else {
throw FanControlError.helperNotEnabled
}
let connection = NSXPCConnection(machServiceName: PuterHelperConstants.machServiceName, options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: PuterPrivilegedHelperProtocol.self)
connection.resume()
defer { connection.invalidate() }
let semaphore = DispatchSemaphore(value: 0)
let box = ReplyBox()
let errorHandler: (Error) -> Void = { error in
box.set(.failure(error))
semaphore.signal()
}
guard let proxy = connection.remoteObjectProxyWithErrorHandler(errorHandler) as? PuterPrivilegedHelperProtocol else {
throw FanControlError.connectionFailed
}
operation(proxy) { success, message in
box.set(success ? .success(()) : .failure(FanControlError.commandFailed(message ?? "")))
semaphore.signal()
}
guard semaphore.wait(timeout: .now() + 12) == .success else { throw FanControlError.timedOut }
try box.get()?.get()
}
}
@@ -29,10 +29,6 @@ struct ProcessContextMenu: View {
let onShowProperties: (ProcessRecord) -> Void
var body: some View {
Button("End task", systemImage: "xmark.circle") {
onTerminate(.init(process: process, kind: .normal))
}
Button("Efficiency mode", systemImage: "leaf") {
monitor.setPriority(10, for: process)
}
@@ -85,6 +81,12 @@ struct ProcessContextMenu: View {
}
Button("Properties") { onShowProperties(process) }
}
Divider()
Button("End task", systemImage: "xmark.circle", role: .destructive) {
onTerminate(.init(process: process, kind: .normal))
}
}
private func copy(_ text: String) {
@@ -124,7 +126,7 @@ enum ProcessDiagnosticReporter {
let panel = NSSavePanel()
panel.title = "Create diagnostic report"
panel.message = "Task Manager will sample \(process.displayName) for \(duration) seconds and save a text report."
panel.message = "puter will sample \(process.displayName) for \(duration) seconds and save a text report."
panel.prompt = "Create Report"
panel.nameFieldStringValue = safeFilename("\(process.displayName)-\(process.pid)-sample.txt")
panel.allowedContentTypes = [.plainText]
@@ -195,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() }
@@ -214,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 {
@@ -244,7 +256,57 @@ struct ProcessPropertiesView: View {
}
.padding(16)
}
.frame(width: 520)
.frame(width: 680, height: 650)
.task(id: process.executablePath) {
security = await Task.detached(priority: .utility) {
ProcessSecurityInspector.inspect(executablePath: process.executablePath)
}.value
}
}
@ViewBuilder
private var securityContent: some View {
if let security {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Label {
VStack(alignment: .leading, spacing: 3) {
Text("Code signature: \(security.signatureStatus)").font(.headline)
if !security.validationDetail.isEmpty {
Text(security.validationDetail).font(.caption).foregroundStyle(.secondary)
}
}
} icon: {
Image(systemName: security.signatureStatus == "Valid" ? "checkmark.shield.fill" : "exclamationmark.shield.fill")
.font(.title2)
.foregroundStyle(security.signatureStatus == "Valid" ? .green : .orange)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background((security.signatureStatus == "Valid" ? Color.green : Color.orange).opacity(0.09))
.clipShape(RoundedRectangle(cornerRadius: 10))
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
property("Identifier", security.signingIdentifier)
property("Team ID", security.teamIdentifier)
property("Authority", security.authority)
property("Gatekeeper", security.gatekeeperStatus)
property("Quarantine", security.quarantineStatus)
property("Hardened runtime", security.hardenedRuntime ? "Enabled" : "Not enabled")
property("App Sandbox", security.appSandbox ? "Enabled" : "Not enabled")
property("Debug entitlement", security.debuggerAllowed ? "Allowed" : "Not allowed")
property("Entitlements", "\(security.entitlementCount)")
property("CDHash", security.codeDirectoryHash)
}
Label("Security details are inspected on demand using Security.framework and Gatekeeper. They are not part of the recurring telemetry sampler.", systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary)
}
.padding(20)
}
} else {
ProgressView("Inspecting code signature and Gatekeeper status…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func property(_ name: String, _ value: String) -> some View {
+162
View File
@@ -0,0 +1,162 @@
import SwiftUI
struct ProcessPreviewPane: View {
@Environment(SystemMonitor.self) private var monitor
let process: ProcessRecord?
let onClose: () -> Void
var onShowDetails: ((ProcessRecord) -> Void)?
var onShowProperties: ((ProcessRecord) -> Void)?
var onEndTask: ((ProcessRecord) -> Void)?
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Process preview")
.font(.headline)
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.help("Close preview")
.accessibilityLabel("Close process preview")
}
.padding(.horizontal, 16)
.frame(height: 46)
Divider()
if let process {
processContent(process)
} else {
ContentUnavailableView(
"No process selected",
systemImage: "sidebar.right",
description: Text("Select a process to see its live information here.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.background(.background)
.accessibilityElement(children: .contain)
.accessibilityLabel("Process preview")
}
private func processContent(_ process: ProcessRecord) -> some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
ProcessIcon(name: process.displayName, path: process.executablePath)
.scaleEffect(1.45)
.frame(width: 48, height: 48)
VStack(alignment: .leading, spacing: 3) {
Text(process.displayName)
.font(.title2.weight(.semibold))
.lineLimit(2)
Text("PID \(process.pid)\(process.user)")
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Spacer(minLength: 0)
}
informationGroup("Resources", systemImage: "gauge.with.dots.needle.67percent") {
infoRow("CPU", Formatters.percent(process.cpu))
infoRow("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
infoRow("Memory share", Formatters.percent(process.memoryPercent))
infoRow("Disk read", Formatters.rate(activity(for: process).diskRead))
infoRow("Disk write", Formatters.rate(activity(for: process).diskWrite))
infoRow("Network", Formatters.rate(activity(for: process).networkTotal))
}
informationGroup("Process", systemImage: "cpu") {
infoRow("Status", readableState(process.state))
infoRow("CPU time", Formatters.duration(process.cpuTime))
infoRow("Elapsed", process.elapsed)
infoRow("Threads", "\(process.threadCount)")
infoRow("Open handles", "\(process.openFileCount)")
infoRow("Architecture", process.architecture)
infoRow("Parent PID", "\(process.parentPID)")
infoRow("Priority", "\(process.priority) (nice \(process.nice))")
}
informationGroup("Executable", systemImage: "terminal") {
Text(process.executablePath)
.font(.callout)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
}
if onShowDetails != nil || onShowProperties != nil || onEndTask != nil {
Divider()
HStack(spacing: 8) {
if let onEndTask {
Button("End task", role: .destructive) { onEndTask(process) }
}
Spacer()
if let onShowDetails {
Button("Details") { onShowDetails(process) }
}
if let onShowProperties {
Button("Properties") { onShowProperties(process) }
.buttonStyle(.borderedProminent)
}
}
.padding(12)
}
}
}
private func informationGroup<Content: View>(
_ title: String,
systemImage: String,
@ViewBuilder content: () -> Content
) -> some View {
GroupBox {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
} label: {
Label(title, systemImage: systemImage)
.font(.callout.weight(.semibold))
}
}
private func infoRow(_ label: String, _ value: String) -> some View {
ViewThatFits(in: .horizontal) {
LabeledContent(label) {
Text(value)
.monospacedDigit()
.textSelection(.enabled)
.multilineTextAlignment(.trailing)
}
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.monospacedDigit()
.textSelection(.enabled)
}
}
}
private func activity(for process: ProcessRecord) -> ProcessActivityRate {
monitor.processActivity[process.pid] ?? ProcessActivityRate()
}
private func readableState(_ state: String) -> String {
if state.hasPrefix("R") { return "Running" }
if state.hasPrefix("S") { return "Sleeping" }
if state.hasPrefix("Z") { return "Zombie" }
if state.hasPrefix("T") { return "Stopped" }
return state
}
}
@@ -0,0 +1,105 @@
import Foundation
import Security
enum ProcessSecurityInspector {
static func inspect(executablePath: String) -> ProcessSecuritySnapshot {
guard executablePath.hasPrefix("/"), FileManager.default.fileExists(atPath: executablePath) else {
return ProcessSecuritySnapshot(signatureStatus: "Executable unavailable", validationDetail: "The process did not expose a readable executable path.")
}
var snapshot = ProcessSecuritySnapshot()
var staticCode: SecStaticCode?
let createStatus = SecStaticCodeCreateWithPath(URL(fileURLWithPath: executablePath) as CFURL, [], &staticCode)
guard createStatus == errSecSuccess, let staticCode else {
snapshot.signatureStatus = createStatus == errSecCSUnsigned ? "Unsigned" : "Inspection unavailable"
snapshot.validationDetail = SecCopyErrorMessageString(createStatus, nil) as String? ?? "Security.framework could not inspect this executable."
return snapshot
}
var validationError: Unmanaged<CFError>?
let validationStatus = SecStaticCodeCheckValidityWithErrors(
staticCode,
SecCSFlags(rawValue: UInt32(kSecCSStrictValidate)),
nil,
&validationError
)
if validationStatus == errSecSuccess {
snapshot.signatureStatus = "Valid"
} else if validationStatus == errSecCSUnsigned {
snapshot.signatureStatus = "Unsigned"
} else {
snapshot.signatureStatus = "Invalid"
}
if let error = validationError?.takeRetainedValue() {
snapshot.validationDetail = CFErrorCopyDescription(error) as String
}
var information: CFDictionary?
if SecCodeCopySigningInformation(
staticCode,
SecCSFlags(rawValue: UInt32(kSecCSSigningInformation)),
&information
) == errSecSuccess,
let info = information as? [CFString: Any] {
snapshot.signingIdentifier = info[kSecCodeInfoIdentifier] as? String ?? "Not reported"
snapshot.teamIdentifier = info[kSecCodeInfoTeamIdentifier] as? String ?? "Not reported"
let flags = (info[kSecCodeInfoFlags] as? NSNumber)?.uint32Value ?? 0
snapshot.hardenedRuntime = flags & 0x0001_0000 != 0
if let entitlements = info[kSecCodeInfoEntitlementsDict] as? [String: Any] {
snapshot.entitlementCount = entitlements.count
snapshot.appSandbox = entitlements["com.apple.security.app-sandbox"] as? Bool ?? false
snapshot.debuggerAllowed = entitlements["com.apple.security.get-task-allow"] as? Bool ?? false
}
if let certificates = info[kSecCodeInfoCertificates] as? [SecCertificate] {
snapshot.authority = certificates.compactMap {
SecCertificateCopySubjectSummary($0) as String?
}.first ?? "Ad hoc"
} else if snapshot.signatureStatus == "Valid" {
snapshot.authority = "Ad hoc"
}
if let unique = info[kSecCodeInfoUnique] as? Data {
snapshot.codeDirectoryHash = unique.map { String(format: "%02x", $0) }.joined()
}
}
let assessmentPath = enclosingApplicationPath(for: executablePath) ?? executablePath
let assessment = run("/usr/sbin/spctl", arguments: ["--assess", "--type", "execute", "--verbose=2", assessmentPath])
let assessmentText = (assessment.output + "\n" + assessment.error).trimmingCharacters(in: .whitespacesAndNewlines)
if assessment.status == 0 {
let origin = assessmentText.components(separatedBy: "origin=").dropFirst().first?.components(separatedBy: "\n").first
snapshot.gatekeeperStatus = origin.map { "Accepted • \($0)" } ?? "Accepted"
} else {
snapshot.gatekeeperStatus = assessmentText.isEmpty ? "Not accepted" : assessmentText.components(separatedBy: "\n").first ?? "Not accepted"
}
let quarantine = run("/usr/bin/xattr", arguments: ["-p", "com.apple.quarantine", assessmentPath])
snapshot.quarantineStatus = quarantine.status == 0 ? "Quarantined" : "Not quarantined"
return snapshot
}
private static func enclosingApplicationPath(for path: String) -> String? {
guard let range = path.range(of: ".app/", options: .caseInsensitive) else {
return path.lowercased().hasSuffix(".app") ? path : nil
}
return String(path[..<range.lowerBound]) + ".app"
}
private static func run(_ executable: String, arguments: [String]) -> (output: String, error: String, status: Int32) {
let process = Process()
let output = Pipe()
let error = Pipe()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.standardOutput = output
process.standardError = error
do {
try process.run()
let outputData = output.fileHandleForReading.readDataToEndOfFile()
let errorData = error.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return (String(decoding: outputData, as: UTF8.self), String(decoding: errorData, as: UTF8.self), process.terminationStatus)
} catch {
return ("", error.localizedDescription, -1)
}
}
}
@@ -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(
@@ -181,45 +279,22 @@ struct ProcessesView: View {
terminationRequest = .init(process: process, kind: .normal)
}
}
.buttonStyle(.borderedProminent)
.buttonStyle(.bordered)
.tint(.red)
.disabled(selectedPID == nil && selectedGroupID == nil)
}
.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 {
@@ -274,6 +349,18 @@ struct ProcessesView: View {
selectedGroupID = group.id
selectedPID = nil
}
.focusable()
.onKeyPress(.return) {
selectedGroupID = group.id
selectedPID = nil
return .handled
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(selectedGroupID == group.id ? .isSelected : [])
.accessibilityAction {
selectedGroupID = group.id
selectedPID = nil
}
.contextMenu {
ProcessGroupContextMenu(
group: group,
@@ -307,6 +394,20 @@ struct ProcessesView: View {
selectedPID = process.pid
selectedGroupID = nil
}
.focusable()
.onKeyPress(.return) {
selectedPID = process.pid
selectedGroupID = nil
return .handled
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user)")
.accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))")
.accessibilityAddTraits(selectedPID == process.pid ? .isSelected : [])
.accessibilityAction {
selectedPID = process.pid
selectedGroupID = nil
}
.contextMenu {
ProcessContextMenu(
process: process,
@@ -503,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)
@@ -513,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)
)
}
}
@@ -620,7 +728,7 @@ private struct ProcessRow: View {
}
.font(.callout)
.padding(.horizontal, 16)
.frame(height: 48)
.frame(minHeight: 48)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
}
@@ -703,7 +811,7 @@ private struct ProcessGroupRow: View {
}
.font(.callout.weight(.medium))
.padding(.horizontal, 16)
.frame(height: 48)
.frame(minHeight: 48)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
.accessibilityElement(children: .combine)
@@ -745,9 +853,6 @@ private struct ProcessGroupContextMenu: View {
let onShowProperties: (ProcessRecord) -> Void
var body: some View {
Button("End task", systemImage: "xmark.circle", action: onTerminate)
.disabled(group.processes.contains(where: { $0.pid == getpid() }))
Button("Efficiency mode", systemImage: "leaf") {
group.processes.forEach { monitor.setPriority(10, for: $0) }
}
@@ -789,6 +894,11 @@ private struct ProcessGroupContextMenu: View {
}
Button("Properties") { onShowProperties(group.primary) }
}
Divider()
Button("End task", systemImage: "xmark.circle", role: .destructive, action: onTerminate)
.disabled(group.processes.contains(where: { $0.pid == getpid() }))
}
private func setPriority(_ value: Int) {
@@ -833,6 +943,7 @@ private struct HeatCell: View {
struct ProcessIcon: View {
let name: String
var path: String? = nil
var size: CGFloat = 30
var body: some View {
Group {
@@ -845,12 +956,12 @@ struct ProcessIcon: View {
RoundedRectangle(cornerRadius: 6)
.fill(color.opacity(0.16))
Image(systemName: symbol)
.font(.system(size: 14, weight: .medium))
.font(.system(size: size * 0.47, weight: .medium))
.foregroundStyle(color)
}
}
}
.frame(width: 30, height: 30)
.frame(width: size, height: size)
.accessibilityHidden(true)
}
+84
View File
@@ -0,0 +1,84 @@
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", id: "main") {
ContentView()
.environment(monitor)
.environmentObject(updates)
.frame(minWidth: 820, minHeight: 560)
.task { monitor.start() }
}
.defaultSize(width: 1180, height: 760)
.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)
}
.keyboardShortcut("n", modifiers: .command)
}
SidebarCommands()
CommandMenu("Options") {
Button("Refresh now") { monitor.refresh() }
.keyboardShortcut("r", modifiers: .command)
Divider()
Button(monitor.isPaused ? "Resume updates" : "Pause updates") {
monitor.isPaused.toggle()
}
.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"
}
}
}
@@ -66,7 +66,7 @@ struct AppHistoryView: View {
Button("Cancel", role: .cancel) {}
Button("Reset", role: .destructive) { monitor.resetAppHistory() }
} message: {
Text("CPU time and network totals collected by Task Manager will be permanently cleared.")
Text("CPU time and network totals collected by puter will be permanently cleared.")
}
.sheet(item: $inspectedUsage) { AppUsagePropertiesView(usage: $0) }
}
@@ -173,24 +173,47 @@ private struct AppUsagePropertiesView: View {
struct StartupAppsView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var items = StartupScanner.scan()
@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") { items = StartupScanner.scan() }
Button("Open Login Items") {
openLoginItemsSettings()
Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() }
.disabled(isLoading)
Menu("Login Items", systemImage: "person.crop.circle.badge.checkmark") {
Button(includesLoginItems ? "Reload Login Items" : "Include Login Items") {
refreshItems(includeLoginItems: true)
}
.disabled(isLoading)
.help("Read macOSs system-wide Background Task Management registry using the built-in sfltool diagnostic")
Divider()
Button("Open Login Items Settings") {
openLoginItemsSettings()
}
}
}
}
if items.isEmpty {
if isLoading && items.isEmpty {
VStack(spacing: 12) {
ProgressView()
Text("Scanning startup items…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .combine)
.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) {
@@ -223,6 +246,7 @@ struct StartupAppsView: View {
.sheet(item: $inspectedItem) { item in
StartupItemPropertiesView(item: item, impact: startupImpact(for: item))
}
.task { refreshItems(includeLoginItems: false) }
}
private func openLoginItemsSettings() {
@@ -231,6 +255,19 @@ struct StartupAppsView: View {
}
}
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(includeLoginItems: shouldIncludeLoginItems)
}.value
isLoading = false
}
}
private func startupImpact(for item: StartupItem) -> StartupImpact {
let candidates = monitor.processes.filter { process in
guard !item.executablePath.isEmpty else { return false }
@@ -566,14 +603,14 @@ private struct UserSessionPropertiesView: View {
}
}
private enum StartupItemKind {
private enum StartupItemKind: Sendable {
case loginItem
case launchAgent
var title: String { self == .loginItem ? "Login item" : "Launch agent" }
}
private struct StartupItem: Identifiable {
private struct StartupItem: Identifiable, Sendable {
let id: String
let name: String
let publisher: String
@@ -585,13 +622,86 @@ private struct StartupItem: Identifiable {
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
@@ -622,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> {
@@ -730,14 +831,29 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
case .nice: "Nice"
}
}
var width: CGFloat {
var defaultWidth: CGFloat {
switch self {
case .name: 230
case .user: 120
case .architecture: 95
case .cpuTime, .memory, .disk, .network, .elapsed: 100
case .parentPID: 80
default: 72
case .name: 260
case .user: 140
case .architecture, .state: 105
case .cpuTime, .memory, .disk, .network, .elapsed: 112
case .parentPID: 92
default: 78
}
}
var minimumWidth: CGFloat {
switch self {
case .name: 170
case .user: 90
case .cpuTime, .memory, .disk, .network, .elapsed: 82
default: 62
}
}
var maximumWidth: CGFloat { self == .name ? 620 : 300 }
var textAlignment: Alignment {
switch self {
case .name, .user, .state, .architecture: .leading
default: .trailing
}
}
var defaultVisible: Bool {
@@ -749,11 +865,14 @@ struct DetailsView: View {
@Environment(SystemMonitor.self) private var monitor
let searchText: String
@Binding var selection: Int32?
@AppStorage("detailVisibleColumns") private var visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
@AppStorage("detailVisibleColumnsV2") private var visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
@AppStorage("detailColumnWidthsV2") private var columnWidthsStorage = ""
@State private var terminationRequest: TerminationRequest?
@State private var inspectedProcess: ProcessRecord?
@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))
@@ -763,32 +882,68 @@ struct DetailsView: View {
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Details", subtitle: "Technical information for running processes") {
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: ",")
.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)
VStack(spacing: 0) {
detailsTable
selectionBar
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.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(filtered) { process in
detailRow(process)
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
detailRow(process, index: index)
}
} header: {
detailHeader
}
}
.frame(minWidth: visibleColumns.reduce(0) { $0 + $1.width + 12 })
.frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
}
}
.terminationConfirmation($terminationRequest)
.sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var filtered: [ProcessRecord] {
@@ -796,8 +951,6 @@ struct DetailsView: View {
searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText)
|| $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText)
}.sorted { lhs, rhs in
if lhs.pid == selection { return true }
if rhs.pid == selection { return false }
let result = comparison(lhs, rhs, column: sortColumn)
if result == .orderedSame { return lhs.pid < rhs.pid }
return ascending ? result == .orderedAscending : result == .orderedDescending
@@ -805,42 +958,44 @@ struct DetailsView: View {
}
private var detailHeader: some View {
HStack(spacing: 12) {
HStack(spacing: 0) {
ForEach(visibleColumns) { column in
Button {
if sortColumn == column { ascending.toggle() }
else { sortColumn = column; ascending = true }
} label: {
HStack(spacing: 3) {
Text(column.title)
if sortColumn == column { Image(systemName: ascending ? "chevron.up" : "chevron.down") }
}
.frame(width: column.width, alignment: column == .name ? .leading : .trailing)
}
.buttonStyle(.plain)
detailHeaderCell(column)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.background)
.background(.regularMaterial)
.overlay(alignment: .bottom) { Divider() }
}
private func detailRow(_ process: ProcessRecord) -> some View {
HStack(spacing: 12) {
private func detailRow(_ process: ProcessRecord, index: Int) -> some View {
HStack(spacing: 0) {
ForEach(visibleColumns) { column in
detailCell(process, column: column)
.frame(width: column.width, alignment: column == .name || column == .user ? .leading : .trailing)
.padding(.horizontal, 10)
.frame(width: width(column), alignment: column.textAlignment)
.clipped()
.overlay(alignment: .trailing) { Divider().opacity(0.18) }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.callout)
.padding(.horizontal, 12)
.frame(height: 38)
.background(selection == process.pid ? Color.accentColor.opacity(0.18) : Color.clear)
.frame(minHeight: 42)
.background(rowBackground(process: process, index: index))
.contentShape(Rectangle())
.onTapGesture { selection = process.pid }
.focusable()
.onKeyPress(.return) {
selection = process.pid
return .handled
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user), \(readableState(process.state))")
.accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))), \(process.threadCount) threads")
.accessibilityAddTraits(selection == process.pid ? .isSelected : [])
.accessibilityAction { selection = process.pid }
.overlay(alignment: .bottom) { Divider().opacity(0.4) }
.contextMenu {
ProcessContextMenu(
@@ -855,7 +1010,11 @@ struct DetailsView: View {
@ViewBuilder
private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View {
switch column {
case .name: Text(process.displayName).lineLimit(1)
case .name:
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1).truncationMode(.tail)
}
case .pid: Text("\(process.pid)").monospacedDigit()
case .parentPID: Text("\(process.parentPID)").monospacedDigit()
case .user: Text(process.user).lineLimit(1)
@@ -864,7 +1023,10 @@ struct DetailsView: View {
case .memory: Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
case .disk: Text(Formatters.rate(monitor.processActivity[process.pid]?.diskTotal ?? 0)).monospacedDigit()
case .network: Text(Formatters.rate(monitor.processActivity[process.pid]?.networkTotal ?? 0)).monospacedDigit()
case .state: Text(process.state)
case .state:
Text(readableState(process.state))
.font(.caption.weight(.medium))
.foregroundStyle(process.state.hasPrefix("R") ? Color.green : Color.secondary)
case .elapsed: Text(process.elapsed).monospacedDigit()
case .threads: Text("\(process.threadCount)").monospacedDigit()
case .handles: Text("\(process.openFileCount)").monospacedDigit()
@@ -874,6 +1036,106 @@ struct DetailsView: View {
}
}
private func detailHeaderCell(_ column: DetailColumn) -> some View {
HStack(spacing: 0) {
Button {
if sortColumn == column { ascending.toggle() }
else { sortColumn = column; ascending = true }
} label: {
HStack(spacing: 5) {
Text(column.title).lineLimit(1)
Spacer(minLength: 4)
if sortColumn == column {
Image(systemName: ascending ? "chevron.up" : "chevron.down")
.font(.caption2.weight(.semibold))
}
}
.padding(.horizontal, 10)
.frame(width: width(column), height: 36, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Sort by \(column.title)")
.accessibilityValue(sortColumn == column ? (ascending ? "Ascending" : "Descending") : "Not sorted")
}
.frame(width: width(column), height: 36)
.overlay(alignment: .trailing) {
Divider().opacity(0.45)
DetailResizeHandle(
width: Binding(get: { width(column) }, set: { columnWidths[column] = $0 }),
minimumWidth: column.minimumWidth,
maximumWidth: column.maximumWidth,
onEnded: persistColumnWidths
)
.offset(x: 5)
}
}
private var selectionBar: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 10) {
if let process = selectedProcess {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
Text("PID \(process.pid)").foregroundStyle(.secondary).monospacedDigit()
Text("").foregroundStyle(.tertiary)
Text(Formatters.percent(process.cpu) + " CPU").monospacedDigit()
Text("").foregroundStyle(.tertiary)
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
Spacer()
Button("End task") { terminationRequest = .init(process: process, kind: .normal) }
Button("Properties") { inspectedProcess = process }.buttonStyle(.borderedProminent)
} else {
Text("\(filtered.count) processes").foregroundStyle(.secondary)
Spacer()
Text("Select a process for actions and live details").foregroundStyle(.tertiary)
}
}
.font(.caption)
.padding(.horizontal, 14)
.frame(minHeight: 48)
}
.background(.bar)
}
private var selectedProcess: ProcessRecord? {
guard let selection else { return nil }
return monitor.processes.first { $0.pid == selection }
}
private var tableWidth: CGFloat { visibleColumns.reduce(0) { $0 + width($1) } }
private func width(_ column: DetailColumn) -> CGFloat { columnWidths[column] ?? column.defaultWidth }
private func rowBackground(process: ProcessRecord, index: Int) -> Color {
if selection == process.pid { return Color.accentColor.opacity(0.18) }
return index.isMultiple(of: 2) ? Color.clear : Color.secondary.opacity(0.035)
}
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
}
private func restoreColumnWidths() {
guard let data = columnWidthsStorage.data(using: .utf8),
let stored = try? JSONDecoder().decode([String: Double].self, from: data) else { return }
for column in DetailColumn.allCases {
if let value = stored[column.rawValue] {
columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, value))
}
}
}
private func persistColumnWidths() {
let stored = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0.rawValue, Double(width($0))) })
guard let data = try? JSONEncoder().encode(stored), let value = String(data: data, encoding: .utf8) else { return }
columnWidthsStorage = value
}
private func columnBinding(_ column: DetailColumn) -> Binding<Bool> {
Binding(
get: { visibleColumns.contains(column) },
@@ -911,6 +1173,43 @@ struct DetailsView: View {
}
}
private struct DetailResizeHandle: View {
@Binding var width: CGFloat
let minimumWidth: CGFloat
let maximumWidth: CGFloat
let onEnded: () -> Void
@State private var startWidth: CGFloat?
@State private var proposedWidth: CGFloat?
@State private var hovered = false
var body: some View {
Rectangle()
.fill(hovered || startWidth != nil ? Color.accentColor : Color.clear)
.frame(width: 2, height: 24)
.frame(width: 10, height: 36)
.offset(x: (proposedWidth ?? width) - width)
.contentShape(Rectangle())
.onHover { value in
hovered = value
(value ? NSCursor.resizeLeftRight : NSCursor.arrow).set()
}
.highPriorityGesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if startWidth == nil { startWidth = width }
proposedWidth = min(maximumWidth, max(minimumWidth, (startWidth ?? width) + value.translation.width))
}
.onEnded { _ in
if let proposedWidth { width = proposedWidth }
proposedWidth = nil
startWidth = nil
onEnded()
}
)
.help("Drag to resize column")
}
}
struct ServicesView: View {
@Environment(SystemMonitor.self) private var monitor
let searchText: String
@@ -0,0 +1,90 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
@MainActor
enum SessionRecordingExporter {
static func exportJSON(monitor: SystemMonitor) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
do {
let document = RecordingDocument(
startedAt: monitor.recordingStartDate,
endedAt: monitor.recordingSamples.last?.timestamp,
samples: monitor.recordingSamples,
alerts: monitor.recentAlerts
)
try save(
data: encoder.encode(document),
name: "puter-session-\(filenameDate()).json",
type: .json,
message: "Export the recorded telemetry session and resource alerts as JSON.",
monitor: monitor
)
} catch {
monitor.errorMessage = "Could not encode the recording: \(error.localizedDescription)"
}
}
static func exportCSV(monitor: SystemMonitor) {
let header = "timestamp,cpu_percent,memory_used_bytes,memory_total_bytes,memory_pressure,disk_read_bytes_per_second,disk_write_bytes_per_second,gpu_percent,network_receive_bytes_per_second,network_send_bytes_per_second,system_power_watts,thermal_state,process_count"
let formatter = ISO8601DateFormatter()
let rows = monitor.recordingSamples.map { sample in
[
formatter.string(from: sample.timestamp),
number(sample.cpuPercent), String(sample.memoryUsedBytes), String(sample.memoryTotalBytes),
csv(sample.memoryPressure), number(sample.diskReadBytesPerSecond), number(sample.diskWriteBytesPerSecond),
number(sample.gpuPercent), number(sample.networkReceiveBytesPerSecond), number(sample.networkSendBytesPerSecond),
number(sample.systemPowerWatts), csv(sample.thermalState), String(sample.processCount)
].joined(separator: ",")
}
let data = Data(([header] + rows).joined(separator: "\n").utf8)
save(
data: data,
name: "puter-session-\(filenameDate()).csv",
type: .commaSeparatedText,
message: "Export each recorded telemetry sample as a CSV row.",
monitor: monitor
)
}
private static func save(data: Data, name: String, type: UTType, message: String, monitor: SystemMonitor) {
let panel = NSSavePanel()
panel.title = "Export telemetry recording"
panel.message = message
panel.prompt = "Export"
panel.allowedContentTypes = [type]
panel.canCreateDirectories = true
panel.nameFieldStringValue = name
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
do {
try data.write(to: url, options: .atomic)
} catch {
monitor.errorMessage = "Could not export the recording: \(error.localizedDescription)"
}
}
}
private static func filenameDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
return formatter.string(from: Date())
}
private static func number(_ value: Double) -> String {
String(format: "%.4f", value.isFinite ? value : 0)
}
private static func csv(_ value: String) -> String {
"\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
}
private struct RecordingDocument: Codable {
let startedAt: Date?
let endedAt: Date?
let samples: [TelemetryRecordingSample]
let alerts: [ResourceAlertEvent]
}
}
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
@MainActor
enum SystemReportExporter {
static func export(monitor: SystemMonitor, selectedResource: String? = nil) {
let panel = NSSavePanel()
panel.title = "Export system report"
panel.message = "Save the current performance, process, power, battery, and connected-hardware snapshot as JSON."
panel.prompt = "Export"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
panel.nameFieldStringValue = "Task-Manager-Report-\(filenameDate()).json"
let data: Data
do {
data = try JSONSerialization.data(
withJSONObject: report(monitor: monitor, selectedResource: selectedResource),
options: [.prettyPrinted, .sortedKeys]
)
} catch {
monitor.errorMessage = "Could not create the system report: \(error.localizedDescription)"
return
}
panel.begin { response in
guard response == .OK, let destination = panel.url else { return }
do {
try data.write(to: destination, options: .atomic)
} catch {
monitor.errorMessage = "Could not export the system report: \(error.localizedDescription)"
}
}
}
private static func report(monitor: SystemMonitor, selectedResource: String?) -> [String: Any] {
let snapshot = monitor.snapshot
let battery = monitor.hardware.battery
return [
"generatedAt": ISO8601DateFormatter().string(from: Date()),
"selectedResource": selectedResource ?? "All",
"system": [
"cpuPercent": snapshot.cpuPercent,
"logicalProcessors": snapshot.corePercents.count,
"memoryBytes": snapshot.memoryTotal,
"memoryUsedBytes": snapshot.memoryUsed,
"memoryType": snapshot.memoryType,
"memorySpeed": snapshot.memorySpeed,
"memoryManufacturer": snapshot.memoryManufacturer,
"memoryPressure": snapshot.memoryPressure.rawValue,
"diskReadBytesPerSecond": snapshot.diskReadRate,
"diskWriteBytesPerSecond": snapshot.diskWriteRate,
"diskActivePercent": snapshot.diskActivePercent,
"gpuPercent": snapshot.gpuPercent,
"gpuCoreCount": snapshot.gpuCoreCount,
"networkInterface": snapshot.networkInterface,
"networkReceiveBytesPerSecond": snapshot.networkReceiveRate,
"networkSendBytesPerSecond": snapshot.networkSendRate,
"processCount": snapshot.processCount,
"threadCount": snapshot.threadCount,
"uptimeSeconds": snapshot.uptime
],
"power": [
"thermalState": monitor.hardware.thermalState,
"systemPowerWatts": battery.systemPowerWatts,
"adapterInputWatts": battery.adapterInputWatts,
"batteryPowerWatts": battery.batteryPowerWatts,
"externalPowerConnected": battery.externalPowerConnected,
"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)
]
}
private static func batteryReport(_ battery: BatterySnapshot) -> [String: Any] {
var result: [String: Any] = [
"present": battery.isPresent,
"chargePercent": battery.chargePercent,
"charging": battery.isCharging,
"fullyCharged": battery.isFullyCharged,
"healthPercent": battery.healthPercent,
"cycleCount": battery.cycleCount,
"designCycleCount": battery.designCycleCount,
"temperatureCelsius": battery.temperatureCelsius,
"voltageVolts": battery.voltageVolts,
"batteryPowerWatts": battery.batteryPowerWatts,
"adapterInputWatts": battery.adapterInputWatts,
"fullChargeCapacityMAh": battery.fullChargeCapacityMAh,
"designCapacityMAh": battery.designCapacityMAh
]
if let adapter = battery.adapter {
result["adapter"] = [
"name": adapter.name,
"manufacturer": adapter.manufacturer,
"ratedWatts": adapter.ratedWatts,
"negotiatedVoltage": adapter.negotiatedVoltage,
"negotiatedCurrent": adapter.negotiatedCurrent,
"negotiatedWatts": adapter.negotiatedWatts,
"powerProfiles": adapter.profiles.map {
["voltage": $0.voltageVolts, "current": $0.currentAmps, "watts": $0.watts]
}
]
}
return result
}
private static func portReport(_ port: HardwarePortSnapshot) -> [String: Any] {
[
"name": port.name,
"transport": port.transport,
"maximumSpeed": port.maximumSpeed,
"connected": port.isConnected,
"status": port.status,
"devices": port.devices.map {
var device: [String: Any] = ["name": $0.name, "vendor": $0.vendor, "speed": $0.speed]
if let value = $0.currentAvailableMA { device["currentAvailableMA"] = value }
if let value = $0.currentRequiredMA { device["currentRequiredMA"] = value }
return device
}
]
}
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()
return [
"name": process.displayName,
"pid": process.pid,
"user": process.user,
"cpuPercent": process.cpu,
"residentBytes": process.residentBytes,
"diskBytesPerSecond": activity.diskTotal,
"networkBytesPerSecond": activity.networkTotal
]
}
}
private static func topUsers(_ monitor: SystemMonitor) -> [[String: Any]] {
struct Totals {
var processes = 0
var cpu = 0.0
var memory: UInt64 = 0
var disk = 0.0
var network = 0.0
}
var users: [String: Totals] = [:]
for process in monitor.processes {
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
users[process.user, default: Totals()].processes += 1
users[process.user, default: Totals()].cpu += process.cpu
users[process.user, default: Totals()].memory += process.residentBytes
users[process.user, default: Totals()].disk += activity.diskTotal
users[process.user, default: Totals()].network += activity.networkTotal
}
return users.map { user, totals in
[
"user": user,
"processes": totals.processes,
"cpuPercent": totals.cpu,
"residentBytes": totals.memory,
"diskBytesPerSecond": totals.disk,
"networkBytesPerSecond": totals.network
]
}
.sorted { ($0["cpuPercent"] as? Double ?? 0) > ($1["cpuPercent"] as? Double ?? 0) }
}
private static func filenameDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
return formatter.string(from: Date())
}
}
+37
View File
@@ -0,0 +1,37 @@
import Foundation
import Sparkle
@MainActor
final class UpdateController: ObservableObject {
let updaterController: SPUStandardUpdaterController?
init() {
let feed = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
let publicKey = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String
if let feed, !feed.isEmpty, let publicKey, !publicKey.isEmpty {
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
} else {
updaterController = nil
}
}
var isConfigured: Bool { updaterController != nil }
var canCheckForUpdates: Bool { updaterController?.updater.canCheckForUpdates ?? false }
var automaticallyChecksForUpdates: Bool {
get { updaterController?.updater.automaticallyChecksForUpdates ?? false }
set { updaterController?.updater.automaticallyChecksForUpdates = newValue }
}
var automaticallyDownloadsUpdates: Bool {
get { updaterController?.updater.automaticallyDownloadsUpdates ?? false }
set { updaterController?.updater.automaticallyDownloadsUpdates = newValue }
}
func checkForUpdates() {
updaterController?.checkForUpdates(nil)
objectWillChange.send()
}
}
@@ -1,53 +0,0 @@
import XCTest
@testable import MacTaskManager
final class ModelTests: XCTestCase {
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)
XCTAssertEqual(system.publisher, "Apple")
XCTAssertEqual(system.domain.launchctlPrefix, "system")
XCTAssertFalse(system.isControllable)
XCTAssertTrue(user.isControllable)
XCTAssertNotEqual(system.id, user.id)
}
func testThirdPartyApplicationServicePublisher() {
let service = ServiceRecord(
label: "application.com.adobe.AdobeResourceSynchronizer.210006977.210006983",
pid: 938,
lastExitStatus: 0,
domain: .user
)
XCTAssertEqual(service.publisher, "Adobe")
XCTAssertEqual(service.displayName, "AdobeResourceSynchronizer")
}
func testProcessDisplayNameFallback() {
let process = ProcessRecord(
pid: 42,
parentPID: 1,
name: " ",
executablePath: "/usr/bin/example",
user: "tester",
cpu: 0,
memoryPercent: 0,
residentBytes: 0,
state: "S",
elapsed: "00:01",
cpuTime: 0,
threadCount: 1,
openFileCount: 0,
architecture: "ARM64",
priority: 31,
nice: 0
)
XCTAssertEqual(process.displayName, "Process 42")
}
func testAllNavigationSectionsHaveIcons() {
XCTAssertEqual(Set(TaskSection.allCases.map(\.rawValue)).count, TaskSection.allCases.count)
XCTAssertTrue(TaskSection.allCases.allSatisfy { !$0.icon.isEmpty })
}
}
+303
View File
@@ -0,0 +1,303 @@
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)
XCTAssertEqual(system.publisher, "Apple")
XCTAssertEqual(system.domain.launchctlPrefix, "system")
XCTAssertFalse(system.isControllable)
XCTAssertTrue(user.isControllable)
XCTAssertNotEqual(system.id, user.id)
}
func testThirdPartyApplicationServicePublisher() {
let service = ServiceRecord(
label: "application.com.adobe.AdobeResourceSynchronizer.210006977.210006983",
pid: 938,
lastExitStatus: 0,
domain: .user
)
XCTAssertEqual(service.publisher, "Adobe")
XCTAssertEqual(service.displayName, "AdobeResourceSynchronizer")
}
func testProcessDisplayNameFallback() {
let process = ProcessRecord(
pid: 42,
parentPID: 1,
name: " ",
executablePath: "/usr/bin/example",
user: "tester",
cpu: 0,
memoryPercent: 0,
residentBytes: 0,
state: "S",
elapsed: "00:01",
cpuTime: 0,
threadCount: 1,
openFileCount: 0,
architecture: "ARM64",
priority: 31,
nice: 0
)
XCTAssertEqual(process.displayName, "Process 42")
}
func testAllNavigationSectionsHaveIcons() {
XCTAssertEqual(Set(TaskSection.allCases.map(\.rawValue)).count, TaskSection.allCases.count)
XCTAssertTrue(TaskSection.allCases.allSatisfy { !$0.icon.isEmpty })
}
func testBatteryHealthUsesFullChargeAndDesignCapacity() {
let battery = BatterySnapshot(fullChargeCapacityMAh: 5_761, designCapacityMAh: 6_249)
XCTAssertEqual(battery.healthPercent, 92.19, accuracy: 0.01)
}
func testBatteryPowerRejectsCorruptTelemetry() {
let normal = BatterySnapshot(voltageVolts: 12.1, currentAmps: -2.5)
let corrupt = BatterySnapshot(voltageVolts: 12.1, currentAmps: Double(UInt64.max))
XCTAssertEqual(normal.batteryPowerWatts, 30.25, accuracy: 0.001)
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(
ratedWatts: 86,
negotiatedVoltage: profile.voltageVolts,
negotiatedCurrent: profile.currentAmps,
profiles: [profile]
)
XCTAssertEqual(profile.watts, 85.8, accuracy: 0.001)
XCTAssertEqual(adapter.negotiatedWatts, 85.8, accuracy: 0.001)
}
func testMemoryBandwidthCatalogUsesSpecificChipBeforeFamilyFallback() {
XCTAssertEqual(
MemoryBandwidthCatalog.description(for: "Apple M4 Pro", cpuCoreCount: 14),
"273 GB/s bandwidth (Apple specification)"
)
XCTAssertEqual(
MemoryBandwidthCatalog.description(for: "Apple M4 Max", cpuCoreCount: 14),
"410 GB/s bandwidth (Apple specification)"
)
XCTAssertEqual(
MemoryBandwidthCatalog.description(for: "Apple M4 Max", cpuCoreCount: 16),
"546 GB/s bandwidth (Apple specification)"
)
XCTAssertEqual(
MemoryBandwidthCatalog.description(for: "Apple M5 Pro", cpuCoreCount: 18),
"Up to 307 GB/s bandwidth (Apple specification)"
)
XCTAssertEqual(MemoryBandwidthCatalog.description(for: "Intel Core i9", cpuCoreCount: 8), "Clock not published by macOS")
XCTAssertEqual(MemoryBandwidthCatalog.specifications.count, 20)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

-62
View File
@@ -1,62 +0,0 @@
{
"fill" : {
"automatic-gradient" : "extended-gray:0.75000,1.00000",
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"groups" : [
{
"layers" : [
{
"fill" : {
"linear-gradient" : [
"extended-srgb:0.00000,0.75294,0.90980,1.00000",
"display-p3:0.00050,0.14736,0.89264,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : -0.2058170223892607
},
"stop" : {
"x" : 0.5,
"y" : 1.2067929646579256
}
}
},
"image-name" : "icon.png",
"name" : "icon",
"position" : {
"scale" : 0.18,
"translation-in-points" : [
0,
31.57455344686923
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+55
View File
@@ -0,0 +1,55 @@
{
"fill" : {
"linear-gradient" : [
"display-p3:0.36538,0.78082,0.24349,1.00000",
"display-p3:0.22503,0.48767,0.15523,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"groups" : [
{
"layers" : [
{
"image-name" : "puter-white.png",
"name" : "puter-white",
"opacity-specializations" : [
{
"appearance" : "dark",
"value" : 0.6
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+95 -10
View File
@@ -2,37 +2,122 @@
set -euo pipefail
PROJECT_DIR="${0:A:h:h}"
APP_DIR="$PROJECT_DIR/dist/Task Manager.app"
ICON_SOURCE="$PROJECT_DIR/mactaskmanager.icon"
STAGING_ROOT="$(mktemp -d /tmp/mactaskmanager-build.XXXXXX)"
STAGED_APP="$STAGING_ROOT/Task Manager.app"
APP_DIR="$PROJECT_DIR/dist/puter.app"
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"
cp "$PROJECT_DIR/.build/release/MacTaskManager" "$CONTENTS_DIR/MacOS/MacTaskManager"
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/MacTaskManager"
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"
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
print "Using Icon Composer source: $ICON_SOURCE"
xcrun actool "$ICON_SOURCE" \
--compile "$CONTENTS_DIR/Resources" \
--platform macosx \
--minimum-deployment-target 14.0 \
--app-icon mactaskmanager \
--app-icon puter \
--output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null
[[ -s "$CONTENTS_DIR/Resources/Assets.car" ]] || {
print -u2 "Icon Composer did not produce an Assets.car file"
exit 1
}
[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$CONTENTS_DIR/Info.plist")" == "puter" ]] || {
print -u2 "CFBundleIconName must reference the Icon Composer asset"
exit 1
}
if /usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$CONTENTS_DIR/Info.plist" >/dev/null 2>&1; then
print -u2 "CFBundleIconFile would override the live Icon Composer asset"
exit 1
fi
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"
ditto --norsrc "$STAGED_APP" "$APP_DIR"
# File-provider workspaces can attach Finder/provenance metadata during the
# final copy. Clean and sign at the delivery path so verification reflects
# the bundle users actually launch.
xattr -cr "$APP_DIR"
codesign --verify --deep --strict "$APP_DIR"
print "Built $APP_DIR"
+13 -7
View File
@@ -2,9 +2,10 @@
set -euo pipefail
PROJECT_DIR="${0:A:h:h}"
APP_PATH="$PROJECT_DIR/dist/Task Manager.app"
DMG_PATH="$PROJECT_DIR/dist/Task Manager-macOS.dmg"
STAGING_DIR="$(mktemp -d /tmp/mactaskmanager-dmg.XXXXXX)"
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() {
rm -rf "$STAGING_DIR"
@@ -15,17 +16,22 @@ if [[ ! -d "$APP_PATH" ]]; then
"$PROJECT_DIR/scripts/build-app.sh"
fi
ditto --norsrc "$APP_PATH" "$STAGING_DIR/Task Manager.app"
xattr -cr "$STAGING_DIR/Task Manager.app"
codesign --verify --deep --strict "$STAGING_DIR/Task Manager.app"
ditto --norsrc "$APP_PATH" "$STAGING_DIR/puter.app"
xattr -cr "$STAGING_DIR/puter.app"
codesign --verify --deep --strict "$STAGING_DIR/puter.app"
ln -s /Applications "$STAGING_DIR/Applications"
hdiutil create \
-volname "Task Manager" \
-volname "puter" \
-srcfolder "$STAGING_DIR" \
-format UDZO \
-imagekey zlib-level=9 \
-ov \
"$DMG_PATH"
if [[ "$SIGN_IDENTITY" != "-" ]]; then
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH"
codesign --verify --strict "$DMG_PATH"
fi
print "Built $DMG_PATH"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/zsh
set -euo pipefail
PROJECT_DIR="${0:A:h:h}"
ARCHIVES_DIR="${1:-$PROJECT_DIR/dist/updates}"
DOWNLOAD_PREFIX="${PUTER_UPDATE_DOWNLOAD_PREFIX:-}"
PRIVATE_KEY="${PUTER_SPARKLE_PRIVATE_KEY:-}"
GENERATOR="$PROJECT_DIR/.build/artifacts/sparkle/Sparkle/bin/generate_appcast"
if [[ -z "$DOWNLOAD_PREFIX" || -z "$PRIVATE_KEY" ]]; then
print -u2 "Set PUTER_UPDATE_DOWNLOAD_PREFIX and PUTER_SPARKLE_PRIVATE_KEY."
exit 2
fi
if [[ ! -x "$GENERATOR" ]]; then
swift package resolve --package-path "$PROJECT_DIR"
fi
mkdir -p "$ARCHIVES_DIR"
print -rn -- "$PRIVATE_KEY" | "$GENERATOR" \
--ed-key-file - \
--download-url-prefix "$DOWNLOAD_PREFIX" \
"$ARCHIVES_DIR"
print "Generated $ARCHIVES_DIR/appcast.xml"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/zsh
set -euo pipefail
PROJECT_DIR="${0:A:h:h}"
DMG_PATH="${1:-$PROJECT_DIR/dist/puter-macOS.dmg}"
APP_PATH="$PROJECT_DIR/dist/puter.app"
NOTARY_PROFILE="${PUTER_NOTARY_PROFILE:-}"
NOTARY_KEY_PATH="${PUTER_NOTARY_KEY_PATH:-}"
NOTARY_KEY_ID="${PUTER_NOTARY_KEY_ID:-}"
NOTARY_ISSUER="${PUTER_NOTARY_ISSUER:-}"
if [[ -z "$NOTARY_PROFILE" && ( -z "$NOTARY_KEY_PATH" || -z "$NOTARY_KEY_ID" || -z "$NOTARY_ISSUER" ) ]]; then
print -u2 "Set PUTER_NOTARY_PROFILE or the PUTER_NOTARY_KEY_PATH, PUTER_NOTARY_KEY_ID, and PUTER_NOTARY_ISSUER variables."
exit 2
fi
if [[ ! -f "$DMG_PATH" ]]; then
print -u2 "DMG not found: $DMG_PATH"
exit 2
fi
codesign --verify --strict "$DMG_PATH"
if [[ -n "$NOTARY_PROFILE" ]]; then
xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
else
xcrun notarytool submit "$DMG_PATH" \
--key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" --wait
fi
xcrun stapler staple "$DMG_PATH"
xcrun stapler validate "$DMG_PATH"
if [[ -d "$APP_PATH" ]]; then
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
fi
spctl --assess --type open --context context:primary-signature -v "$DMG_PATH"
print "Notarized and stapled $DMG_PATH"
+103
View File
@@ -0,0 +1,103 @@
#!/bin/zsh
set -euo pipefail
PROJECT_DIR="${0:A:h:h}"
APP_PATH="${1:-$PROJECT_DIR/dist/puter.app}"
DMG_PATH="${2:-$PROJECT_DIR/dist/puter-macOS.dmg}"
RELEASE_VALIDATION="${PUTER_RELEASE_BUILD:-0}"
MOUNT_DIR=""
MOUNT_DEVICE=""
cleanup() {
if [[ -n "$MOUNT_DEVICE" ]]; then
hdiutil detach "$MOUNT_DEVICE" >/dev/null 2>&1 || true
fi
if [[ -n "$MOUNT_DIR" && -d "$MOUNT_DIR" ]]; then
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
fail() {
print -u2 "Package validation failed: $1"
exit 2
}
validate_app() {
local app="$1"
local contents="$app/Contents"
local info="$contents/Info.plist"
[[ -d "$app" ]] || fail "app bundle not found at $app"
[[ -x "$contents/MacOS/puter" ]] || fail "main executable is missing"
[[ -x "$contents/Resources/puter-helper" ]] || fail "privileged helper is missing"
if [[ "$RELEASE_VALIDATION" == "1" ]]; then
[[ -x "$contents/Resources/smc" ]] || fail "release SMC backend is missing"
[[ -f "$contents/Resources/Stats-SMC-LICENSE.txt" ]] || fail "release SMC license is missing"
elif [[ -e "$contents/Resources/smc" ]]; then
[[ -x "$contents/Resources/smc" ]] || fail "bundled SMC backend is not executable"
[[ -f "$contents/Resources/Stats-SMC-LICENSE.txt" ]] || fail "bundled SMC license is missing"
fi
[[ -f "$contents/Library/LaunchDaemons/dev.soconnor.puter.helper.plist" ]] || fail "helper launch daemon plist is missing"
[[ -d "$contents/Frameworks/Sparkle.framework" ]] || fail "Sparkle framework is missing"
[[ -f "$contents/Resources/Assets.car" ]] || fail "Icon Composer asset catalog is missing"
plutil -lint "$info" "$contents/Library/LaunchDaemons/dev.soconnor.puter.helper.plist" >/dev/null
[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$info")" == "dev.soconnor.puter" ]] \
|| fail "unexpected bundle identifier"
[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$info")" == "puter" ]] \
|| fail "Icon Composer asset is not configured"
[[ "$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$info")" == "14.0" ]] \
|| fail "unexpected deployment target"
if /usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$info" >/dev/null 2>&1; then
fail "legacy CFBundleIconFile overrides the Icon Composer asset"
fi
[[ "$(otool -l "$contents/MacOS/puter")" == *'@executable_path/../Frameworks'* ]] \
|| fail "framework runtime search path is missing"
[[ "$(otool -L "$contents/MacOS/puter")" == *'Sparkle.framework'* ]] \
|| fail "main executable is not linked to Sparkle"
codesign --verify --deep --strict "$app"
local signature
signature="$(codesign -dvv "$app" 2>&1)"
[[ "$signature" == *'runtime'* ]] || fail "Hardened Runtime is missing"
if [[ "$RELEASE_VALIDATION" == "1" ]]; then
[[ "$signature" == *'Authority=Developer ID Application:'* ]] \
|| fail "release app is not Developer ID Application signed"
[[ -n "$(print -r -- "$signature" | sed -n 's/^TeamIdentifier=//p')" ]] \
|| fail "release app has no Team Identifier"
/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$info" >/dev/null 2>&1 \
|| fail "release app has no Sparkle feed"
/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$info" >/dev/null 2>&1 \
|| fail "release app has no Sparkle public key"
local entitlements
entitlements="$(codesign -d --entitlements - "$app" 2>/dev/null || true)"
[[ "$entitlements" != *'com.apple.security.get-task-allow'* ]] \
|| fail "release app contains get-task-allow"
[[ "$entitlements" != *'com.apple.security.cs.disable-library-validation'* ]] \
|| fail "release app disables library validation"
fi
}
validate_app "$APP_PATH"
if [[ -f "$DMG_PATH" ]]; then
hdiutil verify "$DMG_PATH" >/dev/null
if [[ "$RELEASE_VALIDATION" == "1" ]]; then
codesign --verify --strict "$DMG_PATH"
fi
MOUNT_DIR="$(mktemp -d /tmp/puter-package.XXXXXX)"
MOUNT_DEVICE="$(hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT_DIR" "$DMG_PATH" | awk '/Apple_APFS/ {print $1; exit}')"
[[ -n "$MOUNT_DEVICE" ]] || fail "DMG did not mount"
[[ -L "$MOUNT_DIR/Applications" ]] || fail "DMG Applications shortcut is missing"
[[ "$(readlink "$MOUNT_DIR/Applications")" == "/Applications" ]] \
|| fail "DMG Applications shortcut has the wrong target"
validate_app "$MOUNT_DIR/puter.app"
fi
if [[ -f "$DMG_PATH" ]]; then
print "Validated $APP_PATH and $DMG_PATH"
else
print "Validated $APP_PATH"
fi