6 Commits
Author SHA1 Message Date
soconnor 4bfa239aa0 Refresh puter UI, power telemetry and demand-aware monitoring
Build and test / macos (push) Waiting to run
Signed release / release (push) Waiting to run
2026-09-22 20:03:16 -04:00
soconnor 0da94715be Add advanced monitoring and release infrastructure
Build and test / macos (push) Canceled after 0s
Signed release / release (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
soconnor b7faf0ac70 Harden release packaging 2026-08-14 14:25:50 -04:00
soconnor 991867fef0 Improve process table columns and memory display 2026-08-14 14:15:49 -04:00
78 changed files with 8939 additions and 2162 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
+50
View File
@@ -0,0 +1,50 @@
# Puter interface rules
Puter is a compact macOS system utility. Its visual hierarchy is the page title,
live data, and the selected item's details. Keep the user's attention on those.
## Direction
The chosen direction pairs a Liquid Glass navigation layer with a compact,
readable data workspace. The earlier flat utility treatment was too bland;
a full glass dashboard would distract from the dense process table. Glass belongs
to navigation and actions, while resource color and larger values give data hierarchy.
- Use the system font, semantic text styles, and tabular digits for telemetry.
- Use the system accent for selection and actions. Resource/chart colors convey
resource identity; status colors also require a text or symbol label.
- Keep data surfaces opaque and readable. Native toolbar controls receive the
platform's material treatment; the inset sidebar uses regular Liquid Glass.
Related page actions use glass buttons inside a GlassEffectContainer.
Never layer glass on table rows or resource readings.
- Honor Reduce Transparency with an opaque sidebar. Older systems use regular
material and bordered controls. Resource meters show actual percentages,
not decorative or simulated activity.
- Retain macOS 14 support. Newer APIs require macOS availability guards.
- Shared geometry lives in `PuterLayout`: 20-point page inset, 16-point section
spacing, 8-point control spacing. Native control metrics take precedence.
## Shell and interaction
- One native toolbar sits above the content. The sidebar is inset eight points
below it, with one 16-point rounded navigation surface instead of a hard divider.
- The window uses a unified hidden-title toolbar with native traffic lights and
outer corners. A regular-material surround connects the toolbar to the inset,
opaque workspace. Launch, refresh, and update actions share a glass container.
Do not replace native window chrome with a custom draggable header.
- Compact and expanded navigation use the same List, row heights, and ordering.
Width changes are immediate; frequently updated process data is not animated.
- Each page retains its search while switching pages. An explicit "Go to details"
clears that destination's filter so it cannot hide the requested process.
- Window-local selection and search belong to ContentView. Shared telemetry
remains in the observable monitor; do not add a forwarding view-model layer.
- Appearance is System, Light, or Dark. System is the default.
- Resource summaries adapt to the available width; tables keep their user-sized
columns and horizontal scrolling. Search-empty and collecting states are explicit.
## Verification
Check both appearances, compact/expanded navigation, the inspector open/closed,
search-empty states, keyboard navigation, context menus, and sidebar toggles.
Check resizing and motion in the running app. A screenshot verifies the captured
layout, not frame rate. Do not claim animation performance without measuring it.
+53
View File
@@ -0,0 +1,53 @@
# Power reporting
Battery telemetry now uses IORegistryEntryCreateCFProperties on AppleSmartBattery,
not regular expressions over human-readable `ioreg` output. On this macOS build,
the tree-prefix indentation caused the old reader to miss BatteryInstalled and
every top-level battery/adapter field.
Readings have separate meanings:
- System load: PowerTelemetryData.SystemLoad in milliwatts, converted to watts.
- Charger → Mac: SystemPowerIn, only while ExternalConnected. This includes
charging; it is not wall-socket consumption or the charger's rated maximum.
- Battery: voltage × signed current. Positive current charges, negative current
discharges. Two's-complement unsigned current is decoded as signed Int64.
- Reported capability: AdapterDetails.Watts. The charger's model name may say
140 W while the current connection reports only 94 W available.
- Negotiated ceiling: AdapterVoltage × Current, not measured current draw.
Unknown readings are not rendered as zero. Zero battery current remains a valid
idle reading. Disconnected adapters are dropped even if stale registry details
remain. Capacity falls back to nested BatteryData on newer registry layouts.
Missing capacity/temperature no longer implies a dead battery or 0 °C.
Available SMC power readings remain a fallback; fresh registry data takes priority.
Registry keys are hardware/OS-dependent and are not a universal power-meter API.
Values are point-in-time samples; their sum may differ because of sampling times,
conversion losses and sensor coverage. No wall-power or cable e-marker claims.
The dropdown is a native NSPopover anchored to an NSStatusItem. Left-click toggles
the panel, right-click presents native metric/update-speed submenus. Command-Shift-M
opens the same panel from the Options menu. Power rows have contextual copy actions
and explanatory help. The native popover owns its outer material and rounding;
actions use Liquid Glass on macOS 26+. Reduce Transparency uses an opaque content
background. There is no second glass rectangle with competing corner radii.
Popover show/close events join the existing demand scheduler. Opening it requests
power/system samples at five-second cadence, without full process inventory,
port enumeration, fan scanning or slow hardware diagnostics. Closing it drops
that demand. Menu-bar readings remain observed even with the main window closed.
Tests cover charging, discharging, two's-complement current, zero vs unknown,
invalid values, nested capacities and dropdown-only sampling demand.
Validation on 22 September 2026: 38 tests passed and Release built successfully.
Computer use verified the panel's live battery/charger values, removed duplicate
corner arcs, and opened the native reading submenu with Battery power and Charger
input choices. The submenu originally dismissed its parent; native menu-tracking
notifications now suspend transient popover dismissal while menus are active.
Actual right-click status-button behavior and all appearance/accessibility modes
still need broader manual coverage; automated tests do not establish those.
Native API reference:
https://developer.apple.com/documentation/iokit/1514310-ioregistryentrycreatecfpropertie
+132
View File
@@ -0,0 +1,132 @@
# Release profiling — 22 September 2026
## Setup
Apple M4 Pro, 48 GiB RAM, macOS 27.0 (26A428), Xcode 27.
Optimized SwiftPM Release build of the current uncommitted working tree.
Isolated ad-hoc signed app: `/private/tmp/puter-profile-release.app`.
Processes page, default Normal cadence, grouped categories, about 650690 processes.
Installed application was not replaced. No application source was changed during profiling.
## Measurements
Each counter measurement lasted 20 seconds, without Instruments attached.
CPU is a percentage of **one core**, unlike puter's machine-normalized CPU column.
Counters came from `proc_pid_rusage` v4, converting CPU Mach ticks with
`mach_timebase_info`; memory is physical footprint, not resident size.
| State | CPU (one core) | Interrupt wake-ups / 20 s | Physical footprint, start → end |
| --- | ---: | ---: | ---: |
| Processes visible, updating | 9.032% | 35 | 66.55 → 65.72 MiB |
| After minimize action (activity not independently verified) | 8.992% | 52 | 71.64 → 66.84 MiB |
| Hidden/inactive, menu-bar consumer enabled | 8.929% | 58 | 70.09 → 69.81 MiB |
| Paused | 0.014% | 3 | 69.83 → 69.36 MiB |
For the hidden measurement, NSRunningApplication reported `isActive=false` and
`isHidden=true`; those flags were checked again after its separate trace.
The 3-second `sample` capture reported 81.6 MiB footprint, 95.4 MiB lifetime peak.
These short observations do not establish a memory leak or long-term plateau.
## Trace-backed findings
Two separate 15-second Time Profiler traces were captured. Percentages below are
inclusive sampled CPU weights; nested paths overlap and must not be added.
1. **Repeated process grouping dominates the UI work.** In the visible trace,
85.9% of sampled CPU weight belonged to the main thread; 42.0% passed through
`ProcessesView.grouped`, and 29.6% through `ProcessGrouping.appPath(for:)`.
`displayedGroups` invokes this computed property from category filtering,
table rendering and empty-state checks. Each computation enumerates running
applications, groups processes, parses app paths, and sorts the result.
Next fix: prepare one stable grouped/sorted snapshot per relevant input change,
cache executable-path → application identity, and reuse category partitions.
2. **Background demand is not behaving as intended at runtime.** The hidden trace
still includes full `nativeProcessRecords` scans (5.1% inclusive), grouping
(38.6%) and SwiftUI graph transactions (58.0%). CPU did not materially fall when
hidden. Pure SamplingDemand unit tests therefore do not validate lifecycle
integration. The precise reason for the stale demand is not established.
Next fix: instrument scheduler demand transitions and connect them to actual
window visibility/occlusion and application activity; add integration tests.
3. **Disk-capacity lookup remains in every telemetry capture.** `diskUsage()` is
unconditional even on the lightweight path. It accounts for 8.4% of visible
trace weight and 14.4% of hidden trace weight. It requests available capacity
for important usage, which is not a high-frequency activity counter.
Next fix: cache capacity and refresh it on a slower, disk-view-specific cadence.
4. **Pause provides a genuinely quiet baseline.** It reduced measured CPU to
0.014% and produced no process-attributed disk I/O during the sample. This makes
always-on rendering alone unlikely to explain the updating-state cost.
## Evidence
- Visible trace: `/private/tmp/puter-foreground-release.trace`
- Hidden trace: `/private/tmp/puter-background-release.trace`
- Exported tables: `/private/tmp/puter-time-profile.xml`, `/private/tmp/puter-background-profile.xml`
- Stack sample: `/private/tmp/puter-release-sample.txt`
- Counter helper: `/private/tmp/puter-profile-counters.c`
- Trace aggregation: `/private/tmp/analyze-puter-trace.py`
Traces are local only and may contain process names, paths and environment metadata.
They were not uploaded. Temporary artifacts may be removed by macOS.
## Limits and next validation
This was a short live-machine study, not a before/after comparison against the old
poller. Other applications and an earlier preview were running; system memory
utilization was high. No GPU energy, scroll hitch rate or frame-time claim is made.
Instrumented samples are used for attribution, not as the low-overhead CPU metric.
Fix lifecycle integration first, then repeated grouping and capacity lookup.
Repeat these exact 20-second scenarios and add a SwiftUI scrolling trace before
calling the performance work complete.
## Fix pass — 22 September 2026
Implemented window-visibility-driven demand with notification-driven rescheduling
and live-state reconciliation before a scheduled sample. Visible unfocused windows
remain live; hidden/minimized/occluded windows do not request process inventories.
Recording and menu-bar consumers retain their separate demand policies.
Initial load and explicit Refresh may request a single inventory.
Moved process grouping to inventory application, added a bounded app-path cache
(including negative results), and shared each table projection across category
rendering and empty-state checks. Cached disk capacity separately from activity
counters, refreshing on initial/manual requests, volume enumeration and every
60 seconds on visible Performance.
Release preview: `/private/tmp/puter-profile-fixed2.app`, PID 31379.
32 tests passed, including new grouped-search, identity, totals and path-cache
regressions. `git diff --check` passed. Installed app was not replaced.
| Scenario, 20 seconds | Before, one-core CPU | After, one-core CPU |
| --- | ---: | ---: |
| Processes visible, updating | 9.032% | 3.541% |
| Hidden, menu-bar consumer enabled | 8.929% | 0.914% |
Visible wake-ups: 34; footprint 73.41 → 75.24 MiB. Hidden wake-ups: 19;
footprint 141.45 → 118.56 MiB, after visiting Hardware. Hidden state was checked
before and after the valid run (`active=false`, `hidden=true`). An earlier hidden
attempt was discarded because the preview was externally changed to Hardware
and unhidden mid-measurement. No memory-improvement claim is made.
Visible 15-second trace: 963 ms sampled CPU versus the earlier 2114 ms.
`ProcessesView.grouped` fell from 42.0% to 2.8% of sampled weight;
`ProcessGrouping.groups` was 6.5%, and `appPath` about 0.1% (previously 29.6%).
No `diskUsage` stack was sampled. Percentages are inclusive, overlapping weights.
Trace: `/private/tmp/puter-fixed-visible.trace`; export:
`/private/tmp/puter-fixed-visible.xml`.
The separate hidden trace sampled 300 ms CPU in 15 seconds (previously 1444 ms).
It contained no sampled process-inventory, process-group construction or disk
capacity stacks. The remaining sampled work is mostly SwiftUI/AppKit updates;
menu-bar system counters intentionally remain enabled. Hidden state was again
verified after capture. Evidence: `/private/tmp/puter-fixed-hidden.trace` and
`/private/tmp/puter-fixed-hidden.xml`.
These are short live-machine observations, not a controlled benchmark. Process
counts were around 700, system memory pressure differed from the first pass,
and a test build ran during the visible counter sample. The savings are supported
by the changed call stacks, but precise percentages will vary with workload.
Scrolling/frame-time and long-term energy measurements remain separate work.
+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 import PackageDescription
let package = Package( let package = Package(
name: "MacTaskManager", name: "puter",
platforms: [.macOS(.v14)], platforms: [.macOS(.v14)],
products: [ 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: [ targets: [
.target(
name: "PuterHelperProtocol",
path: "Sources/PuterHelperProtocol"
),
.executableTarget( .executableTarget(
name: "MacTaskManager", name: "puter",
path: "Sources/MacTaskManager" dependencies: [
"PuterHelperProtocol",
.product(name: "Sparkle", package: "Sparkle")
],
path: "Sources/puter"
),
.executableTarget(
name: "puter-helper",
dependencies: ["PuterHelperProtocol"],
path: "Sources/puter-helper"
), ),
.testTarget( .testTarget(
name: "MacTaskManagerTests", name: "puterTests",
dependencies: ["MacTaskManager"], dependencies: ["puter", "PuterHelperProtocol"],
path: "Tests/MacTaskManagerTests" path: "Tests/puterTests"
) )
] ]
) )
+61 -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. A native macOS system monitor written in SwiftUI and inspired by Windows 11 Task Manager.
@@ -9,8 +9,9 @@ Validated with automated tests, debug and release builds, packaged-app code-sign
- Live process list with CPU, memory, disk I/O, and network usage - Live process list with CPU, memory, disk I/O, and network usage
- Cached native application icons throughout process groups, app history, startup apps, users, services, properties, and dependency views - 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 - Search and sortable process columns, including live Disk and Network rates
- Column sorting automatically flattens process categories for a true global order; the header menu can restore Group by type - Drag-resizable, persisted Processes columns with memory hover details showing both resident bytes and physical-memory percentage
- 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 - 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 - 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 - Persistent preferences for the default start page, Performance selection and CPU graph mode, update speed, always-on-top behavior, and process/detail table layouts
@@ -24,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 - Persistent per-app history with cumulative CPU time, network totals, download/upload breakdowns, and reset confirmation
- Disk, uptime, process, and thread statistics - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - Configurable 1/5/10-second diagnostic samples with remembered folders and optional automatic timestamped output
- Run New Task sheet and persistent update-speed settings - 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 - 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 ## Run
@@ -50,7 +68,7 @@ To create a standard double-clickable app bundle:
```sh ```sh
./scripts/build-app.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: To create a drag-to-install disk image containing the app and an Applications shortcut:
@@ -60,4 +78,40 @@ To create a drag-to-install disk image containing the app and an Applications sh
./scripts/build-dmg.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.
+16
View File
@@ -0,0 +1,16 @@
# puter 1.2.0
- Refreshed app shell, Liquid Glass controls, aligned toolbar actions and search.
- New geometric loop-p Icon Composer icon and updated header branding.
- Demand-aware sampling and process-group caching to reduce unnecessary monitoring work.
- Improved battery power reporting, distinguishing measured power from charger capability.
- Updated menu-bar monitor with resource summaries and power details.
- Expanded battery, sampling-demand and process-group regression coverage.
## Distribution
Apple Silicon build for macOS 14 or later. Liquid Glass styling requires macOS 26 or later.
The DMG and app ZIP are Developer ID signed and Apple notarized. Install manually from this release; Sparkle automatic updates are not configured in this build.
Validation: 38 tests pass. Package validation, notarization and Gatekeeper checks are performed before publication. Live UI checks were performed on the development preview; a fresh packaged UI smoke test was blocked by repeated computer-use service crashes.
+5 -7
View File
@@ -5,19 +5,17 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Task Manager</string> <string>puter</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>MacTaskManager</string> <string>puter</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>local.mactaskmanager.app</string> <string>dev.soconnor.puter</string>
<key>CFBundleIconFile</key>
<string>mactaskmanager.icns</string>
<key>CFBundleIconName</key> <key>CFBundleIconName</key>
<string>mactaskmanager</string> <string>puter</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Task Manager</string> <string>puter</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <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>
+42
View File
@@ -0,0 +1,42 @@
# Sampling and events
One cancellable scheduler follows `SamplingDemand`. No timer is scheduled while
paused, or when the app has no visible window, menu-bar, alert, or recording consumer.
Settings and startup pages do not request process inventory. Services are sampled
only while their page is active. Background CPU/memory/network menu-bar readings
use lightweight system counters, not process enumeration or per-app nettop.
CPU time, I/O rates, fan speeds and service state still require samples: public
macOS notifications are not a replacement for continuously changing counters.
Recording deliberately retains system/process sampling in the background.
App-history collection is opportunistic rather than an always-running daemon.
Existing workspace notifications invalidate app and volume inventory; app-event
bursts are coalesced over 200ms. Volume enumeration no longer runs every ten
seconds. IOKit power-source, memory-pressure and thermal notifications remain.
USB/port inventory still reconciles while hardware is being consumed, because
the current implementation does not have an IOKit device-matching observer.
Validation: eight SamplingDemand tests cover idle, pause, services, menu-bar,
recording and alerts. The native sampler budget test passed: full process sample
averaged 65.5ms versus 7.5ms for system-only telemetry on this machine. These are
scan-cost measurements, not before/after whole-app CPU or energy measurements.
## Profiling-driven corrections
Demand now reads current application hidden state and the visibility, minimized
state and occlusion of titled windows. A visible unfocused window remains live.
Hide/unhide and window occlusion/minimize notifications reschedule the existing
task; each scheduled wake also reconciles live demand before doing any work.
There is no added visibility polling timer. Startup and explicit Refresh can
request one inventory even without a visible consumer.
Process groups are prepared once per inventory result, outside view evaluation.
A bounded executable-path cache includes misses. Table rendering shares one
sorted/filtered projection among category rows and the empty-state check; search
still preserves matching-member behavior and group identity.
Disk capacity is cached independently of disk activity: initial/manual refresh,
volume enumeration, and at most once per minute on visible Performance. Normal
telemetry samples reuse the cached capacity instead of querying the filesystem.
See PROFILING.md for Release measurements and caveats.
@@ -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
}
}
}
-88
View File
@@ -1,88 +0,0 @@
import SwiftUI
struct PageHeader<Trailing: View>: View {
let title: String
let subtitle: String?
@ViewBuilder let trailing: () -> Trailing
init(title: String, subtitle: String? = nil, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) {
self.title = title
self.subtitle = subtitle
self.trailing = trailing
}
var body: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 28, weight: .semibold))
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
}
Spacer()
trailing()
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 14)
}
}
struct ResourceSummaryBar: View {
let snapshot: SystemSnapshot
var body: some 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.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
Spacer()
}
.font(.callout.weight(.medium))
.padding(.horizontal, 20)
.padding(.vertical, 9)
.background(Color.accentColor.opacity(0.06))
.overlay(alignment: .bottom) { Divider() }
}
}
struct UpdateStatus: View {
@Environment(SystemMonitor.self) private var monitor
var body: some View {
HStack(spacing: 6) {
Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 7, height: 7)
if monitor.isPaused {
Text("Updates paused")
} else if let date = monitor.lastUpdated {
Text("Updated \(date, style: .relative)")
} else {
Text("Collecting system data…")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
struct EmptyState: View {
let icon: String
let title: String
let message: String
var body: some View {
ContentUnavailableView(title, systemImage: icon, description: Text(message))
}
}
struct UpdateSpeedPicker: View {
@Binding var selection: UpdateSpeed
var body: some View {
Picker("Update speed", selection: $selection) {
Text("High").tag(UpdateSpeed.high)
Text("Normal").tag(UpdateSpeed.normal)
Text("Low").tag(UpdateSpeed.low)
Text("Paused").tag(UpdateSpeed.paused)
}
}
}
-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()
+304
View File
@@ -0,0 +1,304 @@
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("appAppearance") private var appearance: PuterAppearance = .system
@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("Appearance", selection: $appearance) {
ForEach(PuterAppearance.allCases) { appearance in
Text(appearance.rawValue).tag(appearance)
}
}
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("Live-view interval", value: intervalLabel)
LabeledContent("Process source", value: "macOS process table")
LabeledContent("Memory units", value: "Automatic")
Text("Process and service scans run only for visible consumers or recording. Power-source and volume changes use system notifications. With no live consumers, scheduled sampling stops.")
.font(.caption).foregroundStyle(.secondary)
}
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
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import Foundation
import IOKit
/// Read typed registry values; `ioreg`'s human-readable indentation is not an API.
enum BatteryRegistryReader {
static func read() -> BatterySnapshot {
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
guard service != 0 else { return BatterySnapshot() }
defer { IOObjectRelease(service) }
var properties: Unmanaged<CFMutableDictionary>?
guard IORegistryEntryCreateCFProperties(service, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS,
let values = properties?.takeRetainedValue() as? [String: Any] else { return BatterySnapshot() }
return parse(values)
}
static func parse(_ values: [String: Any]) -> BatterySnapshot {
let batteryData = values["BatteryData"] as? [String: Any] ?? [:]
func number(_ key: String) -> Double { (values[key] as? NSNumber)?.doubleValue ?? 0 }
func capacity(_ top: String, _ nested: String) -> Double {
(values[top] as? NSNumber)?.doubleValue ?? (batteryData[nested] as? NSNumber)?.doubleValue ?? 0
}
func flag(_ key: String) -> Bool { (values[key] as? NSNumber)?.boolValue ?? false }
let telemetry = values["PowerTelemetryData"] as? [String: Any] ?? [:]
func power(_ key: String) -> Double? {
guard let value = telemetry[key] as? NSNumber else { return nil }
let watts = value.doubleValue / 1_000
return watts.isFinite && (0...1_000).contains(watts) ? watts : nil
}
// IORegistry sometimes represents negative current as an unsigned two's-complement integer.
let rawCurrent = (values["Amperage"] as? NSNumber)?.int64Value ?? 0
let amps = Double(rawCurrent) / 1_000
let voltage = number("Voltage") / 1_000
let hasCurrent = values["Amperage"] != nil && abs(amps) <= 100 && voltage > 0 && voltage < 100
let external = flag("ExternalConnected")
let temperature = number("Temperature")
let time = Int(number("TimeRemaining"))
var battery = BatterySnapshot(
isPresent: flag("BatteryInstalled"), chargePercent: min(100, max(0, number("CurrentCapacity"))),
isCharging: flag("IsCharging"), isFullyCharged: flag("FullyCharged"),
externalPowerConnected: external, cycleCount: Int(number("CycleCount")),
designCycleCount: Int(number("DesignCycleCount9C")),
currentCapacityMAh: capacity("AppleRawCurrentCapacity", "RemainingCapacity"),
fullChargeCapacityMAh: capacity("NominalChargeCapacity", "NominalChargeCapacity"),
designCapacityMAh: capacity("DesignCapacity", "DesignCapacity"),
voltageVolts: voltage, currentAmps: hasCurrent ? amps : 0,
temperatureCelsius: temperature > 1_000 ? temperature / 10 - 273.15 : temperature,
timeRemainingMinutes: time > 0 && time < 65_535 ? time : nil,
serial: values["Serial"] as? String ?? "",
systemPowerWatts: power("SystemLoad") ?? 0,
adapterInputWatts: external ? (power("SystemPowerIn") ?? 0) : 0
)
battery.hasBatteryCurrent = hasCurrent
battery.hasBatteryTemperature = values["Temperature"] != nil
battery.hasAdapterInput = external && power("SystemPowerIn") != nil
battery.hasSystemPower = power("SystemLoad") != nil
if external, let adapter = values["AdapterDetails"] as? [String: Any], !adapter.isEmpty {
func n(_ key: String) -> Double { (adapter[key] as? NSNumber)?.doubleValue ?? 0 }
let profiles = (adapter["UsbHvcMenu"] as? [[String: Any]] ?? []).compactMap { entry -> PowerProfile? in
guard let voltage = entry["MaxVoltage"] as? NSNumber,
let current = entry["MaxCurrent"] as? NSNumber else { return nil }
return PowerProfile(voltageVolts: voltage.doubleValue / 1_000, currentAmps: current.doubleValue / 1_000)
}
battery.adapter = PowerAdapterSnapshot(
name: adapter["Name"] as? String ?? "Power adapter",
manufacturer: adapter["Manufacturer"] as? String ?? "Not reported",
serial: adapter["SerialString"] as? String ?? "", ratedWatts: n("Watts"),
negotiatedVoltage: n("AdapterVoltage") / 1_000,
negotiatedCurrent: n("Current") / 1_000, profiles: profiles
)
}
return battery
}
}
+181
View File
@@ -0,0 +1,181 @@
import SwiftUI
struct PageHeader<Trailing: View>: View {
let title: String
let subtitle: String?
@ViewBuilder let trailing: () -> Trailing
init(title: String, subtitle: String? = nil, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) {
self.title = title
self.subtitle = subtitle
self.trailing = trailing
}
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
heading.fixedSize(horizontal: true, vertical: false)
Spacer(minLength: 0)
GlassActions { trailing() }.fixedSize(horizontal: true, vertical: false)
}
VStack(alignment: .leading, spacing: 8) {
heading.frame(maxWidth: .infinity, alignment: .leading)
GlassActions { trailing() }.fixedSize(horizontal: true, vertical: false)
}
}
.padding(.horizontal, PuterLayout.pageInset)
.padding(.vertical, PuterLayout.sectionSpacing)
}
private var heading: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.title.weight(.semibold)).lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
.help(subtitle)
}
}
}
}
struct ResourceSummaryBar: View {
let snapshot: SystemSnapshot
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(spacing: PuterLayout.sectionSpacing) {
cpu
memory
disk
network
}
Grid(alignment: .leading, horizontalSpacing: PuterLayout.sectionSpacing, verticalSpacing: 8) {
GridRow {
cpu
memory
}
GridRow {
disk
network
}
}
}
.font(.callout)
.monospacedDigit()
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, PuterLayout.pageInset)
.padding(.bottom, PuterLayout.sectionSpacing)
.padding(.top, PuterLayout.controlSpacing)
.overlay(alignment: .bottom) { Divider() }
}
private var cpu: some View {
ResourceSummaryMetric(title: "CPU", symbol: "cpu", value: Formatters.percent(snapshot.cpuPercent), color: .blue, percent: snapshot.cpuPercent)
}
private var memory: some View {
ResourceSummaryMetric(title: "Memory", symbol: "memorychip", value: Formatters.percent(snapshot.memoryPercent), color: .purple, percent: snapshot.memoryPercent)
}
private var disk: some View {
ResourceSummaryMetric(title: "Disk activity", symbol: "internaldrive", value: Formatters.percent(snapshot.diskActivePercent), color: .orange, percent: snapshot.diskActivePercent)
}
private var network: some View {
ResourceSummaryMetric(title: "Network", symbol: "network", value: Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate), color: .teal, percent: nil)
}
}
private struct ResourceSummaryMetric: View {
let title: String
let symbol: String
let value: String
let color: Color
let percent: Double?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Label {
Text(title).foregroundStyle(.secondary)
} icon: {
Image(systemName: symbol).foregroundStyle(color)
}
.font(.caption.weight(.medium))
Text(value).font(.title2.weight(.semibold)).lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
if let percent {
ProgressView(value: min(max(percent, 0), 100), total: 100)
.tint(color)
.accessibilityHidden(true)
} else {
Text("Receive + send").font(.caption2).foregroundStyle(.secondary)
}
}
.frame(minWidth: 112, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
struct ProcessInventoryPlaceholder: View {
let isLoading: Bool
let searchText: String
var body: some View {
if isLoading {
ProgressView("Collecting processes…")
.controlSize(.small)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if !searchText.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
ContentUnavailableView("No processes available", systemImage: "list.bullet.rectangle", description: Text("Use Refresh to request a new process snapshot."))
}
}
}
struct UpdateStatus: View {
@Environment(SystemMonitor.self) private var monitor
var body: some View {
HStack(spacing: 6) {
Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 7, height: 7)
if monitor.isPaused {
Text("Updates paused")
} else if let date = monitor.lastUpdated {
if Date().timeIntervalSince(date) < 1.5 {
Text("Updated now")
} else {
Text("Updated \(date, style: .relative)")
}
} else {
Text("Collecting system data…")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
struct EmptyState: View {
let icon: String
let title: String
let message: String
var body: some View {
ContentUnavailableView(title, systemImage: icon, description: Text(message))
}
}
struct UpdateSpeedPicker: View {
@Binding var selection: UpdateSpeed
var body: some View {
Picker("Update speed", selection: $selection) {
Text("High").tag(UpdateSpeed.high)
Text("Normal").tag(UpdateSpeed.normal)
Text("Low").tag(UpdateSpeed.low)
Text("Paused").tag(UpdateSpeed.paused)
}
}
}
+239
View File
@@ -0,0 +1,239 @@
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 sectionSearches: [TaskSection: String] = [:]
@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 {
HStack(spacing: 0) {
Sidebar(selection: $selection, compact: sidebarCompact)
.frame(width: (sidebarCompact ? PuterLayout.compactSidebarWidth : PuterLayout.expandedSidebarWidth) - 16)
.modifier(NavigationGlass())
.padding(8)
detailView
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background, in: .rect(cornerRadius: 16))
.clipShape(.rect(cornerRadius: 16))
.padding(.vertical, 8)
.padding(.trailing, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.regularMaterial)
.toolbarBackground(.regularMaterial, for: .windowToolbar)
.toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask, sidebarCompact: $sidebarCompact,
searchText: $searchText, searchPrompt: searchPrompt)
}
.navigationTitle("")
.onChange(of: selection) { previous, section in
sectionSearches[previous ?? .processes] = searchText
searchText = sectionSearches[section ?? .processes] ?? ""
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
showDetails(for: process)
}
case .performance:
PerformanceView()
case .history:
AppHistoryView(searchText: searchText) { process in
showDetails(for: process)
}
case .startup:
StartupAppsView()
case .users:
UsersView { process in
showDetails(for: process)
}
case .details:
DetailsView(searchText: searchText, selection: $detailSelection)
case .services:
ServicesView(searchText: searchText) { process in
showDetails(for: process)
}
case .energy:
EnergyView()
case .diagnostics:
DiagnosticsView()
case .hardware:
HardwareView()
case .settings:
SettingsView()
}
}
}
private var searchPrompt: String? {
switch selection ?? .processes {
case .processes: "Search processes"
case .history: "Search app history"
case .details: "Search details"
case .services: "Search services"
default: nil
}
}
private func showDetails(for process: ProcessRecord) {
sectionSearches[.details] = ""
detailSelection = process.pid
selection = .details
}
}
private struct TaskToolbarContent: ToolbarContent {
@Environment(SystemMonitor.self) private var monitor
@Binding var showingNewTask: Bool
@Binding var sidebarCompact: Bool
@Binding var searchText: String
let searchPrompt: String?
var body: some ToolbarContent {
@Bindable var monitor = monitor
ToolbarItem(placement: .navigation) {
GlassActions {
Button {
sidebarCompact.toggle()
} label: {
Image(systemName: "sidebar.left")
.frame(width: 20, height: 20)
}
.buttonStyle(ToolbarIconButtonStyle())
.help(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
.accessibilityLabel(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
}
}
ToolbarItem(placement: .principal) {
PuterBrand()
}
if #available(macOS 26, *) {
ToolbarSpacer(.flexible, placement: .primaryAction)
}
ToolbarItem(placement: .primaryAction) {
GlassActions {
HStack(spacing: 8) {
Button {
showingNewTask = true
} label: {
Label("Run new task", systemImage: "plus.square")
.frame(width: 20, height: 20)
}
.accessibilityLabel("Run new task")
.buttonStyle(ToolbarIconButtonStyle())
.help("Launch an application or executable")
Button {
monitor.refresh()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
.frame(width: 20, height: 20)
}
.accessibilityLabel("Refresh")
.buttonStyle(ToolbarIconButtonStyle())
.help("Refresh now (⌘R)")
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
} label: {
Label("Update speed", systemImage: "ellipsis")
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.horizontal, 12)
.frame(height: PuterLayout.toolbarControlHeight)
.modifier(SearchGlassSurface())
if let searchPrompt {
ToolbarSearchField(text: $searchText, prompt: searchPrompt)
}
}
.labelStyle(.iconOnly)
.buttonBorderShape(.circle)
}
}
}
}
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)
.scrollContentBackground(.hidden)
.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, alignment: compact ? .center : .leading)
.frame(height: PuterLayout.sidebarRowHeight)
.contentShape(Rectangle())
.tag(section)
.help(section.rawValue)
.accessibilityLabel(section.rawValue)
.listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
}
}
+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()
}
}
+527
View File
@@ -0,0 +1,527 @@
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.powerSourceDescription, 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("Capacity status", battery.designCapacityMAh > 0 ? (battery.healthPercent > 80 ? "Normal capacity" : "Reduced capacity") : "Not reported")
Spacer()
metric("Health", battery.designCapacityMAh > 0 ? Formatters.percent(battery.healthPercent) : "Not reported")
}
HStack(alignment: .firstTextBaseline) {
metric("Cycles", cycleText)
Spacer()
metric("Temperature", battery.hasBatteryTemperature ? String(format: "%.1f °C", battery.temperatureCelsius) : "Not reported")
Spacer()
metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
}
HStack(alignment: .firstTextBaseline) {
metric(battery.batteryFlowTitle, battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "Not reported")
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()
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
Text("Reported capability").font(.caption).foregroundStyle(.secondary)
}
}
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("Charger → Mac", battery.hasAdapterInput ? String(format: "%.1f W", battery.adapterInputWatts) : "Not reported")
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 \(Int(adapter.ratedWatts.rounded())) W available and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Measured charger input is \(battery.hasAdapterInput ? watts(battery.adapterInputWatts) : "not reported"); system load is \(watts(battery.systemPowerWatts)). Input includes charging and is not a wall-socket measurement."
}
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) }
}
}
+15
View File
@@ -0,0 +1,15 @@
import SwiftUI
/// NSPopover owns the outer material and corner geometry. Adding a second
/// glass rectangle creates mismatched corner arcs inside the native shell.
struct MenuBarGlassSurface: ViewModifier {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder func body(content: Content) -> some View {
if reduceTransparency {
content.background(.background)
} else {
content
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import SwiftUI
struct MenuBarMonitorActions: View {
@Environment(SystemMonitor.self) private var monitor
@AppStorage("menuBarMetric") private var metric: MenuBarMetric = .cpu
var body: some View {
@Bindable var monitor = monitor
Picker("Menu bar reading", selection: $metric) {
ForEach(MenuBarMetric.allCases) { Text($0.rawValue).tag($0) }
}.pickerStyle(.menu)
Picker("Update speed", selection: $monitor.updateSpeed) {
ForEach(UpdateSpeed.allCases) { Text($0.rawValue).tag($0) }
}.pickerStyle(.menu)
Divider()
Button(monitor.isPaused ? "Resume updates" : "Pause updates") { monitor.isPaused.toggle() }
Button("Refresh now", systemImage: "arrow.clockwise", action: monitor.refresh)
}
}
+61
View File
@@ -0,0 +1,61 @@
import AppKit
import SwiftUI
struct MenuBarMonitorView: View {
var openMain: (() -> Void)? = nil
@Environment(SystemMonitor.self) private var monitor
@Environment(\.openWindow) private var openWindow
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
Text("puter").font(.system(size: 23, weight: .bold, design: .rounded))
Spacer()
Label(monitor.isPaused ? "Paused" : "Live", systemImage: monitor.isPaused ? "pause.circle" : "waveform.path")
.font(.caption.weight(.medium)).foregroundStyle(.secondary)
Menu { MenuBarMonitorActions() } label: {
Image(systemName: "ellipsis").accessibilityLabel("Monitor options")
}
.modifier(GlassMenuSurface())
}
HStack(spacing: 24) {
MenuBarResourceValue(title: "CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), symbol: "cpu", color: .blue)
MenuBarResourceValue(title: "Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), symbol: "memorychip", color: .purple)
}
.padding(16)
.background(.background.opacity(0.65), in: .rect(cornerRadius: 16))
MenuBarPowerView(battery: monitor.hardware.battery, capturedAt: monitor.hardware.capturedAt, paused: monitor.isPaused)
HStack {
Label("Network", systemImage: "network")
Spacer()
Text(Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate)).monospacedDigit()
}
.font(.callout).foregroundStyle(.secondary)
GlassActions {
HStack(spacing: 8) {
Button(monitor.isPaused ? "Resume" : "Pause", systemImage: monitor.isPaused ? "play" : "pause") {
monitor.isPaused.toggle()
}.labelStyle(.iconOnly)
Button("Refresh", systemImage: "arrow.clockwise", action: monitor.refresh).labelStyle(.iconOnly)
Spacer()
Button("Open puter", systemImage: "arrow.up.right", action: showMainWindow)
}
}
}
.padding(20)
.frame(width: 368)
.modifier(MenuBarGlassSurface())
.preferredColorScheme(appearance.colorScheme)
.contextMenu { MenuBarMonitorActions() }
}
private func showMainWindow() {
if let openMain { openMain(); return }
openWindow(id: "main")
NSApp.activate()
}
}
+43
View File
@@ -0,0 +1,43 @@
import SwiftUI
struct MenuBarPowerView: View {
let battery: BatterySnapshot
let capturedAt: Date
let paused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Power flow").font(.headline)
Spacer()
if battery.isPresent {
Label("\(Int(battery.chargePercent))%", systemImage: battery.isCharging ? "battery.100percent.bolt" : "battery.75percent")
.font(.callout.weight(.medium))
}
}
Text(battery.powerSourceDescription).font(.caption).foregroundStyle(.secondary)
PowerReadingRow(title: "System load", symbol: "desktopcomputer",
watts: battery.hasSystemPower ? battery.systemPowerWatts : nil,
detail: "Power used by the Mac, excluding battery charging.")
if battery.isPresent {
PowerReadingRow(title: battery.batteryFlowTitle,
symbol: battery.currentAmps < 0 ? "arrow.up.right" : "arrow.down.left",
watts: battery.measuredBatteryWatts,
detail: "Battery voltage × signed current; charging adds energy, discharging supplies the Mac.")
}
if battery.externalPowerConnected {
PowerReadingRow(title: "Charger → Mac", symbol: "powerplug",
watts: battery.hasAdapterInput ? battery.adapterInputWatts : nil,
detail: "Measured input at the Mac, including charging. Not wall-socket consumption or the charger's rated maximum.")
}
if let adapter = battery.adapter {
Divider()
Text(adapter.name).font(.callout.weight(.medium)).fixedSize(horizontal: false, vertical: true)
Text(String(format: "%.0f W available · %.1f V × %.2f A limit", adapter.ratedWatts, adapter.negotiatedVoltage, adapter.negotiatedCurrent))
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
}
Text(paused ? "Readings paused" : (capturedAt == .distantPast ? "Reading power…" : "Power sampled \(capturedAt.formatted(date: .omitted, time: .standard))"))
.font(.caption2).foregroundStyle(.secondary)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import SwiftUI
struct MenuBarResourceValue: View {
let title: String
let value: String
let symbol: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: symbol).font(.caption.weight(.medium)).foregroundStyle(color)
Text(value).font(.system(size: 28, weight: .medium, design: .rounded)).monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
+563
View File
@@ -0,0 +1,563 @@
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 hasBatteryCurrent = false
var hasBatteryTemperature = false
var hasAdapterInput = false
var hasSystemPower = false
var batteryFlowTitle: String {
if !isPresent { return "Battery unavailable" }
if currentAmps < 0 { return "Battery discharge" }
if isCharging || currentAmps > 0 { return "Battery charging" }
return "Battery idle"
}
var powerSourceDescription: String {
!isPresent ? "External power" : (externalPowerConnected ? "Charger connected" : "On battery")
}
var measuredBatteryWatts: Double? {
guard isPresent else { return nil }
if hasBatteryCurrent { return abs(voltageVolts * currentAmps) }
return sensorBatteryPowerWatts > 0 ? sensorBatteryPowerWatts : nil
}
var healthPercent: Double {
guard designCapacityMAh > 0 else { return 0 }
return min(100, fullChargeCapacityMAh / designCapacityMAh * 100)
}
var batteryPowerWatts: Double {
if hasBatteryCurrent { return abs(voltageVolts * currentAmps) }
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"
case batteryPower = "Battery power"
case chargerInput = "Charger input"
var id: String { rawValue }
var requiresPower: Bool { [.power, .batteryPower, .chargerInput].contains(self) }
}
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.allowsNonnumericFormatting = false
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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
PageHeader(title: "Performance") { PageHeader(title: "Performance") {
HStack(spacing: 12) {
Button {
monitor.isRecording ? monitor.stopRecording() : monitor.startRecording()
} label: {
Label(monitor.isRecording ? "Stop recording" : "Record", systemImage: monitor.isRecording ? "stop.circle.fill" : "record.circle")
}
.tint(monitor.isRecording ? .red : nil)
if !monitor.recordingSamples.isEmpty {
Menu("Recording", systemImage: "waveform.path.ecg.rectangle") {
Text("\(monitor.recordingSamples.count) samples")
if let started = monitor.recordingStartDate {
Text("Started \(started, style: .relative)")
}
Divider()
Button("Export JSON…") { SessionRecordingExporter.exportJSON(monitor: monitor) }
Button("Export CSV…") { SessionRecordingExporter.exportCSV(monitor: monitor) }
Divider()
Button("Clear recording", role: .destructive) { monitor.clearRecording() }
}
}
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: selectedResourceName)
}
UpdateStatus() UpdateStatus()
} }
}
HStack(spacing: 0) { HStack(spacing: 0) {
ScrollView { ScrollView {
VStack(spacing: 10) { VStack(spacing: 4) {
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors") 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( metricCard(
.disk, .disk,
value: monitor.snapshot.diskActivePercent, value: monitor.snapshot.diskActivePercent,
@@ -65,71 +89,154 @@ struct PerformanceView: View {
detail: "\(Formatters.rate(monitor.snapshot.networkReceiveRate))\(Formatters.rate(monitor.snapshot.networkSendRate))" detail: "\(Formatters.rate(monitor.snapshot.networkReceiveRate))\(Formatters.rate(monitor.snapshot.networkSendRate))"
) )
} }
.padding(14) .padding(10)
} }
.frame(width: 245) .frame(minWidth: 220, idealWidth: 245, maxWidth: 280)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45)) .background(Color(nsColor: .controlBackgroundColor).opacity(0.25))
Divider() Divider()
VStack(spacing: 0) {
detail 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 { private func metricCard(_ metric: PerformanceMetric, value: Double, valueText: String? = nil, detail: String) -> some View {
Button { Button {
selectedVolumeID = nil selectedVolumeID = nil
selectedMetric = metric selectedMetric = metric
} label: { } label: {
HStack(spacing: 12) { HStack(alignment: .top, spacing: 10) {
Image(systemName: metric.icon) Image(systemName: metric.icon)
.font(.title3) .font(.body)
.foregroundStyle(metric.color) .foregroundStyle(metric.color)
.frame(width: 30) .frame(width: 22)
VStack(alignment: .leading, spacing: 3) { .padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(metric.rawValue).font(.headline) Text(metric.rawValue).font(.headline)
Text(valueText ?? Formatters.percent(value)).font(.title3.monospacedDigit()) Spacer(minLength: 0)
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1) Text(valueText ?? Formatters.percent(value))
.font(.callout.monospacedDigit())
} }
Spacer() Text(detail)
} .font(.caption)
.padding(12) .foregroundStyle(.secondary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == nil && selectedMetric == metric ? metric.color.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
.overlay {
RoundedRectangle(cornerRadius: 9)
.stroke(
selectedVolumeID == nil && selectedMetric == metric ? metric.color : Color.secondary.opacity(0.15),
lineWidth: selectedVolumeID == nil && selectedMetric == metric ? 1.5 : 1
)
} }
.clipShape(RoundedRectangle(cornerRadius: 9)) }
.padding(.horizontal, 10)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == nil && selectedMetric == metric ? Color.accentColor.opacity(0.13) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 7))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private func volumeCard(_ volume: VolumeSnapshot) -> some View { private func volumeCard(_ volume: VolumeSnapshot) -> some View {
Button { selectedVolumeID = volume.id } label: { Button { selectedVolumeID = volume.id } label: {
HStack(spacing: 12) { HStack(alignment: .top, spacing: 10) {
Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive") Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive")
.font(.title3) .font(.body)
.foregroundStyle(.orange) .foregroundStyle(.orange)
.frame(width: 30) .frame(width: 22)
VStack(alignment: .leading, spacing: 3) { .padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(volume.name).font(.headline).lineLimit(1) Text(volume.name).font(.headline).lineLimit(1)
Text(Formatters.percent(volume.usedPercent)).font(.title3.monospacedDigit()) Spacer(minLength: 0)
Text(Formatters.percent(volume.usedPercent))
.font(.callout.monospacedDigit())
}
Text("\(Formatters.bytes.string(fromByteCount: Int64(volume.capacity)))\(volume.fileSystem)") Text("\(Formatters.bytes.string(fromByteCount: Int64(volume.capacity)))\(volume.fileSystem)")
.font(.caption).foregroundStyle(.secondary).lineLimit(1) .font(.caption).foregroundStyle(.secondary).lineLimit(2)
} }
Spacer()
} }
.padding(12) .padding(.horizontal, 10)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == volume.id ? Color.orange.opacity(0.13) : Color(nsColor: .windowBackgroundColor)) .background(selectedVolumeID == volume.id ? Color.accentColor.opacity(0.13) : .clear)
.overlay { .clipShape(RoundedRectangle(cornerRadius: 7))
RoundedRectangle(cornerRadius: 9)
.stroke(selectedVolumeID == volume.id ? Color.orange : Color.secondary.opacity(0.15), lineWidth: selectedVolumeID == volume.id ? 1.5 : 1)
}
.clipShape(RoundedRectangle(cornerRadius: 9))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("\(volume.name), removable disk") .accessibilityLabel("\(volume.name), removable disk")
@@ -165,6 +272,7 @@ struct PerformanceView: View {
color: .purple, color: .purple,
value: monitor.snapshot.memoryPercent, value: monitor.snapshot.memoryPercent,
history: monitor.memoryHistory, history: monitor.memoryHistory,
advisory: memoryAdvisory,
stats: [ stats: [
("In use", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))), ("In use", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))),
("Available", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal - monitor.snapshot.memoryUsed))), ("Available", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal - monitor.snapshot.memoryUsed))),
@@ -173,6 +281,7 @@ struct PerformanceView: View {
("Wired", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryWired))), ("Wired", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryWired))),
("Compressed", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCompressed))), ("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)))"), ("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), ("Type", monitor.snapshot.memoryType),
("Frequency / speed", monitor.snapshot.memorySpeed), ("Frequency / speed", monitor.snapshot.memorySpeed),
("Manufacturer", monitor.snapshot.memoryManufacturer) ("Manufacturer", monitor.snapshot.memoryManufacturer)
@@ -217,6 +326,32 @@ struct PerformanceView: View {
} }
return String(decoding: buffer.prefix { $0 != 0 }.map(UInt8.init(bitPattern:)), as: UTF8.self) 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 { private struct RemovableDiskDetail: View {
@@ -226,7 +361,7 @@ private struct RemovableDiskDetail: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) { 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)") Text("\(volume.fileSystem)\(volume.mountPath)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -236,7 +371,7 @@ private struct RemovableDiskDetail: View {
.foregroundStyle(.orange) .foregroundStyle(.orange)
Spacer() Spacer()
Text(Formatters.percent(volume.usedPercent)) Text(Formatters.percent(volume.usedPercent))
.font(.system(size: 34, weight: .light).monospacedDigit()) .font(.largeTitle.weight(.light).monospacedDigit())
} }
ProgressView(value: volume.usedPercent, total: 100) ProgressView(value: volume.usedPercent, total: 100)
@@ -266,12 +401,12 @@ private struct GPUPerformanceDetail: View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("GPU").font(.system(size: 30, weight: .semibold)) Text("GPU").font(.title.weight(.semibold))
Text(name).foregroundStyle(.secondary) Text(name).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(Formatters.percent(snapshot.gpuPercent)) Text(Formatters.percent(snapshot.gpuPercent))
.font(.system(size: 34, weight: .light).monospacedDigit()) .font(.largeTitle.weight(.light).monospacedDigit())
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -366,13 +501,13 @@ private struct DiskPerformanceDetail: View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { 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) Text("System volume • APFS • Solid-state").foregroundStyle(.secondary)
} }
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
Text(Formatters.rate(snapshot.diskThroughput)) Text(Formatters.rate(snapshot.diskThroughput))
.font(.system(size: 30, weight: .light).monospacedDigit()) .font(.title.weight(.light).monospacedDigit())
Text("\(Formatters.percent(snapshot.diskActivePercent)) active") Text("\(Formatters.percent(snapshot.diskActivePercent)) active")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
@@ -499,12 +634,12 @@ private struct NetworkPerformanceDetail: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Network").font(.system(size: 30, weight: .semibold)) Text("Network").font(.title.weight(.semibold))
Text(interface).foregroundStyle(.secondary) Text(interface).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(Formatters.rate(receiveRate + sendRate)) Text(Formatters.rate(receiveRate + sendRate))
.font(.system(size: 30, weight: .light).monospacedDigit()) .font(.title.weight(.light).monospacedDigit())
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -584,12 +719,12 @@ private struct CPUPerformanceDetail: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("CPU").font(.system(size: 30, weight: .semibold)) Text("CPU").font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary) Text(subtitle).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(Formatters.percent(value)) Text(Formatters.percent(value))
.font(.system(size: 34, weight: .light).monospacedDigit()) .font(.largeTitle.weight(.light).monospacedDigit())
} }
cpuGraph cpuGraph
@@ -620,9 +755,24 @@ private struct CPUPerformanceDetail: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
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") Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) }
.menuStyle(.borderlessButton)
.fixedSize()
.accessibilityLabel("CPU graph layout")
} }
if displayMode == .summary { if displayMode == .summary {
@@ -743,6 +893,7 @@ private struct PerformanceDetail: View {
let color: Color let color: Color
let value: Double let value: Double
let history: [MetricSample] let history: [MetricSample]
var advisory: PerformanceAdvisory? = nil
let stats: [(String, String)] let stats: [(String, String)]
var chartData: [MetricSample] { var chartData: [MetricSample] {
@@ -755,12 +906,27 @@ private struct PerformanceDetail: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(title).font(.system(size: 30, weight: .semibold)) Text(title).font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary) Text(subtitle).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(Formatters.percent(value)) 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) { VStack(alignment: .leading, spacing: 8) {
Text("% Utilization").font(.caption).foregroundStyle(.secondary) Text("% Utilization").font(.caption).foregroundStyle(.secondary)
@@ -792,3 +958,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() }
}
}
+33
View File
@@ -0,0 +1,33 @@
import AppKit
import SwiftUI
struct PowerReadingRow: View {
let title: String
let symbol: String
let watts: Double?
let detail: String
private var value: String { watts.map { String(format: "%.1f W", $0) } ?? "Not reported" }
var body: some View {
HStack(spacing: 12) {
Image(systemName: symbol).foregroundStyle(.secondary).frame(width: 20)
Text(title).font(.callout)
Spacer(minLength: 8)
Text(value).font(.callout.weight(.semibold)).monospacedDigit()
}
.padding(.vertical, 4)
.contentShape(.rect)
.help(detail)
.accessibilityElement(children: .ignore)
.accessibilityLabel(title)
.accessibilityValue(value)
.accessibilityHint(detail)
.contextMenu {
Button("Copy \(title)", systemImage: "doc.on.doc") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(title): \(value)", forType: .string)
}
Text(detail)
}
}
}
+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 let onShowProperties: (ProcessRecord) -> Void
var body: some View { var body: some View {
Button("End task", systemImage: "xmark.circle") {
onTerminate(.init(process: process, kind: .normal))
}
Button("Efficiency mode", systemImage: "leaf") { Button("Efficiency mode", systemImage: "leaf") {
monitor.setPriority(10, for: process) monitor.setPriority(10, for: process)
} }
@@ -85,6 +81,12 @@ struct ProcessContextMenu: View {
} }
Button("Properties") { onShowProperties(process) } Button("Properties") { onShowProperties(process) }
} }
Divider()
Button("End task", systemImage: "xmark.circle", role: .destructive) {
onTerminate(.init(process: process, kind: .normal))
}
} }
private func copy(_ text: String) { private func copy(_ text: String) {
@@ -124,7 +126,7 @@ enum ProcessDiagnosticReporter {
let panel = NSSavePanel() let panel = NSSavePanel()
panel.title = "Create diagnostic report" 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.prompt = "Create Report"
panel.nameFieldStringValue = safeFilename("\(process.displayName)-\(process.pid)-sample.txt") panel.nameFieldStringValue = safeFilename("\(process.displayName)-\(process.pid)-sample.txt")
panel.allowedContentTypes = [.plainText] panel.allowedContentTypes = [.plainText]
@@ -195,6 +197,7 @@ struct ProcessPropertiesView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
let process: ProcessRecord let process: ProcessRecord
@State private var security: ProcessSecuritySnapshot?
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() } private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
@@ -214,6 +217,8 @@ struct ProcessPropertiesView: View {
Divider() Divider()
TabView {
ScrollView {
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) { Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
property("Executable", process.executablePath) property("Executable", process.executablePath)
property("User", process.user) property("User", process.user)
@@ -232,6 +237,13 @@ struct ProcessPropertiesView: View {
property("Elapsed time", process.elapsed) property("Elapsed time", process.elapsed)
} }
.padding(20) .padding(20)
}
.tabItem { Label("Process", systemImage: "list.bullet.rectangle") }
securityContent
.tabItem { Label("Security", systemImage: "checkmark.shield") }
}
.padding(.horizontal, 12)
Divider() Divider()
HStack { HStack {
@@ -244,7 +256,57 @@ struct ProcessPropertiesView: View {
} }
.padding(16) .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 { 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 AppKit
import SwiftUI import SwiftUI
private enum ProcessSort: String, CaseIterable { enum ProcessSort: String, CaseIterable {
case name = "Name" case name = "Name"
case cpu = "CPU" case cpu = "CPU"
case memory = "Memory" case memory = "Memory"
@@ -10,12 +10,67 @@ private enum ProcessSort: String, CaseIterable {
case pid = "PID" 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 { private enum ResourceDisplayMode: String, CaseIterable {
case values = "Values" case values = "Values"
case percentages = "Percentages" case percentages = "Percentages"
} }
private enum ProcessCategory: String, CaseIterable, Identifiable { private let processNumericTextInset: CGFloat = 7
private enum ProcessColumn: String, CaseIterable, Codable {
case name, pid, status, cpu, memory, disk, network
var defaultWidth: CGFloat {
switch self {
case .name: 300
case .pid: 70
case .status: 80
case .cpu: 86
case .memory: 110
case .disk, .network: 88
}
}
var minimumWidth: CGFloat {
switch self {
case .name: 160
case .pid: 54
case .status: 68
case .cpu: 64
case .memory: 86
case .disk, .network: 76
}
}
var maximumWidth: CGFloat { self == .name ? 720 : 260 }
static var defaultWidths: [ProcessColumn: CGFloat] {
Dictionary(uniqueKeysWithValues: allCases.map { ($0, $0.defaultWidth) })
}
}
enum ProcessCategory: String, CaseIterable, Identifiable {
case apps = "Apps" case apps = "Apps"
case background = "Background processes" case background = "Background processes"
case system = "macOS processes" case system = "macOS processes"
@@ -35,12 +90,27 @@ struct ProcessesView: View {
@AppStorage("processSortAscending") private var ascending = false @AppStorage("processSortAscending") private var ascending = false
@AppStorage("processGroupByType") private var groupByType = true @AppStorage("processGroupByType") private var groupByType = true
@AppStorage("processResourceDisplayMode") private var resourceDisplayMode: ResourceDisplayMode = .values @AppStorage("processResourceDisplayMode") private var resourceDisplayMode: ResourceDisplayMode = .values
@AppStorage("processColumnWidths") private var columnWidthsStorage = ""
@State private var columnWidths = ProcessColumn.defaultWidths
@State private var groupsFrozenForResize: [ProcessGroup]?
@State private var isResizingColumn = false
@State private var terminationRequest: TerminationRequest? @State private var terminationRequest: TerminationRequest?
@State private var groupTerminationRequest: ProcessGroup? @State private var groupTerminationRequest: ProcessGroup?
@State private var inspectedProcess: ProcessRecord? @State private var inspectedProcess: ProcessRecord?
@AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
private var grouped: [ProcessGroup] { private var grouped: [ProcessGroup] {
ProcessGrouping.groups(from: monitor.processes, searchText: searchText).sorted { lhs, rhs in let groups = ProcessGrouping.filtered(monitor.processGroups, 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 let ordering: ComparisonResult
switch sort { switch sort {
case .name: case .name:
@@ -65,69 +135,43 @@ struct ProcessesView: View {
} }
} }
private var displayedGroups: [ProcessGroup] { groupsFrozenForResize ?? grouped }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") { PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") {
HStack(spacing: 10) {
if monitor.isPaused { if monitor.isPaused {
Label("Paused", systemImage: "pause.fill") Label("Paused", systemImage: "pause.fill")
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
Button {
previewPaneVisible.toggle()
} label: {
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
}
.help(previewPaneVisible ? "Hide process preview" : "Show process preview")
}
} }
ResourceSummaryBar(snapshot: monitor.snapshot) ResourceSummaryBar(snapshot: monitor.snapshot)
ProcessTableHeader( processList
sort: $sort, .frame(maxWidth: .infinity, maxHeight: .infinity)
ascending: $ascending,
groupByType: $groupByType,
resourceDisplayMode: $resourceDisplayMode
)
ScrollView {
LazyVStack(spacing: 0) {
if groupByType {
ForEach(ProcessCategory.allCases) { category in
let categoryGroups = grouped.filter { $0.category == category }
if !categoryGroups.isEmpty {
ProcessCategoryHeader(
category: category,
count: categoryGroups.count,
isExpanded: !collapsedCategories.contains(category) || !searchText.isEmpty
) {
if collapsedCategories.contains(category) {
collapsedCategories.remove(category)
} else {
collapsedCategories.insert(category)
} }
} .inspector(isPresented: $previewPaneVisible) {
ProcessPreviewPane(
if !collapsedCategories.contains(category) || !searchText.isEmpty { process: previewProcess,
ForEach(categoryGroups) { group in onClose: { previewPaneVisible = false },
processGroup(group) onShowDetails: onShowDetails,
} onShowProperties: { inspectedProcess = $0 },
} onEndTask: { process in
}
}
} else {
ForEach(grouped) { group in
processGroup(group)
}
}
}
.padding(.leading, 10)
}
Divider()
HStack {
UpdateStatus()
Spacer()
Button("End task") {
if let group = grouped.first(where: { $0.id == selectedGroupID }) { if let group = grouped.first(where: { $0.id == selectedGroupID }) {
groupTerminationRequest = group groupTerminationRequest = group
} else if let process = monitor.processes.first(where: { $0.pid == selectedPID }) { } else {
terminationRequest = .init(process: process, kind: .normal) terminationRequest = .init(process: process, kind: .normal)
} }
} }
.buttonStyle(.borderedProminent) )
.disabled(selectedPID == nil && selectedGroupID == nil) .inspectorColumnWidth(min: 250, ideal: 320, max: 480)
}
.padding(12)
} }
.confirmationDialog( .confirmationDialog(
"\(terminationRequest?.kind.title ?? "End task"): \(terminationRequest?.process.displayName ?? "this task")?", "\(terminationRequest?.kind.title ?? "End task"): \(terminationRequest?.process.displayName ?? "this task")?",
@@ -162,6 +206,126 @@ struct ProcessesView: View {
.sheet(item: $inspectedProcess) { process in .sheet(item: $inspectedProcess) { process in
ProcessPropertiesView(process: process) ProcessPropertiesView(process: process)
} }
.onAppear(perform: restoreColumnWidths)
}
private var processList: some View {
let visibleGroups = displayedGroups
return processList(groups: visibleGroups)
}
private func processList(groups visibleGroups: [ProcessGroup]) -> some View {
VStack(spacing: 0) {
ScrollView(.horizontal) {
VStack(spacing: 0) {
ProcessTableHeader(
sort: $sort,
ascending: $ascending,
groupByType: $groupByType,
resourceDisplayMode: $resourceDisplayMode,
columnWidths: $columnWidths,
onResizeStarted: {
isResizingColumn = true
if groupsFrozenForResize == nil { groupsFrozenForResize = grouped }
},
onResizeEnded: {
persistColumnWidths()
isResizingColumn = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
if !isResizingColumn { groupsFrozenForResize = nil }
}
}
)
ScrollView {
LazyVStack(spacing: 0) {
if groupByType {
ForEach(ProcessCategory.allCases) { category in
let categoryGroups = visibleGroups.filter { $0.category == category }
if !categoryGroups.isEmpty {
ProcessCategoryHeader(
category: category,
count: categoryGroups.count,
isExpanded: !collapsedCategories.contains(category) || !searchText.isEmpty
) {
if collapsedCategories.contains(category) {
collapsedCategories.remove(category)
} else {
collapsedCategories.insert(category)
}
}
if !collapsedCategories.contains(category) || !searchText.isEmpty {
ForEach(categoryGroups) { group in
processGroup(group)
}
}
}
}
} else {
ForEach(visibleGroups) { group in
processGroup(group)
}
}
}
}
}
.frame(width: processTableWidth, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
if visibleGroups.isEmpty {
ProcessInventoryPlaceholder(isLoading: monitor.lastUpdated == nil, searchText: searchText)
}
}
Divider()
HStack {
UpdateStatus()
Spacer()
Button("End task") {
if let group = grouped.first(where: { $0.id == selectedGroupID }) {
groupTerminationRequest = group
} else if let process = monitor.processes.first(where: { $0.pid == selectedPID }) {
terminationRequest = .init(process: process, kind: .normal)
}
}
.buttonStyle(.bordered)
.tint(.red)
.disabled(selectedPID == nil && selectedGroupID == nil)
}
.padding(12)
}
}
private var previewProcess: ProcessRecord? {
if let selectedPID {
return monitor.processes.first { $0.pid == selectedPID }
}
if let selectedGroupID {
return grouped.first { $0.id == selectedGroupID }?.primary
}
return nil
}
private var processTableWidth: CGFloat {
ProcessColumn.allCases.reduce(0) { $0 + (columnWidths[$1] ?? $1.defaultWidth) }
+ CGFloat(ProcessColumn.allCases.count - 1) * 12
+ 32
}
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 ProcessColumn.allCases {
guard let value = stored[column.rawValue] else { continue }
columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, CGFloat(value)))
}
}
private func persistColumnWidths() {
let stored = Dictionary(uniqueKeysWithValues: columnWidths.map { ($0.key.rawValue, Double($0.value)) })
guard let data = try? JSONEncoder().encode(stored),
let value = String(data: data, encoding: .utf8) else { return }
columnWidthsStorage = value
} }
private func expansionBinding(for group: ProcessGroup) -> Binding<Bool> { private func expansionBinding(for group: ProcessGroup) -> Binding<Bool> {
@@ -178,33 +342,34 @@ struct ProcessesView: View {
private func processGroup(_ group: ProcessGroup) -> some View { private func processGroup(_ group: ProcessGroup) -> some View {
if group.processes.count > 1 { if group.processes.count > 1 {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 0) {
Button {
let binding = expansionBinding(for: group)
binding.wrappedValue.toggle()
} label: {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.rotationEffect(.degrees(expansionBinding(for: group).wrappedValue ? 90 : 0))
.frame(width: 16, height: 48)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
.accessibilityLabel(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
ProcessGroupRow( ProcessGroupRow(
group: group, group: group,
selected: selectedGroupID == group.id, selected: selectedGroupID == group.id,
displayMode: resourceDisplayMode, displayMode: resourceDisplayMode,
leadingPadding: 0 columnWidths: columnWidths,
isExpanded: expansionBinding(for: group),
onToggleExpansion: {
let binding = expansionBinding(for: group)
binding.wrappedValue.toggle()
}
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
selectedGroupID = group.id selectedGroupID = group.id
selectedPID = nil 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 { .contextMenu {
ProcessGroupContextMenu( ProcessGroupContextMenu(
group: group, group: group,
@@ -213,7 +378,6 @@ struct ProcessesView: View {
onShowProperties: { inspectedProcess = $0 } onShowProperties: { inspectedProcess = $0 }
) )
} }
}
if expansionBinding(for: group).wrappedValue { if expansionBinding(for: group).wrappedValue {
ForEach(group.processes.sorted { $0.pid < $1.pid }) { process in ForEach(group.processes.sorted { $0.pid < $1.pid }) { process in
@@ -227,12 +391,32 @@ struct ProcessesView: View {
} }
private func processRow(_ process: ProcessRecord, isChild: Bool) -> some View { private func processRow(_ process: ProcessRecord, isChild: Bool) -> some View {
ProcessRow(process: process, selected: selectedPID == process.pid, isChild: isChild, displayMode: resourceDisplayMode) ProcessRow(
process: process,
selected: selectedPID == process.pid,
isChild: isChild,
displayMode: resourceDisplayMode,
columnWidths: columnWidths
)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
selectedPID = process.pid selectedPID = process.pid
selectedGroupID = nil 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 { .contextMenu {
ProcessContextMenu( ProcessContextMenu(
process: process, process: process,
@@ -272,7 +456,7 @@ private struct ProcessCategoryHeader: View {
} }
} }
private struct ProcessGroup: Identifiable { struct ProcessGroup: Identifiable {
let id: String let id: String
let name: String let name: String
let appPath: String? let appPath: String?
@@ -301,7 +485,11 @@ private struct ProcessGroup: Identifiable {
} }
} }
private enum ProcessGrouping { @MainActor
enum ProcessGrouping {
// Cache both hits and misses; executable paths are immutable keys. Bound
// storage so long monitoring sessions don't retain every historical path.
private static var appPaths: [String: String] = [:]
static func groups(from processes: [ProcessRecord], searchText: String) -> [ProcessGroup] { static func groups(from processes: [ProcessRecord], searchText: String) -> [ProcessGroup] {
let foregroundPIDs = Set(NSWorkspace.shared.runningApplications let foregroundPIDs = Set(NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular } .filter { $0.activationPolicy == .regular }
@@ -309,13 +497,22 @@ private enum ProcessGrouping {
let grouped = Dictionary(grouping: processes) { process -> String in let grouped = Dictionary(grouping: processes) { process -> String in
appPath(for: process.executablePath) ?? "pid:\(process.pid)" appPath(for: process.executablePath) ?? "pid:\(process.pid)"
} }
return grouped.compactMap { key, members in let groups = grouped.map { key, members in
let path = key.hasPrefix("pid:") ? nil : key let path = key.hasPrefix("pid:") ? nil : key
let name = path.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent } let name = path.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
?? members[0].displayName ?? members[0].displayName
let category = category(for: members, appPath: path, foregroundPIDs: foregroundPIDs) let category = category(for: members, appPath: path, foregroundPIDs: foregroundPIDs)
let group = ProcessGroup(id: key, name: name, appPath: path, processes: members, category: category) let group = ProcessGroup(id: key, name: name, appPath: path, processes: members, category: category)
guard !searchText.isEmpty else { return group } return group
}
return filtered(groups, searchText: searchText)
}
static func filtered(_ groups: [ProcessGroup], searchText: String) -> [ProcessGroup] {
guard !searchText.isEmpty else { return groups }
return groups.compactMap { group in
let name = group.name
let members = group.processes
let groupMatches = name.localizedCaseInsensitiveContains(searchText) let groupMatches = name.localizedCaseInsensitiveContains(searchText)
let matchingMembers = members.filter { let matchingMembers = members.filter {
$0.displayName.localizedCaseInsensitiveContains(searchText) $0.displayName.localizedCaseInsensitiveContains(searchText)
@@ -324,11 +521,11 @@ private enum ProcessGrouping {
} }
guard groupMatches || !matchingMembers.isEmpty else { return nil } guard groupMatches || !matchingMembers.isEmpty else { return nil }
return ProcessGroup( return ProcessGroup(
id: key, id: group.id,
name: name, name: name,
appPath: path, appPath: group.appPath,
processes: groupMatches ? members : matchingMembers, processes: groupMatches ? members : matchingMembers,
category: category category: group.category
) )
} }
} }
@@ -352,8 +549,12 @@ private enum ProcessGrouping {
} }
private static func appPath(for executablePath: String) -> String? { private static func appPath(for executablePath: String) -> String? {
guard let range = executablePath.range(of: ".app/Contents/", options: .caseInsensitive) else { return nil } if let cached = appPaths[executablePath] { return cached.isEmpty ? nil : cached }
return String(executablePath[..<range.lowerBound]) + ".app" let path = executablePath.range(of: ".app/Contents/", options: .caseInsensitive)
.map { String(executablePath[..<$0.lowerBound]) + ".app" }
if appPaths.count >= 4096 { appPaths.removeAll(keepingCapacity: true) }
appPaths[executablePath] = path ?? ""
return path
} }
} }
@@ -362,16 +563,24 @@ private struct ProcessTableHeader: View {
@Binding var ascending: Bool @Binding var ascending: Bool
@Binding var groupByType: Bool @Binding var groupByType: Bool
@Binding var resourceDisplayMode: ResourceDisplayMode @Binding var resourceDisplayMode: ResourceDisplayMode
@Binding var columnWidths: [ProcessColumn: CGFloat]
let onResizeStarted: () -> Void
let onResizeEnded: () -> Void
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
header("Name", field: .name).frame(maxWidth: .infinity, alignment: .leading) resizable(.name, alignment: .leading) { header("Name", field: .name, column: .name) }
header("PID", field: .pid).frame(width: 70, alignment: .trailing) resizable(.pid, alignment: .leading) { header("PID", field: .pid, column: .pid) }
Text("Status").frame(width: 80, alignment: .leading) resizable(.status, alignment: .leading) {
header("CPU", field: .cpu).frame(width: 86, alignment: .trailing) Text("Status")
header("Memory", field: .memory).frame(width: 110, alignment: .trailing) .lineLimit(1)
header("Disk", field: .disk).frame(width: 88, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .leading)
header("Network", field: .network).frame(width: 88, alignment: .trailing) .padding(.trailing, processNumericTextInset)
}
resizable(.cpu, alignment: .leading) { header("CPU", field: .cpu, column: .cpu) }
resizable(.memory, alignment: .leading) { header("Memory", field: .memory, column: .memory) }
resizable(.disk, alignment: .leading) { header("Disk", field: .disk, column: .disk) }
resizable(.network, alignment: .leading) { header("Network", field: .network, column: .network) }
} }
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -394,17 +603,107 @@ private struct ProcessTableHeader: View {
} }
} }
private func header(_ title: String, field: ProcessSort) -> some View { private func resizable<Content: View>(
Button { _ column: ProcessColumn,
groupByType = false alignment: Alignment,
if sort == field { ascending.toggle() } else { sort = field; ascending = false } @ViewBuilder content: @escaping () -> Content
} label: { ) -> some View {
HStack(spacing: 3) { ResizableProcessHeaderCell(
Text(title) width: Binding(
if sort == field { Image(systemName: ascending ? "chevron.up" : "chevron.down") } get: { columnWidths[column] ?? column.defaultWidth },
set: { columnWidths[column] = $0 }
),
minimumWidth: column.minimumWidth,
maximumWidth: column.maximumWidth,
alignment: alignment,
onResizeStarted: onResizeStarted,
onResizeEnded: onResizeEnded,
content: content
)
}
private func header(
_ title: String,
field: ProcessSort,
column: ProcessColumn
) -> some View {
HStack(spacing: 0) {
Text(title).lineLimit(1)
Spacer(minLength: 4)
if !groupByType && sort == field {
Color.clear.frame(width: 6, height: 0)
Image(systemName: ascending ? "chevron.up" : "chevron.down")
.frame(width: 10)
}
Color.clear.frame(width: processNumericTextInset, height: 0)
}
.frame(width: columnWidths[column] ?? column.defaultWidth)
.contentShape(Rectangle())
.onTapGesture {
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(
ProcessSortCycleState(field: sort, ascending: ascending, grouped: groupByType)
.accessibilityValue(for: field)
)
} }
} }
.buttonStyle(.plain)
private struct ResizableProcessHeaderCell<Content: View>: View {
@Binding var width: CGFloat
let minimumWidth: CGFloat
let maximumWidth: CGFloat
let alignment: Alignment
let onResizeStarted: () -> Void
let onResizeEnded: () -> Void
@ViewBuilder let content: () -> Content
@State private var dragStartWidth: CGFloat?
@State private var proposedWidth: CGFloat?
@State private var isHandleHovered = false
var body: some View {
content()
.frame(width: width, alignment: alignment)
.clipped()
.overlay(alignment: .trailing) {
ZStack {
Rectangle()
.fill(isHandleHovered || dragStartWidth != nil ? Color.accentColor : Color.secondary.opacity(0.25))
.frame(width: isHandleHovered || dragStartWidth != nil ? 2 : 1, height: 22)
}
.frame(width: 10, height: 34)
.offset(x: (proposedWidth ?? width) - width + 5)
.contentShape(Rectangle())
.onHover { hovering in
isHandleHovered = hovering
(hovering ? NSCursor.resizeLeftRight : NSCursor.arrow).set()
}
.highPriorityGesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if dragStartWidth == nil {
dragStartWidth = width
onResizeStarted()
}
let proposed = (dragStartWidth ?? width) + value.translation.width
proposedWidth = min(maximumWidth, max(minimumWidth, proposed))
}
.onEnded { _ in
if let proposedWidth { width = proposedWidth }
proposedWidth = nil
dragStartWidth = nil
onResizeEnded()
}
)
.help("Drag to resize column")
}
} }
} }
@@ -414,6 +713,7 @@ private struct ProcessRow: View {
let selected: Bool let selected: Bool
let isChild: Bool let isChild: Bool
let displayMode: ResourceDisplayMode let displayMode: ResourceDisplayMode
let columnWidths: [ProcessColumn: CGFloat]
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() } private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
@@ -424,29 +724,37 @@ private struct ProcessRow: View {
Image(systemName: "arrow.turn.down.right") Image(systemName: "arrow.turn.down.right")
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.frame(width: 12) .frame(width: 16)
} else {
Color.clear.frame(width: 16)
} }
ProcessIcon(name: process.displayName, path: process.executablePath) ProcessIcon(name: process.displayName, path: process.executablePath, size: 26)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(process.displayName).lineLimit(1) Text(process.displayName).lineLimit(1).truncationMode(.tail)
Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1) Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
}
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
Text("\(process.pid)").monospacedDigit().frame(width: 70, alignment: .trailing) }
Text(process.state.hasPrefix("R") ? "Running" : "").frame(width: 80, alignment: .leading) .frame(width: width(.name), alignment: .leading)
.clipped()
Text("\(process.pid)").monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
.padding(.trailing, processNumericTextInset)
.frame(width: width(.pid), alignment: .trailing).clipped()
Text(process.state.hasPrefix("R") ? "Running" : "").lineLimit(1).truncationMode(.tail)
.frame(width: width(.status), alignment: .leading).clipped()
HeatCell(text: Formatters.percent(process.cpu), intensity: process.cpu / 100) HeatCell(text: Formatters.percent(process.cpu), intensity: process.cpu / 100)
.frame(width: 86, alignment: .trailing) .frame(width: width(.cpu), alignment: .trailing)
HeatCell(text: memoryText, intensity: process.memoryPercent / 30) HeatCell(text: memoryText, intensity: process.memoryPercent / 30)
.frame(width: 110, alignment: .trailing) .frame(width: width(.memory), alignment: .trailing)
.help(memoryHelp)
HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000) HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000)
.frame(width: 88, alignment: .trailing) .frame(width: width(.disk), alignment: .trailing)
HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000) HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000)
.frame(width: 88, alignment: .trailing) .frame(width: width(.network), alignment: .trailing)
} }
.font(.callout) .font(.callout)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(height: 48) .frame(minHeight: 42)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) } .overlay(alignment: .bottom) { Divider().opacity(0.45) }
} }
@@ -455,6 +763,14 @@ private struct ProcessRow: View {
displayMode == .percentages ? Formatters.percent(process.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)) displayMode == .percentages ? Formatters.percent(process.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))
} }
private var memoryHelp: String {
"Resident memory: \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))\(Formatters.percent(process.memoryPercent)) of physical memory"
}
private func width(_ column: ProcessColumn) -> CGFloat {
columnWidths[column] ?? column.defaultWidth
}
private var diskText: String { private var diskText: String {
displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal) displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal)
} }
@@ -474,36 +790,55 @@ private struct ProcessGroupRow: View {
let group: ProcessGroup let group: ProcessGroup
let selected: Bool let selected: Bool
let displayMode: ResourceDisplayMode let displayMode: ResourceDisplayMode
let leadingPadding: CGFloat let columnWidths: [ProcessColumn: CGFloat]
@Binding var isExpanded: Bool
let onToggleExpansion: () -> Void
private var activity: ProcessActivityRate { group.activity(using: monitor.processActivity) } private var activity: ProcessActivityRate { group.activity(using: monitor.processActivity) }
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
HStack(spacing: 10) { HStack(spacing: 10) {
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath) Button(action: onToggleExpansion) {
VStack(alignment: .leading, spacing: 2) { Image(systemName: "chevron.right")
Text("\(group.name) (\(group.processes.count))").lineLimit(1) .font(.caption.weight(.semibold))
Text("Application group").font(.caption).foregroundStyle(.secondary) .rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 16, height: 38)
.contentShape(Rectangle())
} }
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
.accessibilityLabel(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath, size: 26)
VStack(alignment: .leading, spacing: 2) {
Text(group.name).lineLimit(1).truncationMode(.tail)
Text("\(group.processes.count) processes")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
Text("").frame(width: 70, alignment: .trailing).foregroundStyle(.secondary) }
.frame(width: width(.name), alignment: .leading)
.clipped()
Text("").lineLimit(1)
.padding(.trailing, processNumericTextInset)
.frame(width: width(.pid), alignment: .trailing).foregroundStyle(.secondary).clipped()
Text(group.processes.contains(where: { $0.state.hasPrefix("R") }) ? "Running" : "") Text(group.processes.contains(where: { $0.state.hasPrefix("R") }) ? "Running" : "")
.frame(width: 80, alignment: .leading) .lineLimit(1).truncationMode(.tail)
.frame(width: width(.status), alignment: .leading).clipped()
HeatCell(text: Formatters.percent(group.cpu), intensity: group.cpu / 100) HeatCell(text: Formatters.percent(group.cpu), intensity: group.cpu / 100)
.frame(width: 86, alignment: .trailing) .frame(width: width(.cpu), alignment: .trailing)
HeatCell(text: memoryText, intensity: group.memoryPercent / 30) HeatCell(text: memoryText, intensity: group.memoryPercent / 30)
.frame(width: 110, alignment: .trailing) .frame(width: width(.memory), alignment: .trailing)
.help(memoryHelp)
HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000) HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000)
.frame(width: 88, alignment: .trailing) .frame(width: width(.disk), alignment: .trailing)
HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000) HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000)
.frame(width: 88, alignment: .trailing) .frame(width: width(.network), alignment: .trailing)
} }
.font(.callout.weight(.medium)) .font(.callout.weight(.medium))
.padding(.leading, leadingPadding) .padding(.horizontal, 16)
.padding(.trailing, 16) .frame(minHeight: 42)
.frame(height: 48)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) } .overlay(alignment: .bottom) { Divider().opacity(0.45) }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
@@ -515,6 +850,14 @@ private struct ProcessGroupRow: View {
displayMode == .percentages ? Formatters.percent(group.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(group.residentBytes)) displayMode == .percentages ? Formatters.percent(group.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(group.residentBytes))
} }
private var memoryHelp: String {
"Resident memory: \(Formatters.bytes.string(fromByteCount: Int64(group.residentBytes)))\(Formatters.percent(group.memoryPercent)) of physical memory"
}
private func width(_ column: ProcessColumn) -> CGFloat {
columnWidths[column] ?? column.defaultWidth
}
private var diskText: String { private var diskText: String {
displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal) displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal)
} }
@@ -537,9 +880,6 @@ private struct ProcessGroupContextMenu: View {
let onShowProperties: (ProcessRecord) -> Void let onShowProperties: (ProcessRecord) -> Void
var body: some View { 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") { Button("Efficiency mode", systemImage: "leaf") {
group.processes.forEach { monitor.setPriority(10, for: $0) } group.processes.forEach { monitor.setPriority(10, for: $0) }
} }
@@ -581,6 +921,11 @@ private struct ProcessGroupContextMenu: View {
} }
Button("Properties") { onShowProperties(group.primary) } 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) { private func setPriority(_ value: Int) {
@@ -610,10 +955,14 @@ private struct HeatCell: View {
var body: some View { var body: some View {
Text(text) Text(text)
.monospacedDigit() .monospacedDigit()
.padding(.vertical, 9) .lineLimit(1)
.padding(.horizontal, 7) .minimumScaleFactor(0.72)
.truncationMode(.tail)
.padding(.vertical, 6)
.padding(.horizontal, processNumericTextInset)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
.background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32))) .clipped()
.background(Color.accentColor.opacity(min(0.22, max(0, intensity) * 0.22)))
.clipShape(RoundedRectangle(cornerRadius: 4)) .clipShape(RoundedRectangle(cornerRadius: 4))
} }
} }
@@ -621,6 +970,7 @@ private struct HeatCell: View {
struct ProcessIcon: View { struct ProcessIcon: View {
let name: String let name: String
var path: String? = nil var path: String? = nil
var size: CGFloat = 30
var body: some View { var body: some View {
Group { Group {
@@ -633,12 +983,12 @@ struct ProcessIcon: View {
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.fill(color.opacity(0.16)) .fill(color.opacity(0.16))
Image(systemName: symbol) Image(systemName: symbol)
.font(.system(size: 14, weight: .medium)) .font(.system(size: size * 0.47, weight: .medium))
.foregroundStyle(color) .foregroundStyle(color)
} }
} }
} }
.frame(width: 30, height: 30) .frame(width: size, height: size)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
+90
View File
@@ -0,0 +1,90 @@
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()
@State private var statusBar = StatusBarController()
@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
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
var body: some Scene {
WindowGroup("puter", id: "main") {
ContentView()
.environment(monitor)
.environmentObject(updates)
.preferredColorScheme(appearance.colorScheme)
.frame(minWidth: 820, minHeight: 560)
.task { monitor.start() }
.background(StatusBarBridge(controller: statusBar, monitor: monitor, visible: showMenuBarMonitor, value: menuBarValue))
}
.defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.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)
}
CommandMenu("Options") {
Button("Show menu-bar monitor") { statusBar.togglePanel() }
.keyboardShortcut("m", modifiers: [.command, .shift])
.disabled(!showMenuBarMonitor)
Divider()
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])
}
}
}
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"
case .batteryPower:
monitor.hardware.battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "— W"
case .chargerInput:
monitor.hardware.battery.hasAdapterInput
? String(format: "%.1f W", monitor.hardware.battery.adapterInputWatts) : "— W"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import AppKit
import SwiftUI
/// A quiet wordmark, not a button: the icon supplies the glass depth.
struct PuterBrand: View {
var body: some View {
HStack(spacing: 8) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.accessibilityHidden(true)
Text("puter")
.font(.system(size: 20, weight: .semibold, design: .rounded))
.tracking(-0.4)
.foregroundStyle(.primary)
}
.padding(.horizontal, 16)
.frame(height: PuterLayout.toolbarControlHeight)
.fixedSize()
.accessibilityElement(children: .ignore)
.accessibilityLabel("puter")
.accessibilityAddTraits(.isHeader)
}
}
+78
View File
@@ -0,0 +1,78 @@
import SwiftUI
/// Shared geometry for the window shell and data pages. Colors and controls
/// follow the system appearance, including accessibility contrast settings.
enum PuterLayout {
static let pageInset: CGFloat = 20
static let sectionSpacing: CGFloat = 16
static let controlSpacing: CGFloat = 8
static let compactSidebarWidth: CGFloat = 84
static let expandedSidebarWidth: CGFloat = 210
static let sidebarRowHeight: CGFloat = 28
static let toolbarControlHeight: CGFloat = 36
}
enum PuterAppearance: String, CaseIterable, Identifiable {
case system = "System"
case light = "Light"
case dark = "Dark"
var id: String { rawValue }
var colorScheme: ColorScheme? {
switch self {
case .system: nil
case .light: .light
case .dark: .dark
}
}
}
/// A single navigation surface; data views deliberately do not use glass.
struct NavigationGlass: ViewModifier {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder func body(content: Content) -> some View {
if reduceTransparency {
content.background(.background, in: .rect(cornerRadius: 16))
} else if #available(macOS 26, *) {
content.glassEffect(.regular, in: .rect(cornerRadius: 16))
} else {
content.background(.regularMaterial, in: .rect(cornerRadius: 16))
}
}
}
struct GlassActions<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
if #available(macOS 26, *) {
GlassEffectContainer(spacing: 8) {
content()
.buttonStyle(.glass)
.buttonBorderShape(.capsule)
.controlSize(.large)
.menuStyle(.button)
}
} else {
content().buttonStyle(.bordered)
}
}
}
/// Preserve native menu semantics without the legacy pop-up button bezel.
struct GlassMenuSurface: ViewModifier {
@ViewBuilder func body(content: Content) -> some View {
if #available(macOS 26, *) {
content
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.horizontal, 12)
.frame(minHeight: 32)
.glassEffect(.regular.interactive(), in: .capsule)
} else {
content.menuStyle(.button)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import Foundation
/// Only counters with a live consumer need periodic samples. Inventory changes
/// continue to arrive through workspace and power-source notifications.
struct SamplingDemand: Equatable {
let processInventory: Bool
let hardware: Bool
let services: Bool
let telemetry: Bool
let interval: Duration?
init(active: Bool, section: TaskSection, speed: UpdateSpeed,
recording: Bool, alerts: Bool, menuBar: Bool, powerMenu: Bool, menuPresented: Bool = false) {
processInventory = recording || (active && section.usesLiveProcessInventory)
hardware = recording || menuPresented || (menuBar && powerMenu)
|| (active && [.hardware, .performance, .energy].contains(section))
services = active && section == .services
telemetry = processInventory || alerts || menuPresented || (menuBar && !powerMenu)
if speed == .paused || !(telemetry || hardware || services) {
interval = nil
} else if recording || (active && section.usesLiveProcessInventory) {
interval = speed.interval
} else if services {
interval = .seconds(3)
} else if active || alerts || hardware {
interval = .seconds(5)
} else {
interval = .seconds(10)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import SwiftUI
struct SearchGlassSurface: ViewModifier {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder func body(content: Content) -> some View {
if reduceTransparency {
content.background(.background, in: .capsule)
.overlay { Capsule().strokeBorder(.secondary.opacity(0.3)) }
} else if #available(macOS 26, *) {
content.glassEffect(.regular.interactive(), in: .capsule)
} else {
content.background(.regularMaterial, in: .capsule)
.overlay { Capsule().strokeBorder(.secondary.opacity(0.2)) }
}
}
}
@@ -66,7 +66,7 @@ struct AppHistoryView: View {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
Button("Reset", role: .destructive) { monitor.resetAppHistory() } Button("Reset", role: .destructive) { monitor.resetAppHistory() }
} message: { } 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) } .sheet(item: $inspectedUsage) { AppUsagePropertiesView(usage: $0) }
} }
@@ -173,24 +173,47 @@ private struct AppUsagePropertiesView: View {
struct StartupAppsView: View { struct StartupAppsView: View {
@Environment(SystemMonitor.self) private var monitor @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? @State private var inspectedItem: StartupItem?
var body: some View { var body: some View {
VStack(spacing: 0) { 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 { HStack {
Button("Refresh", systemImage: "arrow.clockwise") { items = StartupScanner.scan() } Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() }
Button("Open Login Items") { .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() 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( EmptyState(
icon: "rectangle.stack.badge.play", icon: "rectangle.stack.badge.play",
title: "No startup items found", 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 { } else {
Table(items) { Table(items) {
@@ -223,6 +246,7 @@ struct StartupAppsView: View {
.sheet(item: $inspectedItem) { item in .sheet(item: $inspectedItem) { item in
StartupItemPropertiesView(item: item, impact: startupImpact(for: item)) StartupItemPropertiesView(item: item, impact: startupImpact(for: item))
} }
.task { refreshItems(includeLoginItems: false) }
} }
private func openLoginItemsSettings() { 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 { private func startupImpact(for item: StartupItem) -> StartupImpact {
let candidates = monitor.processes.filter { process in let candidates = monitor.processes.filter { process in
guard !item.executablePath.isEmpty else { return false } 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 loginItem
case launchAgent case launchAgent
var title: String { self == .loginItem ? "Login item" : "Launch agent" } var title: String { self == .loginItem ? "Login item" : "Launch agent" }
} }
private struct StartupItem: Identifiable { private struct StartupItem: Identifiable, Sendable {
let id: String let id: String
let name: String let name: String
let publisher: String let publisher: String
@@ -585,13 +622,86 @@ private struct StartupItem: Identifiable {
var enabled: Bool 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 { private enum StartupScanner {
static func scan() -> [StartupItem] { static func scan(includeLoginItems: Bool) -> [StartupItem] {
let home = FileManager.default.homeDirectoryForCurrentUser.path let home = FileManager.default.homeDirectoryForCurrentUser.path
let directories = ["\(home)/Library/LaunchAgents", "/Library/LaunchAgents"] let directories = ["\(home)/Library/LaunchAgents", "/Library/LaunchAgents"]
let disabled = disabledServices() let disabled = disabledServices()
let agents = directories.flatMap { scanDirectory($0, disabled: disabled) } let agents = directories.flatMap { scanDirectory($0, disabled: disabled) }
let loginItems = sessionLoginItems() let loginItems = includeLoginItems ? sessionLoginItems() : []
return (loginItems + agents).sorted { return (loginItems + agents).sorted {
if $0.kind != $1.kind { return $0.kind == .loginItem } if $0.kind != $1.kind { return $0.kind == .loginItem }
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
@@ -622,42 +732,33 @@ private enum StartupScanner {
} }
private static func sessionLoginItems() -> [StartupItem] { private static func sessionLoginItems() -> [StartupItem] {
guard let unmanagedList = LSSharedFileListCreate( let process = Process()
nil, let pipe = Pipe()
"com.apple.LSSharedFileList.SessionLoginItems" as CFString, process.executableURL = URL(fileURLWithPath: "/usr/bin/sfltool")
nil process.arguments = ["dumpbtm"]
) else { return [] } process.standardOutput = pipe
let list = unmanagedList.takeRetainedValue() process.standardError = FileHandle.nullDevice
guard let unmanagedSnapshot = LSSharedFileListCopySnapshot(list, nil) else { return [] } do {
let snapshot = unmanagedSnapshot.takeRetainedValue() try process.run()
var seen: Set<String> = [] let output = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
var items: [StartupItem] = [] process.waitUntilExit()
let flags = UInt32(kLSSharedFileListNoUserInteraction | kLSSharedFileListDoNotMountVolumes) guard process.terminationStatus == 0 else { return [] }
for index in 0..<CFArrayGetCount(snapshot) { return BackgroundTaskRegistryParser.parse(output).map { item in
let raw = CFArrayGetValueAtIndex(snapshot, index) StartupItem(
let item = unsafeBitCast(raw, to: LSSharedFileListItem.self) id: "login:\(item.path)",
guard let unmanagedURL = LSSharedFileListItemCopyResolvedURL(item, flags, nil) else { continue } name: item.name,
let url = unmanagedURL.takeRetainedValue() as URL publisher: item.developer == "Unknown"
let path = url.standardizedFileURL.path ? publisherName(label: item.bundleIdentifier, command: item.path)
guard seen.insert(path).inserted else { continue } : item.developer,
let bundle = Bundle(url: url) sourcePath: item.path,
let identifier = bundle?.bundleIdentifier ?? path executablePath: item.path,
let name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) serviceIdentifier: item.bundleIdentifier,
?? (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, kind: .loginItem,
isDirectlyManageable: false, isDirectlyManageable: false,
enabled: true enabled: item.enabled
)) )
} }
return items } catch { return [] }
} }
private static func disabledServices() -> Set<String> { private static func disabledServices() -> Set<String> {
@@ -730,14 +831,31 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
case .nice: "Nice" case .nice: "Nice"
} }
} }
var width: CGFloat { var defaultWidth: CGFloat {
switch self { switch self {
case .name: 230 case .name: 260
case .user: 120 case .user: 140
case .architecture: 95 case .architecture: 125
case .cpuTime, .memory, .disk, .network, .elapsed: 100 case .state: 105
case .parentPID: 80 case .cpuTime, .memory, .disk, .network, .elapsed: 112
default: 72 case .parentPID: 92
default: 78
}
}
var minimumWidth: CGFloat {
switch self {
case .name: 170
case .user: 90
case .cpuTime, .memory, .disk, .network, .elapsed: 82
case .architecture: 110
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 { var defaultVisible: Bool {
@@ -749,11 +867,14 @@ struct DetailsView: View {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
let searchText: String let searchText: String
@Binding var selection: Int32? @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 terminationRequest: TerminationRequest?
@State private var inspectedProcess: ProcessRecord? @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("detailSortColumn") private var sortColumn: DetailColumn = .pid
@AppStorage("detailSortAscending") private var ascending = true @AppStorage("detailSortAscending") private var ascending = true
@AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
private var visibleColumns: [DetailColumn] { private var visibleColumns: [DetailColumn] {
let stored = Set(visibleColumnStorage.split(separator: ",").map(String.init)) let stored = Set(visibleColumnStorage.split(separator: ",").map(String.init))
@@ -763,7 +884,22 @@ struct DetailsView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
PageHeader(title: "Details", subtitle: "Technical information for running processes") { PageHeader(title: "Details", subtitle: "Technical information for running processes") {
HStack(spacing: 10) {
Button {
previewPaneVisible.toggle()
} label: {
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
}
Button("Properties", systemImage: "info.circle") {
inspectedProcess = selectedProcess
}
.disabled(selectedProcess == nil)
Menu("Columns", systemImage: "rectangle.split.3x1") { Menu("Columns", systemImage: "rectangle.split.3x1") {
Button("Show essential columns") {
visibleColumnStorage = [DetailColumn.name, .pid, .user, .cpu, .memory]
.map(\.rawValue).joined(separator: ",")
}
Divider()
ForEach(DetailColumn.allCases) { column in ForEach(DetailColumn.allCases) { column in
Toggle(column.title, isOn: columnBinding(column)) Toggle(column.title, isOn: columnBinding(column))
.disabled(column == .name || column == .pid) .disabled(column == .name || column == .pid)
@@ -771,24 +907,56 @@ struct DetailsView: View {
Divider() Divider()
Button("Reset columns") { Button("Reset columns") {
visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",") visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
persistColumnWidths()
} }
} }
.modifier(GlassMenuSurface())
.fixedSize()
} }
}
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]) { ScrollView([.horizontal, .vertical]) {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section { Section {
ForEach(filtered) { process in ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
detailRow(process) detailRow(process, index: index)
} }
} header: { } header: {
detailHeader detailHeader
} }
} }
.frame(minWidth: visibleColumns.reduce(0) { $0 + $1.width + 12 }) .frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
}
.overlay {
if filtered.isEmpty {
ProcessInventoryPlaceholder(isLoading: monitor.lastUpdated == nil, searchText: searchText)
} }
} }
.terminationConfirmation($terminationRequest) }
.sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) } .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
private var filtered: [ProcessRecord] { private var filtered: [ProcessRecord] {
@@ -796,8 +964,6 @@ struct DetailsView: View {
searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText) searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText)
|| $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText) || $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText)
}.sorted { lhs, rhs in }.sorted { lhs, rhs in
if lhs.pid == selection { return true }
if rhs.pid == selection { return false }
let result = comparison(lhs, rhs, column: sortColumn) let result = comparison(lhs, rhs, column: sortColumn)
if result == .orderedSame { return lhs.pid < rhs.pid } if result == .orderedSame { return lhs.pid < rhs.pid }
return ascending ? result == .orderedAscending : result == .orderedDescending return ascending ? result == .orderedAscending : result == .orderedDescending
@@ -805,42 +971,39 @@ struct DetailsView: View {
} }
private var detailHeader: some View { private var detailHeader: some View {
HStack(spacing: 12) { HStack(spacing: 0) {
ForEach(visibleColumns) { column in ForEach(visibleColumns) { column in
Button { detailHeaderCell(column)
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)
} }
} }
.font(.caption.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading)
.font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 12) .background(.regularMaterial)
.padding(.vertical, 9)
.background(.background)
.overlay(alignment: .bottom) { Divider() } .overlay(alignment: .bottom) { Divider() }
} }
private func detailRow(_ process: ProcessRecord) -> some View { private func detailRow(_ process: ProcessRecord, index: Int) -> some View {
HStack(spacing: 12) { HStack(spacing: 0) {
ForEach(visibleColumns) { column in ForEach(visibleColumns) { column in
detailCell(process, column: column) 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) .font(.callout)
.padding(.horizontal, 12) .frame(minHeight: 42)
.frame(height: 38) .background(rowBackground(process: process, index: index))
.background(selection == process.pid ? Color.accentColor.opacity(0.18) : Color.clear)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { selection = process.pid } .onTapGesture { selection = process.pid }
.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) } .overlay(alignment: .bottom) { Divider().opacity(0.4) }
.contextMenu { .contextMenu {
ProcessContextMenu( ProcessContextMenu(
@@ -855,7 +1018,18 @@ struct DetailsView: View {
@ViewBuilder @ViewBuilder
private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View { private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View {
switch column { switch column {
case .name: Text(process.displayName).lineLimit(1) case .name:
Button {
selection = process.pid
} label: {
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1).truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
case .pid: Text("\(process.pid)").monospacedDigit() case .pid: Text("\(process.pid)").monospacedDigit()
case .parentPID: Text("\(process.parentPID)").monospacedDigit() case .parentPID: Text("\(process.parentPID)").monospacedDigit()
case .user: Text(process.user).lineLimit(1) case .user: Text(process.user).lineLimit(1)
@@ -864,7 +1038,10 @@ struct DetailsView: View {
case .memory: Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit() case .memory: Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
case .disk: Text(Formatters.rate(monitor.processActivity[process.pid]?.diskTotal ?? 0)).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 .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 .elapsed: Text(process.elapsed).monospacedDigit()
case .threads: Text("\(process.threadCount)").monospacedDigit() case .threads: Text("\(process.threadCount)").monospacedDigit()
case .handles: Text("\(process.openFileCount)").monospacedDigit() case .handles: Text("\(process.openFileCount)").monospacedDigit()
@@ -874,6 +1051,119 @@ 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, size: 24)
VStack(alignment: .leading, spacing: 2) {
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
Text("PID \(process.pid)")
Text("CPU \(Formatters.percent(process.cpu))")
Text("Memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))")
}
.fixedSize(horizontal: true, vertical: false)
Text("PID \(process.pid)").fixedSize(horizontal: true, vertical: false)
}
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
Button("End task") { terminationRequest = .init(process: process, kind: .normal) }
.controlSize(.small)
Button("Properties") { inspectedProcess = process }
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else {
Text("\(filtered.count) processes").foregroundStyle(.secondary)
Spacer()
Text("Select a process to inspect it").foregroundStyle(.tertiary)
.lineLimit(1)
}
}
.font(.callout)
.padding(.horizontal, 14)
.frame(minHeight: 52)
}
.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> { private func columnBinding(_ column: DetailColumn) -> Binding<Bool> {
Binding( Binding(
get: { visibleColumns.contains(column) }, get: { visibleColumns.contains(column) },
@@ -911,6 +1201,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 { struct ServicesView: View {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
let searchText: String 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]
}
}
+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
struct StatusBarBridge: NSViewRepresentable {
@Environment(\.openWindow) private var openWindow
let controller: StatusBarController
let monitor: SystemMonitor
let visible: Bool
let value: String
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ view: NSView, context: Context) {
controller.configure(monitor: monitor, visible: visible, value: value) { openWindow(id: "main") }
}
}
+150
View File
@@ -0,0 +1,150 @@
import AppKit
import SwiftUI
import Observation
/// AppKit owns status-item click semantics and popover lifetime; SwiftUI owns content.
@MainActor
final class StatusBarController: NSObject, NSPopoverDelegate {
private var item: NSStatusItem?
private let popover = NSPopover()
private weak var monitor: SystemMonitor?
private var openMain: (() -> Void)?
private var observing = false
func configure(monitor: SystemMonitor, visible: Bool, value: String, openMain: @escaping () -> Void) {
self.monitor = monitor
self.openMain = openMain
if !observing {
observing = true
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: UserDefaults.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(menuTrackingBegan), name: NSMenu.didBeginTrackingNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(menuTrackingEnded), name: NSMenu.didEndTrackingNotification, object: nil)
observeReadings()
}
guard visible else {
popover.performClose(nil)
if let item { NSStatusBar.system.removeStatusItem(item) }
item = nil
return
}
if item == nil {
let status = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
status.autosaveName = "puter.monitor"
status.button?.target = self
status.button?.action = #selector(clicked)
status.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
status.button?.image = NSImage(systemSymbolName: "gauge.with.dots.needle.50percent", accessibilityDescription: "puter")
status.button?.imagePosition = .imageLeading
item = status
popover.behavior = .transient
popover.animates = false
popover.delegate = self
popover.contentViewController = NSHostingController(rootView:
MenuBarMonitorView(openMain: { [weak self] in self?.openMainWindow() }).environment(monitor))
}
if item?.button?.title != value { item?.button?.title = value }
item?.button?.toolTip = "puter · click for readings, right-click for options"
item?.button?.setAccessibilityLabel("puter monitor, \(value)")
}
@objc private func clicked() {
if NSApp.currentEvent?.type == .rightMouseUp { showOptions() }
else { togglePanel() }
}
func togglePanel() {
guard let button = item?.button else { return }
if popover.isShown { popover.performClose(nil) }
else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
private func showOptions() {
guard let item, let button = item.button, let monitor else { return }
popover.performClose(nil)
let menu = NSMenu()
let metrics = NSMenu()
for metric in MenuBarMetric.allCases {
let entry = NSMenuItem(title: metric.rawValue, action: #selector(selectMetric(_:)), keyEquivalent: "")
entry.target = self
entry.representedObject = metric.rawValue
entry.state = UserDefaults.standard.string(forKey: "menuBarMetric") == metric.rawValue
|| (UserDefaults.standard.string(forKey: "menuBarMetric") == nil && metric == .cpu) ? .on : .off
metrics.addItem(entry)
}
let metricRoot = menu.addItem(withTitle: "Menu bar reading", action: nil, keyEquivalent: "")
metricRoot.submenu = metrics
let speeds = NSMenu()
for speed in UpdateSpeed.allCases {
let entry = NSMenuItem(title: speed.rawValue, action: #selector(selectSpeed(_:)), keyEquivalent: "")
entry.target = self
entry.representedObject = speed.rawValue
entry.state = monitor.updateSpeed == speed ? .on : .off
speeds.addItem(entry)
}
menu.addItem(withTitle: "Update speed", action: nil, keyEquivalent: "").submenu = speeds
menu.addItem(.separator())
let open = menu.addItem(withTitle: "Open puter", action: #selector(openMainWindow), keyEquivalent: "")
open.target = self
item.menu = menu
button.performClick(nil)
item.menu = nil
}
@objc private func selectMetric(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String else { return }
UserDefaults.standard.set(raw, forKey: "menuBarMetric")
}
@objc private func selectSpeed(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String, let speed = UpdateSpeed(rawValue: raw) else { return }
monitor?.updateSpeed = speed
}
@objc private func openMainWindow() {
popover.performClose(nil)
openMain?()
NSApp.activate()
}
func popoverDidShow(_ notification: Notification) { monitor?.menuBarPresented = true }
func popoverDidClose(_ notification: Notification) { monitor?.menuBarPresented = false }
@objc private func menuTrackingBegan() {
// Native submenus extend outside the panel; don't dismiss their parent.
if popover.isShown { popover.behavior = .applicationDefined }
}
@objc private func menuTrackingEnded() {
popover.behavior = .transient
}
@objc private func preferencesChanged() {
guard let monitor, let openMain else { return }
configure(monitor: monitor,
visible: UserDefaults.standard.object(forKey: "showMenuBarMonitor") as? Bool ?? true,
value: reading(monitor), openMain: openMain)
}
private func reading(_ monitor: SystemMonitor) -> String {
switch MenuBarMetric(rawValue: UserDefaults.standard.string(forKey: "menuBarMetric") ?? "") ?? .cpu {
case .cpu: return Formatters.percent(monitor.snapshot.cpuPercent)
case .memory: return Formatters.percent(monitor.snapshot.memoryPercent)
case .network: return Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate)
case .power: return monitor.hardware.battery.hasSystemPower ? String(format: "%.0f W", monitor.hardware.battery.systemPowerWatts) : "— W"
case .batteryPower: return monitor.hardware.battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "— W"
case .chargerInput: return monitor.hardware.battery.hasAdapterInput ? String(format: "%.1f W", monitor.hardware.battery.adapterInputWatts) : "— W"
}
}
private func observeReadings() {
guard let monitor else { return }
withObservationTracking {
// Track both sources, even when the selected metric changes with no main window.
_ = monitor.snapshot
_ = monitor.hardware
let value = reading(monitor)
if item?.button?.title != value { item?.button?.title = value }
} onChange: { [weak self] in
Task { @MainActor in self?.observeReadings() }
}
}
}
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())
}
}
@@ -0,0 +1,12 @@
import SwiftUI
/// Own the outer glass dimensions instead of inheriting bezel padding.
struct ToolbarIconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: PuterLayout.toolbarControlHeight, height: PuterLayout.toolbarControlHeight)
.contentShape(.circle)
.modifier(SearchGlassSurface())
.opacity(configuration.isPressed ? 0.7 : 1)
}
}
+33
View File
@@ -0,0 +1,33 @@
import SwiftUI
struct ToolbarSearchField: View {
@Binding var text: String
let prompt: String
@State private var focused = false
var body: some View {
HStack(spacing: 8) {
Button("Find", systemImage: "magnifyingglass") { focused = true }
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.keyboardShortcut("f", modifiers: .command)
.help("Find (⌘F)")
ToolbarSearchInput(text: $text, focused: $focused, prompt: prompt)
Button("Clear search", systemImage: "xmark.circle.fill") { text = ""; focused = true }
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.disabled(text.isEmpty)
.opacity(text.isEmpty ? 0 : 1)
.accessibilityHidden(text.isEmpty)
}
.padding(.horizontal, 12)
.frame(width: 256, height: PuterLayout.toolbarControlHeight)
.modifier(SearchGlassSurface())
.overlay {
Capsule().strokeBorder(focused ? Color.accentColor : .clear, lineWidth: 2)
.allowsHitTesting(false)
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import AppKit
import SwiftUI
/// Native field-editor focus is reliable inside a hosted macOS toolbar item.
struct ToolbarSearchInput: NSViewRepresentable {
@Binding var text: String
@Binding var focused: Bool
let prompt: String
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField()
field.isBordered = false
field.drawsBackground = false
field.focusRingType = .none
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.delegate = context.coordinator
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return field
}
func updateNSView(_ field: NSTextField, context: Context) {
context.coordinator.parent = self
if field.stringValue != text { field.stringValue = text }
field.placeholderString = prompt
field.setAccessibilityLabel(prompt)
if focused, field.currentEditor() == nil {
field.window?.makeFirstResponder(field)
} else if !focused, field.currentEditor() != nil {
field.window?.makeFirstResponder(nil)
}
}
final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: ToolbarSearchInput
init(_ parent: ToolbarSearchInput) { self.parent = parent }
func controlTextDidChange(_ notification: Notification) {
guard let field = notification.object as? NSTextField else { return }
parent.text = field.stringValue
}
func controlTextDidBeginEditing(_ notification: Notification) {
parent.focused = true
}
func controlTextDidEndEditing(_ notification: Notification) {
parent.focused = false
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy command: Selector) -> Bool {
guard command == #selector(NSResponder.cancelOperation(_:)) else { return false }
if parent.text.isEmpty {
parent.focused = false
} else {
parent.text = ""
}
return true
}
}
}
+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 })
}
}
@@ -0,0 +1,77 @@
import XCTest
@testable import puter
final class BatteryRegistryTests: XCTestCase {
func testChargingSeparatesLoadInputAndCapability() {
let result = BatteryRegistryReader.parse([
"BatteryInstalled": true, "ExternalConnected": true, "IsCharging": true,
"CurrentCapacity": 77, "Voltage": 12_500, "Amperage": 3_600,
"PowerTelemetryData": ["SystemLoad": 15_000, "SystemPowerIn": 61_000],
"AdapterDetails": ["Name": "140W USB-C Power Adapter", "Watts": 94,
"AdapterVoltage": 20_000, "Current": 4_700,
"UsbHvcMenu": [["MaxVoltage": 20_000, "MaxCurrent": 4_700]]]
])
XCTAssertTrue(result.isPresent)
XCTAssertEqual(result.systemPowerWatts, 15)
XCTAssertEqual(result.adapterInputWatts, 61)
XCTAssertEqual(result.measuredBatteryWatts, 45)
XCTAssertEqual(result.batteryFlowTitle, "Battery charging")
XCTAssertEqual(result.adapter?.negotiatedWatts, 94)
XCTAssertEqual(result.adapter?.profiles.count, 1)
}
func testUnsignedNegativeCurrentAndDisconnectedStaleAdapter() {
let result = BatteryRegistryReader.parse([
"BatteryInstalled": true, "ExternalConnected": false, "Voltage": 12_000,
"Amperage": NSNumber(value: UInt64(bitPattern: -2_000)),
"PowerTelemetryData": ["SystemPowerIn": 50_000],
"AdapterDetails": ["Watts": 94]
])
XCTAssertEqual(result.currentAmps, -2)
XCTAssertEqual(result.measuredBatteryWatts, 24)
XCTAssertEqual(result.batteryFlowTitle, "Battery discharge")
XCTAssertNil(result.adapter)
XCTAssertFalse(result.hasAdapterInput)
XCTAssertEqual(result.adapterInputWatts, 0)
}
func testZeroIsDifferentFromMissing() {
let idle = BatteryRegistryReader.parse(["BatteryInstalled": true, "Voltage": 12_000, "Amperage": 0])
XCTAssertEqual(idle.measuredBatteryWatts, 0)
XCTAssertEqual(idle.batteryFlowTitle, "Battery idle")
let absent = BatteryRegistryReader.parse([:])
XCTAssertNil(absent.measuredBatteryWatts)
XCTAssertFalse(absent.hasSystemPower)
XCTAssertEqual(absent.powerSourceDescription, "External power")
}
func testNestedCapacityOnNewerBatteryRegistry() {
let result = BatteryRegistryReader.parse(["BatteryInstalled": true,
"BatteryData": ["NominalChargeCapacity": 5630, "DesignCapacity": 6249, "RemainingCapacity": 4600]])
XCTAssertEqual(result.fullChargeCapacityMAh, 5630)
XCTAssertEqual(result.designCapacityMAh, 6249)
XCTAssertEqual(result.currentCapacityMAh, 4600)
XCTAssertEqual(result.healthPercent, 90.09, accuracy: 0.01)
XCTAssertFalse(result.hasBatteryTemperature)
}
func testInvalidPowerAndCurrentAreNotMeasurements() {
let result = BatteryRegistryReader.parse([
"BatteryInstalled": true, "Voltage": 12_000, "Amperage": 999_999,
"PowerTelemetryData": ["SystemLoad": Double.nan, "SystemPowerIn": -10]
])
XCTAssertNil(result.measuredBatteryWatts)
XCTAssertFalse(result.hasSystemPower)
XCTAssertFalse(result.hasAdapterInput)
}
func testOpenDropdownRequestsPowerButNotFullInventory() {
let demand = SamplingDemand(active: false, section: .processes, speed: .normal,
recording: false, alerts: false, menuBar: true, powerMenu: false,
menuPresented: true)
XCTAssertTrue(demand.hardware)
XCTAssertTrue(demand.telemetry)
XCTAssertFalse(demand.processInventory)
XCTAssertEqual(demand.interval, .seconds(5))
}
}
+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)
}
}
@@ -0,0 +1,37 @@
import XCTest
@testable import puter
final class ProcessGroupingTests: XCTestCase {
@MainActor
func testPrecomputedGroupsPreserveMemberSearchAndTotals() {
let records = [record(41, path: "/Applications/Example.app/Contents/MacOS/Example"),
record(42, path: "/Applications/Example.app/Contents/Helpers/Worker"),
record(43, path: "/usr/bin/tool")]
let groups = ProcessGrouping.groups(from: records, searchText: "")
XCTAssertEqual(groups.count, 2)
let app = ProcessGrouping.filtered(groups, searchText: "Example")
XCTAssertEqual(app.count, 1)
XCTAssertEqual(app.first?.processes.count, 2)
XCTAssertEqual(app.first?.cpu, 4)
let member = ProcessGrouping.filtered(groups, searchText: "42")
XCTAssertEqual(member.first?.processes.map(\.pid), [42])
XCTAssertEqual(member.first?.id, app.first?.id)
XCTAssertTrue(ProcessGrouping.filtered(groups, searchText: "unmatched").isEmpty)
}
@MainActor
func testPathCachePreservesSingletonsAndFreshTelemetry() {
let first = ProcessGrouping.groups(from: [record(41, path: "/usr/bin/tool")], searchText: "")
let second = ProcessGrouping.groups(from: [record(42, path: "/usr/bin/tool")], searchText: "")
XCTAssertNotEqual(first.first?.id, second.first?.id)
let mixedCase = ProcessGrouping.groups(from: [record(43, path: "/Applications/Test.APP/Contents/MacOS/Test")], searchText: "")
XCTAssertEqual(mixedCase.first?.appPath, "/Applications/Test.app")
}
private func record(_ pid: Int32, path: String) -> ProcessRecord {
ProcessRecord(pid: pid, parentPID: 1, name: "worker", executablePath: path,
user: "tester", cpu: 2, memoryPercent: 1, residentBytes: 1024,
state: "S", elapsed: "00:01", cpuTime: 0, threadCount: 1,
openFileCount: 0, architecture: "ARM64", priority: 31, nice: 0)
}
}
@@ -0,0 +1,66 @@
import XCTest
@testable import puter
final class SamplingDemandTests: XCTestCase {
func testNoConsumerMeansNoTimer() {
let demand = make(active: false)
XCTAssertNil(demand.interval)
XCTAssertFalse(demand.processInventory)
XCTAssertFalse(demand.hardware)
XCTAssertFalse(demand.services)
}
func testBackgroundMenuUsesLightweightCountersOnly() {
let demand = make(active: false, menu: true)
XCTAssertEqual(demand.interval, .seconds(10))
XCTAssertTrue(demand.telemetry)
XCTAssertFalse(demand.processInventory)
XCTAssertFalse(demand.services)
XCTAssertFalse(demand.hardware)
}
func testSettingsWithoutConsumersIsIdle() {
XCTAssertNil(make(section: .settings).interval)
}
func testServicesOnlyScansVisibleServices() {
let demand = make(section: .services)
XCTAssertTrue(demand.services)
XCTAssertFalse(demand.telemetry)
XCTAssertFalse(demand.processInventory)
XCTAssertEqual(demand.interval, .seconds(3))
}
func testRecordingKeepsBackgroundTelemetry() {
let demand = make(active: false, recording: true)
XCTAssertTrue(demand.processInventory)
XCTAssertTrue(demand.telemetry)
XCTAssertTrue(demand.hardware)
XCTAssertEqual(demand.interval, UpdateSpeed.normal.interval)
}
func testPauseStopsEvenRecordingTimer() {
XCTAssertNil(make(speed: .paused, recording: true, menu: true).interval)
}
func testPowerMenuOnlyNeedsHardware() {
let demand = make(active: false, menu: true, power: true)
XCTAssertTrue(demand.hardware)
XCTAssertFalse(demand.telemetry)
XCTAssertFalse(demand.processInventory)
}
func testAlertsRetainSystemSamplingWithoutProcessScan() {
let demand = make(active: false, alerts: true)
XCTAssertTrue(demand.telemetry)
XCTAssertFalse(demand.processInventory)
XCTAssertNotNil(demand.interval)
}
private func make(active: Bool = true, section: TaskSection = .processes,
speed: UpdateSpeed = .normal, recording: Bool = false,
alerts: Bool = false, menu: Bool = false, power: Bool = false) -> SamplingDemand {
SamplingDemand(active: active, section: section, speed: speed,
recording: recording, alerts: alerts, menuBar: menu, powerMenu: power)
}
}
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.
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- Circular loop: concentric radii 300 and 188, exactly 112 units thick.
Stem: x=212...324, also 112 units, tangent to the outer circle.
A single outline avoids overlapping glass surfaces at the join. -->
<path fill="#f2fff7" fill-rule="evenodd" d="
M 212 454
A 300 300 0 1 1 324 687.760561259
L 324 814
A 56 56 0 0 1 212 814
Z
M 324 454
A 188 188 0 1 0 700 454
A 188 188 0 1 0 324 454
Z"/>
</svg>

After

Width:  |  Height:  |  Size: 546 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- Constant-width 48-unit capsule, separate editable Composer layer. -->
<path d="M 504 462 L 574 392" fill="none" stroke="#f2fff7" stroke-width="48" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

+57
View File
@@ -0,0 +1,57 @@
{
"fill" : {
"linear-gradient" : [
"display-p3:0.14000,0.43000,0.33000,1.00000",
"display-p3:0.03500,0.18000,0.16000,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"groups" : [
{
"layers" : [
{
"fill" : {
"solid" : "display-p3:0.95000,1.00000,0.97000,1.00000"
},
"image-name" : "loop-p-geometric.svg",
"name" : "Circular loop p · 112pt",
"position" : {
"scale" : 1,
"translation-in-points" : [
0,
0
]
}
},
{
"image-name" : "meter-needle-geometric.svg",
"name" : "Meter needle",
"fill" : { "solid" : "display-p3:0.95000,1.00000,0.97000,1.00000" }
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.35
},
"translucency" : {
"enabled" : true,
"value" : 0.25
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+105 -9
View File
@@ -2,26 +2,122 @@
set -euo pipefail set -euo pipefail
PROJECT_DIR="${0:A:h:h}" PROJECT_DIR="${0:A:h:h}"
APP_DIR="$PROJECT_DIR/dist/Task Manager.app" APP_DIR="$PROJECT_DIR/dist/puter.app"
CONTENTS_DIR="$APP_DIR/Contents" ICON_SOURCE="${PUTER_ICON_SOURCE:-$PROJECT_DIR/puter.icon}"
ICON_SOURCE="$PROJECT_DIR/mactaskmanager.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" cd "$PROJECT_DIR"
swift build -c release swift build -c release --product puter
swift build -c release --product puter-helper
mkdir -p "$CONTENTS_DIR/MacOS" "$CONTENTS_DIR/Resources" mkdir -p "$CONTENTS_DIR/MacOS" "$CONTENTS_DIR/Resources" "$CONTENTS_DIR/Frameworks" "$CONTENTS_DIR/Library/LaunchDaemons"
cp "$PROJECT_DIR/.build/release/MacTaskManager" "$CONTENTS_DIR/MacOS/MacTaskManager" 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" 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 if [[ -d "$ICON_SOURCE" ]]; then
print "Using Icon Composer source: $ICON_SOURCE"
xcrun actool "$ICON_SOURCE" \ xcrun actool "$ICON_SOURCE" \
--compile "$CONTENTS_DIR/Resources" \ --compile "$CONTENTS_DIR/Resources" \
--platform macosx \ --platform macosx \
--minimum-deployment-target 14.0 \ --minimum-deployment-target 14.0 \
--app-icon mactaskmanager \ --app-icon puter \
--output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null --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 fi
codesign --force --deep --sign - "$APP_DIR" >/dev/null # Finder and cloud-sync metadata can make an otherwise valid app bundle
# unsignable when rebuilding in place.
xattr -cr "$STAGED_APP"
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" print "Built $APP_DIR"
+13 -5
View File
@@ -2,9 +2,10 @@
set -euo pipefail set -euo pipefail
PROJECT_DIR="${0:A:h:h}" PROJECT_DIR="${0:A:h:h}"
APP_PATH="$PROJECT_DIR/dist/Task Manager.app" APP_PATH="$PROJECT_DIR/dist/puter.app"
DMG_PATH="$PROJECT_DIR/dist/Task Manager-macOS.dmg" DMG_PATH="$PROJECT_DIR/dist/puter-macOS.dmg"
STAGING_DIR="$(mktemp -d /tmp/mactaskmanager-dmg.XXXXXX)" SIGN_IDENTITY="${PUTER_SIGN_IDENTITY:--}"
STAGING_DIR="$(mktemp -d /tmp/puter-dmg.XXXXXX)"
cleanup() { cleanup() {
rm -rf "$STAGING_DIR" rm -rf "$STAGING_DIR"
@@ -15,15 +16,22 @@ if [[ ! -d "$APP_PATH" ]]; then
"$PROJECT_DIR/scripts/build-app.sh" "$PROJECT_DIR/scripts/build-app.sh"
fi fi
ditto "$APP_PATH" "$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" ln -s /Applications "$STAGING_DIR/Applications"
hdiutil create \ hdiutil create \
-volname "Task Manager" \ -volname "puter" \
-srcfolder "$STAGING_DIR" \ -srcfolder "$STAGING_DIR" \
-format UDZO \ -format UDZO \
-imagekey zlib-level=9 \ -imagekey zlib-level=9 \
-ov \ -ov \
"$DMG_PATH" "$DMG_PATH"
if [[ "$SIGN_IDENTITY" != "-" ]]; then
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH"
codesign --verify --strict "$DMG_PATH"
fi
print "Built $DMG_PATH" 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