Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bfa239aa0 | ||
|
|
0da94715be | ||
|
|
a67bb5fdf3 | ||
|
|
053bb71da2 | ||
|
|
b7faf0ac70 | ||
|
|
991867fef0 |
@@ -0,0 +1,24 @@
|
|||||||
|
name: Build and test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos:
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Swift version
|
||||||
|
run: swift --version
|
||||||
|
- name: Build
|
||||||
|
run: swift build
|
||||||
|
- name: Test
|
||||||
|
run: swift test
|
||||||
|
- name: Package unsigned validation build
|
||||||
|
run: ./scripts/build-app.sh
|
||||||
|
- name: Build validation DMG
|
||||||
|
run: ./scripts/build-dmg.sh
|
||||||
|
- name: Verify packaged app and DMG
|
||||||
|
run: ./scripts/validate-package.sh
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Signed release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Import Developer ID certificate
|
||||||
|
env:
|
||||||
|
SIGNING_P12: ${{ secrets.DEVELOPER_ID_P12_BASE64 }}
|
||||||
|
SIGNING_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
KEYCHAIN="$RUNNER_TEMP/puter-signing.keychain-db"
|
||||||
|
security create-keychain -p runner "$KEYCHAIN"
|
||||||
|
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||||
|
security unlock-keychain -p runner "$KEYCHAIN"
|
||||||
|
printf '%s' "$SIGNING_P12" | base64 --decode > "$RUNNER_TEMP/puter-signing.p12"
|
||||||
|
security import "$RUNNER_TEMP/puter-signing.p12" -k "$KEYCHAIN" -P "$SIGNING_PASSWORD" -T /usr/bin/codesign
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -s -k runner "$KEYCHAIN"
|
||||||
|
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
|
||||||
|
- name: Prepare notarization key
|
||||||
|
env:
|
||||||
|
NOTARY_KEY: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }}
|
||||||
|
run: printf '%s' "$NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8"
|
||||||
|
- name: Build signed app and DMG
|
||||||
|
env:
|
||||||
|
PUTER_RELEASE_BUILD: '1'
|
||||||
|
PUTER_SIGN_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }}
|
||||||
|
PUTER_UPDATE_FEED_URL: ${{ secrets.SPARKLE_FEED_URL }}
|
||||||
|
PUTER_UPDATE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }}
|
||||||
|
run: |
|
||||||
|
export PUTER_VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
export PUTER_BUILD_NUMBER="$GITHUB_RUN_NUMBER"
|
||||||
|
./scripts/build-app.sh
|
||||||
|
./scripts/build-dmg.sh
|
||||||
|
./scripts/validate-package.sh
|
||||||
|
- name: Notarize and staple
|
||||||
|
env:
|
||||||
|
PUTER_NOTARY_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
|
||||||
|
PUTER_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
|
||||||
|
PUTER_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
|
||||||
|
run: ./scripts/notarize-release.sh
|
||||||
|
- name: Validate notarized release
|
||||||
|
env:
|
||||||
|
PUTER_RELEASE_BUILD: '1'
|
||||||
|
run: |
|
||||||
|
./scripts/validate-package.sh
|
||||||
|
xcrun stapler validate dist/puter.app
|
||||||
|
xcrun stapler validate dist/puter-macOS.dmg
|
||||||
|
- name: Generate signed appcast
|
||||||
|
env:
|
||||||
|
PUTER_UPDATE_DOWNLOAD_PREFIX: ${{ secrets.SPARKLE_DOWNLOAD_PREFIX }}
|
||||||
|
PUTER_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/updates
|
||||||
|
ditto dist/puter-macOS.dmg "dist/updates/puter-${GITHUB_REF_NAME#v}.dmg"
|
||||||
|
./scripts/generate-appcast.sh dist/updates
|
||||||
|
- name: Prepare release assets
|
||||||
|
run: |
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent dist/puter.app "dist/puter-${GITHUB_REF_NAME#v}-macOS.zip"
|
||||||
|
shasum -a 256 \
|
||||||
|
dist/puter-macOS.dmg \
|
||||||
|
"dist/puter-${GITHUB_REF_NAME#v}-macOS.zip" \
|
||||||
|
dist/updates/appcast.xml > dist/SHA256SUMS
|
||||||
|
- name: Publish Gitea release
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
API_ROOT="${GITHUB_API_URL:-${GITEA_SERVER_URL}/api/v1}"
|
||||||
|
RELEASE_JSON="$(curl --fail-with-body -sS -X POST \
|
||||||
|
-H "Authorization: token $RELEASE_TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
"$API_ROOT/repos/$GITHUB_REPOSITORY/releases" \
|
||||||
|
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"puter $GITHUB_REF_NAME\",\"draft\":false,\"prerelease\":false}")"
|
||||||
|
RELEASE_ID="$(printf '%s' "$RELEASE_JSON" | jq -r .id)"
|
||||||
|
for ASSET in \
|
||||||
|
dist/puter-macOS.dmg \
|
||||||
|
"dist/puter-${GITHUB_REF_NAME#v}-macOS.zip" \
|
||||||
|
dist/updates/appcast.xml \
|
||||||
|
dist/SHA256SUMS; do
|
||||||
|
curl --fail-with-body -sS -X POST -H "Authorization: token $RELEASE_TOKEN" \
|
||||||
|
-H 'Content-Type: application/octet-stream' --data-binary "@$ASSET" \
|
||||||
|
"$API_ROOT/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$(basename "$ASSET")"
|
||||||
|
done
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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
@@ -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 650–690 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.
|
||||||
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# puter release checklist
|
||||||
|
|
||||||
|
This checklist is the authoritative handoff for producing a public puter release. Run it from the repository root on a macOS runner.
|
||||||
|
|
||||||
|
## Local quality gates
|
||||||
|
|
||||||
|
- [x] `swift test --parallel` passes, including native full/lightweight sampler budgets, process sort semantics, memory pressure, storage, power parsing, helper input validation, security inspection, recording, alerts, and diagnostic serialization.
|
||||||
|
- [x] `./scripts/build-app.sh` produces a Hardened Runtime app with the live Icon Composer asset, Sparkle framework, command-limited helper, launch-daemon property list, and SMC backend/license when available.
|
||||||
|
- [x] `./scripts/build-dmg.sh` produces a drag-to-install DMG.
|
||||||
|
- [x] `./scripts/validate-package.sh` validates signatures, nested code, runtime search paths, Sparkle linkage, bundle metadata, Icon Composer configuration, helper payload, SMC payload/license, DMG checksum, mounted app, and Applications shortcut.
|
||||||
|
- [x] Packaged-app accessibility smoke tests cover sidebar hide/recovery, the descending/ascending/categorized sort cycle, process rows, and live Hardware values.
|
||||||
|
- [x] Current sampler benchmark: full process telemetry 0.0529 seconds/iteration; lightweight system telemetry 0.0076 seconds/iteration.
|
||||||
|
- [x] Current 20-second packaged Hardware-page benchmark: 0.83% average CPU, 9.7% one-second peak while hardware work completed.
|
||||||
|
|
||||||
|
Benchmark numbers are evidence from the development Mac, not universal product guarantees. CI enforces the portable per-iteration budget rather than a machine-specific CPU percentage.
|
||||||
|
|
||||||
|
## Required protected release inputs
|
||||||
|
|
||||||
|
- [ ] `DEVELOPER_ID_P12_BASE64`: Developer ID Application certificate and private key in PKCS#12 form.
|
||||||
|
- [ ] `DEVELOPER_ID_P12_PASSWORD`: PKCS#12 password.
|
||||||
|
- [ ] `DEVELOPER_ID_IDENTITY`: exact `Developer ID Application: … (TEAMID)` identity.
|
||||||
|
- [ ] `APPLE_NOTARY_KEY_BASE64`: App Store Connect API private key.
|
||||||
|
- [ ] `APPLE_NOTARY_KEY_ID` and `APPLE_NOTARY_ISSUER_ID`.
|
||||||
|
- [ ] `SPARKLE_FEED_URL`, `SPARKLE_PUBLIC_KEY`, `SPARKLE_PRIVATE_KEY`, and `SPARKLE_DOWNLOAD_PREFIX`.
|
||||||
|
- [ ] `RELEASE_TOKEN`: token allowed to create a release and upload assets to this Gitea repository.
|
||||||
|
- [ ] Stats-derived `smc` executable through `PUTER_SMC_SOURCE`, or Stats installed at `/Applications/Stats.app` on the runner.
|
||||||
|
|
||||||
|
Never commit certificates, API keys, Sparkle private keys, or release tokens.
|
||||||
|
|
||||||
|
## Signed release procedure
|
||||||
|
|
||||||
|
1. Confirm the working tree and intended tag are clean and reviewed.
|
||||||
|
2. Confirm `security find-identity -v -p codesigning` lists the Developer ID Application identity.
|
||||||
|
3. Run the local gates above.
|
||||||
|
4. Push a semantic version tag such as `v1.0.0`.
|
||||||
|
5. The `Signed release` workflow must:
|
||||||
|
- import the certificate into an ephemeral keychain;
|
||||||
|
- build with Hardened Runtime, library validation, update feed, and EdDSA public key;
|
||||||
|
- validate the signed app and DMG before submission;
|
||||||
|
- submit to Apple, staple and validate both app and DMG;
|
||||||
|
- validate the notarized package again;
|
||||||
|
- generate the EdDSA-signed appcast;
|
||||||
|
- publish the notarized DMG, stapled app ZIP, appcast, and `SHA256SUMS`.
|
||||||
|
6. Download the published assets on a separate Mac and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hdiutil verify puter-macOS.dmg
|
||||||
|
spctl --assess --type open --context context:primary-signature -v puter-macOS.dmg
|
||||||
|
xcrun stapler validate puter-macOS.dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Install into `/Applications`, launch it, approve the helper only when testing fan control, and verify monitoring works without helper approval.
|
||||||
|
8. Exercise Check for Updates against the published appcast from the previous release before announcing the new release.
|
||||||
|
|
||||||
|
## Release acceptance
|
||||||
|
|
||||||
|
A release is complete only when Apple notarization is accepted, stapler and Gatekeeper validation pass, the Gitea assets are downloadable with matching SHA-256 values, Sparkle verifies the appcast, and the installed app passes the packaged smoke tests. An ad-hoc or Apple Development build is not a public release.
|
||||||
@@ -0,0 +1,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,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>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>dev.soconnor.puter.helper</string>
|
||||||
|
<key>BundleProgram</key>
|
||||||
|
<string>Contents/Resources/puter-helper</string>
|
||||||
|
<key>MachServices</key>
|
||||||
|
<dict>
|
||||||
|
<key>dev.soconnor.puter.helper</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>AssociatedBundleIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>dev.soconnor.puter</string>
|
||||||
|
</array>
|
||||||
|
<key>ProcessType</key>
|
||||||
|
<string>Interactive</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Ad-hoc signatures do not carry a Team ID. Production Developer ID
|
||||||
|
builds use puter.entitlements and retain library validation. -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
+42
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct DiagnosticCapture: Identifiable, Codable, Hashable, Sendable {
|
||||||
|
let id: UUID
|
||||||
|
var name: String
|
||||||
|
let createdAt: Date
|
||||||
|
let system: DiagnosticSystemMetrics
|
||||||
|
let processes: [DiagnosticProcessMetrics]
|
||||||
|
let disks: [DiagnosticDiskMetrics]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiagnosticSystemMetrics: Codable, Hashable, Sendable {
|
||||||
|
let cpuPercent: Double
|
||||||
|
let memoryUsedBytes: UInt64
|
||||||
|
let memoryTotalBytes: UInt64
|
||||||
|
let memoryPressure: String
|
||||||
|
let compressedBytes: UInt64
|
||||||
|
let swapUsedBytes: UInt64
|
||||||
|
let diskReadBytesPerSecond: Double
|
||||||
|
let diskWriteBytesPerSecond: Double
|
||||||
|
let diskActivePercent: Double
|
||||||
|
let gpuPercent: Double
|
||||||
|
let networkReceiveBytesPerSecond: Double
|
||||||
|
let networkSendBytesPerSecond: Double
|
||||||
|
let systemPowerWatts: Double
|
||||||
|
let thermalState: String
|
||||||
|
let processCount: Int
|
||||||
|
let threadCount: Int
|
||||||
|
let uptimeSeconds: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiagnosticProcessMetrics: Identifiable, Codable, Hashable, Sendable {
|
||||||
|
let pid: Int32
|
||||||
|
let name: String
|
||||||
|
let executablePath: String
|
||||||
|
let user: String
|
||||||
|
let cpuPercent: Double
|
||||||
|
let residentBytes: UInt64
|
||||||
|
let diskBytesPerSecond: Double
|
||||||
|
let networkBytesPerSecond: Double
|
||||||
|
var id: Int32 { pid }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiagnosticDiskMetrics: Identifiable, Codable, Hashable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let smartStatus: String
|
||||||
|
let remainingLifePercent: Double?
|
||||||
|
let temperatureCelsius: Double?
|
||||||
|
let mediaErrors: UInt64?
|
||||||
|
let unsafeShutdowns: UInt64?
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum DiagnosticCaptureStore {
|
||||||
|
static var directory: URL {
|
||||||
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("puter/Diagnostics", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load() -> [DiagnosticCapture] {
|
||||||
|
guard let urls = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
|
||||||
|
) else { return [] }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return urls.filter { $0.pathExtension == "json" }.compactMap {
|
||||||
|
guard let data = try? Data(contentsOf: $0) else { return nil }
|
||||||
|
return try? decoder.decode(DiagnosticCapture.self, from: data)
|
||||||
|
}.sorted { $0.createdAt > $1.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ capture: DiagnosticCapture) throws {
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
try encoder.encode(capture).write(to: url(for: capture.id), options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(_ capture: DiagnosticCapture) throws {
|
||||||
|
try FileManager.default.removeItem(at: url(for: capture.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func url(for id: UUID) -> URL { directory.appendingPathComponent("\(id.uuidString).json") }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiagnosticsView: View {
|
||||||
|
@Environment(SystemMonitor.self) private var monitor
|
||||||
|
@State private var captures = DiagnosticCaptureStore.load()
|
||||||
|
@State private var selectedIDs: Set<UUID> = []
|
||||||
|
@State private var pendingDeletion: DiagnosticCapture?
|
||||||
|
|
||||||
|
private var selectedCaptures: [DiagnosticCapture] {
|
||||||
|
captures.filter { selectedIDs.contains($0.id) }.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
PageHeader(title: "Diagnostics", subtitle: "Capture system state and compare changes over time") {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button("Capture now", systemImage: "camera.metering.center.weighted") { captureNow() }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
Button("Open folder", systemImage: "folder") { openFolder() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResourceSummaryBar(snapshot: monitor.snapshot)
|
||||||
|
HSplitView {
|
||||||
|
captureList.frame(minWidth: 310, idealWidth: 360, maxWidth: 440)
|
||||||
|
comparison.frame(minWidth: 430, maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete diagnostic capture?",
|
||||||
|
isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } })
|
||||||
|
) {
|
||||||
|
Button("Delete", role: .destructive) { deletePendingCapture() }
|
||||||
|
Button("Cancel", role: .cancel) { pendingDeletion = nil }
|
||||||
|
} message: {
|
||||||
|
Text("This removes the saved capture from puter's Diagnostics folder.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var captureList: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Saved captures").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text("Select two").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
Divider()
|
||||||
|
if captures.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "waveform.badge.plus")
|
||||||
|
.font(.system(size: 30))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text("No captures").font(.headline)
|
||||||
|
Text("Capture current system state to establish a baseline.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 260)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(20)
|
||||||
|
} else {
|
||||||
|
List(captures) { capture in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Toggle("Compare \(capture.name)", isOn: selectionBinding(capture.id)).labelsHidden().toggleStyle(.checkbox)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(capture.name).font(.callout.weight(.medium)).lineLimit(1)
|
||||||
|
Text(capture.createdAt.formatted(date: .abbreviated, time: .standard))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text("CPU \(Formatters.percent(capture.system.cpuPercent)) • Memory \(Formatters.percent(memoryPercent(capture.system)))")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Menu {
|
||||||
|
Button("Export…") { export(capture) }
|
||||||
|
Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([DiagnosticCaptureStore.url(for: capture.id)]) }
|
||||||
|
Divider()
|
||||||
|
Button("Delete", role: .destructive) { pendingDeletion = capture }
|
||||||
|
} label: { Image(systemName: "ellipsis.circle") }
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.listStyle(.inset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var comparison: some View {
|
||||||
|
if selectedCaptures.count == 2 {
|
||||||
|
comparisonContent(from: selectedCaptures[0], to: selectedCaptures[1])
|
||||||
|
} else if selectedCaptures.count == 1 {
|
||||||
|
captureDetail(selectedCaptures[0])
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"Select captures to inspect",
|
||||||
|
systemImage: "arrow.left.and.right",
|
||||||
|
description: Text("Select one capture for details or two to compare deltas.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func captureDetail(_ capture: DiagnosticCapture) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text(capture.name).font(.title2.weight(.semibold))
|
||||||
|
metricGrid(capture.system)
|
||||||
|
topProcesses(capture.processes)
|
||||||
|
diskHealth(capture.disks)
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func comparisonContent(from baseline: DiagnosticCapture, to current: DiagnosticCapture) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Comparison").font(.title2.weight(.semibold))
|
||||||
|
Text("\(baseline.name) → \(current.name)").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(Formatters.duration(current.createdAt.timeIntervalSince(baseline.createdAt)))
|
||||||
|
.font(.callout.monospacedDigit()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) {
|
||||||
|
deltaCard("CPU", baseline.system.cpuPercent, current.system.cpuPercent, suffix: " pp", lowerIsBetter: true)
|
||||||
|
deltaBytes("Memory used", baseline.system.memoryUsedBytes, current.system.memoryUsedBytes, lowerIsBetter: true)
|
||||||
|
deltaCard("Disk activity", baseline.system.diskActivePercent, current.system.diskActivePercent, suffix: " pp", lowerIsBetter: true)
|
||||||
|
deltaRate("Disk throughput", baseline.system.diskReadBytesPerSecond + baseline.system.diskWriteBytesPerSecond, current.system.diskReadBytesPerSecond + current.system.diskWriteBytesPerSecond)
|
||||||
|
deltaCard("GPU", baseline.system.gpuPercent, current.system.gpuPercent, suffix: " pp", lowerIsBetter: true)
|
||||||
|
deltaRate("Network", baseline.system.networkReceiveBytesPerSecond + baseline.system.networkSendBytesPerSecond, current.system.networkReceiveBytesPerSecond + current.system.networkSendBytesPerSecond)
|
||||||
|
deltaCard("System power", baseline.system.systemPowerWatts, current.system.systemPowerWatts, suffix: " W", lowerIsBetter: true)
|
||||||
|
deltaCard("Processes", Double(baseline.system.processCount), Double(current.system.processCount), suffix: "", lowerIsBetter: true)
|
||||||
|
}
|
||||||
|
Text("Current top processes").font(.headline)
|
||||||
|
topProcesses(current.processes)
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metricGrid(_ metrics: DiagnosticSystemMetrics) -> some View {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
|
||||||
|
valueCard("CPU", Formatters.percent(metrics.cpuPercent))
|
||||||
|
valueCard("Memory", Formatters.percent(memoryPercent(metrics)))
|
||||||
|
valueCard("Pressure", metrics.memoryPressure)
|
||||||
|
valueCard("Disk", Formatters.percent(metrics.diskActivePercent))
|
||||||
|
valueCard("GPU", Formatters.percent(metrics.gpuPercent))
|
||||||
|
valueCard("Power", metrics.systemPowerWatts > 0 ? String(format: "%.1f W", metrics.systemPowerWatts) : "Not reported")
|
||||||
|
valueCard("Thermals", metrics.thermalState)
|
||||||
|
valueCard("Processes", "\(metrics.processCount)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func topProcesses(_ processes: [DiagnosticProcessMetrics]) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(processes.prefix(12)) { process in
|
||||||
|
HStack {
|
||||||
|
ProcessIcon(name: process.name, path: process.executablePath, size: 20)
|
||||||
|
Text(process.name).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(Formatters.percent(process.cpuPercent)).monospacedDigit().frame(width: 60, alignment: .trailing)
|
||||||
|
Text(Formatters.bytes.string(fromByteCount: Int64(clamping: process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
|
||||||
|
}.font(.caption).padding(.vertical, 5)
|
||||||
|
if process.id != processes.prefix(12).last?.id { Divider() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func diskHealth(_ disks: [DiagnosticDiskMetrics]) -> some View {
|
||||||
|
if !disks.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Physical storage health").font(.headline)
|
||||||
|
ForEach(disks) { disk in
|
||||||
|
HStack {
|
||||||
|
Label(disk.name, systemImage: "internaldrive")
|
||||||
|
Spacer()
|
||||||
|
Text(disk.smartStatus).foregroundStyle(disk.smartStatus == "Verified" ? .green : .orange)
|
||||||
|
if let life = disk.remainingLifePercent { Text("\(Formatters.percent(life)) life").monospacedDigit() }
|
||||||
|
}.font(.callout).padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectionBinding(_ id: UUID) -> Binding<Bool> {
|
||||||
|
Binding(get: { selectedIDs.contains(id) }, set: { selected in
|
||||||
|
if selected {
|
||||||
|
if selectedIDs.count == 2, let oldest = selectedCaptures.first { selectedIDs.remove(oldest.id) }
|
||||||
|
selectedIDs.insert(id)
|
||||||
|
} else { selectedIDs.remove(id) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func captureNow() {
|
||||||
|
let snapshot = monitor.snapshot
|
||||||
|
let metrics = DiagnosticSystemMetrics(
|
||||||
|
cpuPercent: snapshot.cpuPercent, memoryUsedBytes: snapshot.memoryUsed,
|
||||||
|
memoryTotalBytes: snapshot.memoryTotal, memoryPressure: snapshot.memoryPressure.rawValue,
|
||||||
|
compressedBytes: snapshot.memoryCompressed, swapUsedBytes: snapshot.swapUsed,
|
||||||
|
diskReadBytesPerSecond: snapshot.diskReadRate, diskWriteBytesPerSecond: snapshot.diskWriteRate,
|
||||||
|
diskActivePercent: snapshot.diskActivePercent, gpuPercent: snapshot.gpuPercent,
|
||||||
|
networkReceiveBytesPerSecond: snapshot.networkReceiveRate, networkSendBytesPerSecond: snapshot.networkSendRate,
|
||||||
|
systemPowerWatts: monitor.hardware.battery.systemPowerWatts, thermalState: monitor.hardware.thermalState,
|
||||||
|
processCount: snapshot.processCount, threadCount: snapshot.threadCount, uptimeSeconds: snapshot.uptime
|
||||||
|
)
|
||||||
|
let processes = monitor.processes.sorted {
|
||||||
|
($0.cpu + $0.memoryPercent) > ($1.cpu + $1.memoryPercent)
|
||||||
|
}.prefix(30).map { process in
|
||||||
|
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
|
||||||
|
return DiagnosticProcessMetrics(
|
||||||
|
pid: process.pid, name: process.displayName, executablePath: process.executablePath,
|
||||||
|
user: process.user, cpuPercent: process.cpu, residentBytes: process.residentBytes,
|
||||||
|
diskBytesPerSecond: activity.diskTotal, networkBytesPerSecond: activity.networkTotal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let disks = monitor.hardware.physicalDisks.map {
|
||||||
|
DiagnosticDiskMetrics(id: $0.id, name: $0.name, smartStatus: $0.smartStatus,
|
||||||
|
remainingLifePercent: $0.remainingLifePercent, temperatureCelsius: $0.temperatureCelsius,
|
||||||
|
mediaErrors: $0.mediaErrors, unsafeShutdowns: $0.unsafeShutdowns)
|
||||||
|
}
|
||||||
|
let now = Date()
|
||||||
|
let capture = DiagnosticCapture(
|
||||||
|
id: UUID(), name: "Capture \(now.formatted(date: .abbreviated, time: .shortened))",
|
||||||
|
createdAt: now, system: metrics, processes: processes, disks: disks
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
try DiagnosticCaptureStore.save(capture)
|
||||||
|
captures.insert(capture, at: 0)
|
||||||
|
selectedIDs = [capture.id]
|
||||||
|
} catch { monitor.errorMessage = "Could not save diagnostic capture: \(error.localizedDescription)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deletePendingCapture() {
|
||||||
|
guard let capture = pendingDeletion else { return }
|
||||||
|
do {
|
||||||
|
try DiagnosticCaptureStore.delete(capture)
|
||||||
|
captures.removeAll { $0.id == capture.id }
|
||||||
|
selectedIDs.remove(capture.id)
|
||||||
|
} catch { monitor.errorMessage = "Could not delete diagnostic capture: \(error.localizedDescription)" }
|
||||||
|
pendingDeletion = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func export(_ capture: DiagnosticCapture) {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.allowedContentTypes = [.json]
|
||||||
|
panel.nameFieldStringValue = "puter-diagnostic-\(capture.id.uuidString.prefix(8)).json"
|
||||||
|
panel.begin { response in
|
||||||
|
guard response == .OK, let destination = panel.url else { return }
|
||||||
|
do { try Data(contentsOf: DiagnosticCaptureStore.url(for: capture.id)).write(to: destination, options: .atomic) }
|
||||||
|
catch { monitor.errorMessage = "Could not export diagnostic capture: \(error.localizedDescription)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openFolder() {
|
||||||
|
try? FileManager.default.createDirectory(at: DiagnosticCaptureStore.directory, withIntermediateDirectories: true)
|
||||||
|
NSWorkspace.shared.open(DiagnosticCaptureStore.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func memoryPercent(_ metrics: DiagnosticSystemMetrics) -> Double {
|
||||||
|
metrics.memoryTotalBytes > 0 ? Double(metrics.memoryUsedBytes) / Double(metrics.memoryTotalBytes) * 100 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func valueCard(_ title: String, _ value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) { Text(title).font(.caption).foregroundStyle(.secondary); Text(value).font(.title3.monospacedDigit()) }
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deltaCard(_ title: String, _ old: Double, _ new: Double, suffix: String, lowerIsBetter: Bool) -> some View {
|
||||||
|
let delta = new - old
|
||||||
|
let favorable = lowerIsBetter ? delta <= 0 : delta >= 0
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "%+.1f%@", delta, suffix)).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : (favorable ? Color.green : Color.orange))
|
||||||
|
Text(String(format: "%.1f → %.1f", old, new)).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deltaBytes(_ title: String, _ old: UInt64, _ new: UInt64, lowerIsBetter: Bool) -> some View {
|
||||||
|
let delta = Int64(clamping: new) - Int64(clamping: old)
|
||||||
|
let text = (delta >= 0 ? "+" : "−") + Formatters.bytes.string(fromByteCount: abs(delta))
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(text).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : ((lowerIsBetter ? delta < 0 : delta > 0) ? Color.green : Color.orange))
|
||||||
|
Text("\(Formatters.bytes.string(fromByteCount: Int64(clamping: old))) → \(Formatters.bytes.string(fromByteCount: Int64(clamping: new)))").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deltaRate(_ title: String, _ old: Double, _ new: Double) -> some View {
|
||||||
|
let delta = new - old
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text((delta >= 0 ? "+" : "−") + Formatters.rate(abs(delta))).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : Color.orange)
|
||||||
|
Text("\(Formatters.rate(old)) → \(Formatters.rate(new))").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EnergyView: View {
|
||||||
|
@Environment(SystemMonitor.self) private var monitor
|
||||||
|
|
||||||
|
private var power: PowerManagementSnapshot { monitor.hardware.powerManagement }
|
||||||
|
private var sleepBlockers: [SleepAssertionSnapshot] {
|
||||||
|
power.assertions.filter(\.preventsSleep)
|
||||||
|
}
|
||||||
|
private var rankedProcesses: [(process: ProcessRecord, score: Double)] {
|
||||||
|
monitor.processes.map { ($0, energyScore(for: $0)) }
|
||||||
|
.filter { $0.1 > 0.05 }
|
||||||
|
.sorted { $0.1 > $1.1 }
|
||||||
|
.prefix(12)
|
||||||
|
.map { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
PageHeader(
|
||||||
|
title: "Energy & Sleep",
|
||||||
|
subtitle: "Live energy estimates, power policy, and apps preventing sleep"
|
||||||
|
) {
|
||||||
|
Button("Battery Settings", systemImage: "gear") {
|
||||||
|
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
|
||||||
|
}
|
||||||
|
UpdateStatus()
|
||||||
|
}
|
||||||
|
ResourceSummaryBar(snapshot: monitor.snapshot)
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
summary
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
processSection
|
||||||
|
sleepSection
|
||||||
|
}
|
||||||
|
policySection
|
||||||
|
Label(
|
||||||
|
"Energy impact is an estimate based on sampled CPU, disk, and network activity. macOS does not expose Activity Monitor's proprietary per-process Energy Impact value.",
|
||||||
|
systemImage: "info.circle"
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summary: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
summaryCard("Power source", power.source, "powerplug", .orange)
|
||||||
|
summaryCard("Power mode", power.mode, "gauge.with.dots.needle.50percent", .green)
|
||||||
|
summaryCard("System power", watts(monitor.hardware.battery.systemPowerWatts), "bolt.fill", .yellow)
|
||||||
|
summaryCard("Sleep blockers", "\(sleepBlockers.count)", "moon.zzz", sleepBlockers.isEmpty ? .green : .orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var processSection: some View {
|
||||||
|
GroupBox("Estimated energy impact") {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Process")
|
||||||
|
Spacer()
|
||||||
|
Text("Impact")
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
if rankedProcesses.isEmpty {
|
||||||
|
ContentUnavailableView("No active energy users", systemImage: "leaf", description: Text("No process activity was measured in this sample."))
|
||||||
|
.frame(minHeight: 220)
|
||||||
|
} else {
|
||||||
|
ForEach(rankedProcesses, id: \.process.pid) { item in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProcessIcon(name: item.process.displayName, path: item.process.executablePath, size: 22)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.process.displayName).lineLimit(1)
|
||||||
|
Text("CPU \(Formatters.percent(item.process.cpu))")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(impactLabel(item.score))
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(impactColor(item.score))
|
||||||
|
.frame(width: 68, alignment: .trailing)
|
||||||
|
Text(String(format: "%.1f", item.score))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
if item.process.pid != rankedProcesses.last?.process.pid { Divider() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sleepSection: some View {
|
||||||
|
GroupBox("Preventing sleep") {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if sleepBlockers.isEmpty {
|
||||||
|
ContentUnavailableView("Nothing is preventing sleep", systemImage: "moon.zzz", description: Text("No active sleep assertions were reported by macOS."))
|
||||||
|
.frame(minHeight: 220)
|
||||||
|
} else {
|
||||||
|
ForEach(sleepBlockers) { assertion in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(assertion.processName).font(.callout.weight(.medium)).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(assertion.duration).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(assertion.reason).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||||
|
Text("PID \(assertion.pid) • \(friendlyAssertionType(assertion.type))")
|
||||||
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
if assertion.id != sleepBlockers.last?.id { Divider() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var policySection: some View {
|
||||||
|
GroupBox("Active power policy") {
|
||||||
|
HStack(spacing: 28) {
|
||||||
|
policyValue("System sleep", minuteText(power.systemSleepMinutes), "powersleep")
|
||||||
|
policyValue("Display sleep", minuteText(power.displaySleepMinutes), "display")
|
||||||
|
policyValue("Power Nap", power.powerNapEnabled ? "On" : "Off", "clock.arrow.circlepath")
|
||||||
|
policyValue("Wake for network", power.wakeOnNetworkEnabled ? "On" : "Off", "network")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func summaryCard(_ title: String, _ value: String, _ icon: String, _ color: Color) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon).font(.title2).foregroundStyle(color).frame(width: 30)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(value).font(.headline).lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.48))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay { RoundedRectangle(cornerRadius: 10).stroke(.secondary.opacity(0.14)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func policyValue(_ title: String, _ value: String, _ icon: String) -> some View {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(value).font(.callout.weight(.medium))
|
||||||
|
}
|
||||||
|
} icon: { Image(systemName: icon).foregroundStyle(.green) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func energyScore(for process: ProcessRecord) -> Double {
|
||||||
|
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
|
||||||
|
let diskMB = activity.diskTotal / 1_048_576
|
||||||
|
let networkMB = activity.networkTotal / 1_048_576
|
||||||
|
return min(100, process.cpu + diskMB * 3.5 + networkMB * 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func impactLabel(_ score: Double) -> String {
|
||||||
|
switch score {
|
||||||
|
case 30...: "Very high"
|
||||||
|
case 15..<30: "High"
|
||||||
|
case 5..<15: "Medium"
|
||||||
|
case 1..<5: "Low"
|
||||||
|
default: "Very low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func impactColor(_ score: Double) -> Color {
|
||||||
|
switch score {
|
||||||
|
case 30...: .red
|
||||||
|
case 15..<30: .orange
|
||||||
|
case 5..<15: .yellow
|
||||||
|
default: .green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minuteText(_ value: Int?) -> String {
|
||||||
|
guard let value else { return "Not reported" }
|
||||||
|
return value == 0 ? "Never" : "\(value) min"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func friendlyAssertionType(_ value: String) -> String {
|
||||||
|
value.replacingOccurrences(of: "PreventUserIdle", with: "Prevent ")
|
||||||
|
.replacingOccurrences(of: "NoIdleSleepAssertion", with: "Prevent idle sleep")
|
||||||
|
.replacingOccurrences(of: "SystemSleep", with: "system sleep")
|
||||||
|
.replacingOccurrences(of: "DisplaySleep", with: "display sleep")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func watts(_ value: Double) -> String {
|
||||||
|
value > 0 ? String(format: "%.1f W", value) : "Not reported"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+228
-55
@@ -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") {
|
||||||
UpdateStatus()
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
monitor.isRecording ? monitor.stopRecording() : monitor.startRecording()
|
||||||
|
} label: {
|
||||||
|
Label(monitor.isRecording ? "Stop recording" : "Record", systemImage: monitor.isRecording ? "stop.circle.fill" : "record.circle")
|
||||||
|
}
|
||||||
|
.tint(monitor.isRecording ? .red : nil)
|
||||||
|
if !monitor.recordingSamples.isEmpty {
|
||||||
|
Menu("Recording", systemImage: "waveform.path.ecg.rectangle") {
|
||||||
|
Text("\(monitor.recordingSamples.count) samples")
|
||||||
|
if let started = monitor.recordingStartDate {
|
||||||
|
Text("Started \(started, style: .relative)")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("Export JSON…") { SessionRecordingExporter.exportJSON(monitor: monitor) }
|
||||||
|
Button("Export CSV…") { SessionRecordingExporter.exportCSV(monitor: monitor) }
|
||||||
|
Divider()
|
||||||
|
Button("Clear recording", role: .destructive) { monitor.clearRecording() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Export", systemImage: "square.and.arrow.up") {
|
||||||
|
SystemReportExporter.export(monitor: monitor, selectedResource: selectedResourceName)
|
||||||
|
}
|
||||||
|
UpdateStatus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 0) {
|
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()
|
||||||
detail
|
VStack(spacing: 0) {
|
||||||
|
detail
|
||||||
|
Divider()
|
||||||
|
topConsumers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var selectedResourceName: String {
|
||||||
|
if let selectedVolumeID,
|
||||||
|
let volume = monitor.snapshot.removableVolumes.first(where: { $0.id == selectedVolumeID }) {
|
||||||
|
return volume.name
|
||||||
|
}
|
||||||
|
return selectedMetric.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeConsumerMetric: PerformanceMetric {
|
||||||
|
selectedVolumeID == nil ? selectedMetric : .disk
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rankedConsumers: [ProcessRecord] {
|
||||||
|
guard activeConsumerMetric != .gpu else { return [] }
|
||||||
|
return monitor.processes
|
||||||
|
.filter { consumerValue($0) > 0 }
|
||||||
|
.sorted { consumerValue($0) > consumerValue($1) }
|
||||||
|
.prefix(4)
|
||||||
|
.map { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var topConsumers: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack {
|
||||||
|
Text("Top processes — \(selectedResourceName)").font(.callout.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
Text("User").frame(width: 110, alignment: .leading)
|
||||||
|
Text(activeConsumerMetric.rawValue).frame(width: 92, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if activeConsumerMetric == .gpu {
|
||||||
|
Label("Per-process GPU attribution is not exposed by the current macOS telemetry.", systemImage: "info.circle")
|
||||||
|
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 12)
|
||||||
|
} else if rankedConsumers.isEmpty {
|
||||||
|
Text("No active consumers in this sample.").font(.caption).foregroundStyle(.secondary).padding(.vertical, 12)
|
||||||
|
} else {
|
||||||
|
ForEach(rankedConsumers) { process in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProcessIcon(name: process.displayName, path: process.executablePath, size: 20)
|
||||||
|
Text(process.displayName).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(process.user).lineLimit(1).frame(width: 110, alignment: .leading)
|
||||||
|
Text(consumerValueText(process)).monospacedDigit().frame(width: 92, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(height: 146, alignment: .top)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.35))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func consumerValue(_ process: ProcessRecord) -> Double {
|
||||||
|
switch activeConsumerMetric {
|
||||||
|
case .cpu: process.cpu
|
||||||
|
case .memory: Double(process.residentBytes)
|
||||||
|
case .disk: monitor.processActivity[process.pid]?.diskTotal ?? 0
|
||||||
|
case .network: monitor.processActivity[process.pid]?.networkTotal ?? 0
|
||||||
|
case .gpu: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func consumerValueText(_ process: ProcessRecord) -> String {
|
||||||
|
switch activeConsumerMetric {
|
||||||
|
case .cpu: Formatters.percent(process.cpu)
|
||||||
|
case .memory: Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))
|
||||||
|
case .disk, .network: Formatters.rate(consumerValue(process))
|
||||||
|
case .gpu: "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func metricCard(_ metric: PerformanceMetric, value: Double, valueText: String? = nil, detail: String) -> some View {
|
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)
|
||||||
Text(metric.rawValue).font(.headline)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(valueText ?? Formatters.percent(value)).font(.title3.monospacedDigit())
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
Text(metric.rawValue).font(.headline)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(valueText ?? Formatters.percent(value))
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
}
|
||||||
|
Text(detail)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 9)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(selectedVolumeID == nil && selectedMetric == metric ? metric.color.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
|
.background(selectedVolumeID == nil && selectedMetric == metric ? Color.accentColor.opacity(0.13) : .clear)
|
||||||
.overlay {
|
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
.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)
|
||||||
Text(volume.name).font(.headline).lineLimit(1)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(Formatters.percent(volume.usedPercent)).font(.title3.monospacedDigit())
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text(volume.name).font(.headline).lineLimit(1)
|
||||||
|
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()
|
||||||
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
|
Menu {
|
||||||
.font(.caption)
|
Button {
|
||||||
.foregroundStyle(.secondary)
|
displayMode = .summary
|
||||||
|
} label: {
|
||||||
|
Label("Summary view", systemImage: displayMode == .summary ? "checkmark" : "chart.xyaxis.line")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
displayMode = .logicalProcessors
|
||||||
|
} label: {
|
||||||
|
Label("Logical processors", systemImage: displayMode == .logicalProcessors ? "checkmark" : "square.grid.3x3")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
.accessibilityLabel("CPU graph layout")
|
||||||
}
|
}
|
||||||
|
|
||||||
if displayMode == .summary {
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PhysicalDiskScanner {
|
||||||
|
static func capture() -> [PhysicalDiskSnapshot] {
|
||||||
|
let listData = run("/usr/sbin/diskutil", ["list", "-plist", "physical"])
|
||||||
|
guard let list = try? PropertyListSerialization.propertyList(from: listData, format: nil) as? [String: Any] else { return [] }
|
||||||
|
let identifiers = (list["WholeDisks"] as? [String])
|
||||||
|
?? (list["AllDisksAndPartitions"] as? [[String: Any]])?.compactMap { $0["DeviceIdentifier"] as? String }
|
||||||
|
?? []
|
||||||
|
let trimByDisk = storageTrimSupport()
|
||||||
|
|
||||||
|
return identifiers.compactMap { identifier in
|
||||||
|
let data = run("/usr/sbin/diskutil", ["info", "-plist", identifier])
|
||||||
|
guard let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil }
|
||||||
|
let smart = info["SMARTDeviceSpecificKeysMayVaryNotGuaranteed"] as? [String: Any] ?? [:]
|
||||||
|
func number(_ key: String) -> UInt64? { (smart[key] as? NSNumber)?.uint64Value }
|
||||||
|
func dataBytes(_ key: String) -> UInt64? {
|
||||||
|
guard let units = number(key) else { return nil }
|
||||||
|
let result = units.multipliedReportingOverflow(by: 512_000)
|
||||||
|
return result.overflow ? nil : result.partialValue
|
||||||
|
}
|
||||||
|
let temperature = number("TEMPERATURE").map {
|
||||||
|
let raw = Double($0)
|
||||||
|
return raw > 200 ? raw - 273.15 : raw
|
||||||
|
}
|
||||||
|
return PhysicalDiskSnapshot(
|
||||||
|
id: identifier,
|
||||||
|
name: info["MediaName"] as? String ?? identifier,
|
||||||
|
protocolName: info["BusProtocol"] as? String ?? "Not reported",
|
||||||
|
capacity: (info["TotalSize"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
|
isInternal: info["Internal"] as? Bool ?? false,
|
||||||
|
isRemovable: info["Removable"] as? Bool ?? false,
|
||||||
|
isSolidState: info["SolidState"] as? Bool ?? false,
|
||||||
|
smartStatus: info["SMARTStatus"] as? String ?? "Not supported",
|
||||||
|
trimEnabled: trimByDisk[identifier],
|
||||||
|
temperatureCelsius: temperature,
|
||||||
|
percentageUsed: number("PERCENTAGE_USED").map { Double($0) },
|
||||||
|
availableSparePercent: number("AVAILABLE_SPARE").map { Double($0) },
|
||||||
|
powerOnHours: number("POWER_ON_HOURS_0"),
|
||||||
|
powerCycles: number("POWER_CYCLES_0"),
|
||||||
|
unsafeShutdowns: number("UNSAFE_SHUTDOWNS_0"),
|
||||||
|
mediaErrors: number("MEDIA_ERRORS_0"),
|
||||||
|
bytesRead: dataBytes("DATA_UNITS_READ_0"),
|
||||||
|
bytesWritten: dataBytes("DATA_UNITS_WRITTEN_0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storageTrimSupport() -> [String: Bool] {
|
||||||
|
let data = run("/usr/sbin/system_profiler", ["SPNVMeDataType", "SPSerialATADataType", "-json", "-detailLevel", "mini"])
|
||||||
|
guard let root = try? JSONSerialization.jsonObject(with: data) else { return [:] }
|
||||||
|
var result: [String: Bool] = [:]
|
||||||
|
func visit(_ value: Any) {
|
||||||
|
if let dictionary = value as? [String: Any] {
|
||||||
|
if let identifier = dictionary["bsd_name"] as? String,
|
||||||
|
let trim = (dictionary["spnvme_trim_support"] as? String) ?? (dictionary["spsata_trim_support"] as? String) {
|
||||||
|
result[identifier] = trim.localizedCaseInsensitiveCompare("yes") == .orderedSame
|
||||||
|
}
|
||||||
|
dictionary.values.forEach(visit)
|
||||||
|
} else if let array = value as? [Any] { array.forEach(visit) }
|
||||||
|
}
|
||||||
|
visit(root)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func run(_ executable: String, _ arguments: [String]) -> Data {
|
||||||
|
let process = Process()
|
||||||
|
let output = Pipe()
|
||||||
|
process.executableURL = URL(fileURLWithPath: executable)
|
||||||
|
process.arguments = arguments
|
||||||
|
process.standardOutput = output
|
||||||
|
process.standardError = FileHandle.nullDevice
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
process.waitUntilExit()
|
||||||
|
return process.terminationStatus == 0 ? data : Data()
|
||||||
|
} catch { return Data() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,24 +217,33 @@ struct ProcessPropertiesView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
|
TabView {
|
||||||
property("Executable", process.executablePath)
|
ScrollView {
|
||||||
property("User", process.user)
|
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
|
||||||
property("Parent PID", "\(process.parentPID)")
|
property("Executable", process.executablePath)
|
||||||
property("State", process.state)
|
property("User", process.user)
|
||||||
property("CPU", Formatters.percent(process.cpu))
|
property("Parent PID", "\(process.parentPID)")
|
||||||
property("CPU time", Formatters.duration(process.cpuTime))
|
property("State", process.state)
|
||||||
property("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
|
property("CPU", Formatters.percent(process.cpu))
|
||||||
property("Threads", "\(process.threadCount)")
|
property("CPU time", Formatters.duration(process.cpuTime))
|
||||||
property("Open handles", "\(process.openFileCount)")
|
property("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
|
||||||
property("Architecture", process.architecture)
|
property("Threads", "\(process.threadCount)")
|
||||||
property("Priority", "\(process.priority) (nice \(process.nice))")
|
property("Open handles", "\(process.openFileCount)")
|
||||||
property("Disk read", Formatters.rate(activity.diskRead))
|
property("Architecture", process.architecture)
|
||||||
property("Disk write", Formatters.rate(activity.diskWrite))
|
property("Priority", "\(process.priority) (nice \(process.nice))")
|
||||||
property("Network", Formatters.rate(activity.networkTotal))
|
property("Disk read", Formatters.rate(activity.diskRead))
|
||||||
property("Elapsed time", process.elapsed)
|
property("Disk write", Formatters.rate(activity.diskWrite))
|
||||||
|
property("Network", Formatters.rate(activity.networkTotal))
|
||||||
|
property("Elapsed time", process.elapsed)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.tabItem { Label("Process", systemImage: "list.bullet.rectangle") }
|
||||||
|
|
||||||
|
securityContent
|
||||||
|
.tabItem { Label("Security", systemImage: "checkmark.shield") }
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
Divider()
|
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 {
|
||||||
@@ -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") {
|
||||||
if monitor.isPaused {
|
HStack(spacing: 10) {
|
||||||
Label("Paused", systemImage: "pause.fill")
|
if monitor.isPaused {
|
||||||
.foregroundStyle(.orange)
|
Label("Paused", systemImage: "pause.fill")
|
||||||
|
.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,
|
.inspector(isPresented: $previewPaneVisible) {
|
||||||
resourceDisplayMode: $resourceDisplayMode
|
ProcessPreviewPane(
|
||||||
)
|
process: previewProcess,
|
||||||
ScrollView {
|
onClose: { previewPaneVisible = false },
|
||||||
LazyVStack(spacing: 0) {
|
onShowDetails: onShowDetails,
|
||||||
if groupByType {
|
onShowProperties: { inspectedProcess = $0 },
|
||||||
ForEach(ProcessCategory.allCases) { category in
|
onEndTask: { process 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !collapsedCategories.contains(category) || !searchText.isEmpty {
|
|
||||||
ForEach(categoryGroups) { group in
|
|
||||||
processGroup(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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,41 +342,41 @@ 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) {
|
ProcessGroupRow(
|
||||||
Button {
|
group: group,
|
||||||
|
selected: selectedGroupID == group.id,
|
||||||
|
displayMode: resourceDisplayMode,
|
||||||
|
columnWidths: columnWidths,
|
||||||
|
isExpanded: expansionBinding(for: group),
|
||||||
|
onToggleExpansion: {
|
||||||
let binding = expansionBinding(for: group)
|
let binding = expansionBinding(for: group)
|
||||||
binding.wrappedValue.toggle()
|
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)
|
.contentShape(Rectangle())
|
||||||
.help(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
|
.onTapGesture {
|
||||||
.accessibilityLabel(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
|
selectedGroupID = group.id
|
||||||
|
selectedPID = nil
|
||||||
ProcessGroupRow(
|
}
|
||||||
|
.focusable()
|
||||||
|
.onKeyPress(.return) {
|
||||||
|
selectedGroupID = group.id
|
||||||
|
selectedPID = nil
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityAddTraits(selectedGroupID == group.id ? .isSelected : [])
|
||||||
|
.accessibilityAction {
|
||||||
|
selectedGroupID = group.id
|
||||||
|
selectedPID = nil
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
ProcessGroupContextMenu(
|
||||||
group: group,
|
group: group,
|
||||||
selected: selectedGroupID == group.id,
|
onTerminate: { groupTerminationRequest = group },
|
||||||
displayMode: resourceDisplayMode,
|
onShowDetails: onShowDetails,
|
||||||
leadingPadding: 0
|
onShowProperties: { inspectedProcess = $0 }
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedGroupID = group.id
|
|
||||||
selectedPID = nil
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
ProcessGroupContextMenu(
|
|
||||||
group: group,
|
|
||||||
onTerminate: { groupTerminationRequest = group },
|
|
||||||
onShowDetails: onShowDetails,
|
|
||||||
onShowProperties: { inspectedProcess = $0 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if expansionBinding(for: group).wrappedValue {
|
if expansionBinding(for: group).wrappedValue {
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
.frame(width: width(.name), alignment: .leading)
|
||||||
Text("\(process.pid)").monospacedDigit().frame(width: 70, alignment: .trailing)
|
.clipped()
|
||||||
Text(process.state.hasPrefix("R") ? "Running" : "").frame(width: 80, alignment: .leading)
|
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)
|
.frame(width: width(.name), alignment: .leading)
|
||||||
Text("—").frame(width: 70, alignment: .trailing).foregroundStyle(.secondary)
|
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
openLoginItemsSettings()
|
Menu("Login Items", systemImage: "person.crop.circle.badge.checkmark") {
|
||||||
|
Button(includesLoginItems ? "Reload Login Items" : "Include Login Items") {
|
||||||
|
refreshItems(includeLoginItems: true)
|
||||||
|
}
|
||||||
|
.disabled(isLoading)
|
||||||
|
.help("Read macOS’s system-wide Background Task Management registry using the built-in sfltool diagnostic")
|
||||||
|
Divider()
|
||||||
|
Button("Open Login Items Settings") {
|
||||||
|
openLoginItemsSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if 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)
|
kind: .loginItem,
|
||||||
?? url.deletingPathExtension().lastPathComponent
|
isDirectlyManageable: false,
|
||||||
items.append(StartupItem(
|
enabled: item.enabled
|
||||||
id: "login:\(path)",
|
)
|
||||||
name: name,
|
}
|
||||||
publisher: publisherName(label: identifier, command: path),
|
} catch { return [] }
|
||||||
sourcePath: path,
|
|
||||||
executablePath: path,
|
|
||||||
serviceIdentifier: identifier,
|
|
||||||
kind: .loginItem,
|
|
||||||
isDirectlyManageable: false,
|
|
||||||
enabled: true
|
|
||||||
))
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,32 +884,79 @@ 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") {
|
||||||
Menu("Columns", systemImage: "rectangle.split.3x1") {
|
HStack(spacing: 10) {
|
||||||
ForEach(DetailColumn.allCases) { column in
|
Button {
|
||||||
Toggle(column.title, isOn: columnBinding(column))
|
previewPaneVisible.toggle()
|
||||||
.disabled(column == .name || column == .pid)
|
} label: {
|
||||||
|
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
|
||||||
}
|
}
|
||||||
Divider()
|
Button("Properties", systemImage: "info.circle") {
|
||||||
Button("Reset columns") {
|
inspectedProcess = selectedProcess
|
||||||
visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
|
|
||||||
}
|
}
|
||||||
|
.disabled(selectedProcess == nil)
|
||||||
|
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
|
||||||
|
Toggle(column.title, isOn: columnBinding(column))
|
||||||
|
.disabled(column == .name || column == .pid)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("Reset columns") {
|
||||||
|
visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
|
||||||
|
columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
|
||||||
|
persistColumnWidths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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.
@@ -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 |
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="${0:A:h:h}"
|
||||||
|
ARCHIVES_DIR="${1:-$PROJECT_DIR/dist/updates}"
|
||||||
|
DOWNLOAD_PREFIX="${PUTER_UPDATE_DOWNLOAD_PREFIX:-}"
|
||||||
|
PRIVATE_KEY="${PUTER_SPARKLE_PRIVATE_KEY:-}"
|
||||||
|
GENERATOR="$PROJECT_DIR/.build/artifacts/sparkle/Sparkle/bin/generate_appcast"
|
||||||
|
|
||||||
|
if [[ -z "$DOWNLOAD_PREFIX" || -z "$PRIVATE_KEY" ]]; then
|
||||||
|
print -u2 "Set PUTER_UPDATE_DOWNLOAD_PREFIX and PUTER_SPARKLE_PRIVATE_KEY."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -x "$GENERATOR" ]]; then
|
||||||
|
swift package resolve --package-path "$PROJECT_DIR"
|
||||||
|
fi
|
||||||
|
mkdir -p "$ARCHIVES_DIR"
|
||||||
|
print -rn -- "$PRIVATE_KEY" | "$GENERATOR" \
|
||||||
|
--ed-key-file - \
|
||||||
|
--download-url-prefix "$DOWNLOAD_PREFIX" \
|
||||||
|
"$ARCHIVES_DIR"
|
||||||
|
print "Generated $ARCHIVES_DIR/appcast.xml"
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="${0:A:h:h}"
|
||||||
|
DMG_PATH="${1:-$PROJECT_DIR/dist/puter-macOS.dmg}"
|
||||||
|
APP_PATH="$PROJECT_DIR/dist/puter.app"
|
||||||
|
NOTARY_PROFILE="${PUTER_NOTARY_PROFILE:-}"
|
||||||
|
NOTARY_KEY_PATH="${PUTER_NOTARY_KEY_PATH:-}"
|
||||||
|
NOTARY_KEY_ID="${PUTER_NOTARY_KEY_ID:-}"
|
||||||
|
NOTARY_ISSUER="${PUTER_NOTARY_ISSUER:-}"
|
||||||
|
|
||||||
|
if [[ -z "$NOTARY_PROFILE" && ( -z "$NOTARY_KEY_PATH" || -z "$NOTARY_KEY_ID" || -z "$NOTARY_ISSUER" ) ]]; then
|
||||||
|
print -u2 "Set PUTER_NOTARY_PROFILE or the PUTER_NOTARY_KEY_PATH, PUTER_NOTARY_KEY_ID, and PUTER_NOTARY_ISSUER variables."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$DMG_PATH" ]]; then
|
||||||
|
print -u2 "DMG not found: $DMG_PATH"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
codesign --verify --strict "$DMG_PATH"
|
||||||
|
if [[ -n "$NOTARY_PROFILE" ]]; then
|
||||||
|
xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||||
|
else
|
||||||
|
xcrun notarytool submit "$DMG_PATH" \
|
||||||
|
--key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" --wait
|
||||||
|
fi
|
||||||
|
xcrun stapler staple "$DMG_PATH"
|
||||||
|
xcrun stapler validate "$DMG_PATH"
|
||||||
|
if [[ -d "$APP_PATH" ]]; then
|
||||||
|
xcrun stapler staple "$APP_PATH"
|
||||||
|
xcrun stapler validate "$APP_PATH"
|
||||||
|
fi
|
||||||
|
spctl --assess --type open --context context:primary-signature -v "$DMG_PATH"
|
||||||
|
print "Notarized and stapled $DMG_PATH"
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="${0:A:h:h}"
|
||||||
|
APP_PATH="${1:-$PROJECT_DIR/dist/puter.app}"
|
||||||
|
DMG_PATH="${2:-$PROJECT_DIR/dist/puter-macOS.dmg}"
|
||||||
|
RELEASE_VALIDATION="${PUTER_RELEASE_BUILD:-0}"
|
||||||
|
MOUNT_DIR=""
|
||||||
|
MOUNT_DEVICE=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$MOUNT_DEVICE" ]]; then
|
||||||
|
hdiutil detach "$MOUNT_DEVICE" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if [[ -n "$MOUNT_DIR" && -d "$MOUNT_DIR" ]]; then
|
||||||
|
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
print -u2 "Package validation failed: $1"
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_app() {
|
||||||
|
local app="$1"
|
||||||
|
local contents="$app/Contents"
|
||||||
|
local info="$contents/Info.plist"
|
||||||
|
|
||||||
|
[[ -d "$app" ]] || fail "app bundle not found at $app"
|
||||||
|
[[ -x "$contents/MacOS/puter" ]] || fail "main executable is missing"
|
||||||
|
[[ -x "$contents/Resources/puter-helper" ]] || fail "privileged helper is missing"
|
||||||
|
if [[ "$RELEASE_VALIDATION" == "1" ]]; then
|
||||||
|
[[ -x "$contents/Resources/smc" ]] || fail "release SMC backend is missing"
|
||||||
|
[[ -f "$contents/Resources/Stats-SMC-LICENSE.txt" ]] || fail "release SMC license is missing"
|
||||||
|
elif [[ -e "$contents/Resources/smc" ]]; then
|
||||||
|
[[ -x "$contents/Resources/smc" ]] || fail "bundled SMC backend is not executable"
|
||||||
|
[[ -f "$contents/Resources/Stats-SMC-LICENSE.txt" ]] || fail "bundled SMC license is missing"
|
||||||
|
fi
|
||||||
|
[[ -f "$contents/Library/LaunchDaemons/dev.soconnor.puter.helper.plist" ]] || fail "helper launch daemon plist is missing"
|
||||||
|
[[ -d "$contents/Frameworks/Sparkle.framework" ]] || fail "Sparkle framework is missing"
|
||||||
|
[[ -f "$contents/Resources/Assets.car" ]] || fail "Icon Composer asset catalog is missing"
|
||||||
|
|
||||||
|
plutil -lint "$info" "$contents/Library/LaunchDaemons/dev.soconnor.puter.helper.plist" >/dev/null
|
||||||
|
[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$info")" == "dev.soconnor.puter" ]] \
|
||||||
|
|| fail "unexpected bundle identifier"
|
||||||
|
[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$info")" == "puter" ]] \
|
||||||
|
|| fail "Icon Composer asset is not configured"
|
||||||
|
[[ "$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$info")" == "14.0" ]] \
|
||||||
|
|| fail "unexpected deployment target"
|
||||||
|
if /usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$info" >/dev/null 2>&1; then
|
||||||
|
fail "legacy CFBundleIconFile overrides the Icon Composer asset"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$(otool -l "$contents/MacOS/puter")" == *'@executable_path/../Frameworks'* ]] \
|
||||||
|
|| fail "framework runtime search path is missing"
|
||||||
|
[[ "$(otool -L "$contents/MacOS/puter")" == *'Sparkle.framework'* ]] \
|
||||||
|
|| fail "main executable is not linked to Sparkle"
|
||||||
|
codesign --verify --deep --strict "$app"
|
||||||
|
local signature
|
||||||
|
signature="$(codesign -dvv "$app" 2>&1)"
|
||||||
|
[[ "$signature" == *'runtime'* ]] || fail "Hardened Runtime is missing"
|
||||||
|
|
||||||
|
if [[ "$RELEASE_VALIDATION" == "1" ]]; then
|
||||||
|
[[ "$signature" == *'Authority=Developer ID Application:'* ]] \
|
||||||
|
|| fail "release app is not Developer ID Application signed"
|
||||||
|
[[ -n "$(print -r -- "$signature" | sed -n 's/^TeamIdentifier=//p')" ]] \
|
||||||
|
|| fail "release app has no Team Identifier"
|
||||||
|
/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$info" >/dev/null 2>&1 \
|
||||||
|
|| fail "release app has no Sparkle feed"
|
||||||
|
/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$info" >/dev/null 2>&1 \
|
||||||
|
|| fail "release app has no Sparkle public key"
|
||||||
|
local entitlements
|
||||||
|
entitlements="$(codesign -d --entitlements - "$app" 2>/dev/null || true)"
|
||||||
|
[[ "$entitlements" != *'com.apple.security.get-task-allow'* ]] \
|
||||||
|
|| fail "release app contains get-task-allow"
|
||||||
|
[[ "$entitlements" != *'com.apple.security.cs.disable-library-validation'* ]] \
|
||||||
|
|| fail "release app disables library validation"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_app "$APP_PATH"
|
||||||
|
|
||||||
|
if [[ -f "$DMG_PATH" ]]; then
|
||||||
|
hdiutil verify "$DMG_PATH" >/dev/null
|
||||||
|
if [[ "$RELEASE_VALIDATION" == "1" ]]; then
|
||||||
|
codesign --verify --strict "$DMG_PATH"
|
||||||
|
fi
|
||||||
|
MOUNT_DIR="$(mktemp -d /tmp/puter-package.XXXXXX)"
|
||||||
|
MOUNT_DEVICE="$(hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT_DIR" "$DMG_PATH" | awk '/Apple_APFS/ {print $1; exit}')"
|
||||||
|
[[ -n "$MOUNT_DEVICE" ]] || fail "DMG did not mount"
|
||||||
|
[[ -L "$MOUNT_DIR/Applications" ]] || fail "DMG Applications shortcut is missing"
|
||||||
|
[[ "$(readlink "$MOUNT_DIR/Applications")" == "/Applications" ]] \
|
||||||
|
|| fail "DMG Applications shortcut has the wrong target"
|
||||||
|
validate_app "$MOUNT_DIR/puter.app"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$DMG_PATH" ]]; then
|
||||||
|
print "Validated $APP_PATH and $DMG_PATH"
|
||||||
|
else
|
||||||
|
print "Validated $APP_PATH"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user