diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 53063a6..998fc89 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,6 +21,8 @@ hosting SwiftUI, no third-party dependencies. The Xcode project is generated fro replace → relaunch steps. - `Packages/OpenStatsKit/Sources/HelperShared` — the XPC protocol and maintenance commands shared by the app and the helper. +- `Packages/OpenStatsKit/Sources/AccountSync` — the sync API client, PKCE helpers and the keychain + token store; `server/sync` is the Go service it talks to. - `Packages/OpenStatsKit/Sources/OpenStatsUI` — design tokens, panel pages, settings, menu-bar renderer, app controller, snapshot renderer. - `Packages/OpenStatsKit/Tests` — metrics, SMC decoding, cleanup safety, updates, UI logic and @@ -113,6 +115,22 @@ right before deletion, because apps start in between. Regenerable caches are deleted outright; user files go to the Trash. Each action is appended to `~/Library/Logs/OpenStats/cleanup.log` as a JSON line. +## Disk tools + +The disk page is also where users act on the disk. `DiskToolsController` (`OpenStatsUI/State`) fronts four +pieces of read-mostly logic in `Cleaner/DiskTools.swift`, each testable without the UI: + +- `SpaceScanner` walks a root (the home folder) once, sums every top-level entry and keeps the largest files; + packages (`.app`, `.photoslibrary`, …) count as one item. It never follows symlinks and skips folders it + cannot read. `canTrash` allows Trash only for files inside the home folder, outside `Library`, with no hidden + path component, checked again after resolving symlinks; everything else only gets "reveal in Finder". +- `VolumeVerifier` runs `diskutil verifyVolume /` (read-only, no privileges) and reduces the output to OK / + problem plus the offending line. +- `LocalSnapshots` parses `tmutil listlocalsnapshots /`. Deleting goes through the helper + (`deleteLocalSnapshots`, protocol version 4) and falls back to a one-off administrator prompt; both paths only + accept identifiers shaped like `2026-09-14-120000` (`HelperShared/LocalSnapshotCommand`). +- `MountedVolumes` lists browsable non-root volumes; ejecting uses `NSWorkspace`. + ## Menu bar, popovers and main window `MenuBarController` owns the status items. In the *separate* layout every enabled metric gets @@ -144,7 +162,7 @@ is open and back to accessory when all are closed. format (search tree + data section decoder, no dependencies). `server/geoip/` holds the systemd timer that syncs GeoLite2 onto getopenstats.com with the MaxMind key kept in `/etc/openstats` on the server; the app fetches `geoip/manifest.json`, downloads changed files, verifies sha256 and the - database type, then swaps them in. Without a local database it falls back to ipinfo.io. Country + database type, then swaps them in. Without a local database it falls back to ipapi.is (anonymous, called directly from each Mac; 30 lookups per client IP per day, so results are cached per IP for 24 h and a 429 stops further calls until the next UTC day). Country codes are validated before being used as flag file names. - **Per-process traffic** — cumulative bytes from `/usr/bin/nettop`, diffed between samples. - **DNS** — `networksetup -setdnsservers` through the helper (protocol 3), which re-validates the @@ -163,6 +181,30 @@ used when the folder is not writable), and a detached shell waits for the proces reopening the app. After an update the old helper may still be running; the app disconnects, waits for its 30 s idle exit and checks the protocol version again before asking for a reinstall. +## Account sync + +Optional. `SyncController` (in `OpenStatsUI/State`) signs in through `ASWebAuthenticationSession`: +the app generates a PKCE verifier, opens `https://getopenstats.com/api/v1/auth//start` with the +S256 challenge, the server runs the GitHub / Google / Apple OAuth flow and redirects to +`openstats://auth/callback?code=…` (the scheme is declared in `project.yml`). The app trades the one-time +code plus the verifier for a bearer token, so a stray app that claims the scheme gains nothing. The token +lives in the login keychain (`KeychainTokenStore`; the file keychain, because ad-hoc dev builds have no +application identifier for the data-protection keychain). + +`SettingsDocument` is the synced subset of `AppSettings` — preferences only, never machine state such as the +current tab, helper status, the history database or the GeoIP files. Enums are stored by raw value and every +field is optional, so old and new builds can read each other's documents; `apply` validates each value and +skips what it does not know. `exportDocument()` reads every synced property, which is also how the +controller observes changes (`withObservationTracking`). A change is pushed whole after a 2 s debounce; the +cloud is pulled at launch, on wake and every 15 minutes, and applied when its version differs from the last +one seen. The last pushed or applied document is remembered so applying the cloud copy does not echo back +as a push. On first sign-in: no cloud copy → upload; local still at defaults → apply cloud; both present and +different → the account page asks which to keep. After that the last write wins. + +The service (`server/sync`, Go + SQLite, deployed by `server/sync/install.sh` behind Caddy) stores one +document per user with a version counter, links providers that share a verified email into one user, and +deletes everything on `DELETE /account`. + ## Power, disk and history - System, adapter and battery power come from SMC `PSTR`, `PDTR`, `PPBR`. GPU power comes from the diff --git a/App/Info.plist b/App/Info.plist index ea23e1f..4595412 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -18,6 +18,19 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.openstats.app.auth + CFBundleURLSchemes + + openstats + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) LSApplicationCategoryType diff --git a/Assets/readme/activity.svg b/Assets/readme/activity.svg index 0b575b0..331c338 100644 --- a/Assets/readme/activity.svg +++ b/Assets/readme/activity.svg @@ -1,200 +1,195 @@ -0 commits in the last 26 weeks +44 commits in the last 26 weeks -Apr -May -Jun -Jul -Aug -Sep +Apr +May +Jun +Jul +Aug +Sep Mon Wed Fri -0 commits on 2026-03-15 -0 commits on 2026-03-16 -0 commits on 2026-03-17 -0 commits on 2026-03-18 -0 commits on 2026-03-19 -0 commits on 2026-03-20 -0 commits on 2026-03-21 -0 commits on 2026-03-22 -0 commits on 2026-03-23 -0 commits on 2026-03-24 -0 commits on 2026-03-25 -0 commits on 2026-03-26 -0 commits on 2026-03-27 -0 commits on 2026-03-28 -0 commits on 2026-03-29 -0 commits on 2026-03-30 -0 commits on 2026-03-31 -0 commits on 2026-04-01 -0 commits on 2026-04-02 -0 commits on 2026-04-03 -0 commits on 2026-04-04 -0 commits on 2026-04-05 -0 commits on 2026-04-06 -0 commits on 2026-04-07 -0 commits on 2026-04-08 -0 commits on 2026-04-09 -0 commits on 2026-04-10 -0 commits on 2026-04-11 -0 commits on 2026-04-12 -0 commits on 2026-04-13 -0 commits on 2026-04-14 -0 commits on 2026-04-15 -0 commits on 2026-04-16 -0 commits on 2026-04-17 -0 commits on 2026-04-18 -0 commits on 2026-04-19 -0 commits on 2026-04-20 -0 commits on 2026-04-21 -0 commits on 2026-04-22 -0 commits on 2026-04-23 -0 commits on 2026-04-24 -0 commits on 2026-04-25 -0 commits on 2026-04-26 -0 commits on 2026-04-27 -0 commits on 2026-04-28 -0 commits on 2026-04-29 -0 commits on 2026-04-30 -0 commits on 2026-05-01 -0 commits on 2026-05-02 -0 commits on 2026-05-03 -0 commits on 2026-05-04 -0 commits on 2026-05-05 -0 commits on 2026-05-06 -0 commits on 2026-05-07 -0 commits on 2026-05-08 -0 commits on 2026-05-09 -0 commits on 2026-05-10 -0 commits on 2026-05-11 -0 commits on 2026-05-12 -0 commits on 2026-05-13 -0 commits on 2026-05-14 -0 commits on 2026-05-15 -0 commits on 2026-05-16 -0 commits on 2026-05-17 -0 commits on 2026-05-18 -0 commits on 2026-05-19 -0 commits on 2026-05-20 -0 commits on 2026-05-21 -0 commits on 2026-05-22 -0 commits on 2026-05-23 -0 commits on 2026-05-24 -0 commits on 2026-05-25 -0 commits on 2026-05-26 -0 commits on 2026-05-27 -0 commits on 2026-05-28 -0 commits on 2026-05-29 -0 commits on 2026-05-30 -0 commits on 2026-05-31 -0 commits on 2026-06-01 -0 commits on 2026-06-02 -0 commits on 2026-06-03 -0 commits on 2026-06-04 -0 commits on 2026-06-05 -0 commits on 2026-06-06 -0 commits on 2026-06-07 -0 commits on 2026-06-08 -0 commits on 2026-06-09 -0 commits on 2026-06-10 -0 commits on 2026-06-11 -0 commits on 2026-06-12 -0 commits on 2026-06-13 -0 commits on 2026-06-14 -0 commits on 2026-06-15 -0 commits on 2026-06-16 -0 commits on 2026-06-17 -0 commits on 2026-06-18 -0 commits on 2026-06-19 -0 commits on 2026-06-20 -0 commits on 2026-06-21 -0 commits on 2026-06-22 -0 commits on 2026-06-23 -0 commits on 2026-06-24 -0 commits on 2026-06-25 -0 commits on 2026-06-26 -0 commits on 2026-06-27 -0 commits on 2026-06-28 -0 commits on 2026-06-29 -0 commits on 2026-06-30 -0 commits on 2026-07-01 -0 commits on 2026-07-02 -0 commits on 2026-07-03 -0 commits on 2026-07-04 -0 commits on 2026-07-05 -0 commits on 2026-07-06 -0 commits on 2026-07-07 -0 commits on 2026-07-08 -0 commits on 2026-07-09 -0 commits on 2026-07-10 -0 commits on 2026-07-11 -0 commits on 2026-07-12 -0 commits on 2026-07-13 -0 commits on 2026-07-14 -0 commits on 2026-07-15 -0 commits on 2026-07-16 -0 commits on 2026-07-17 -0 commits on 2026-07-18 -0 commits on 2026-07-19 -0 commits on 2026-07-20 -0 commits on 2026-07-21 -0 commits on 2026-07-22 -0 commits on 2026-07-23 -0 commits on 2026-07-24 -0 commits on 2026-07-25 -0 commits on 2026-07-26 -0 commits on 2026-07-27 -0 commits on 2026-07-28 -0 commits on 2026-07-29 -0 commits on 2026-07-30 -0 commits on 2026-07-31 -0 commits on 2026-08-01 -0 commits on 2026-08-02 -0 commits on 2026-08-03 -0 commits on 2026-08-04 -0 commits on 2026-08-05 -0 commits on 2026-08-06 -0 commits on 2026-08-07 -0 commits on 2026-08-08 -0 commits on 2026-08-09 -0 commits on 2026-08-10 -0 commits on 2026-08-11 -0 commits on 2026-08-12 -0 commits on 2026-08-13 -0 commits on 2026-08-14 -0 commits on 2026-08-15 -0 commits on 2026-08-16 -0 commits on 2026-08-17 -0 commits on 2026-08-18 -0 commits on 2026-08-19 -0 commits on 2026-08-20 -0 commits on 2026-08-21 -0 commits on 2026-08-22 -0 commits on 2026-08-23 -0 commits on 2026-08-24 -0 commits on 2026-08-25 -0 commits on 2026-08-26 -0 commits on 2026-08-27 -0 commits on 2026-08-28 -0 commits on 2026-08-29 -0 commits on 2026-08-30 -0 commits on 2026-08-31 -0 commits on 2026-09-01 -0 commits on 2026-09-02 -0 commits on 2026-09-03 -0 commits on 2026-09-04 -0 commits on 2026-09-05 -0 commits on 2026-09-06 -0 commits on 2026-09-07 -0 commits on 2026-09-08 -0 commits on 2026-09-09 -0 commits on 2026-09-10 -0 commits on 2026-09-11 -0 commits on 2026-09-12 +0 commits on 2026-03-22 +0 commits on 2026-03-23 +0 commits on 2026-03-24 +0 commits on 2026-03-25 +0 commits on 2026-03-26 +0 commits on 2026-03-27 +0 commits on 2026-03-28 +0 commits on 2026-03-29 +0 commits on 2026-03-30 +0 commits on 2026-03-31 +0 commits on 2026-04-01 +0 commits on 2026-04-02 +0 commits on 2026-04-03 +0 commits on 2026-04-04 +0 commits on 2026-04-05 +0 commits on 2026-04-06 +0 commits on 2026-04-07 +0 commits on 2026-04-08 +0 commits on 2026-04-09 +0 commits on 2026-04-10 +0 commits on 2026-04-11 +0 commits on 2026-04-12 +0 commits on 2026-04-13 +0 commits on 2026-04-14 +0 commits on 2026-04-15 +0 commits on 2026-04-16 +0 commits on 2026-04-17 +0 commits on 2026-04-18 +0 commits on 2026-04-19 +0 commits on 2026-04-20 +0 commits on 2026-04-21 +0 commits on 2026-04-22 +0 commits on 2026-04-23 +0 commits on 2026-04-24 +0 commits on 2026-04-25 +0 commits on 2026-04-26 +0 commits on 2026-04-27 +0 commits on 2026-04-28 +0 commits on 2026-04-29 +0 commits on 2026-04-30 +0 commits on 2026-05-01 +0 commits on 2026-05-02 +0 commits on 2026-05-03 +0 commits on 2026-05-04 +0 commits on 2026-05-05 +0 commits on 2026-05-06 +0 commits on 2026-05-07 +0 commits on 2026-05-08 +0 commits on 2026-05-09 +0 commits on 2026-05-10 +0 commits on 2026-05-11 +0 commits on 2026-05-12 +0 commits on 2026-05-13 +0 commits on 2026-05-14 +0 commits on 2026-05-15 +0 commits on 2026-05-16 +0 commits on 2026-05-17 +0 commits on 2026-05-18 +0 commits on 2026-05-19 +0 commits on 2026-05-20 +0 commits on 2026-05-21 +0 commits on 2026-05-22 +0 commits on 2026-05-23 +0 commits on 2026-05-24 +0 commits on 2026-05-25 +0 commits on 2026-05-26 +0 commits on 2026-05-27 +0 commits on 2026-05-28 +0 commits on 2026-05-29 +0 commits on 2026-05-30 +0 commits on 2026-05-31 +0 commits on 2026-06-01 +0 commits on 2026-06-02 +0 commits on 2026-06-03 +0 commits on 2026-06-04 +0 commits on 2026-06-05 +0 commits on 2026-06-06 +0 commits on 2026-06-07 +0 commits on 2026-06-08 +0 commits on 2026-06-09 +0 commits on 2026-06-10 +0 commits on 2026-06-11 +0 commits on 2026-06-12 +0 commits on 2026-06-13 +0 commits on 2026-06-14 +0 commits on 2026-06-15 +0 commits on 2026-06-16 +0 commits on 2026-06-17 +0 commits on 2026-06-18 +0 commits on 2026-06-19 +0 commits on 2026-06-20 +0 commits on 2026-06-21 +0 commits on 2026-06-22 +0 commits on 2026-06-23 +0 commits on 2026-06-24 +0 commits on 2026-06-25 +0 commits on 2026-06-26 +0 commits on 2026-06-27 +0 commits on 2026-06-28 +0 commits on 2026-06-29 +0 commits on 2026-06-30 +0 commits on 2026-07-01 +0 commits on 2026-07-02 +0 commits on 2026-07-03 +0 commits on 2026-07-04 +0 commits on 2026-07-05 +0 commits on 2026-07-06 +0 commits on 2026-07-07 +0 commits on 2026-07-08 +0 commits on 2026-07-09 +0 commits on 2026-07-10 +0 commits on 2026-07-11 +0 commits on 2026-07-12 +0 commits on 2026-07-13 +0 commits on 2026-07-14 +0 commits on 2026-07-15 +0 commits on 2026-07-16 +0 commits on 2026-07-17 +0 commits on 2026-07-18 +0 commits on 2026-07-19 +0 commits on 2026-07-20 +0 commits on 2026-07-21 +0 commits on 2026-07-22 +0 commits on 2026-07-23 +0 commits on 2026-07-24 +0 commits on 2026-07-25 +0 commits on 2026-07-26 +0 commits on 2026-07-27 +0 commits on 2026-07-28 +0 commits on 2026-07-29 +0 commits on 2026-07-30 +0 commits on 2026-07-31 +0 commits on 2026-08-01 +0 commits on 2026-08-02 +0 commits on 2026-08-03 +0 commits on 2026-08-04 +0 commits on 2026-08-05 +0 commits on 2026-08-06 +0 commits on 2026-08-07 +0 commits on 2026-08-08 +0 commits on 2026-08-09 +0 commits on 2026-08-10 +0 commits on 2026-08-11 +0 commits on 2026-08-12 +0 commits on 2026-08-13 +0 commits on 2026-08-14 +0 commits on 2026-08-15 +0 commits on 2026-08-16 +0 commits on 2026-08-17 +0 commits on 2026-08-18 +0 commits on 2026-08-19 +0 commits on 2026-08-20 +0 commits on 2026-08-21 +0 commits on 2026-08-22 +0 commits on 2026-08-23 +0 commits on 2026-08-24 +0 commits on 2026-08-25 +0 commits on 2026-08-26 +0 commits on 2026-08-27 +0 commits on 2026-08-28 +0 commits on 2026-08-29 +0 commits on 2026-08-30 +0 commits on 2026-08-31 +0 commits on 2026-09-01 +0 commits on 2026-09-02 +0 commits on 2026-09-03 +0 commits on 2026-09-04 +0 commits on 2026-09-05 +0 commits on 2026-09-06 +0 commits on 2026-09-07 +0 commits on 2026-09-08 +0 commits on 2026-09-09 +0 commits on 2026-09-10 +0 commits on 2026-09-11 +0 commits on 2026-09-12 +42 commits on 2026-09-13 +2 commits on 2026-09-14 -0 commits in the last 26 weeks · through 2026-09-12 +44 commits in the last 26 weeks · through 2026-09-14 Less diff --git a/Assets/readme/activity.zh.svg b/Assets/readme/activity.zh.svg index 01538f2..a5cb7b7 100644 --- a/Assets/readme/activity.zh.svg +++ b/Assets/readme/activity.zh.svg @@ -1,200 +1,195 @@ -近 26 周共 0 次提交 +近 26 周共 44 次提交 -4月 -5月 -6月 -7月 -8月 -9月 +4月 +5月 +6月 +7月 +8月 +9月 一 三 五 -2026-03-15:0 次提交 -2026-03-16:0 次提交 -2026-03-17:0 次提交 -2026-03-18:0 次提交 -2026-03-19:0 次提交 -2026-03-20:0 次提交 -2026-03-21:0 次提交 -2026-03-22:0 次提交 -2026-03-23:0 次提交 -2026-03-24:0 次提交 -2026-03-25:0 次提交 -2026-03-26:0 次提交 -2026-03-27:0 次提交 -2026-03-28:0 次提交 -2026-03-29:0 次提交 -2026-03-30:0 次提交 -2026-03-31:0 次提交 -2026-04-01:0 次提交 -2026-04-02:0 次提交 -2026-04-03:0 次提交 -2026-04-04:0 次提交 -2026-04-05:0 次提交 -2026-04-06:0 次提交 -2026-04-07:0 次提交 -2026-04-08:0 次提交 -2026-04-09:0 次提交 -2026-04-10:0 次提交 -2026-04-11:0 次提交 -2026-04-12:0 次提交 -2026-04-13:0 次提交 -2026-04-14:0 次提交 -2026-04-15:0 次提交 -2026-04-16:0 次提交 -2026-04-17:0 次提交 -2026-04-18:0 次提交 -2026-04-19:0 次提交 -2026-04-20:0 次提交 -2026-04-21:0 次提交 -2026-04-22:0 次提交 -2026-04-23:0 次提交 -2026-04-24:0 次提交 -2026-04-25:0 次提交 -2026-04-26:0 次提交 -2026-04-27:0 次提交 -2026-04-28:0 次提交 -2026-04-29:0 次提交 -2026-04-30:0 次提交 -2026-05-01:0 次提交 -2026-05-02:0 次提交 -2026-05-03:0 次提交 -2026-05-04:0 次提交 -2026-05-05:0 次提交 -2026-05-06:0 次提交 -2026-05-07:0 次提交 -2026-05-08:0 次提交 -2026-05-09:0 次提交 -2026-05-10:0 次提交 -2026-05-11:0 次提交 -2026-05-12:0 次提交 -2026-05-13:0 次提交 -2026-05-14:0 次提交 -2026-05-15:0 次提交 -2026-05-16:0 次提交 -2026-05-17:0 次提交 -2026-05-18:0 次提交 -2026-05-19:0 次提交 -2026-05-20:0 次提交 -2026-05-21:0 次提交 -2026-05-22:0 次提交 -2026-05-23:0 次提交 -2026-05-24:0 次提交 -2026-05-25:0 次提交 -2026-05-26:0 次提交 -2026-05-27:0 次提交 -2026-05-28:0 次提交 -2026-05-29:0 次提交 -2026-05-30:0 次提交 -2026-05-31:0 次提交 -2026-06-01:0 次提交 -2026-06-02:0 次提交 -2026-06-03:0 次提交 -2026-06-04:0 次提交 -2026-06-05:0 次提交 -2026-06-06:0 次提交 -2026-06-07:0 次提交 -2026-06-08:0 次提交 -2026-06-09:0 次提交 -2026-06-10:0 次提交 -2026-06-11:0 次提交 -2026-06-12:0 次提交 -2026-06-13:0 次提交 -2026-06-14:0 次提交 -2026-06-15:0 次提交 -2026-06-16:0 次提交 -2026-06-17:0 次提交 -2026-06-18:0 次提交 -2026-06-19:0 次提交 -2026-06-20:0 次提交 -2026-06-21:0 次提交 -2026-06-22:0 次提交 -2026-06-23:0 次提交 -2026-06-24:0 次提交 -2026-06-25:0 次提交 -2026-06-26:0 次提交 -2026-06-27:0 次提交 -2026-06-28:0 次提交 -2026-06-29:0 次提交 -2026-06-30:0 次提交 -2026-07-01:0 次提交 -2026-07-02:0 次提交 -2026-07-03:0 次提交 -2026-07-04:0 次提交 -2026-07-05:0 次提交 -2026-07-06:0 次提交 -2026-07-07:0 次提交 -2026-07-08:0 次提交 -2026-07-09:0 次提交 -2026-07-10:0 次提交 -2026-07-11:0 次提交 -2026-07-12:0 次提交 -2026-07-13:0 次提交 -2026-07-14:0 次提交 -2026-07-15:0 次提交 -2026-07-16:0 次提交 -2026-07-17:0 次提交 -2026-07-18:0 次提交 -2026-07-19:0 次提交 -2026-07-20:0 次提交 -2026-07-21:0 次提交 -2026-07-22:0 次提交 -2026-07-23:0 次提交 -2026-07-24:0 次提交 -2026-07-25:0 次提交 -2026-07-26:0 次提交 -2026-07-27:0 次提交 -2026-07-28:0 次提交 -2026-07-29:0 次提交 -2026-07-30:0 次提交 -2026-07-31:0 次提交 -2026-08-01:0 次提交 -2026-08-02:0 次提交 -2026-08-03:0 次提交 -2026-08-04:0 次提交 -2026-08-05:0 次提交 -2026-08-06:0 次提交 -2026-08-07:0 次提交 -2026-08-08:0 次提交 -2026-08-09:0 次提交 -2026-08-10:0 次提交 -2026-08-11:0 次提交 -2026-08-12:0 次提交 -2026-08-13:0 次提交 -2026-08-14:0 次提交 -2026-08-15:0 次提交 -2026-08-16:0 次提交 -2026-08-17:0 次提交 -2026-08-18:0 次提交 -2026-08-19:0 次提交 -2026-08-20:0 次提交 -2026-08-21:0 次提交 -2026-08-22:0 次提交 -2026-08-23:0 次提交 -2026-08-24:0 次提交 -2026-08-25:0 次提交 -2026-08-26:0 次提交 -2026-08-27:0 次提交 -2026-08-28:0 次提交 -2026-08-29:0 次提交 -2026-08-30:0 次提交 -2026-08-31:0 次提交 -2026-09-01:0 次提交 -2026-09-02:0 次提交 -2026-09-03:0 次提交 -2026-09-04:0 次提交 -2026-09-05:0 次提交 -2026-09-06:0 次提交 -2026-09-07:0 次提交 -2026-09-08:0 次提交 -2026-09-09:0 次提交 -2026-09-10:0 次提交 -2026-09-11:0 次提交 -2026-09-12:0 次提交 +2026-03-22:0 次提交 +2026-03-23:0 次提交 +2026-03-24:0 次提交 +2026-03-25:0 次提交 +2026-03-26:0 次提交 +2026-03-27:0 次提交 +2026-03-28:0 次提交 +2026-03-29:0 次提交 +2026-03-30:0 次提交 +2026-03-31:0 次提交 +2026-04-01:0 次提交 +2026-04-02:0 次提交 +2026-04-03:0 次提交 +2026-04-04:0 次提交 +2026-04-05:0 次提交 +2026-04-06:0 次提交 +2026-04-07:0 次提交 +2026-04-08:0 次提交 +2026-04-09:0 次提交 +2026-04-10:0 次提交 +2026-04-11:0 次提交 +2026-04-12:0 次提交 +2026-04-13:0 次提交 +2026-04-14:0 次提交 +2026-04-15:0 次提交 +2026-04-16:0 次提交 +2026-04-17:0 次提交 +2026-04-18:0 次提交 +2026-04-19:0 次提交 +2026-04-20:0 次提交 +2026-04-21:0 次提交 +2026-04-22:0 次提交 +2026-04-23:0 次提交 +2026-04-24:0 次提交 +2026-04-25:0 次提交 +2026-04-26:0 次提交 +2026-04-27:0 次提交 +2026-04-28:0 次提交 +2026-04-29:0 次提交 +2026-04-30:0 次提交 +2026-05-01:0 次提交 +2026-05-02:0 次提交 +2026-05-03:0 次提交 +2026-05-04:0 次提交 +2026-05-05:0 次提交 +2026-05-06:0 次提交 +2026-05-07:0 次提交 +2026-05-08:0 次提交 +2026-05-09:0 次提交 +2026-05-10:0 次提交 +2026-05-11:0 次提交 +2026-05-12:0 次提交 +2026-05-13:0 次提交 +2026-05-14:0 次提交 +2026-05-15:0 次提交 +2026-05-16:0 次提交 +2026-05-17:0 次提交 +2026-05-18:0 次提交 +2026-05-19:0 次提交 +2026-05-20:0 次提交 +2026-05-21:0 次提交 +2026-05-22:0 次提交 +2026-05-23:0 次提交 +2026-05-24:0 次提交 +2026-05-25:0 次提交 +2026-05-26:0 次提交 +2026-05-27:0 次提交 +2026-05-28:0 次提交 +2026-05-29:0 次提交 +2026-05-30:0 次提交 +2026-05-31:0 次提交 +2026-06-01:0 次提交 +2026-06-02:0 次提交 +2026-06-03:0 次提交 +2026-06-04:0 次提交 +2026-06-05:0 次提交 +2026-06-06:0 次提交 +2026-06-07:0 次提交 +2026-06-08:0 次提交 +2026-06-09:0 次提交 +2026-06-10:0 次提交 +2026-06-11:0 次提交 +2026-06-12:0 次提交 +2026-06-13:0 次提交 +2026-06-14:0 次提交 +2026-06-15:0 次提交 +2026-06-16:0 次提交 +2026-06-17:0 次提交 +2026-06-18:0 次提交 +2026-06-19:0 次提交 +2026-06-20:0 次提交 +2026-06-21:0 次提交 +2026-06-22:0 次提交 +2026-06-23:0 次提交 +2026-06-24:0 次提交 +2026-06-25:0 次提交 +2026-06-26:0 次提交 +2026-06-27:0 次提交 +2026-06-28:0 次提交 +2026-06-29:0 次提交 +2026-06-30:0 次提交 +2026-07-01:0 次提交 +2026-07-02:0 次提交 +2026-07-03:0 次提交 +2026-07-04:0 次提交 +2026-07-05:0 次提交 +2026-07-06:0 次提交 +2026-07-07:0 次提交 +2026-07-08:0 次提交 +2026-07-09:0 次提交 +2026-07-10:0 次提交 +2026-07-11:0 次提交 +2026-07-12:0 次提交 +2026-07-13:0 次提交 +2026-07-14:0 次提交 +2026-07-15:0 次提交 +2026-07-16:0 次提交 +2026-07-17:0 次提交 +2026-07-18:0 次提交 +2026-07-19:0 次提交 +2026-07-20:0 次提交 +2026-07-21:0 次提交 +2026-07-22:0 次提交 +2026-07-23:0 次提交 +2026-07-24:0 次提交 +2026-07-25:0 次提交 +2026-07-26:0 次提交 +2026-07-27:0 次提交 +2026-07-28:0 次提交 +2026-07-29:0 次提交 +2026-07-30:0 次提交 +2026-07-31:0 次提交 +2026-08-01:0 次提交 +2026-08-02:0 次提交 +2026-08-03:0 次提交 +2026-08-04:0 次提交 +2026-08-05:0 次提交 +2026-08-06:0 次提交 +2026-08-07:0 次提交 +2026-08-08:0 次提交 +2026-08-09:0 次提交 +2026-08-10:0 次提交 +2026-08-11:0 次提交 +2026-08-12:0 次提交 +2026-08-13:0 次提交 +2026-08-14:0 次提交 +2026-08-15:0 次提交 +2026-08-16:0 次提交 +2026-08-17:0 次提交 +2026-08-18:0 次提交 +2026-08-19:0 次提交 +2026-08-20:0 次提交 +2026-08-21:0 次提交 +2026-08-22:0 次提交 +2026-08-23:0 次提交 +2026-08-24:0 次提交 +2026-08-25:0 次提交 +2026-08-26:0 次提交 +2026-08-27:0 次提交 +2026-08-28:0 次提交 +2026-08-29:0 次提交 +2026-08-30:0 次提交 +2026-08-31:0 次提交 +2026-09-01:0 次提交 +2026-09-02:0 次提交 +2026-09-03:0 次提交 +2026-09-04:0 次提交 +2026-09-05:0 次提交 +2026-09-06:0 次提交 +2026-09-07:0 次提交 +2026-09-08:0 次提交 +2026-09-09:0 次提交 +2026-09-10:0 次提交 +2026-09-11:0 次提交 +2026-09-12:0 次提交 +2026-09-13:42 次提交 +2026-09-14:2 次提交 -近 26 周共 0 次提交 · 截至 2026-09-12 +近 26 周共 44 次提交 · 截至 2026-09-14 少 diff --git a/CHANGELOG.md b/CHANGELOG.md index 18effe0..bb950f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,74 @@ ## 未发布 +### 2026-09-15 + +#### 新增 + +- 网络详情“接口”区块右上角新增重置按钮:把开机后下载 / 上传归零、从现在起重新累计,标题旁标出起算时间,两行改叫“重置后下载 / 上传”;重启后自动回到开机后累计,右键按钮可随时改回。 +- 磁盘页从展示页变成能动手的磁盘工具,新增四张卡片: + - 空间占用:统计家目录里每个文件夹占多少,并列出最大的 20 个文件(应用、照片图库等按整体算一个);可在访达中显示,家目录里、Library 之外的文件可以直接移到废纸篓。只读扫描,文件多时需要几十秒,可随时停止。 + - 文件系统检查:调用系统的 diskutil 对启动盘做一次只读检查,相当于“磁盘工具”的急救但不修改任何东西;记住上次检查的时间与结果,发现问题时提示去磁盘工具修复。 + - 本地快照:列出 Time Machine 留在本机的快照(它们计入“可清除”空间),可一键全部删除;辅助工具够新时由它执行,否则请求一次管理员授权。辅助工具协议升到第 4 版,已安装的旧版会提示重新安装。 + - 其他磁盘:列出外置硬盘、U 盘、镜像与网络共享的容量,可在访达中显示或直接推出;接入、拔出时自动刷新。 + +#### 新增 + +- 网络详情新增「IP 纯净度」区块:CleanIP.io 的纯净度评分与等级画成 F 到 A+ 的六段色带,得分处有标记;下面列出风险评分、命中的风险标记(VPN、代理、Tor、机房、滥用记录等)与一句评价,可打开完整报告。IP 地址区块也多了反向解析、网络类型与接入方式、IP 类型(住宅 / 机房)、原生 / 广播、住宅概率。 +- 「设置 · 网络」新增归属地数据源选择:CleanIP.io(默认,信息最全、中文地名)、ipapi.is、DB-IP、ipinfo.io 或本地 GeoLite2 数据库;每一项都注明会把公网 IP 发给谁。在线数据源都由每台 Mac 自己直接查、不经过我们的服务器,同一个公网 IP 的结果会记住一段时间,收到限流后当天不再请求;切换数据源后立即重新查询。 + +#### 调整 + +- 「设置 · 菜单栏」每个显示项目下面多了一行“菜单栏风格”:给这一项单独选一种风格(默认跟随整体,菜单里标出整体现在是哪种),旁边是这一项按当前风格、实时读数画出的预览,改了立刻能看到。以前这个选择藏在开关旁一个没有说明的下拉里,不容易发现。温度、风扇只提供文字类的三种风格;原“风格”分组改名“整体风格”并加了说明。 +- 菜单栏弹窗右上角的两个按钮:左边改为该指标自己的图标(网络是网络图标、磁盘是硬盘),点了直接进主窗口里这个指标的页面;右边的齿轮进总设置。 +- 网络详情的 IP 地址区块精简:原生 / 广播、住宅 / 机房、运营商类型(ISP 等)改为 cleanip.io 样式的徽章,去掉反向解析、网络类型、住宅概率、数据来源四行;数据来源改为标题后的小标记,CleanIP.io 显示它的字标:与标题文字同高、不留上下空白,点了打开 cleanip.io。徽章样式作为通用组件收进设计系统。 +- 公网 IP 双栈支持:IPv4 与 IPv6 各自查询归属地与纯净度、各自缓存;两族都有时 IP 地址区块用胶囊开关切换 IPv4 / IPv6。纯净度区块的标题一行排开:地址族徽章(只有一族时不标)、置信度徽章(高绿、中灰、低黄)、完整报告链接,右侧是刷新按钮;IP 地址区块也有同样的刷新按钮,都是忽略缓存立即重查,查询进行中按钮变成系统的转圈。 +- 公网 IP 的归属地结果保存在本机:点开网络详情立刻显示上次的结果,后台只向 Cloudflare 核对一下地址;地址没变且不满 7 天就不再查归属地,换了 IP、超过 7 天或点了刷新才重新查。 +- 网络详情的纯净度色带占满整行,分数放到色带上方,同一行右侧是 cleanip.io 字标,点了打开这个 IP 的完整报告。 +- 磁盘页与磁盘弹窗的“读取 / 写入”两组数值各占一半宽度,数字长短变化时位置不再左右挪动。 +- 磁盘“读写最多的应用”与网络详情“高占用进程”改为稳定的 5 条榜单:按最近十几秒的平均速率排序和画条,数字仍是当前速率;刚安静下来的应用会在榜上停留几秒再退出,不再随每秒的波动忽隐忽现、上下乱跳。 +- 磁盘页与磁盘弹窗的容量改为分段条:已用、可清除、可用三段按比例排在一条里,段内直接标名称与百分比,下方图例给出各自的容量;可清除是系统随时可以腾出的缓存,访达的“可用”把它算在内。 +- 磁盘页“读写最多的应用”与 CPU、内存详情“按应用汇总”的排行条不再画灰色底槽,只保留代表相对占比的蓝色条。 +- 「设置 · 菜单栏」的显示项目上方加了一条说明:按住 ⌘ 拖动可以调整菜单栏图标的顺序,新开启的项目由系统安排位置;各页面右上角的开关悬停时也有同样的提示。官网常见问题同步补充。 + +#### 修复 + +- 网络详情的卡片被 IP 地址标题行撑宽、两侧几乎没有留白:标题、数据来源字标、IPv4 / IPv6 切换与刷新按钮一行放不下时字标自动缩小,切换胶囊也收窄了一点,卡片恢复与其他弹窗一致的边距。 +- 菜单栏弹窗的内容整体偏左、右边留白更宽:接了鼠标时系统默认常驻滚动条,滚动区域给它预留了一条宽度。现在应用内一律用浮层滚动条,内容占满整个弹窗宽度,主窗口页面同样处理。 +- 网络详情里的归属地国旗画错:中国、乌兹别克斯坦等 63 面旗子的 SVG 用了嵌套引用,系统渲染器画成一大块白。现在国旗改为预先渲染好的 PNG,全部按参考图核对过。 +- 各监控页右上角的“在菜单栏显示”开关点不动:页面的滚动区域会自动向上延伸到顶栏底下,把开关的点击截走了;现在顶栏盖在滚动区域之上,GPU、磁盘等页面的开关可以正常点击。 + +#### 新增 + +- 账号与设置同步(设置 · 账号与同步):用 GitHub、Google 或 Apple 登录后,菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限会保存到 getopenstats.com,换一台 Mac 登录即自动恢复。设置改动 2 秒后上传,启动、唤醒与每 15 分钟检查一次云端;第一次登录时本机与云端都有内容且不同,会让你选用哪一份,之后以最后写入为准。登录走系统的授权窗口,令牌存在钥匙串,云端只保存邮箱、姓名与设置文档,可随时退出或删除云端数据。不登录时应用行为不变。 + +### 2026-09-15 + +#### 新增 + +- CPU 走势时长可选 1 / 3 / 5 分钟:走势线上方一行小字切换,弹窗与主窗口共用;曲线按真实采样时间定位,最新一次采样固定在右边缘,睡眠等超过 10 秒没有采样的地方线条断开。 +- 菜单栏新增“磁盘”项目:显示启动磁盘已用占比,八种风格都可用;点击弹出磁盘详情(容量与可用空间、读写速度、SSD 健康、读写最多的应用),区块可在设置里逐个隐藏。主窗口磁盘页右上角同样可以直接开关,“温度与风扇”页右上角现在有温度、风扇两个开关。 +- CPU 详情新增“各核心占用”区块:主窗口里每个核心一个小圆环,中间写百分比,按“核心 1…N”编号并按类型分组,超过 60% 变橙、85% 变红;弹窗里是每核一根柱子的紧凑版,默认关闭,可在“设置 · 菜单栏”的 CPU 弹窗显示里打开。 + +#### 调整 + +- 卸载应用:去掉“同时从程序坞移除图标”开关,卸载时始终移除程序坞图标;应用列表的滚动条改为只在滚动时显示的细条(之前接了鼠标会显示一条粗的传统滚动条),升级说明里的滚动条同样修正。 +- 主窗口的 CPU 走势图加上 0–100% 刻度与起点、中点、现在三个时间标签。 +- CPU 详情的解释性文字不再常驻:热力图怎么看、每类核心是干什么的、平均负载怎么理解、按应用汇总为什么会超过 100%,都收进区块标题旁的 ⓘ,鼠标悬停 ⓘ 或卡片内容时才显示,界面上只留数据。 +- 核心类型说人话:核心分工的悬停说明写明每类核心是干什么的(最快的处理重活、更省电的负责后台和轻量任务,系统自动分配);各核心占用的分组标题、热力图的行标签都带上核心数量,热力图说明写明“每行一个核心(共 N 个),颜色越深越忙”;最忙的核心改为“核心 7(性能核)”这种统一编号。 + +### 2026-09-14 + +#### 新增 + +- 系统通知新增“CPU 持续高负载”:总占用持续 1 分钟高于设定值(70% / 80% / 90%)时提醒,点通知打开 CPU 详情。 +- 菜单栏风格新增“折线历史”:最近 30 次采样(约 1 分钟)的走势折线,线下填淡色;设置里有示例预览。 + +#### 调整 + +- CPU 详情重新整理:顶部直接显示 CPU 温度(下方附离 100°C 的余量,不再把余量当主数值),走势线下标出最近 60 秒的峰值;核心热力图右侧加一根粗条画此刻各核心的占用;核心分工标出芯片型号,频率跟在占用后面用次要颜色显示;按应用汇总注明“以单核满载为 100%”,解释各应用相加为什么会超过顶部的总占用。 +- 主窗口的 CPU 页:走势线加高并带 25% / 50% / 75% 参考线,核心分工与排队程度并排显示,不再整页单列拉长。 + ### 2026-09-13 #### 新增 diff --git a/Helper/main.swift b/Helper/main.swift index a4fad57..7f3c708 100644 --- a/Helper/main.swift +++ b/Helper/main.swift @@ -138,6 +138,22 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto } } + func deleteLocalSnapshots(identifiers: [String], reply: @escaping @Sendable (String?) -> Void) { + queue.async { [self] in + guard !identifiers.isEmpty, identifiers.count <= LocalSnapshotCommand.maxCount, + identifiers.allSatisfy(LocalSnapshotCommand.isValidIdentifier) else { return reply("无效的快照标识") } + for identifier in identifiers { + let result = runTool(LocalSnapshotCommand.tool, LocalSnapshotCommand.arguments(identifier: identifier)) + guard result.status == 0 else { + log.error("删除快照失败:\(result.output, privacy: .public)") + return reply("tmutil 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))") + } + } + log.notice("已删除 \(identifiers.count) 个本地快照") + reply(nil) + } + } + // MARK: 状态恢复 private func connectionClosed(_ id: ObjectIdentifier) { diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift index 2fa7d6b..d9df347 100644 --- a/Packages/OpenStatsKit/Package.swift +++ b/Packages/OpenStatsKit/Package.swift @@ -12,6 +12,7 @@ let package = Package( .library(name: "HelperShared", targets: ["HelperShared"]), .library(name: "Cleaner", targets: ["Cleaner"]), .library(name: "Updates", targets: ["Updates"]), + .library(name: "AccountSync", targets: ["AccountSync"]), .library(name: "OpenStatsUI", targets: ["OpenStatsUI"]), ], targets: [ @@ -21,12 +22,14 @@ let package = Package( .target(name: "HelperShared", dependencies: ["Localization"]), .target(name: "Cleaner", dependencies: ["Localization"]), .target(name: "Updates", dependencies: ["Localization"]), - .target(name: "OpenStatsUI", dependencies: ["Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates"], - resources: [.copy("Resources/Flags")]), + .target(name: "AccountSync", dependencies: ["Localization"]), + .target(name: "OpenStatsUI", dependencies: ["Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "AccountSync"], + resources: [.copy("Resources/Flags"), .copy("Resources/Logos")]), .testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]), .testTarget(name: "CleanerTests", dependencies: ["Cleaner"]), .testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]), .testTarget(name: "UpdatesTests", dependencies: ["Updates"]), + .testTarget(name: "AccountSyncTests", dependencies: ["AccountSync"]), .testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics"]), .testTarget(name: "LocalizationTests", dependencies: ["Localization"]), ], diff --git a/Packages/OpenStatsKit/Sources/AccountSync/SyncClient.swift b/Packages/OpenStatsKit/Sources/AccountSync/SyncClient.swift new file mode 100644 index 0000000..1f378ad --- /dev/null +++ b/Packages/OpenStatsKit/Sources/AccountSync/SyncClient.swift @@ -0,0 +1,221 @@ +import CryptoKit +import Foundation +import Localization + +/// 支持的登录方式;服务器 /auth/providers 告诉应用哪些已经配置好 +public enum SyncProvider: String, CaseIterable, Codable, Identifiable, Sendable { + case github, google, apple + + public var id: String { rawValue } + + public var title: String { + switch self { + case .github: "GitHub" + case .google: "Google" + case .apple: "Apple" + } + } +} + +public struct SyncUser: Codable, Equatable, Sendable { + public var id: String + public var email: String? + public var name: String? + public var avatar: URL? + public var providers: [String] + + public init(id: String, email: String? = nil, name: String? = nil, avatar: URL? = nil, providers: [String] = []) { + self.id = id + self.email = email + self.name = name + self.avatar = avatar + self.providers = providers + } + + /// 界面上显示的名字:姓名,其次邮箱 + public var displayName: String { name?.isEmpty == false ? name! : (email ?? id) } +} + +/// 云端保存的一份设置:整份覆盖,版本号递增 +public struct RemoteSettings: Codable, Sendable { + public var version: Int + public var updatedAt: Date + public var device: String? + public var document: Document +} + +public struct SettingsReceipt: Codable, Equatable, Sendable { + public var version: Int + public var updatedAt: Date +} + +public struct SyncSession: Codable, Equatable, Sendable { + public var token: String + public var user: SyncUser +} + +public enum SyncError: Error, Equatable, LocalizedError { + /// 令牌已作废(退出登录、删除账号或服务器清理) + case unauthorized + case server(Int, String) + case transport(String) + case invalidCallback + case cancelled + + public var errorDescription: String? { + switch self { + case .unauthorized: tr("登录已失效,请重新登录") + case .server(let status, let message): message.isEmpty ? tr("服务器返回 \(status)") : message + case .transport(let message): message + case .invalidCallback: tr("登录回调不正确") + case .cancelled: tr("已取消登录") + } + } +} + +/// 同步服务的 HTTP 客户端:只做请求与解码,不保存状态 +public struct SyncClient: Sendable { + public static let defaultBaseURL = URL(string: "https://getopenstats.com/api/v1")! + /// 应用在 Info.plist 里注册的 URL scheme,登录完成后浏览器跳回这里 + public static let callbackScheme = "openstats" + + public let baseURL: URL + private let session: URLSession + + public init(baseURL: URL = SyncClient.defaultBaseURL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + } + + // MARK: 登录 + + /// 浏览器打开的登录入口;challenge 是 PKCE verifier 的 S256 摘要 + public func loginURL(provider: SyncProvider, challenge: String) -> URL { + var components = URLComponents(url: baseURL.appending(path: "auth/\(provider.rawValue)/start"), resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "challenge", value: challenge)] + return components.url! + } + + /// 解析 openstats://auth/callback?code=… 或 ?error=… + public static func parseCallback(_ url: URL) -> Result { + guard url.scheme == callbackScheme, + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { + return .failure(.invalidCallback) + } + if let code = items.first(where: { $0.name == "code" })?.value, !code.isEmpty { + return .success(code) + } + let error = items.first(where: { $0.name == "error" })?.value ?? "" + return .failure(error == "cancelled" ? .cancelled : .server(0, error)) + } + + public func providers() async throws -> [SyncProvider] { + struct Response: Decodable { var providers: [String] } + let response: Response = try await send(request("GET", "auth/providers")) + return response.providers.compactMap(SyncProvider.init(rawValue:)) + } + + public func exchange(code: String, verifier: String, device: String) async throws -> SyncSession { + struct Body: Encodable { var code: String; var verifier: String; var device: String } + return try await send(request("POST", "auth/exchange", body: Body(code: code, verifier: verifier, device: device))) + } + + public func me(token: String) async throws -> SyncUser { + try await send(request("GET", "me", token: token)) + } + + public func logout(token: String) async throws { + _ = try await sendRaw(request("POST", "auth/logout", token: token)) + } + + public func deleteAccount(token: String) async throws { + _ = try await sendRaw(request("DELETE", "account", token: token)) + } + + // MARK: 设置 + + /// 没有保存过时返回 nil + public func fetchSettings(token: String, as type: Document.Type) async throws -> RemoteSettings? { + let (data, status) = try await sendRaw(request("GET", "settings", token: token), allowing: [404]) + guard status != 404 else { return nil } + return try Self.decoder.decode(RemoteSettings.self, from: data) + } + + public func putSettings(token: String, document: Document, device: String) async throws -> SettingsReceipt { + try await send(request("PUT", "settings", token: token, body: PutSettingsBody(document: document, device: device))) + } + + private struct PutSettingsBody: Encodable { + var document: Document + var device: String + } + + // MARK: 请求 + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + private func request(_ method: String, _ path: String, token: String? = nil, body: (some Encodable)? = nil as Data?) -> URLRequest { + var request = URLRequest(url: baseURL.appending(path: path), cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) + request.httpMethod = method + request.setValue("OpenStats", forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONEncoder().encode(body) + } + return request + } + + private func send(_ request: URLRequest) async throws -> Response { + let (data, _) = try await sendRaw(request) + do { + return try Self.decoder.decode(Response.self, from: data) + } catch { + throw SyncError.server(200, tr("服务器返回的数据格式不正确")) + } + } + + /// 返回响应体与状态码;2xx 与 allowing 之外的状态码转成 SyncError + private func sendRaw(_ request: URLRequest, allowing: Set = []) async throws -> (Data, Int) { + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw SyncError.transport(error.localizedDescription) + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + if (200..<300).contains(status) || allowing.contains(status) { return (data, status) } + if status == 401 { throw SyncError.unauthorized } + struct Failure: Decodable { var error: String } + let message = (try? JSONDecoder().decode(Failure.self, from: data))?.error ?? "" + throw SyncError.server(status, message) + } +} + +/// PKCE(RFC 7636):verifier 留在应用里,只把 S256 摘要交给服务器 +public enum PKCE { + public static func verifier() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes).base64URLEncoded + } + + public static func challenge(for verifier: String) -> String { + Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded + } +} + +extension Data { + var base64URLEncoded: String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Packages/OpenStatsKit/Sources/AccountSync/TokenStore.swift b/Packages/OpenStatsKit/Sources/AccountSync/TokenStore.swift new file mode 100644 index 0000000..1a02e0d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/AccountSync/TokenStore.swift @@ -0,0 +1,74 @@ +import Foundation +import Security + +/// 保存登录令牌的地方:正式环境是钥匙串,测试用内存 +public protocol TokenStore: Sendable { + func read() -> String? + func write(_ token: String) throws + func delete() +} + +/// 登录钥匙串里的通用密码项。用文件式钥匙串而不是数据保护钥匙串:后者要求应用有 application-identifier, +/// 本地 ad-hoc 签名的开发构建没有 +public struct KeychainTokenStore: TokenStore { + public let service: String + public let account: String + + public init(service: String = "com.openstats.sync", account: String = "session") { + self.service = service + self.account = account + } + + private var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account] + } + + public func read() -> String? { + var query = query + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + public func write(_ token: String) throws { + let data = Data(token.utf8) + let update = [kSecValueData as String: data] + var status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = data + item[kSecAttrLabel as String] = "OpenStats 同步" + status = SecItemAdd(item as CFDictionary, nil) + } + guard status == errSecSuccess else { throw KeychainError(status: status) } + } + + public func delete() { + SecItemDelete(query as CFDictionary) + } +} + +public struct KeychainError: Error, LocalizedError { + public let status: OSStatus + + public var errorDescription: String? { + (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error \(status)" + } +} + +/// 测试与截图用的内存实现 +public final class MemoryTokenStore: TokenStore, @unchecked Sendable { + private let lock = NSLock() + private var token: String? + + public init(token: String? = nil) { self.token = token } + + public func read() -> String? { lock.withLock { token } } + public func write(_ token: String) { lock.withLock { self.token = token } } + public func delete() { lock.withLock { token = nil } } +} diff --git a/Packages/OpenStatsKit/Sources/Cleaner/DiskTools.swift b/Packages/OpenStatsKit/Sources/Cleaner/DiskTools.swift new file mode 100644 index 0000000..e7704f1 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Cleaner/DiskTools.swift @@ -0,0 +1,349 @@ +import Foundation +import Localization + +// MARK: - Time Machine 本地快照 + +/// Time Machine 在本机保留的快照。它们计入“可清除”空间,系统缺空间时会自动删,也可以手动清掉 +public struct LocalSnapshot: Sendable, Equatable, Identifiable { + public let name: String + /// 形如 2026-09-14-120000,删除时用它指定快照 + public let identifier: String + public let date: Date? + + public var id: String { name } +} + +public enum LocalSnapshots { + static let prefix = "com.apple.TimeMachine." + static let suffix = ".local" + + /// 解析 `tmutil listlocalsnapshots /` 的输出 + public static func parse(_ output: String) -> [LocalSnapshot] { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + formatter.timeZone = .current + return output.split(whereSeparator: \.isNewline).compactMap { line in + let name = line.trimmingCharacters(in: .whitespaces) + guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { return nil } + let identifier = String(name.dropFirst(prefix.count).dropLast(suffix.count)) + return LocalSnapshot(name: name, identifier: identifier, date: formatter.date(from: identifier)) + } + .sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) } + } + + /// 列出启动盘上的本地快照;tmutil 只读列出不需要权限 + public static func list() -> [LocalSnapshot] { + parse(ShellTool.run("/usr/bin/tmutil", ["listlocalsnapshots", "/"]).output) + } +} + +// MARK: - 文件系统检查 + +public struct VolumeVerification: Sendable, Equatable { + public let ok: Bool + /// 一句话结论 + public let summary: String + /// diskutil 的完整输出 + public let log: String + public let date: Date + + public init(ok: Bool, summary: String, log: String, date: Date = Date()) { + self.ok = ok + self.summary = summary + self.log = log + self.date = date + } +} + +/// 用系统自带的 diskutil 做一次只读的文件系统检查,相当于“磁盘工具”里的急救但不修改任何东西。 +/// 检查启动盘不需要管理员权限,通常几秒到几十秒 +public enum VolumeVerifier { + public static func verify(volume: String = "/") -> VolumeVerification { + let result = ShellTool.run("/usr/sbin/diskutil", ["verifyVolume", volume]) + return interpret(status: result.status, output: result.output) + } + + static func interpret(status: Int32, output: String) -> VolumeVerification { + let lines = output.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) } + let ok = status == 0 && lines.contains { $0.contains("appears to be OK") } + let summary: String + if ok { + summary = tr("文件系统结构完好,没有发现错误") + } else if let problem = lines.first(where: { $0.lowercased().contains("error") || $0.lowercased().contains("invalid") || $0.lowercased().contains("corrupt") }) { + summary = tr("发现问题:\(problem)") + } else if status != 0 { + summary = tr("检查未能完成(退出码 \(status))") + } else { + summary = tr("检查完成,但没有得到明确结论") + } + return VolumeVerification(ok: ok, summary: summary, log: output) + } +} + +// MARK: - 已装载的卷宗 + +public struct MountedVolume: Sendable, Equatable, Identifiable { + public let url: URL + public let name: String + public let total: UInt64 + public let available: UInt64 + public let isInternal: Bool + public let isRemovable: Bool + public let isEjectable: Bool + public let isLocal: Bool + + public var id: String { url.path } + public var used: UInt64 { total > available ? total - available : 0 } + public var usedFraction: Double { total == 0 ? 0 : Double(used) / Double(total) } +} + +public enum MountedVolumes { + /// 启动盘之外、访达里能看到的卷:外置硬盘、U 盘、镜像、网络共享 + public static func list() -> [MountedVolume] { + let keys: Set = [.volumeNameKey, .volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey, + .volumeIsInternalKey, .volumeIsRemovableKey, .volumeIsEjectableKey, + .volumeIsRootFileSystemKey, .volumeIsBrowsableKey, .volumeIsLocalKey] + let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: Array(keys), options: [.skipHiddenVolumes]) ?? [] + return urls.compactMap { url in + guard let values = try? url.resourceValues(forKeys: keys), + values.volumeIsRootFileSystem != true, values.volumeIsBrowsable != false, + let total = values.volumeTotalCapacity, total > 0 else { return nil } + return MountedVolume(url: url, + name: values.volumeName ?? url.lastPathComponent, + total: UInt64(total), + available: UInt64(max(0, values.volumeAvailableCapacityForImportantUsage ?? 0)), + isInternal: values.volumeIsInternal ?? false, + isRemovable: values.volumeIsRemovable ?? false, + isEjectable: values.volumeIsEjectable ?? false, + isLocal: values.volumeIsLocal ?? true) + } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } +} + +// MARK: - 空间占用分析 + +public struct SpaceScanResult: Sendable, Equatable { + public struct Folder: Sendable, Equatable, Identifiable { + public let url: URL + public let name: String + public let bytes: UInt64 + public let items: Int + public var id: String { url.path } + } + + public struct File: Sendable, Equatable, Identifiable { + public let url: URL + public let bytes: UInt64 + public let modified: Date? + public let isPackage: Bool + public var id: String { url.path } + public var name: String { url.lastPathComponent } + } + + public let root: URL + /// 根目录下每个一级条目,按大小降序 + public let folders: [Folder] + /// 最大的文件(应用、照片图库等包按整体算一个),按大小降序 + public let largestFiles: [File] + public let scannedItems: Int + public let totalBytes: UInt64 + public let date: Date + + public init(root: URL, folders: [Folder], largestFiles: [File], scannedItems: Int, totalBytes: UInt64, date: Date) { + self.root = root + self.folders = folders + self.largestFiles = largestFiles + self.scannedItems = scannedItems + self.totalBytes = totalBytes + self.date = date + } +} + +public struct SpaceScanProgress: Sendable, Equatable { + public var items: Int + public var bytes: UInt64 + public var current: String + + public init(items: Int, bytes: UInt64, current: String) { + self.items = items + self.bytes = bytes + self.current = current + } +} + +/// 扫描一个目录(默认家目录):算出每个一级条目占多少,并找出最大的文件。 +/// 用 fts 逐层遍历,比 FileManager 的枚举器快一个数量级;只读,不跟随符号链接、不跨越挂载点, +/// 没有权限的目录跳过。扫描在调用方的任务里进行,任务取消即停止 +public enum SpaceScanner { + public static let largestFileCount = 20 + /// 小于这个体积的文件不进“最大的文件” + public static let largestFileFloor: UInt64 = 50_000_000 + static let progressEvery = 5_000 + /// 按整体算一个条目的包:应用、各种图库、工程与文档包 + static let packageExtensions: Set = [ + "app", "appex", "bundle", "framework", "plugin", "kext", "xpc", "qlgenerator", "prefpane", + "photoslibrary", "musiclibrary", "tvlibrary", "imovielibrary", "fcpbundle", "logicx", "band", + "xcodeproj", "xcworkspace", "playground", "xcarchive", "dSYM", + "pages", "numbers", "key", "rtfd", "scriv", "sparsebundle", "pvm", "vmwarevm", "utm", "pkg", "mpkg", + ] + + public static func scan(root: URL = FileManager.default.homeDirectoryForCurrentUser, + progress: @Sendable (SpaceScanProgress) -> Void = { _ in }) throws -> SpaceScanResult { + let manager = FileManager.default + let keys: Set = [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey, + .totalFileAllocatedSizeKey, .contentModificationDateKey] + let entries = (try? manager.contentsOfDirectory(at: root, includingPropertiesForKeys: Array(keys), options: [])) ?? [] + + var folders: [SpaceScanResult.Folder] = [] + var candidates: [SpaceScanResult.File] = [] + var scanned = 0 + var totalBytes: UInt64 = 0 + + func consider(_ file: SpaceScanResult.File) { + guard file.bytes >= largestFileFloor else { return } + if candidates.count < largestFileCount { + candidates.append(file) + } else if let smallest = candidates.indices.min(by: { candidates[$0].bytes < candidates[$1].bytes }), + candidates[smallest].bytes < file.bytes { + candidates[smallest] = file + } + } + + for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + try Task.checkCancellation() + guard let values = try? entry.resourceValues(forKeys: keys), values.isSymbolicLink != true else { continue } + var bytes: UInt64 = 0 + var items = 0 + if values.isDirectory == true { + let walked = try walk(entry, scannedBefore: scanned, bytesBefore: totalBytes, consider: consider, progress: progress) + bytes = walked.bytes + items = walked.items + } else if values.isRegularFile == true { + bytes = UInt64(values.totalFileAllocatedSize ?? 0) + items = 1 + consider(SpaceScanResult.File(url: entry, bytes: bytes, modified: values.contentModificationDate, isPackage: false)) + } else { + continue + } + scanned += items + totalBytes += bytes + folders.append(SpaceScanResult.Folder(url: entry, name: entry.lastPathComponent, bytes: bytes, items: items)) + progress(SpaceScanProgress(items: scanned, bytes: totalBytes, current: entry.lastPathComponent)) + } + + return SpaceScanResult(root: root, + folders: folders.sorted { $0.bytes > $1.bytes }, + largestFiles: candidates.sorted { $0.bytes > $1.bytes }, + scannedItems: scanned, totalBytes: totalBytes, date: Date()) + } + + /// 用 fts 遍历一个目录:返回占用与条目数;包整体作为一个候选,包里的文件不单独进候选 + private static func walk(_ directory: URL, scannedBefore: Int, bytesBefore: UInt64, + consider: (SpaceScanResult.File) -> Void, + progress: (SpaceScanProgress) -> Void) throws -> (bytes: UInt64, items: Int) { + var argv: [UnsafeMutablePointer?] = [strdup(directory.path), nil] + defer { free(argv[0]) } + guard let stream = fts_open(&argv, FTS_PHYSICAL | FTS_NOCHDIR | FTS_XDEV, nil) else { return (0, 0) } + defer { fts_close(stream) } + + var bytes: UInt64 = 0 + var items = 0 + // 正在经过的包(最外层在前);内层包的体积并入外层 + var packages: [(path: String, bytes: UInt64, modified: Date?)] = [] + let name = directory.lastPathComponent + + while let entry = fts_read(stream) { + let info = Int32(entry.pointee.fts_info) + // 顶层目录本身不算条目 + if entry.pointee.fts_level == 0 { continue } + switch info { + case FTS_D: + items += 1 + let path = String(cString: entry.pointee.fts_path) + if isPackage(path) { + let stat = entry.pointee.fts_statp.pointee + packages.append((path, 0, Date(timeIntervalSince1970: TimeInterval(stat.st_mtimespec.tv_sec)))) + } + case FTS_DP: + let path = String(cString: entry.pointee.fts_path) + if let last = packages.last, last.path == path { + packages.removeLast() + if packages.isEmpty { + consider(SpaceScanResult.File(url: URL(fileURLWithPath: path), bytes: last.bytes, modified: last.modified, isPackage: true)) + } else { + packages[packages.count - 1].bytes += last.bytes + } + } + case FTS_F: + items += 1 + let stat = entry.pointee.fts_statp.pointee + let size = UInt64(max(0, stat.st_blocks)) * 512 + bytes += size + if packages.isEmpty { + consider(SpaceScanResult.File(url: URL(fileURLWithPath: String(cString: entry.pointee.fts_path)), bytes: size, + modified: Date(timeIntervalSince1970: TimeInterval(stat.st_mtimespec.tv_sec)), isPackage: false)) + } else { + packages[packages.count - 1].bytes += size + } + default: + // 符号链接、没权限的目录、读取出错的条目都跳过 + continue + } + if items % progressEvery == 0 { + try Task.checkCancellation() + progress(SpaceScanProgress(items: scannedBefore + items, bytes: bytesBefore + bytes, current: name)) + } + } + return (bytes, items) + } + + static func isPackage(_ path: String) -> Bool { + let ext = (path as NSString).pathExtension + return !ext.isEmpty && packageExtensions.contains(ext.lowercased()) || packageExtensions.contains(ext) + } + + /// 能不能把扫描出来的文件移到废纸篓:必须在家目录里、不在 Library 里、路径里没有隐藏目录, + /// 解析符号链接后仍然满足。不满足的只提供“在访达中显示” + public static func canTrash(_ url: URL, home: URL = FileManager.default.homeDirectoryForCurrentUser) -> Bool { + let homePath = home.standardizedFileURL.path + for candidate in [url.standardizedFileURL, url.resolvingSymlinksInPath().standardizedFileURL] { + let path = candidate.path + guard path.hasPrefix(homePath + "/") else { return false } + let relative = path.dropFirst(homePath.count + 1) + let components = relative.split(separator: "/") + guard components.count >= 2 else { return false } + guard components.first != "Library" else { return false } + guard !components.contains(where: { $0.hasPrefix(".") }) else { return false } + } + return true + } + + /// 移到废纸篓(可恢复);不允许的路径直接报错 + public static func trash(_ url: URL) throws { + guard canTrash(url) else { throw CocoaError(.fileWriteNoPermission) } + try FileManager.default.trashItem(at: url, resultingItemURL: nil) + } +} + +// MARK: - 外部命令 + +enum ShellTool { + static func run(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + } catch { + return (-1, error.localizedDescription) + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (process.terminationStatus, String(decoding: data, as: UTF8.self)) + } +} diff --git a/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift index f4c7462..00c9a41 100644 --- a/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift +++ b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift @@ -6,7 +6,7 @@ public enum HelperConstants { public static let launchdPlistName = "com.openstats.helper.plist" public static let appBundleIdentifier = "com.openstats.app" /// 与 App 版本同步;App 发现辅助工具版本不一致时提示重新安装 - public static let protocolVersion = 3 + public static let protocolVersion = 4 /// 辅助工具对调用方的签名要求:与辅助工具自身同一团队签名的 OpenStats。 /// ad-hoc 签名的开发构建没有团队,只能校验 bundle identifier。 @@ -43,6 +43,8 @@ public enum CodeSigningInfo { func purgeMemory(reply: @escaping @Sendable (String?) -> Void) /// 为指定网络服务设置 DNS;`servers` 为空表示恢复自动获取。辅助工具会校验服务名与每个地址 func setDNSServers(service: String, servers: [String], reply: @escaping @Sendable (String?) -> Void) + /// 删除 Time Machine 本地快照;每个标识都按固定格式校验,逐个执行 + func deleteLocalSnapshots(identifiers: [String], reply: @escaping @Sendable (String?) -> Void) } /// 需要管理员权限的系统维护命令。辅助工具与未安装辅助工具时的授权回退共用同一份固定命令 diff --git a/Packages/OpenStatsKit/Sources/HelperShared/LocalSnapshotCommand.swift b/Packages/OpenStatsKit/Sources/HelperShared/LocalSnapshotCommand.swift new file mode 100644 index 0000000..73460dd --- /dev/null +++ b/Packages/OpenStatsKit/Sources/HelperShared/LocalSnapshotCommand.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Time Machine 本地快照的删除命令。辅助工具与未安装辅助工具时的授权回退共用同一份参数校验: +/// 快照只能用形如 2026-09-14-120000 的时间标识指定,不接受任何其他字符 +public enum LocalSnapshotCommand { + public static let tool = "/usr/bin/tmutil" + public static let maxCount = 64 + + public static func isValidIdentifier(_ identifier: String) -> Bool { + let scalars = Array(identifier.unicodeScalars) + guard scalars.count == 17 else { return false } + for (index, scalar) in scalars.enumerated() { + if index == 4 || index == 7 || index == 10 { + guard scalar == "-" else { return false } + } else { + guard ("0"..."9").contains(scalar) else { return false } + } + } + return true + } + + public static func arguments(identifier: String) -> [String] { + ["deletelocalsnapshots", identifier] + } + + /// 授权回退用的一整条 shell;调用前必须已用 isValidIdentifier 校验 + public static func shellCommand(identifiers: [String]) -> String { + identifiers.map { "\(tool) deletelocalsnapshots \($0)" }.joined(separator: "; ") + } +} diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index 6b2778b..b9f4f3a 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -11,8 +11,12 @@ extension Translations { · {} 项使用中 · {} in use · 移到废纸篓 · Moves to Trash # OpenStats 诊断信息 # OpenStats Diagnostics +1 个传感器 1 sensor +1 个应用,系统自带的不列出 1 app; built-in apps are not listed +1 个进程 1 process 1 分钟 1 min 1 小时 1 hour +1 项 1 item 10 根柱子是最近 10 次采样(约 20 秒)的变化 10 bars show the last 10 samples (about 20 seconds) 15 分钟 15 min 2 小时 2 hours @@ -28,6 +32,9 @@ Apple 智能没有给出回答:{} Apple Intelligence didn't answer: {} Apple 智能解释 Apple Intelligence Apple(系统自带) Apple (built in) Application Support 中只允许清理缓存目录 Only cache folders inside Application Support can be cleaned +CPU 占用 {},已持续超过 1 分钟。可以打开 CPU 详情,在“按应用汇总”里看看是哪个应用在占用。 CPU usage has been {} for over a minute. Open the CPU details and check By App to see which app is busy. +CPU 总占用持续 1 分钟高于设定值,通常是某个应用卡住或在后台狂跑 Total CPU usage stays above the set level for 1 minute, usually an app that is stuck or running wild in the background +CPU 持续高负载 CPU Under Sustained Load CPU 时间 CPU Time CPU 时间 {} CPU time {} CPU 最高 CPU max @@ -53,6 +60,8 @@ GPU 占用 GPU Usage Google(8.8.8.8) Google (8.8.8.8) IP 地址 IP Addresses IP 归属地数据库 IP Location Database +IP 类型 IP type +IP 纯净度 IP cleanliness OpenStats {} 已发布 OpenStats {} is available OpenStats 控制 Controlled by OpenStats OpenStats 控制中 Controlled by OpenStats @@ -62,6 +71,7 @@ OpenStats 通知测试 OpenStats Test Notification OpenStats 防休眠 OpenStats Keep Awake OpenStats 需要授权以安装新版本。 OpenStats needs permission to install the new version. OpenStats 需要管理员权限来修改“{}”的 DNS。 OpenStats needs administrator permission to change DNS for “{}”. +OpenStats 需要管理员权限来删除 Time Machine 本地快照。 OpenStats needs administrator privileges to delete Time Machine local snapshots. OpenStats 需要管理员权限来刷新 DNS 缓存。 OpenStats needs administrator permission to flush the DNS cache. OpenStats 需要管理员权限来释放内存。 OpenStats needs administrator permission to free memory. OpenStats-诊断-{} OpenStats-Diagnostics-{} @@ -71,15 +81,18 @@ SMC [{}] 被固件拒绝 (0x{}) SMC [{}] rejected by firmware (0x{}) SMC [{}] 调用失败 ({}) SMC [{}] call failed ({}) SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License SMC access and the Apple Silicon fan unlock sequence are ported from this project · MIT License SSD 健康 SSD Health +Time Machine 在备份之间会先在本机留下快照,它们占的空间计入“可清除”,系统缺空间时会自动删。手动删除不影响已完成的备份。 Between backups Time Machine keeps snapshots on this Mac. Their space counts as purgeable and macOS removes them when it runs low. Deleting them by hand does not affect completed backups. VPN / 代理 VPN / Proxy VPN 隧道 VPN Tunnel Xcode 归档 Xcode Archives Xcode 编译缓存 Xcode Build Cache +ipapi.is 在线查询 ipapi.is online lookup ipinfo.io 在线查询 ipinfo.io (online) macOS 系统目录(系统组件) macOS system folder (system component) npm 缓存 npm Cache {} {} 英寸 {} {}-inch -{} · {} 核{} {} · {} cores{} +{} · {} 个 {} · {} +{} · {} 核 {} · {} cores {} · 每 {} 秒 {} · every {}s {} · 约 {} {} · about {} {} · 负载 {}/{} {} · load {}/{} @@ -89,6 +102,7 @@ npm 缓存 npm Cache {} 个进程 {} processes {} 分钟 {} min {} 可用 {} · 共 {} {} {} free · {} total +{} 在线查询 {} online lookup {} 天 {} d {} 天 {} 小时 {} d {} h {} 小时 {} h @@ -110,6 +124,7 @@ npm 缓存 npm Cache {}%(阈值 {}%) {}% (threshold {}%) {}{}只剩 {}%,记得充电。 {}{} is down to {}%. Time to charge. {}{}({}) {}{} ({}) +{}。系统会自动分配任务,不用你操心 {}. macOS assigns work between them automatically. {}中 {} {}前 {} ago {}后充满 Full in {} @@ -122,13 +137,18 @@ npm 缓存 npm Cache ~/Library/Caches 中各应用的缓存,删除后会按需重建 App caches in ~/Library/Caches; rebuilt as needed ~/Library/Logs 中的日志和诊断报告 Logs and diagnostic reports in ~/Library/Logs “{}”只剩 {} 可用。可以用 OpenStats 的清理功能释放缓存。 “{}” has only {} free. Use OpenStats Cleanup to clear caches. +“{}”({})会移到废纸篓,可以从废纸篓放回。 “{1}” ({2}) will be moved to the Trash; you can put it back from there. 、 , 一般 Fair 一键安装 Install Now 上传 Upload 上传 {} · 下载 {} Up {} · Down {} 上传与下载速度、IP 地址、DNS Upload and download speed, IP addresses, DNS +上传本机设置 Upload this Mac's settings +上次同步:{} Last synced: {} 上次检查 {},最新 {} Last checked {}, latest {} +上次检查发现问题 Last check found problems +上次检查正常 Last check was fine 上次检查:{} Last checked: {} 下载 Download 下载 {} 失败 Failed to download {} @@ -151,25 +171,40 @@ npm 缓存 npm Cache 丢包 Loss 严重 Critical 中等负载 Moderate load +中等风险 Medium risk +中继 Relay 主显示器 Main display 主窗口与弹窗的配色;菜单栏始终跟随系统 Colors for the window and popovers; the menu bar always follows the system +云端只保存邮箱、姓名与设置文档 The cloud only keeps your email, name and the settings document +云端已有 {} 在 {} 保存的设置,和这台 Mac 上的不一样。要用哪一份? {1} saved settings to the cloud {2}, and they differ from this Mac's. Which one should be used? 交换区 Swap 仅系统不休眠 Keep system awake 介质错误 Media errors 从左侧选择应用,或把应用拖到这里 Choose an app on the left, or drop one here 从文件导入… Import from File… 从未 Never -从程序坞移除图标 Remove from Dock +代理 Proxy 以单核满载为 100% 100% = one full core 以后再说 Later 以系统权限运行的后台服务,只接受本应用的请求 A background service with system privileges that only accepts requests from this app 仪表盘 Dashboard +企业 Business +企业 IP Business IP +企业网络 Business network 会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回 Also finds its caches, preferences, containers and launch agents in your Library and moves them all to the Trash, where you can restore them 会结束这个应用的 {} 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 This ends {} processes of the app and unsaved work may be lost. Force Quit ends them immediately without letting the app save. 传输速率 Link rate 位置 Location 位置:{} Location: {} 低负载 Light load +低风险 Low risk +住宅 IP Residential IP +住宅代理 Residential proxy +住宅宽带 Residential +住宅概率 Residential probability +余量 {} Headroom {} +使用 {} 登录 Sign in with {} +使用云端设置 Use cloud settings 使用历史 Usage History 使用电池 On battery 使用电池且电量低于该值时,自动关闭合盖运行 Turn off lid-closed mode when on battery below this level @@ -180,6 +215,7 @@ npm 缓存 npm Cache 修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。 Changing DNS needs administrator permission: it's applied directly when the helper is installed, otherwise the system asks each time. 偏好设置 Preferences 偏高 Elevated +停止 Stop 停用 Turn Off 停用失败:{} Failed to turn off: {} 健康 {} Health {} @@ -191,18 +227,21 @@ npm 缓存 npm Cache 全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃) Global shortcuts (work in any app; must include ⌘, ⌥ or ⌃) 全选 Select All 全部 All +全部删除 Delete all 公网 IP Public IP 公网 IP 查询 Public IP Lookup 公网 IPv4 Public IPv4 公网 IPv6 Public IPv6 共 {} {} total 共 {} 条记录 {} records +共 {},{} 个条目 · {} {1} in {2} items · {3} 关 Off 关于 About 关于 OpenStats About OpenStats 关闭 Off 其他 Other 其他位置 Other locations +其他磁盘 Other disks 其他程序手动控制 Controlled by another app 其他程序控制 Another app 内存 Memory @@ -222,6 +261,13 @@ npm 缓存 npm Cache 切换到深色 Switch to Dark 刚刚 Just now 刚刚被使用 Recently used +删除 Delete +删除… Delete… +删除中… Deleting… +删除云端数据 Delete cloud data +删除云端数据? Delete cloud data? +删除全部本地快照? Delete all local snapshots? +删除账号、所有登录方式与云端保存的设置;这台 Mac 上的设置保留 Deletes the account, every sign-in method and the settings stored in the cloud; settings on this Mac stay 到时后自动恢复系统默认的睡眠行为。 Normal sleep behavior resumes when time runs out. 刷新 Refresh 刷新 DNS 缓存 Flush DNS Cache @@ -231,6 +277,7 @@ npm 缓存 npm Cache 刷新频率 Refresh Rate 剩余 {} {} left 剩余寿命 Life left +剩余寿命 {}% · 已写入 {} {}% life left · {} written 剪切 Cut 功耗 Power 包名是 {} Bundle ID is {} @@ -240,6 +287,7 @@ npm 缓存 npm Cache 单行文字 Inline Text 占用 Usage 占用超过 85% 时数值与图形显示为红色 Show values and graphs in red above 85% +占用高于 Usage above 卷 Volume 卸载 Uninstall 卸载“{}”? Uninstall “{}”? @@ -258,19 +306,25 @@ npm 缓存 npm Cache 压缩包里应当只有一个 .app The archive should contain exactly one .app 压缩比 Compression ratio 原因 Reason +原生 / 广播 Native / broadcast +原生 IP Native IP 去批准 Approve 去授权 Grant Access 双行圆点 Dots 双行文字 Stacked Text 双行箭头 Arrows +反向解析 Reverse DNS 反馈问题 Report a Problem 发现新版本 {} Version {} available +发现问题 Problems found +发现问题:{} Problem found: {} 发生这些状况时发送系统通知,点通知打开对应页面 Send a notification when these happen; click it to open the related page 发送 Send 发送测试通知 Send Test Notification 取消 Cancel 受保护 Protected 受保护的项目:{} Protected item: {} +另一台 Mac Another Mac 另有 {} 移到废纸篓 Plus {} moved to Trash 另有 {} 项 {} more 只含当前用户的进程 Your processes only @@ -278,16 +332,21 @@ npm 缓存 npm Cache 只在菜单栏显示 GPU 或打开 GPU 相关页面时记录 Recorded only when GPU is in the menu bar or a GPU page is open 只在菜单栏显示温度、开启过热通知或打开温度页面时记录 Recorded only when temperature is in the menu bar, overheating alerts are on, or a temperature page is open 只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒) Sampling interval when only the menu bar is shown; 1 second with a popover or window open (2 seconds on Processes) -只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动 Only files named after the app's bundle ID, plus folders with the app's name in Application Support and Logs, are included. Keychains and data shared with other apps are never touched. +只有国家、省 / 州与城市;把公网 IP 发给 db-ip.com Country, state and city only; sends your public IP to db-ip.com +只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除 Only files named after the app's bundle ID, plus folders with the app's name in Application Support and Logs, are included. Keychains and data shared with other apps are never touched. The Dock icon is removed as well 可以恢复,但清空废纸篓前不会释放空间 Recoverable, but space isn't freed until the Trash is emptied 可执行文件:{} Executable: {} 可清理 · 已选 {} 项 Cleanable · {} selected +可清除 Purgeable +可清除是系统随时可以腾出的缓存;访达显示的“可用”把它算在内 Purgeable is cache the system can free at any time; Finder's “available” figure includes it. 可用 Available 可用 {} {} available +可用 {} / 共 {} {1} free of {2} 可用内存只剩 {},系统正在压缩和交换内存。关闭不用的应用可以缓解。 Only {} of memory is available and the system is compressing and swapping. Quitting unused apps will help. 可用空间包含系统可以随时清除的缓存,与访达显示一致 Available space includes purgeable caches, matching Finder 右侧风扇 Right Fan 右耳 Right +各核心占用 Per-Core Load 各核心负载 Core Load 各项指标正常 Everything looks good 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 Your Mac stays awake with the lid closed, so downloads, renders and remote sessions continue. @@ -299,12 +358,15 @@ npm 缓存 npm Cache 合盖运行时散热变差,请勿放入包中。使用电池且电量低于 {}% 时会自动关闭。 Cooling is worse with the lid closed, so don't put your Mac in a bag. Turns off automatically on battery below {}%. 合盖运行设置 Lid Mode 合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。 Lid mode changes system sleep settings and needs the helper (one-time admin approval). -同时从程序坞移除图标 Also remove its Dock icon +同步 Sync +同步失败 Sync failed +同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具、历史记录与归属地库留在本机。 What syncs: menu bar items and styles, refresh rate, appearance and language, popover sections, connection probe, notifications, hotkeys, fan safety temperature and lid-mode battery floor. The current page, the helper, history and the GeoIP database stay on this Mac. 后台低频探测 Background Probing 启动于 Booted 启动时和之后每天检查一次,发现新版本时显示更新摘要 Checks at launch and daily, and shows what's new when an update is found 启动磁盘 Startup Disk 启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次 Startup disk has less than 10% or 10 GB free; checked every 10 minutes +启动磁盘已用占比 Startup disk used 启动项 Startup Items 启用 Turn On 启用 {} Enable {} @@ -320,6 +382,9 @@ npm 缓存 npm Cache 图标 Icon 圆环 Ring 圆环表示当前占用比例 A ring shows current usage +在 cleanip.io 查看这个 IP 的完整报告 Open this IP's full report on cleanip.io +在主窗口打开“{}” Open “{}” in the main window +在本机的 MaxMind 数据库里查国家、城市与 ASN,不向任何服务发送 IP;数据库未就绪时暂用 CleanIP.io Looks up country, city and ASN in the local MaxMind database and sends your IP nowhere; uses CleanIP.io until the database is ready 在线升级 Online Updates 在线升级:发现新版本时显示更新摘要,一键安装并自动重启 Online updates: see what's new, install in one click and relaunch automatically 在线查询 Online lookup @@ -332,6 +397,7 @@ npm 缓存 npm Cache 处理器核心 CPU cores 备用空间 Available spare 备用空间低于阈值,建议备份 Spare capacity below threshold; back up your data +外置硬盘、U 盘、镜像与网络共享 External drives, USB sticks, disk images and network shares 外观 Appearance 存储 Storage 它只做这几件事 It only does these things @@ -343,8 +409,10 @@ npm 缓存 npm Cache 安装失败:{} Install failed: {} 安装辅助工具 Install Helper 安静 Quiet +完整报告 Full report 定时 ping 一个地址,记录网络是否通畅 Pings an address regularly to record whether the network is reachable 定时探测网络 Connectivity Probe +家目录 Home folder 容量 Capacity 导入失败:{} Import failed: {} 导出… Export… @@ -362,6 +430,7 @@ npm 缓存 npm Cache 屏幕可按设置关闭,下载、编译等后台任务继续运行 The display can turn off as usual while downloads and builds keep running 屏幕和系统都不会因闲置而关闭或休眠 Neither the display nor the system sleeps when idle 峰值 Peak +峰值 {} Peak {} 工具 Tools 左侧风扇 Left Fan 左耳 Left @@ -369,15 +438,21 @@ npm 缓存 npm Cache 已使用 Developer ID 签名(团队 {}),辅助工具只接受同一团队签名的 OpenStats。 Signed with Developer ID (team {}); the helper only accepts OpenStats signed by the same team. 已停用 Off 已充满 Charged +已删除 {} 个快照,腾出的空间稍后会反映在“可清除”里 Deleted {} snapshots; the freed space will show up under purgeable shortly 已到设定时间,防休眠已关闭 Time's up; Keep Awake is off 已加载 Loaded 已取消 Cancelled +已取消登录 Sign-in cancelled 已启用 Enabled 已导入 {} Imported {} 已导出 {} Exported {} 已将 {} 与 {} 项残留移到废纸篓,约 {}{}{}。需要时可以在废纸篓里放回。 Moved {} and {} leftovers to the Trash, about {}{}{}. You can put them back from the Trash. +已开启同步 Sync is on 已恢复自动获取 DNS DNS is automatic again +已扫描 {} 个条目 · {} Scanned {} items · {} +已把“{}”移到废纸篓 Moved “{}” to the Trash 已拷贝 Copied +已推出“{}” Ejected “{}” 已是最新版本 Up to date 已更新 {} 个数据库 Updated {} databases 已用 Used @@ -393,11 +468,13 @@ npm 缓存 npm Cache 已释放 Freed 已释放 {} 缓存内存 Freed {} of cached memory 布局 Layout +干净 Clean 平均 Average 平均 {} · 峰值 {}{} Avg {} · Peak {}{} 平均 {} · 最高 {} Avg {} · Max {} 平均温度 Average temperature 平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU Load average per core: below 1 means no waiting; above 1 means tasks are waiting for the CPU +广播 IP Broadcast IP 序列号 Serial Number 应用 Apps 应用 {} / 辅助工具 {} App {} / Helper {} @@ -421,6 +498,8 @@ npm 缓存 npm Cache 开发者 Developer 开启 On 开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep) Turn lid-closed sleep prevention on or off (same as pmset disablesleep) +开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置 Adds the icon to the menu bar; hold ⌘ and drag the icon to move it +开始分析 Analyze 开机以来 Since boot 开机后上传 Uploaded since boot 开机后下载 Downloaded since boot @@ -428,10 +507,14 @@ npm 缓存 npm Cache 异常断电 Unsafe shutdowns 弹窗显示 Popover Sections 强冷 Max +强制刷新:忽略缓存,立即重新查询公网 IP 与归属地 Force refresh: ignore the cache and look up the public IP and location again now 强制退出 Force Quit 归属地 Location +归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名;把公网 IP 发给 cleanip.io Location, ASN, network type, native / broadcast, cleanliness and risk scores; sends your public IP to cleanip.io +归属地与 ASN;把公网 IP 发给 ipapi.is,匿名额度每天 30 次,同一 IP 的结果记住一天 Location and ASN; sends your public IP to ipapi.is. Anonymous quota is 30 a day, so a result is kept for a day +归属地与 ASN;把公网 IP 发给 ipinfo.io,匿名额度很小,容易被限流 Location and ASN; sends your public IP to ipinfo.io. The anonymous quota is tiny and easily exhausted 归属地数据库 Location database -当前 {} Current {} +归属地数据源 Location data source 当前 {} · 发布于 {} · {} · {} Current {} · Released {} · {} · {} 当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。 This is an ad-hoc signed development build, so the helper can only check the bundle ID. Team verification turns on automatically with a Developer ID build. 当前效果(实时数据) Preview (live data) @@ -444,6 +527,7 @@ npm 缓存 npm Cache 循环次数 Cycle count 忙 Busy 快捷开关 Quick Toggles +快照名称格式不认识,没有删除 Unrecognized snapshot names; nothing was deleted 性能核 Performance 总占用 Total 恢复系统睡眠失败:{} Failed to restore system sleep: {} @@ -464,15 +548,19 @@ npm 缓存 npm Cache 打开 SMC 失败 ({}) Failed to open SMC ({}) 打开登录项设置 Open Login Items Settings 打开网络详情时使用 Used while network details are open +打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求 Asks Cloudflare (1.1.1.1) or ipify for the public address once when network details open, at most every 10 minutes 打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求 When network details open, asks Cloudflare (1.1.1.1) or ipify for your public address; location and ASN come from the local database. At most once every 10 minutes. 打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询 Looks up your public address when network details open; until the local database is ready, location and ASN come from ipinfo.io 打开进程页 Show Processes 打开通知设置 Open Notification Settings +托管 Hosting 执行中 Working 扫描中 Scanning 扫描中… Scanning… 扫描于 Scanned 抖动 Jitter +折线历史 Line History +折线是最近 30 次采样(约 1 分钟)的走势 The line shows the last 30 samples (about a minute) 拷贝 Copy 拷贝 PID Copy PID 拷贝 PID {} Copy PID {} @@ -480,8 +568,10 @@ npm 缓存 npm Cache 按下快捷键… Press shortcut… 按应用 By App 按应用汇总 By App +按文件夹 By folder 按系统休眠 System sleep 按进程 By Process +换一台 Mac 登录后,菜单栏、外观、通知与快捷键等偏好会自动恢复。不登录也能使用全部功能。 Sign in on another Mac and your menu bar, appearance, notification and hotkey preferences come back automatically. Everything works without signing in. 换入 / 换出 Swap in / out 掌托 Palm rest 排队程度 Run Queue @@ -489,14 +579,26 @@ npm 缓存 npm Cache 探测间隔 Probe Interval 接口 Interface 接通电源 Plugged in +推出 Eject +推出中… Ejecting… 搜索名称、PID 或用户 Search name, PID or user 搜索应用 Search apps 撤销 Undo +改回开机后累计 Back to since-boot totals +政府 Government +教育网 Education 散热模式 Cooling +数据 Data 数据来源 Source +数据来源:cleanip.io Data source: cleanip.io +数据来源:cleanip.io,点击打开官网 Data source: cleanip.io. Click to open the site +整体风格 Overall style +整体风格套用到所有项目。想让某个项目不一样,在下面“显示项目”里给它单独选一种,比如 CPU 用圆环、风扇用数字。 The overall style applies to every item. To make one item different, pick a style for it under Items below, say a ring for CPU and plain numbers for the fan. 整机 System 整机 {} System {} 整机功耗 System Power +文件系统检查 File system check +文件系统结构完好,没有发现错误 The file system structure is intact; no errors found 新版本未通过 Apple 公证检查,已放弃安装 The new version failed Apple notarization; installation stopped 新版本需要 macOS {} 或更高版本 The new version requires macOS {} or later 无 None @@ -504,6 +606,9 @@ npm 缓存 npm Cache 无法创建电源断言({}) Couldn't create power assertion ({}) 无法创建签名要求 Couldn't create the signing requirement 无法打开 Couldn't open +无法打开登录窗口 Couldn't open the sign-in window +无法推出“{}”:{} Couldn't eject “{1}”: {2} +无法移到废纸篓:{} Couldn't move to the Trash: {} 无法读取 Unreadable 无法读取签名 Couldn't read the signature 无法连接辅助工具 Couldn't connect to the helper @@ -519,14 +624,18 @@ npm 缓存 npm Cache 显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB) Shows city names; the database is about 60 MB. When off, only the country database (about 9 MB) is downloaded. 显示序列号 Show Serial Number 显示项目 Items +暂时没有拿到评分,稍后会再试 No score yet; it will try again later 更新内容 What's New 更新失败:{} Update failed: {} +更省电,负责后台和轻量任务 More efficient; handles background and light work 替换后未找到应用 App not found after replacement 替换应用失败:{} Failed to replace the app: {} 最低 {} · 最高 {} · 目标 {} RPM Min {} · Max {} · Target {} RPM +最大的文件 Largest files 最小化 Minimize 最强 Strongest 最忙的核心 Busiest core +最快,重活优先交给它们 Fastest; heavy work goes here first 最近 60 秒 Last 60 seconds 最近没有应用在读写磁盘 No apps are reading or writing right now 最近的内存压力走势 Recent memory pressure @@ -535,14 +644,17 @@ npm 缓存 npm Cache 最高 / 平均 Max / Avg 最高温度 Max temperature 服务器上还没有数据库,稍后再试 The server doesn't have the database yet. Try again later. +服务器在 getopenstats.com,不上传任何监控数据;随时可以删除。 The server is at getopenstats.com; no monitoring data is uploaded, and you can delete everything at any time. 服务器清单里没有 {} {} is not in the server manifest 服务器返回 {} Server returned {} +服务器返回的数据格式不正确 The server returned malformed data 未使用 Unused 未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 Unsaved work may be lost. Force Quit ends it immediately without letting it save. 未安装 Not Installed 未完成的下载 Incomplete Downloads 未开启,Mac 按系统设置休眠 Off; your Mac sleeps as usual 未找到 AppleSMC 服务 AppleSMC service not found +未检出 None detected 未检测到可调节的风扇 No adjustable fans found 未检测到风扇 No fans detected 未知状态 Unknown status @@ -551,18 +663,24 @@ npm 缓存 npm Cache 未连接 Not connected 未连接网络 No network 本产品包含 MaxMind 创建的 GeoLite2 数据,可从 https://www.maxmind.com 获取。 This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com. +本地 GeoLite2 数据库 Local GeoLite2 database 本地 GeoLite2(MaxMind) Local GeoLite2 (MaxMind) 本地 IPv4 Local IPv4 本地 IPv6 Local IPv6 本地 MaxMind GeoLite2 Local MaxMind GeoLite2 +本地快照 Local snapshots 本机信息 This Mac 机型 Model 机型标识符 Model Identifier +机房 Data center +机房 IP Data center IP 极好 Excellent +极高风险 Very high risk 构建 Build 查看 View 查看全部进程 All Processes 查看包含的项目 Show items +查看完整报告 View full report 查看完整更新日志 Full Changelog 查看清理日志 View Cleanup Log 查询中… Looking up… @@ -572,13 +690,20 @@ npm 缓存 npm Cache 标签与数值同一行,最易读 Label and value on one line; easiest to read 标识 Identifier 核心 Cores +核心 {} Core {} +核心 {}({})· {} Core {} ({}) · {} 核心分工 Core Types 核心数 Cores +核心最高温度;余量是离 100°C 还差多少度,越接近 0 越可能因过热降频 Hottest core; headroom is how many degrees remain before 100°C — the closer to 0, the more likely thermal throttling 核心热力图 Core Heatmap 核心负载 Core Load +检查 Check +检查中… Checking… +检查完成,但没有得到明确结论 The check finished without a clear verdict 检查更新 Check for Updates 检查更新… Check for Updates… 检查更新失败 Update check failed +检查未能完成(退出码 {}) The check didn't complete (exit code {}) 模式 Mode 模拟器 Simulators 模拟器缓存 Simulator Caches @@ -586,6 +711,8 @@ npm 缓存 npm Cache 正在使用 In use 正在修改 DNS… Changing DNS… 正在刷新… Flushing… +正在同步 Syncing +正在同步… Syncing… 正在安装,完成后 OpenStats 会自动重启。 Installing. OpenStats will relaunch when it's done. 正在导出… Exporting… 正在扫描应用… Scanning apps… @@ -593,6 +720,8 @@ npm 缓存 npm Cache 正在更新… Updating… 正在本机生成… Generating on this Mac… 正在查找残留… Finding leftovers… +正在查询… Looking up… +正在核对启动盘的文件系统结构,通常几秒到几十秒 Verifying the startup disk's file system structure; this usually takes seconds to a minute 正在核对校验值、开发者签名与 Apple 公证… Verifying checksum, developer signature and Apple notarization… 正在检查… Checking… 正在检查更新 Checking for updates @@ -608,16 +737,19 @@ npm 缓存 npm Cache 正在读取风扇… Reading fans… 正在释放… Freeing… 正常 Normal +此刻各核心的占用;超过 60% 变橙色、85% 变红色 Current load of each core; orange above 60%, red above 85% 此设备没有可调节的风扇 This Mac has no adjustable fans 每 3 天检查一次 Checks every 3 days 每 3 天检查一次 · 上次检查 {} Checks every 3 days · last checked {} +每个应用按一个核心满载为 100% 计算,与活动监视器一致,所以各应用相加可以超过顶部的总占用 Each app is measured with one full core as 100%, like Activity Monitor, so the apps can add up to more than the total above 每个指标一个图标,点击弹出该项详情 One icon per metric; click for its details 每分钟把主要指标的平均值与峰值写入本机数据库,保留 7 天,不上传 Writes averages and peaks of key metrics to a local database every minute. Kept for 7 days, never uploaded. 每核 {} {} per core +每根柱子一个核心,按核心类型着色 One bar per core, colored by core type 每点 {} {} per point 每秒让 CPU 从空闲中唤醒的次数,越高越耗电 Times per second the CPU is woken from idle; higher uses more power 每秒读写磁盘的字节数 Bytes read and written per second -每行一个核心,每列一次采样,最新的在右边 One row per core, one column per sample; newest on the right +每行一个核心(共 {} 个),每列一次采样,颜色越深越忙;右侧粗条是此刻的占用 One row per core ({} in total), one column per sample, darker means busier; the wide bar on the right is the current load 每项独立 Separate 比 30 秒前低 {}% {}% lower than 30s ago 比 30 秒前高 {}% {}% higher than 30s ago @@ -628,6 +760,7 @@ npm 缓存 npm Cache 没有已连接的蓝牙设备 No connected Bluetooth devices 没有检测到显示器 No displays detected 没有移动任何文件{} Nothing was moved{} +没有超过 50 MB 的文件 No files over 50 MB 流量历史 Traffic History 流量经过 {},系统 DNS 可能由它接管,修改后不一定生效。 Traffic goes through {}, which may take over system DNS, so changes might not apply. 浅色 Light @@ -650,9 +783,9 @@ npm 缓存 npm Cache 温度 Temperature 温度与风扇 Thermals 温度传感器 Temperature sensors -温度余量 Thermal headroom 温度单位 Temperature Unit 满载 Maxed out +滥用记录 Abuse reports 点击拷贝 Click to copy 版本 Version 版本 {} Version {} @@ -663,8 +796,10 @@ npm 缓存 npm Cache 物理地址 Hardware address 状态 Status 状态圆点 Status Dot +状态正常 Healthy 状态良好 Healthy 现在 Now +现在没有本地快照 No local snapshots right now 用 Apple 智能解释 Explain with Apple Intelligence 用 ping 测量延迟与丢包,在网络详情里以格子显示 Measures latency and loss with ping, shown as a grid in network details 用户 User @@ -691,30 +826,42 @@ npm 缓存 npm Cache 电量下限 Battery floor 电量低于 {}%,已关闭合盖运行 Battery below {}%; lid mode turned off 电量条 Meter +登录以同步设置 Sign in to sync settings 登录启动项 Launch Agents +登录回调不正确 Invalid sign-in callback +登录已失效,请重新登录 Your session has expired. Please sign in again. 登录时启动 Launch at Login 登录时运行 Runs at login 登录项设置 Login Items 监控 Monitor 目标转速 Target speed +相当于“磁盘工具”里的急救,但只检查不修改:核对启动盘的目录结构、文件分配与快照元数据是否一致。 Like First Aid in Disk Utility, but read-only: checks that the startup disk's directory structure, allocation and snapshot metadata are consistent. 确认清理 Clean 确认通知能正常显示 Check that notifications appear 磁盘 Disk +磁盘已用 {} Disk {} used 磁盘报告了严重警告,建议尽快备份 The disk reported a critical warning; back up soon 磁盘清理 Disk Cleanup 磁盘空间不足 Low Disk Space 磁盘空间偏紧 Disk space is tight -离 100°C 还差多少度;越接近 0,越可能因过热降频 Degrees below 100°C; the closer to 0, the more likely thermal throttling 离线查询 Offline lookup 移到废纸篓 Move to Trash +移到废纸篓? Move to the Trash? +移动网络 Mobile +移动网络 IP Mobile IP +移动运营商 Mobile carrier 空 empty 空格 Space 空闲 Idle +空间占用 Space usage 窗口 Window 窗口状态 Saved State +立即切换;显示器、应用名称等由系统提供的文字在下次启动时切换 Applies right away; names supplied by macOS, such as displays and apps, switch at next launch +立即同步 Sync now 立即检查更新 Check Now 竖向电量条表示当前占用比例 A vertical meter shows current usage 等待批准 Waiting for Approval +等待选择 Waiting for your choice 签名团队 Signing team 签名方:{} Signed by: {} 签名校验未通过:{} Signature check failed: {} @@ -732,6 +879,9 @@ npm 缓存 npm Cache 累计写入 Data written 累计读取 Data read 繁忙 Busy +纯净度 {} 分 Cleanliness score {} +纯净度看信誉、来路、邻居与网络类型四项;机房 IP 常见信誉高、来路和类型偏低。数据来自 cleanip.io Cleanliness weighs reputation, origin, neighbors and network type; data-center IPs often score high on reputation but low on origin and type. Data from cleanip.io +纯净度评分来自 CleanIP.io。在「设置 · 网络」把归属地数据源改为 CleanIP.io 后显示。 Cleanliness scores come from CleanIP.io. Choose it as the location data source in Settings › Network to show them. 线程 Threads 线程 {} Threads {} 线程数(只能读取自己的进程) Thread count (your processes only) @@ -740,6 +890,7 @@ npm 缓存 npm Cache 结束它会注销当前用户,已禁止 Ending it would log you out, so it's blocked 结束进程… End Process… 统一内存(CPU 与 GPU 共享) Unified memory (shared by CPU and GPU) +统计家目录里每个文件夹占多少空间,并找出最大的文件;只读取大小,不改动任何文件。文件多时需要几十秒。 Measures how much space each folder in your home folder takes and finds the largest files. It only reads sizes and changes nothing; with many files it can take a minute. 继续运行 Keep running 绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) Green is normal, orange is high (above 60%), red is very high (above 85%) 缓存 Caches @@ -751,6 +902,7 @@ npm 缓存 npm Cache 网络名称 Network name 网络已恢复 Network Restored 网络断开 Network Down +网络类型 Network type 网络运营方 Network operator 网络连接中断超过 20 秒,恢复后再提示一次 Network has been down for over 20 seconds; notifies again when it's back 网络连接已中断超过 20 秒。可以打开网络详情查看接口与路由器状态。 The network has been down for over 20 seconds. Open network details to check the interface and router. @@ -758,10 +910,12 @@ npm 缓存 npm Cache 网络:{} Network: {} 网页数据 Web Data 网页缓存与脚本缓存,不涉及 Cookie、历史记录和密码 Web and script caches; cookies, history and passwords are untouched +置信度 {}% Confidence {}% 联动 Wired 能效核 Efficiency 腾讯 DNSPod Tencent 腾讯(119.29.29.29) Tencent (119.29.29.29) +自 {} 起 Since {} 自动 Auto 自动更新 Auto Update 自动检查更新 Check Automatically @@ -776,8 +930,9 @@ npm 缓存 npm Cache 菜单栏 Menu Bar 菜单栏图标 Menu Bar Icons 菜单栏布局 Menu Bar Layout +菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。 You can reorder the icons in the menu bar: hold ⌘ and drag any of them, and the position sticks. macOS picks where a newly enabled item first appears, sometimes away from the others; just drag it over. 菜单栏项目 Menu Bar Items -菜单栏风格 Menu Bar Style +菜单栏风格 Menu bar style 蓝牙设备 Bluetooth Devices 蓝牙设备电量低 Bluetooth Battery Low 解压失败:{} Unzip failed: {} @@ -786,12 +941,16 @@ npm 缓存 npm Cache 设置 Settings 设置 · {} Settings · {} 设置… Settings… +设置改动后 2 秒内上传;启动、唤醒与每 15 分钟检查一次云端 Changes upload within 2 seconds; the cloud is checked at launch, on wake and every 15 minutes 设置风扇目标转速,或恢复系统自动控制 Set fan target speeds, or return them to automatic +评分由 cleanip.io 提供,点击查看完整报告 Score by cleanip.io. Click to open the full report +该项目在菜单栏里的实时效果 Live preview of this item in the menu bar 详情关闭时,菜单栏显示网络项期间每 {} 秒探测一次,打开详情就能看到最近的连接情况;关闭后只在打开详情时探测,更省电 While details are closed and the network item is in the menu bar, probes every {} seconds so recent results are ready when you open details. Turn off to probe only while details are open and save power. 语言 Language 请从菜单退出 OpenStats Quit OpenStats from its menu 请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。 Turn on Apple Intelligence in System Settings → Apple Intelligence & Siri first. 请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。 Allow OpenStats in System Settings › General › Login Items. +请打开系统自带的“磁盘工具”,选择启动盘运行“急救”进行修复 Open the built-in Disk Utility, select the startup disk and run First Aid to repair it 读 {} · 写 {} Read {} · Write {} 读写最多的应用 Top Disk Activity 读写速度 Read & Write @@ -802,9 +961,15 @@ npm 缓存 npm Cache 负载在上升 Load rising 负载在下降 Load falling 负载平稳 Load steady +账号 Account +账号与云端设置会立即删除,无法恢复。 The account and cloud settings are deleted immediately and cannot be recovered. +账号与同步 Account & Sync +走势图显示最近多长时间 How far back the trend chart shows +走势时长 Trend duration 超级核 Super 超过 1 天未更新的 .crdownload / .part / .download .crdownload / .part / .download files untouched for over a day 跟随整体 Follow global +跟随整体({}) Follow overall ({}) 跟随系统 System 路径不可读 Path not readable 路径包含 .. Path contains .. @@ -824,6 +989,7 @@ npm 缓存 npm Cache 过热温度 Overheat Threshold 运行中 · PID {} Running · PID {} 还可用 Available +还有 {} 个 {} more 这个快捷键已被其他应用或系统占用,请换一个 This shortcut is used by another app or the system. Choose another. 这台 Mac 不提供功耗读数 This Mac doesn't report power 这台 Mac 不支持 Apple 智能。 This Mac doesn't support Apple Intelligence. @@ -843,6 +1009,7 @@ npm 缓存 npm Cache 退出 OpenStats Quit OpenStats 退出应用 Quit App 退出应用… Quit App… +退出登录 Sign out 适中 Moderate 选择 GeoLite2 City / Country / ASN 的 .mmdb 文件 Choose a GeoLite2 City / Country / ASN .mmdb file 通用 General @@ -850,26 +1017,36 @@ npm 缓存 npm Cache 通电次数 Power cycles 通知 Notifications 通知可以正常显示。发生你打开的状况时,会像这样提醒你。 Notifications are working. You'll be alerted like this when something you turned on happens. +通过 {} 登录 Signed in with {} +速度与省电介于两者之间 In between on speed and efficiency 部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。 Some items need Full Disk Access to scan (Safari caches, Trash). 配置方式 Configuration 释放内存 Free Memory 重做 Redo +重新分析 Analyze again 重新安装 Reinstall 重新扫描 Rescan 重新查询公网 IP Look Up Public IP Again 重新生成 Regenerate +重置上传与下载统计:从现在起重新累计。重启后自动回到开机后的累计;右键可改回 Reset the upload and download totals and count from now on. After a restart they go back to since-boot totals; right-click to switch back +重置后上传 Uploaded since reset +重置后下载 Downloaded since reset 闲 Idle 防休眠 Keep Awake 防休眠已开启 Keep Awake is on 阿里云 Alibaba Cloud 阿里云(223.5.5.5) Alibaba Cloud (223.5.5.5) 降温 Cooling +隐私 Privacy +隐私政策 Privacy policy 隐藏 OpenStats Hide OpenStats 隐藏序列号 Hide Serial Number 需要 macOS 26 及以上,并在系统设置中开启 Apple 智能。 Requires macOS 26 or later with Apple Intelligence turned on in System Settings. 需要完全磁盘访问权限 Needs Full Disk Access 需要注意 Needs attention 需要管理员授权 Needs admin approval +需要管理员权限。已完成的 Time Machine 备份不受影响,只是本机上这些快照对应的时间点无法再从本地恢复。 Requires administrator privileges. Completed Time Machine backups are unaffected; you just won't be able to restore these points in time from this Mac. +非常干净 Very clean 风扇 Fans 风扇 {} Fan {} 风扇安全温度 Fan Safety Temperature @@ -877,21 +1054,19 @@ npm 缓存 npm Cache 风扇设置 Fan Settings 风扇转速 Fan Speed 风格 Style +风险标记 Risk flags +风险评分 Risk score 饼图 Pie 饼图表示当前占用比例 A pie shows current usage 首次发送时系统会询问是否允许通知 The system asks for permission the first time 高占用进程 Top Processes 高负载 Heavy load 高负载时着色 Color High Load +高风险 High risk ({}) ({}) , , ,{} 项未能移动 , {} couldn't be moved ,已从程序坞移除 , removed from the Dock :{} : {} -立即切换;显示器、应用名称等由系统提供的文字在下次启动时切换 Applies right away; names supplied by macOS, such as displays and apps, switch at next launch -1 项 1 item -1 个进程 1 process -1 个应用,系统自带的不列出 1 app; built-in apps are not listed -1 个传感器 1 sensor """# } diff --git a/Packages/OpenStatsKit/Sources/Metrics/HardwareSamplers.swift b/Packages/OpenStatsKit/Sources/Metrics/HardwareSamplers.swift index b95a003..7b73b4f 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/HardwareSamplers.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/HardwareSamplers.swift @@ -8,13 +8,15 @@ import Localization public enum DiskSampler { public static func sample(volume: URL = URL(fileURLWithPath: "/")) -> DiskUsage? { let keys: Set = [.volumeLocalizedNameKey, .volumeTotalCapacityKey, - .volumeAvailableCapacityForImportantUsageKey] + .volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey] guard let values = try? volume.resourceValues(forKeys: keys), let total = values.volumeTotalCapacity else { return nil } - // “重要用途可用空间”包含可清除空间,与访达显示一致 - let available = values.volumeAvailableCapacityForImportantUsage ?? 0 + // “重要用途可用空间”包含可清除空间,与访达显示一致;减去普通可用空间就是可清除的部分 + let available = max(0, values.volumeAvailableCapacityForImportantUsage ?? 0) + let strict = Int64(values.volumeAvailableCapacity ?? 0) return DiskUsage(volumeName: values.volumeLocalizedName ?? tr("磁盘"), - total: UInt64(total), available: UInt64(max(0, available))) + total: UInt64(total), available: UInt64(available), + purgeable: UInt64(max(0, available - strict))) } } diff --git a/Packages/OpenStatsKit/Sources/Metrics/Models.swift b/Packages/OpenStatsKit/Sources/Metrics/Models.swift index 9f65772..38d9306 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Models.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Models.swift @@ -75,10 +75,24 @@ public struct NetworkRate: Sendable, Equatable { public struct DiskUsage: Sendable, Equatable { public var volumeName: String public var total: UInt64 + /// 可用空间,包含系统可随时清除的缓存,与访达显示一致 public var available: UInt64 + /// 可用空间里可清除的那部分 + public var purgeable: UInt64 + + public init(volumeName: String, total: UInt64, available: UInt64, purgeable: UInt64 = 0) { + self.volumeName = volumeName + self.total = total + self.available = available + self.purgeable = min(purgeable, available) + } public var used: UInt64 { total > available ? total - available : 0 } public var usedFraction: Double { total == 0 ? 0 : Double(used) / Double(total) } + /// 不含可清除部分的真正空闲空间 + public var free: UInt64 { available - purgeable } + public var purgeableFraction: Double { total == 0 ? 0 : Double(purgeable) / Double(total) } + public var freeFraction: Double { total == 0 ? 0 : Double(free) / Double(total) } } public struct BatteryStatus: Sendable, Equatable { diff --git a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift index a22cd98..9abf5a7 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift @@ -1,108 +1,433 @@ import Foundation -public struct PublicAddresses: Sendable, Equatable { +/// 在线归属地数据源。都由每台 Mac 直接查询自己的公网 IP,不经过我们的服务器 +public enum GeoProvider: String, CaseIterable, Sendable, Codable { + /// cleanip.io:归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名 + case cleanIP + /// ipapi.is 匿名接口:归属地与 ASN,按客户端 IP 每天 30 次 + case ipapi + /// DB-IP 免费接口:只有国家、省 / 州与城市 + case dbip + /// ipinfo.io 匿名接口:归属地与 ASN,匿名额度很小 + case ipinfo + + public var displayName: String { + switch self { + case .cleanIP: "CleanIP.io" + case .ipapi: "ipapi.is" + case .dbip: "DB-IP" + case .ipinfo: "ipinfo.io" + } + } + + /// 同一个 IP 的结果保留多久 + var cacheLifetime: TimeInterval { + self == .cleanIP ? 60 * 60 : 24 * 60 * 60 + } + + func url(for ip: String) -> URL? { + var components: URLComponents? + switch self { + case .cleanIP: + components = URLComponents(string: "https://cleanip.io/cli") + components?.queryItems = [URLQueryItem(name: "ip", value: ip), URLQueryItem(name: "json", value: "1")] + case .ipapi: + components = URLComponents(string: "https://api.ipapi.is/") + components?.queryItems = [URLQueryItem(name: "q", value: ip)] + case .dbip: + components = URLComponents(string: "https://api.db-ip.com/v2/free/\(ip)") + case .ipinfo: + components = URLComponents(string: "https://ipinfo.io/\(ip)/json") + } + return components?.url + } +} + +public struct PublicAddresses: Sendable, Equatable, Codable { public var ipv4: String? public var ipv6: String? /// ISO 3166 两位国家 / 地区代码(大写),例如 “CN” public var countryCode: String? public var city: String? + /// 省 / 州,例如 “New York” + public var region: String? + /// 数据源给的是中文地名时,这里是英文,供英文界面使用 + public var cityEnglish: String? + public var regionEnglish: String? /// 自治系统号,例如 “AS4134” public var asn: String? /// 网络所属组织(英文),例如 “CHINANET-BACKBONE” public var organization: String? - public var source: Source = .online + /// 以下只有 CleanIP.io 提供 + public var hostname: String? + /// 网络类型:Residential / Business / Hosting / Mobile … + public var networkType: String? + /// 自治系统的类型:isp / hosting / business / education / government / mobile + public var asnType: String? + /// 接入方式:Cable/DSL / Cellular / Corporate … + public var connectionType: String? + /// IP 类型:Residential IP / Datacenter IP … + public var ipType: String? + /// 原生 IP(登记国、宣告 AS 注册国与定位国一致)还是广播 IP + public var isNative: Bool? + public var residentialProbability: Int? + public var purity: Purity? + public var risk: Risk? + public var reportURL: URL? + public var source: Source = .online(.cleanIP) - public enum Source: Sendable, Equatable { + public struct Purity: Sendable, Equatable, Codable { + public var score: Int + public var grade: String + public var confidence: Int? + public var recommendation: String? + public init(score: Int, grade: String, confidence: Int? = nil, recommendation: String? = nil) { + self.score = score + self.grade = grade + self.confidence = confidence + self.recommendation = recommendation + } + } + + public struct Risk: Sendable, Equatable, Codable { + public var score: Int + public var label: String? + /// 命中的标记:vpn、proxy、tor、datacenter、hosting、relay、abuser、residentialProxy + public var flags: [String] + public init(score: Int, label: String? = nil, flags: [String] = []) { + self.score = score + self.label = label + self.flags = flags + } + } + + public enum Source: Sendable, Equatable, Codable { /// 本机的 MaxMind GeoLite2 数据库 case localDatabase - /// ipinfo.io 在线查询 - case online + case online(GeoProvider) } public init(ipv4: String? = nil, ipv6: String? = nil, countryCode: String? = nil, - city: String? = nil, asn: String? = nil, organization: String? = nil) { + city: String? = nil, region: String? = nil, asn: String? = nil, organization: String? = nil) { self.ipv4 = ipv4 self.ipv6 = ipv6 self.countryCode = countryCode self.city = city + self.region = region self.asn = asn self.organization = organization } + + /// 把在线查到的归属地信息并进来;国家代码以 Cloudflare 给的为准,没有时才用数据源的 + public mutating func apply(_ geo: PublicAddressLookup.GeoInfo) { + city = geo.city + region = geo.region + cityEnglish = geo.cityEnglish + regionEnglish = geo.regionEnglish + asn = geo.asn + organization = geo.organization + hostname = geo.hostname + networkType = geo.networkType + asnType = geo.asnType + connectionType = geo.connectionType + ipType = geo.ipType + isNative = geo.isNative + residentialProbability = geo.residentialProbability + purity = geo.purity + risk = geo.risk + reportURL = geo.reportURL + if countryCode == nil { countryCode = geo.countryCode } + } } /// 查询公网 IP 与归属地。只在用户打开网络详情时请求,不携带任何本机信息。 -/// 归属地与 ASN 来自 ipinfo.io;IPv6 地址与失败时的回退使用 Cloudflare trace(直连 IP,不依赖 DNS)与 ipify。 +/// 公网地址来自 Cloudflare trace(直连 IP,不依赖 DNS,同时给出国家代码),回退 ipify; +/// 归属地等信息按用户选择的数据源在线查询,由每台 Mac 自己直接查。这些接口按客户端 IP 限额, +/// 所以同一个公网 IP 的结果会记住一段时间,收到 429 后到当天(UTC)结束都不再请求 public enum PublicAddressLookup { private static let session: URLSession = { let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 5 - configuration.timeoutIntervalForResource = 8 + configuration.timeoutIntervalForRequest = 8 + configuration.timeoutIntervalForResource = 12 configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.httpShouldSetCookies = false configuration.waitsForConnectivity = false + configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"] return URLSession(configuration: configuration) }() - /// `includeGeo` 为假时只查询公网 IP(Cloudflare trace / ipify),不请求任何归属地服务 - public static func fetch(includeGeo: Bool = true) async -> PublicAddresses { - guard includeGeo else { - async let v4 = lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org") - async let v6 = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org") - let (ipv4, ipv6) = await (v4, v6) - return PublicAddresses(ipv4: ipv4.ip, ipv6: ipv6.ip, countryCode: ipv4.country ?? ipv6.country) - } - async let geoResult = geo() - async let v6Result = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org") - var result = await geoResult ?? PublicAddresses() - let v6 = await v6Result + private static let geoCache = GeoCache() - if let ip = result.ipv6, result.ipv4 == nil { - // 本机优先走 IPv6 时,归属地接口返回的是 IPv6 地址,IPv4 另外查询 - result.ipv6 = ip - let v4 = await lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org") - result.ipv4 = v4.ip - } else if result.ipv4 == nil { - let v4 = await lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org") - result.ipv4 = v4.ip - result.countryCode = result.countryCode ?? v4.country - } - if result.ipv6 == nil { result.ipv6 = v6.ip } - if result.countryCode == nil { result.countryCode = v6.country } + /// `includeGeo` 为假时只查询公网 IP,不请求任何归属地服务 + public static func fetch(includeGeo: Bool = true, provider: GeoProvider = .cleanIP) async -> PublicAddresses { + async let v4 = lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org") + async let v6 = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org") + let (ipv4, ipv6) = await (v4, v6) + var result = PublicAddresses(ipv4: ipv4.ip, ipv6: ipv6.ip, countryCode: ipv4.country ?? ipv6.country) + result.source = .online(provider) + guard includeGeo, let ip = result.ipv4 ?? result.ipv6, let geo = await geoCache.lookup(ip, provider: provider) else { return result } + result.apply(geo) return result } + /// 只查一个已知地址的归属地,公网 IP 没变时用它补全,不必重新取地址 + public static func geo(for ip: String, provider: GeoProvider) async -> GeoInfo? { + await geoCache.lookup(ip, provider: provider) + } + // MARK: 归属地 - /// ipinfo.io 的响应:`country` 是两位代码,`org` 形如 “AS151338 POLONETWORK LIMITED” + public struct GeoInfo: Sendable, Equatable { + public var countryCode: String? + public var city: String? + public var region: String? + public var cityEnglish: String? + public var regionEnglish: String? + public var asn: String? + public var organization: String? + public var hostname: String? + public var networkType: String? + public var asnType: String? + public var connectionType: String? + public var ipType: String? + public var isNative: Bool? + public var residentialProbability: Int? + public var purity: PublicAddresses.Purity? + public var risk: PublicAddresses.Risk? + public var reportURL: URL? + } + + static func parse(_ data: Data, provider: GeoProvider) -> GeoInfo? { + switch provider { + case .cleanIP: parseCleanIP(data) + case .ipapi: parseGeo(data) + case .dbip: parseDBIP(data) + case .ipinfo: parseIpinfo(data) + } + } + + // MARK: CleanIP.io + + struct CleanIPResponse: Decodable { + struct Geo: Decodable { + let countryCode: String? + let country: String? + let countryEn: String? + let region: String? + let city: String? + } + struct GeoSource: Decodable { + let source: String? + let region: String? + let city: String? + } + struct Network: Decodable { + let asn: Int? + let asnName: String? + let asnType: String? + let isp: String? + let networkType: String? + let connectionType: String? + let nativeOrBroadcast: String? + } + struct Purity: Decodable { + let score: Int? + let grade: String? + let confidence: Int? + let ipType: String? + let isNative: Bool? + let residentialProbability: Int? + let recommendation: String? + } + struct Risk: Decodable { + let riskScore: Int? + let riskLabel: String? + let isVpn: Bool? + let isProxy: Bool? + let isTor: Bool? + let isDatacenter: Bool? + let isHosting: Bool? + let isRelay: Bool? + let isAbuser: Bool? + let isResidentialProxy: Bool? + } + let ok: Bool? + let ip: String? + let hostname: String? + let geo: Geo? + let geoSources: [GeoSource]? + let network: Network? + let purity: Purity? + let risk: Risk? + } + + static func parseCleanIP(_ data: Data) -> GeoInfo? { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + guard let response = try? decoder.decode(CleanIPResponse.self, from: data), response.ok != false else { return nil } + var info = GeoInfo() + info.countryCode = response.geo?.countryCode.flatMap(validCountryCode) + info.city = response.geo?.city.flatMap(nonEmpty) + info.region = response.geo?.region.flatMap(nonEmpty) + // 英文地名取 ipinfo 那一路的 + if let english = response.geoSources?.first(where: { $0.source == "ipinfo" }) { + info.cityEnglish = english.city.flatMap(nonEmpty) + info.regionEnglish = english.region.flatMap(nonEmpty) + } + if let asn = response.network?.asn, asn > 0 { info.asn = "AS\(asn)" } + info.organization = response.network?.isp.flatMap(nonEmpty) ?? response.network?.asnName.flatMap(nonEmpty) + if let hostname = response.hostname.flatMap(nonEmpty), hostname != response.ip { info.hostname = hostname } + info.networkType = response.network?.networkType.flatMap(nonEmpty) + info.asnType = response.network?.asnType.flatMap(nonEmpty)?.lowercased() + info.connectionType = response.network?.connectionType.flatMap(nonEmpty) + info.ipType = response.purity?.ipType.flatMap(nonEmpty) + info.isNative = response.purity?.isNative ?? response.network?.nativeOrBroadcast.map { $0.uppercased() == "NATIVE" } + info.residentialProbability = response.purity?.residentialProbability + if let score = response.purity?.score, let grade = response.purity?.grade.flatMap(nonEmpty) { + info.purity = PublicAddresses.Purity(score: min(100, max(0, score)), grade: grade, + confidence: response.purity?.confidence, + recommendation: response.purity?.recommendation.flatMap(nonEmpty)) + } + if let risk = response.risk, let score = risk.riskScore { + var flags: [String] = [] + if risk.isVpn == true { flags.append("vpn") } + if risk.isProxy == true { flags.append("proxy") } + if risk.isResidentialProxy == true { flags.append("residentialProxy") } + if risk.isTor == true { flags.append("tor") } + if risk.isRelay == true { flags.append("relay") } + if risk.isDatacenter == true { flags.append("datacenter") } + if risk.isHosting == true { flags.append("hosting") } + if risk.isAbuser == true { flags.append("abuser") } + info.risk = PublicAddresses.Risk(score: min(100, max(0, score)), label: risk.riskLabel.flatMap(nonEmpty), flags: flags) + } + if let ip = response.ip, isAddress(ip) { info.reportURL = URL(string: "https://cleanip.io/\(ip)") } + return info + } + + // MARK: ipapi.is + + /// ipapi.is 匿名接口的响应:`asn` 形如 “AS7018 AT&T Enterprises, LLC”,`country` 是英文全名,`company` 是地址持有方 struct GeoResponse: Decodable { let ip: String? + let company: String? + let asn: String? let city: String? + let region: String? let country: String? + } + + static func parseGeo(_ data: Data) -> GeoInfo? { + guard let response = try? JSONDecoder().decode(GeoResponse.self, from: data) else { return nil } + var info = GeoInfo() + info.countryCode = response.country.flatMap(nonEmpty).flatMap(countryCode(fromName:)) + info.city = response.city.flatMap(nonEmpty) + info.region = response.region.flatMap(nonEmpty) + let (asn, asnOrganization) = splitASN(response.asn) + info.asn = asn + // 地址持有方通常比自治系统的注册名更能说明“是谁的网络” + info.organization = response.company.flatMap(nonEmpty) ?? asnOrganization + return info + } + + // MARK: DB-IP + + struct DBIPResponse: Decodable { + let countryCode: String? + let stateProv: String? + let city: String? + } + + static func parseDBIP(_ data: Data) -> GeoInfo? { + guard let response = try? JSONDecoder().decode(DBIPResponse.self, from: data) else { return nil } + var info = GeoInfo() + info.countryCode = response.countryCode.flatMap(validCountryCode) + info.region = response.stateProv.flatMap(nonEmpty) + info.city = response.city.flatMap(nonEmpty) + return info + } + + // MARK: ipinfo.io + + struct IpinfoResponse: Decodable { + let country: String? + let region: String? + let city: String? let org: String? } - private static func geo() async -> PublicAddresses? { - guard let data = await get("https://ipinfo.io/json") else { return nil } - return parseGeo(data) + static func parseIpinfo(_ data: Data) -> GeoInfo? { + guard let response = try? JSONDecoder().decode(IpinfoResponse.self, from: data) else { return nil } + var info = GeoInfo() + info.countryCode = response.country.flatMap(validCountryCode) + info.region = response.region.flatMap(nonEmpty) + info.city = response.city.flatMap(nonEmpty) + let (asn, organization) = splitASN(response.org) + info.asn = asn + info.organization = organization + return info } - static func parseGeo(_ data: Data) -> PublicAddresses? { - guard let response = try? JSONDecoder().decode(GeoResponse.self, from: data), - let ip = response.ip, isAddress(ip) else { return nil } - var result = PublicAddresses() - if ip.contains(":") { result.ipv6 = ip } else { result.ipv4 = ip } - result.countryCode = response.country.flatMap(validCountryCode) - result.city = response.city.flatMap(nonEmpty) - if let org = response.org.flatMap(nonEmpty) { - let parts = org.split(separator: " ", maxSplits: 1).map(String.init) - if let first = parts.first, first.hasPrefix("AS"), Int(first.dropFirst(2)) != nil { - result.asn = first - result.organization = parts.count > 1 ? parts[1] : nil - } else { - result.organization = org + /// “AS7018 AT&T Enterprises, LLC” → (“AS7018”, “AT&T Enterprises, LLC”);没有 AS 前缀时整段当组织名 + static func splitASN(_ text: String?) -> (String?, String?) { + guard let text = text.flatMap(nonEmpty) else { return (nil, nil) } + let parts = text.split(separator: " ", maxSplits: 1).map(String.init) + if let first = parts.first, first.hasPrefix("AS"), Int(first.dropFirst(2)) != nil { + return (first, parts.count > 1 ? parts[1] : nil) + } + return (nil, text) + } + + /// 英文国家名转两位代码:先查几个系统名称对不上的别名,再按系统的英文区域名反查 + static func countryCode(fromName name: String) -> String? { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if let alias = countryAliases[trimmed.lowercased()] { return alias } + let english = Locale(identifier: "en_US") + for region in Locale.Region.isoRegions where region.identifier.count == 2 { + if english.localizedString(forRegionCode: region.identifier)?.caseInsensitiveCompare(trimmed) == .orderedSame { + return validCountryCode(region.identifier) } } - return result + return nil + } + + private static let countryAliases: [String: String] = [ + "china": "CN", "china mainland": "CN", "united states": "US", "usa": "US", "united kingdom": "GB", "uk": "GB", + "russia": "RU", "south korea": "KR", "north korea": "KP", "hong kong": "HK", "macau": "MO", "macao": "MO", + "taiwan": "TW", "vietnam": "VN", "viet nam": "VN", "turkey": "TR", "türkiye": "TR", "czech republic": "CZ", + "czechia": "CZ", "iran": "IR", "syria": "SY", "laos": "LA", "brunei": "BN", "bolivia": "BO", "venezuela": "VE", + "tanzania": "TZ", "moldova": "MD", "palestine": "PS", "the netherlands": "NL", "netherlands": "NL", + "ivory coast": "CI", "cape verde": "CV", "swaziland": "SZ", "myanmar": "MM", "burma": "MM", "congo": "CG", + "dr congo": "CD", "democratic republic of the congo": "CD", "micronesia": "FM", "vatican": "VA", "reunion": "RE", + "curacao": "CW", "saint martin": "MF", "sint maarten": "SX", + ] + + /// 按数据源与 IP 缓存;收到 429 后该数据源到当天(UTC)结束都不再请求 + actor GeoCache { + private var entries: [String: (info: GeoInfo?, date: Date)] = [:] + private var blockedUntil: [GeoProvider: Date] = [:] + + func lookup(_ ip: String, provider: GeoProvider) async -> GeoInfo? { + let key = "\(provider.rawValue)|\(ip)" + if let entry = entries[key], Date().timeIntervalSince(entry.date) < provider.cacheLifetime { return entry.info } + if let until = blockedUntil[provider], Date() < until { return nil } + guard let url = provider.url(for: ip) else { return nil } + let (data, status) = await PublicAddressLookup.getWithStatus(url) + if status == 429 { + blockedUntil[provider] = Self.nextUTCMidnight() + return nil + } + guard status == 200, let data else { return nil } + let info = PublicAddressLookup.parse(data, provider: provider) + entries[key] = (info, Date()) + return info + } + + private static func nextUTCMidnight() -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let start = calendar.startOfDay(for: Date()) + return calendar.date(byAdding: .day, value: 1, to: start) ?? Date().addingTimeInterval(12 * 3600) + } } /// 只接受两位字母,之后会用作国旗文件名 @@ -132,10 +457,14 @@ public enum PublicAddressLookup { } private static func get(_ string: String) async -> Data? { - guard let url = URL(string: string), - let (data, response) = try? await session.data(from: url), - (response as? HTTPURLResponse)?.statusCode == 200 else { return nil } - return data + guard let url = URL(string: string) else { return nil } + let (data, status) = await getWithStatus(url) + return status == 200 ? data : nil + } + + static func getWithStatus(_ url: URL) async -> (Data?, Int) { + guard let (data, response) = try? await session.data(from: url) else { return (nil, 0) } + return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) } static func parseTrace(_ body: String) -> [String: String] { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index 34e8c62..b9d38b8 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -63,6 +63,8 @@ public final class AppController: NSObject, NSApplicationDelegate { model.alerts.start() hotKeys.onAction = { [weak self] action in self?.perform(action) } applyHotKeys() + model.sync.presentationAnchor = { [weak self] in self?.mainWindow.nsWindow } + model.sync.start() // 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口 let arguments = CommandLine.arguments @@ -208,13 +210,13 @@ public final class AppController: NSObject, NSApplicationDelegate { _ = model.settings.probeTarget _ = model.settings.probeSeconds _ = model.settings.publicIPLookup + _ = model.settings.geoSource } onChange: { [weak self] in Task { @MainActor in guard let self else { return } self.model.network.restartProbing() - if !self.model.settings.publicIPLookup { - self.model.network.clearPublicAddresses() - } else if self.model.isNetworkDetailVisible { + self.model.network.clearPublicAddresses() + if self.model.settings.publicIPLookup, self.model.isNetworkDetailVisible { self.model.network.lookUpPublicAddresses() } self.observeProbeSettings() @@ -276,6 +278,7 @@ public final class AppController: NSObject, NSApplicationDelegate { MainActor.assumeIsolated { guard let self else { return } self.model.network.setPaused(false) + self.model.sync.pullSoon() Task { await self.model.hub.setPaused(false) await self.model.fans.reapply() @@ -289,6 +292,9 @@ public final class AppController: NSObject, NSApplicationDelegate { public enum OpenStatsApplication { @MainActor public static func main() { + // 滚动条一律用浮层样式:接了鼠标时系统默认是常驻滚动条,SwiftUI 的 ScrollView 会给它预留一条宽度, + // 弹窗里的内容就会整体偏左。这是本应用自己的偏好域,只影响 OpenStats + UserDefaults.standard.set("WhenScrolling", forKey: "AppleShowScrollBars") let app = NSApplication.shared // 界面语言在创建任何界面之前确定,切换后重启生效 let stored = UserDefaults.standard.string(forKey: AppSettings.languageKey).flatMap(AppLanguage.init(rawValue:)) ?? .system diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift index 185af72..f572060 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift @@ -14,6 +14,8 @@ final class MainWindowController: NSObject, NSWindowDelegate { } var isVisible: Bool { window?.isVisible == true } + /// 登录窗口等系统面板挂靠用 + var nsWindow: NSWindow? { window } func close() { window?.performClose(nil) @@ -94,4 +96,5 @@ enum WindowChrome { window.toolbar = toolbar window.toolbarStyle = .unifiedCompact } + } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift index 3c8d311..1142762 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift @@ -10,6 +10,9 @@ struct MenuBarReading { var gpuHistory: [Double] = [] var memory: Double? var memoryHistory: [Double] = [] + /// 启动磁盘已用占比;用量几乎不变,柱状 / 折线风格画成一条平线 + var disk: Double? + var diskHistory: [Double] = [] var upload: Double? var download: Double? var temperature: Double? @@ -25,6 +28,8 @@ struct MenuBarReading { gpuHistory = store.gpuHistory.elements memory = store.memory?.usedFraction memoryHistory = store.memoryHistory.elements + disk = store.disk?.usedFraction + diskHistory = disk.map { Array(repeating: $0, count: 30) } ?? [] upload = store.network?.uploadBytesPerSecond download = store.network?.downloadBytesPerSecond temperature = store.sensors?.temperature(.cpu)?.maximum @@ -34,15 +39,21 @@ struct MenuBarReading { init() {} - /// 设置页预览用的示例读数 + /// 设置页预览用的示例读数:历史给 30 个点,柱状风格取最后 10 个,折线风格用全部 static let sample: MenuBarReading = { var reading = MenuBarReading() reading.cpu = 0.34 - reading.cpuHistory = [0.18, 0.22, 0.41, 0.36, 0.28, 0.52, 0.47, 0.31, 0.29, 0.34] + reading.cpuHistory = [0.12, 0.15, 0.11, 0.20, 0.33, 0.27, 0.19, 0.24, 0.45, 0.38, + 0.30, 0.26, 0.22, 0.35, 0.58, 0.49, 0.41, 0.37, 0.30, 0.25, + 0.18, 0.22, 0.41, 0.36, 0.28, 0.52, 0.47, 0.31, 0.29, 0.34] reading.gpu = 0.22 - reading.gpuHistory = [0.05, 0.12, 0.30, 0.18, 0.10, 0.26, 0.40, 0.21, 0.15, 0.22] + reading.gpuHistory = [0.02, 0.04, 0.03, 0.08, 0.15, 0.12, 0.09, 0.11, 0.24, 0.20, + 0.14, 0.10, 0.08, 0.17, 0.35, 0.28, 0.22, 0.19, 0.14, 0.10, + 0.05, 0.12, 0.30, 0.18, 0.10, 0.26, 0.40, 0.21, 0.15, 0.22] reading.memory = 0.68 - reading.memoryHistory = Array(repeating: 0.68, count: 10) + reading.memoryHistory = Array(repeating: 0.68, count: 30) + reading.disk = 0.58 + reading.diskHistory = Array(repeating: 0.58, count: 30) reading.upload = 12 * 1024 reading.download = 1.4 * 1024 * 1024 reading.temperature = 52 @@ -55,6 +66,7 @@ struct MenuBarReading { case .cpu: (cpu, cpuHistory) case .gpu: (gpu, gpuHistory) case .memory: (memory, memoryHistory) + case .disk: (disk, diskHistory) default: (nil, []) } } @@ -66,6 +78,7 @@ struct MenuBarReading { case .cpu: cpu.map { "CPU \(Format.percent($0))" } case .gpu: gpu.map { "GPU \(Format.percent($0))" } case .memory: memory.map { tr("内存 \(Format.percent($0))") } + case .disk: disk.map { tr("磁盘已用 \(Format.percent($0))") } case .network: upload.flatMap { up in download.map { tr("上传 \(Format.menuBarRate(up)) · 下载 \(Format.menuBarRate($0))") } } case .temperature: temperature.map { tr("CPU 温度 \(Format.temperature($0, fahrenheit: fahrenheit))") } @@ -96,6 +109,8 @@ enum MenuBarRenderer { static let historyCount = 10 static let historyBarWidth: CGFloat = 2 static let historyBarGap: CGFloat = 1 + static let lineCount = 30 + static let lineWidth: CGFloat = 30 static let chartHeight: CGFloat = 13 static let gaugeDiameter: CGFloat = 13 static let ringWidth: CGFloat = 2 @@ -172,7 +187,7 @@ enum MenuBarRenderer { private static func segment(for item: MenuBarItem, reading: MenuBarReading, style: MenuBarStyle, networkStyle: NetworkMenuStyle, colorizeHighLoad: Bool, fahrenheit: Bool) -> Segment { switch item { - case .cpu, .gpu, .memory: + case .cpu, .gpu, .memory, .disk: let (value, history) = reading.percent(item) return percentSegment(item: item, value: value, history: history, style: style, colorizeHighLoad: colorizeHighLoad) case .network: @@ -208,6 +223,8 @@ enum MenuBarRenderer { return combine([pie(fraction: fraction, alert: alert), stacked]) case .history: return combine([historyBars(history, alert: alert), stacked]) + case .line: + return combine([historyLine(history, alert: alert), stacked]) case .meter: return combine([meter(fraction: fraction, alert: alert), stacked]) case .dot: @@ -335,6 +352,37 @@ enum MenuBarRenderer { } } + /// 最近 30 次采样的折线,最新的点在右边,线下填淡色;数据不足时从右往左只画已有的部分 + private static func historyLine(_ history: [Double], alert: Bool) -> Segment { + let recent = Array(history.suffix(Metrics.lineCount)) + return Segment(width: Metrics.lineWidth) { rect in + let bottom = rect.midY - Metrics.chartHeight / 2 + NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setFill() + NSRect(x: rect.minX, y: bottom, width: Metrics.lineWidth, height: 1).fill() + guard recent.count > 1 else { return } + + let step = Metrics.lineWidth / CGFloat(Metrics.lineCount - 1) + let startX = rect.maxX - CGFloat(recent.count - 1) * step + let line = NSBezierPath() + for (index, value) in recent.enumerated() { + let point = NSPoint(x: startX + CGFloat(index) * step, + y: bottom + 1 + (Metrics.chartHeight - 2) * CGFloat(min(1, max(0, value)))) + index == 0 ? line.move(to: point) : line.line(to: point) + } + let area = line.copy() as! NSBezierPath + area.line(to: NSPoint(x: rect.maxX, y: bottom)) + area.line(to: NSPoint(x: startX, y: bottom)) + area.close() + foreground(alert).withAlphaComponent(Metrics.trackAlpha).setFill() + area.fill() + + line.lineWidth = 1 + line.lineJoinStyle = .round + foreground(alert).setStroke() + line.stroke() + } + } + /// 竖向电量条 private static func meter(fraction: Double, alert: Bool) -> Segment { Segment(width: Metrics.meterSize.width) { rect in diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift index 2df5a74..8fd3e74 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift @@ -49,10 +49,20 @@ struct LineHistoryChart: View { var maxValue: Double = 1 var color: Color = DS.Palette.primary var filled = true + /// 在 25% / 50% / 75% 画三条淡淡的参考线,图表较高时(主窗口)用来读数 + var grid = false var height: CGFloat = DS.Size.chartHeight var body: some View { Canvas { context, size in + if grid { + let usable = size.height - DS.Size.chartLine * 2 + for fraction in [0.25, 0.5, 0.75] { + let y = (DS.Size.chartLine + usable * CGFloat(1 - fraction)).rounded() + context.fill(Path(CGRect(x: 0, y: y, width: size.width, height: DS.Size.stroke)), + with: .color(DS.Palette.border)) + } + } let line = LineHistoryChart.path(values: values, capacity: capacity, maxValue: maxValue, in: size) guard let line else { return } if filled { @@ -87,6 +97,67 @@ struct LineHistoryChart: View { } } +/// 按时间定位的短期走势线:最新的采样固定在右边缘,往左按真实时间间隔铺开, +/// 采样间隔变化(后台 2 秒、打开详情后 1 秒)也不会把曲线拉伸;相邻采样间隔超过 10 秒(睡眠)线条断开 +struct TimedLineChart: View { + let points: [TimedValue] + let duration: TimeInterval + let end: Date + var color: Color = DS.Palette.primary + /// 在 25% / 50% / 75% 画三条淡淡的参考线 + var grid = false + var height: CGFloat = DS.Size.chartHeight + + static let gapLimit: TimeInterval = 10 + + var body: some View { + Canvas { context, size in + if grid { + let usable = size.height - DS.Size.chartLine * 2 + for fraction in [0.25, 0.5, 0.75] { + let y = (DS.Size.chartLine + usable * CGFloat(1 - fraction)).rounded() + context.fill(Path(CGRect(x: 0, y: y, width: size.width, height: DS.Size.stroke)), + with: .color(DS.Palette.border)) + } + } + for segment in Self.segments(points, duration: duration, end: end, in: size) { + guard let first = segment.first, let last = segment.last else { continue } + var line = Path() + line.addLines(segment.count == 1 ? [first, CGPoint(x: first.x + DS.Size.stroke, y: first.y)] : segment) + var area = line + area.addLine(to: CGPoint(x: last.x, y: size.height)) + area.addLine(to: CGPoint(x: first.x, y: size.height)) + area.closeSubpath() + context.fill(area, with: .color(color.opacity(0.14))) + context.stroke(line, with: .color(color), + style: StrokeStyle(lineWidth: DS.Size.chartLine, lineCap: .round, lineJoin: .round)) + } + } + .frame(height: height) + .accessibilityHidden(true) + } + + /// 落在 [end − duration, end] 内的采样换算成坐标(0 在下、1 在上),按间隔断开成若干段 + static func segments(_ points: [TimedValue], duration: TimeInterval, end: Date, in size: CGSize) -> [[CGPoint]] { + let start = end.addingTimeInterval(-duration) + let inset = DS.Size.chartLine + let usable = size.height - inset * 2 + var segments: [[CGPoint]] = [] + var previous: Date? + for point in points where point.date >= start && point.date <= end { + let location = CGPoint(x: size.width * CGFloat(point.date.timeIntervalSince(start) / max(duration, 1)), + y: inset + usable * CGFloat(1 - min(1, max(0, point.value)))) + if let previous, point.date.timeIntervalSince(previous) <= gapLimit, !segments.isEmpty { + segments[segments.count - 1].append(location) + } else { + segments.append([location]) + } + previous = point.date + } + return segments + } +} + /// 上传 / 下载双折线,按两者峰值归一化 struct DualLineChart: View { let upload: [Double] @@ -214,7 +285,8 @@ struct CoreClusterBars: View { } } -/// 核心热力图:每行一个核心(按类型分组,高性能档在上),每列一次采样,最新在右。 +/// 核心热力图:每行一个核心(按类型分组,高性能档在上),每列一次采样,最新在右; +/// 最右侧单独一根粗条画此刻的占用,一眼能看出现在哪些核心在忙。 /// 颜色越深越忙,超过 85% 用警告色 struct CoreHeatmap: View { let topology: CPUTopology @@ -224,6 +296,8 @@ struct CoreHeatmap: View { private static let gap = DS.Size.stroke private static let groupGap = DS.Space.s1 + private static let nowWidth = DS.Space.s2 + private static let nowGap = DS.Space.s1 var body: some View { let clusters = topology.clusters.sorted { $0.id < $1.id } @@ -233,7 +307,8 @@ struct CoreHeatmap: View { HStack(alignment: .top, spacing: DS.Space.s2) { VStack(alignment: .leading, spacing: Self.groupGap) { ForEach(clusters) { cluster in - Text(verbatim: cluster.name) + // 类型名后面带上核心数,与“各核心占用”里的分组标题一致 + Text(verbatim: "\(cluster.name) · \(cluster.coreIndices.count)") .dsFont(.xs) .foregroundStyle(DS.Palette.textTertiary) .lineLimit(1) @@ -244,8 +319,10 @@ struct CoreHeatmap: View { Canvas { context, size in let recent = Array(history.suffix(columns)) - let cellWidth = (size.width - Self.gap * CGFloat(columns - 1)) / CGFloat(columns) + let gridWidth = size.width - Self.nowWidth - Self.nowGap + let cellWidth = (gridWidth - Self.gap * CGFloat(columns - 1)) / CGFloat(columns) let offset = columns - recent.count + let latest = recent.last var y: CGFloat = 0 for cluster in clusters { for core in cluster.coreIndices.reversed() { @@ -254,6 +331,8 @@ struct CoreHeatmap: View { let value = column >= offset ? recent[column - offset][safe: core] : nil context.fill(Path(rect), with: .color(Self.color(value))) } + let now = CGRect(x: size.width - Self.nowWidth, y: y, width: Self.nowWidth, height: rowHeight) + context.fill(Path(roundedRect: now, cornerRadius: DS.Radius.sm / 2), with: .color(Self.color(latest?[safe: core]))) y += rowHeight + Self.gap } y += Self.groupGap @@ -423,3 +502,87 @@ struct ProbeGrid: View { .accessibilityLabel(tr("连接探测历史")) } } + +// MARK: - 评分色带 + +/// 0–100 分的六段色带:F、D、C、B、A、A+ 各占自己的分数区间,段内写等级,下方标刻度, +/// 得分处有一个带当前等级的小标记 +struct ScoreBand: View { + let score: Int + var height: CGFloat = DS.Size.segmentHeight + + private static let markerHeight: CGFloat = DS.Size.iconStandalone + DS.Space.s1 + private static let tickHeight: CGFloat = DS.Space.s3 + + var body: some View { + let bands = DS.Grade.bands + let clamped = min(100, max(0, score)) + GeometryReader { proxy in + let width = proxy.size.width + ZStack(alignment: .topLeading) { + VStack(spacing: DS.Space.s1 / 2) { + HStack(spacing: 1) { + ForEach(Array(bands.enumerated()), id: \.offset) { index, band in + let lower = index == 0 ? 0 : bands[index - 1].upper + let segmentWidth = width * CGFloat(band.upper - lower) / 100 + ZStack { + Rectangle().fill(band.color) + if segmentWidth >= DS.Space.s6 { + Text(verbatim: band.grade) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.onPrimary) + } + } + .frame(width: max(0, segmentWidth - (index == bands.count - 1 ? 0 : 1))) + } + } + .frame(height: height) + .clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm)) + ZStack(alignment: .topLeading) { + // A 与 A+ 的分界太密,刻度上不标 95;弹窗那么窄时 85 也挤不下 + ForEach([0] + bands.map(\.upper).filter { $0 != 95 && ($0 != 85 || width >= DS.Size.panelWidth / 2) }, id: \.self) { tick in + Text(verbatim: "\(tick)") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .monospacedDigit() + .fixedSize() + .alignmentGuide(.leading) { dimensions in + // 0 靠左、100 靠右,其余居中对齐刻度 + tick == 0 ? 0 : tick == 100 ? dimensions.width - width : dimensions.width / 2 - width * CGFloat(tick) / 100 + } + } + } + .frame(width: width, height: Self.tickHeight, alignment: .topLeading) + } + .padding(.top, Self.markerHeight) + + // 得分标记:气泡里写等级,尖角指向色带 + let color = DS.Grade.color(for: clamped) + VStack(spacing: 0) { + Text(verbatim: DS.Grade.bands.first { clamped < $0.upper }?.grade ?? "A+") + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.onPrimary) + .padding(.horizontal, DS.Space.s1) + .frame(height: DS.Size.iconInline) + .background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm)) + Triangle().fill(color).frame(width: DS.Space.s2, height: DS.Space.s1) + } + .fixedSize() + .alignmentGuide(.leading) { dimensions in dimensions.width / 2 - width * CGFloat(clamped) / 100 } + } + } + .frame(height: height + Self.markerHeight + Self.tickHeight + DS.Space.s1 / 2) + .accessibilityLabel(tr("纯净度 \(clamped) 分")) + } +} + +private struct Triangle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.closeSubpath() + return path + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift index 543acf0..a11a1eb 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift @@ -84,11 +84,15 @@ struct ProgressTrack: View { let fraction: Double var color: Color = DS.Palette.primary var height: CGFloat = DS.Size.barHeight + /// 占比类(容量、电量)画灰色底槽;排行类(按应用汇总)只画彩色条,底槽拉满一整行反而显得杂乱 + var showsTrack = true var body: some View { GeometryReader { proxy in ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: DS.Radius.sm).fill(DS.Palette.track) + if showsTrack { + RoundedRectangle(cornerRadius: DS.Radius.sm).fill(DS.Palette.track) + } RoundedRectangle(cornerRadius: DS.Radius.sm) .fill(color) .frame(width: proxy.size.width * min(1, max(0, fraction))) @@ -98,6 +102,183 @@ struct ProgressTrack: View { } } +/// 分段条:把一个整体按比例切成几段,每段填自己的颜色,段内写“名称 百分比”, +/// 第一段靠左、最后一段靠右、中间居中;段太窄放不下文字就只画色块,数值由旁边的图例补充 +struct SegmentedBar: View { + struct Segment: Identifiable { + let id: String + let label: String + let fraction: Double + let color: Color + var labelColor: Color = DS.Palette.onPrimary + } + + let segments: [Segment] + var height: CGFloat = DS.Size.controlHeight + + var body: some View { + GeometryReader { proxy in + let total = max(segments.reduce(0) { $0 + max(0, $1.fraction) }, 0.000_001) + HStack(spacing: 0) { + ForEach(Array(segments.enumerated()), id: \.element.id) { index, segment in + let width = proxy.size.width * max(0, segment.fraction) / total + let percent = Format.percent(segment.fraction / total) + ZStack(alignment: index == 0 ? .leading : index == segments.count - 1 ? .trailing : .center) { + Rectangle().fill(segment.color) + // 放得下就写“名称 百分比”,只放得下数字就只写百分比,再窄就只画色块 + ViewThatFits(in: .horizontal) { + Text(verbatim: "\(segment.label) \(percent)") + Text(verbatim: percent) + Color.clear.frame(width: 0, height: 0) + } + .dsFont(.xs, weight: .semibold) + .foregroundStyle(segment.labelColor) + .monospacedDigit() + .lineLimit(1) + .padding(.horizontal, DS.Space.s2) + } + .frame(width: width) + .clipped() + } + } + } + .frame(height: height) + .clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm)) + } +} + +/// 带图标的徽章(cleanip.io 样式):浅色底、细边框、同色系文字,用于“原生 IP”“住宅 IP”“ISP”这类结论性标签 +struct TagBadge: View { + enum Tone { case success, warning, neutral, primary } + + var icon: String? + let text: String + var tone: Tone = .success + /// 放在区块标题行里的小号:更窄的内边距 + var compact = false + + var body: some View { + HStack(spacing: DS.Space.s1) { + if let icon { + Image(systemName: icon) + .font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold)) + .frame(width: DS.Size.iconInline, height: DS.Size.iconInline) + } + Text(text).dsFont(.xs, weight: .semibold) + } + .foregroundStyle(foreground) + .padding(.horizontal, compact ? DS.Space.s1 : DS.Space.s3) + .padding(.vertical, compact ? DS.Space.s1 / 2 : DS.Space.s1) + .background(fill, in: RoundedRectangle(cornerRadius: compact ? DS.Radius.sm : DS.Radius.md)) + .overlay(RoundedRectangle(cornerRadius: compact ? DS.Radius.sm : DS.Radius.md).strokeBorder(border, lineWidth: DS.Size.stroke)) + .lineLimit(1) + .fixedSize() + } + + private var foreground: Color { + switch tone { + case .success: DS.Badge.successText + case .warning: DS.Badge.warningText + case .neutral: DS.Badge.neutralText + case .primary: DS.Palette.primary + } + } + + private var fill: Color { + switch tone { + case .success: DS.Badge.successFill + case .warning: DS.Badge.warningFill + case .neutral: DS.Badge.neutralFill + case .primary: DS.Palette.primary.opacity(0.10) + } + } + + private var border: Color { + switch tone { + case .success: DS.Badge.successBorder + case .warning: DS.Badge.warningBorder + case .neutral: DS.Badge.neutralBorder + case .primary: DS.Palette.primary.opacity(0.30) + } + } +} + +/// cleanip.io 的字标:黑色部分按文字色着色(随深浅色变化),圆环与 “.io” 保持品牌绿。 +/// 画布已裁到字形边缘,指定的高度就是字形实际高度,不留上下空白 +struct CleanIPLogo: View { + var height: CGFloat = DS.Size.iconInline + + var body: some View { + // 字标画布已裁到字形边缘:600 × 98 + let width = height * 600 / 98 + ZStack { + if let ink = LogoCache.shared.image(named: "cleanip-ink", template: true) { + Image(nsImage: ink).resizable().foregroundStyle(DS.Palette.textPrimary) + } + if let green = LogoCache.shared.image(named: "cleanip-green", template: false) { + Image(nsImage: green).resizable() + } + } + .frame(width: width, height: height) + .accessibilityLabel("cleanip.io") + } +} + +/// 可点击的 cleanip.io 字标:点了打开链接,悬停时略微变淡 +struct CleanIPLink: View { + static let site = URL(string: "https://cleanip.io")! + var height: CGFloat = DS.Size.iconInline + var url = CleanIPLink.site + var help = "cleanip.io" + @State private var hovering = false + + var body: some View { + Button { NSWorkspace.shared.open(url) } label: { + CleanIPLogo(height: height) + .opacity(hovering ? 0.7 : 1) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .animation(DS.Motion.quick, value: hovering) + .help(help) + } +} + +/// 胶囊切换:底槽是一条灰色胶囊,选中项是一颗蓝色胶囊滑过去,文字随之变白 +struct PillSwitch: View { + @Binding var selection: Value + let options: [(value: Value, title: String)] + @Namespace private var namespace + + var body: some View { + HStack(spacing: 0) { + ForEach(options, id: \.value) { option in + let selected = option.value == selection + Button { selection = option.value } label: { + Text(option.title) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(selected ? DS.Palette.onPrimary : DS.Palette.textSecondary) + .padding(.horizontal, DS.Space.s2) + .frame(height: DS.Size.segmentHeight - DS.Space.s1) + .background { + if selected { + Capsule().fill(DS.Palette.primary) + .matchedGeometryEffect(id: "pill", in: namespace) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(DS.Space.s1 / 2) + .background(DS.Palette.track, in: Capsule()) + .animation(DS.Motion.quick, value: selection) + .fixedSize() + } +} + // MARK: - 徽标 enum Tone { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/PanelContainer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/PanelContainer.swift index b38eafc..381e785 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/PanelContainer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/PanelContainer.swift @@ -59,8 +59,12 @@ struct OverlayScrollers: NSViewRepresentable { DispatchQueue.main.async { [weak self] in MainActor.assumeIsolated { guard let scrollView = self?.enclosingScrollView else { return } + guard scrollView.scrollerStyle != .overlay || !scrollView.autohidesScrollers else { return } scrollView.scrollerStyle = .overlay scrollView.autohidesScrollers = true + // 样式改了之后重新排版,让内容占满原来留给滚动条的那一条 + scrollView.tile() + scrollView.documentView?.needsLayout = true } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index 33f9a2c..f6db19b 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -34,6 +34,8 @@ extension DS { static let border = Color.dynamic(light: 0x000000, lightAlpha: 0.07, dark: 0xFFFFFF, darkAlpha: 0.09) static let track = Color.dynamic(light: 0x000000, lightAlpha: 0.06, dark: 0xFFFFFF, darkAlpha: 0.10) static let neutral300 = Color.dynamic(light: 0xD1D5DB, dark: 0x3F434A) + /// 分段条里表示“剩余 / 空闲”的中性段,深到能衬白字 + static let neutral500 = Color.dynamic(light: 0x6B7280, dark: 0x4B5563) /// 浅一档的品牌蓝,用来区分同一图表里的第二类数据(如性能核) static let primarySoft = Color.dynamic(light: 0x93C5FD, dark: 0x60A5FA) @@ -90,6 +92,38 @@ extension DS { case xs = 12, sm = 14, base = 16, lg = 20, xl = 24, xxl = 32 } + /// 徽章配色:浅底、细边、深字,与 cleanip.io 的徽章一致 + enum Badge { + static let successText = Color.dynamic(light: 0x15803D, dark: 0x75D99B) + static let successFill = Color.dynamic(light: 0xF0FDF4, dark: 0x142F24) + static let successBorder = Color.dynamic(light: 0xBBF7D0, dark: 0x29543C) + static let warningText = Color.dynamic(light: 0xB45309, dark: 0xF3C66C) + static let warningFill = Color.dynamic(light: 0xFFFBEB, dark: 0x342B1B) + static let warningBorder = Color.dynamic(light: 0xFDE68A, dark: 0x625030) + static let neutralText = Palette.textSecondary + static let neutralFill = Palette.elevated + static let neutralBorder = Palette.border + } + + /// 0–100 分的六档色阶(F / D / C / B / A / A+),只用于评分类图表,不是界面配色 + enum Grade { + static let f = Palette.error + static let d = Palette.warning + static let c = Color.dynamic(light: 0xEAB308, dark: 0xFACC15) + static let b = Color.dynamic(light: 0x84CC16, dark: 0xA3E635) + static let a = Palette.success + static let aPlus = Color.dynamic(light: 0x15803D, dark: 0x16A34A) + + /// 分段上限与颜色,从低到高 + static let bands: [(grade: String, upper: Int, color: Color)] = [ + ("F", 25, f), ("D", 50, d), ("C", 70, c), ("B", 85, b), ("A", 95, a), ("A+", 100, aPlus), + ] + + static func color(for score: Int) -> Color { + bands.first { score < $0.upper }?.color ?? aPlus + } + } + enum Space { static let s1: CGFloat = 4 static let s2: CGFloat = 8 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift index 6b8654d..ba04019 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/DiskPage.swift @@ -1,8 +1,9 @@ +import Cleaner import Localization import Metrics import SwiftUI -/// 磁盘:容量、实时读写、SSD 健康(SMART)与读写最多的应用 +/// 磁盘:容量、实时读写、空间分析、文件系统检查、本地快照、其他磁盘、SSD 健康(SMART)与读写最多的应用 struct DiskPage: View { @Environment(AppModel.self) private var model @@ -13,9 +14,256 @@ struct DiskPage: View { ActivityCard() } .fixedSize(horizontal: false, vertical: true) + SpaceCard() + WeightedRow(weights: [1, 1]) { + VerifyCard() + SnapshotsCard() + } + .fixedSize(horizontal: false, vertical: true) + VolumesCard() HealthCard() DiskProcessesCard() } + .onAppear { model.diskTools.pageOpened() } + } +} + +// MARK: - 空间占用 + +private struct SpaceCard: View { + @Environment(AppModel.self) private var model + @State private var confirming: SpaceScanResult.File? + + var body: some View { + let tools = model.diskTools + Card { + CardHeader(icon: "chart.pie", title: tr("空间占用")) { + HStack(spacing: DS.Space.s3) { + Text(tr("家目录")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).lineLimit(1) + Group { + switch tools.scanPhase { + case .scanning: + Button(tr("停止")) { tools.cancelScan() }.buttonStyle(DSButtonStyle(kind: .secondary)) + case .finished: + Button(tr("重新分析")) { tools.startScan() }.buttonStyle(DSButtonStyle(kind: .secondary)) + default: + Button(tr("开始分析")) { tools.startScan() }.buttonStyle(DSButtonStyle(kind: .primary)) + } + } + .fixedSize() + } + } + switch tools.scanPhase { + case .idle: + Text(tr("统计家目录里每个文件夹占多少空间,并找出最大的文件;只读取大小,不改动任何文件。文件多时需要几十秒。")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + case .scanning(let progress): + HStack(spacing: DS.Space.s2) { + ProgressView().controlSize(.small) + Text(verbatim: tr("已扫描 \(progress.items) 个条目 · \(Format.bytes(progress.bytes, base: .decimal))") + (progress.current.isEmpty ? "" : " · \(progress.current)")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).monospacedDigit().lineLimit(1) + } + case .failed(let message): + InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) + case .finished(let result): + results(result, tools: tools) + } + } + .confirmationDialog(tr("移到废纸篓?"), isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }), + titleVisibility: .visible, presenting: confirming) { file in + Button(tr("移到废纸篓"), role: .destructive) { tools.trash(file) } + Button(tr("取消"), role: .cancel) {} + } message: { file in + Text(verbatim: tr("“\(file.name)”(\(Format.bytes(file.bytes, base: .decimal)))会移到废纸篓,可以从废纸篓放回。")) + } + } + + @ViewBuilder + private func results(_ result: SpaceScanResult, tools: DiskToolsController) -> some View { + let top = result.folders.prefix(8) + let peak = top.first?.bytes ?? 1 + Text(verbatim: tr("共 \(Format.bytes(result.totalBytes, base: .decimal)),\(result.scannedItems) 个条目 · \(result.date.formatted(.relative(presentation: .named).locale(L10n.locale)))")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + WeightedRow(weights: [1, 1], spacing: DS.Space.s6) { + VStack(alignment: .leading, spacing: DS.Space.s2) { + Text(tr("按文件夹")).dsFont(.xs, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) + ForEach(Array(top)) { folder in + VStack(spacing: DS.Space.s1) { + HStack(spacing: DS.Space.s2) { + Text(verbatim: folder.name).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1) + Spacer(minLength: DS.Space.s2) + Text(verbatim: Format.bytes(folder.bytes, base: .decimal)) + .dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textPrimary).monospacedDigit() + } + ProgressTrack(fraction: peak > 0 ? Double(folder.bytes) / Double(peak) : 0, height: DS.Space.s1, showsTrack: false) + } + .contentShape(Rectangle()) + .onTapGesture { tools.reveal(folder.url) } + .help(tr("在访达中显示")) + } + } + VStack(alignment: .leading, spacing: DS.Space.s2) { + Text(tr("最大的文件")).dsFont(.xs, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) + if result.largestFiles.isEmpty { + Text(tr("没有超过 50 MB 的文件")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(result.largestFiles.prefix(8)) { file in + HStack(spacing: DS.Space.s2) { + VStack(alignment: .leading, spacing: 0) { + Text(verbatim: file.name).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1) + Text(verbatim: file.url.deletingLastPathComponent().path.replacingOccurrences(of: result.root.path, with: "~")) + .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1).truncationMode(.middle) + } + Spacer(minLength: DS.Space.s2) + Text(verbatim: Format.bytes(file.bytes, base: .decimal)) + .dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textPrimary).monospacedDigit() + IconButton(systemName: "magnifyingglass", help: tr("在访达中显示")) { tools.reveal(file.url) } + if SpaceScanner.canTrash(file.url) { + IconButton(systemName: "trash", help: tr("移到废纸篓")) { confirming = file } + } + } + } + if let outcome = tools.trashOutcome { + InfoBanner(icon: outcome.isError ? "exclamationmark.triangle" : "checkmark.circle.fill", text: outcome.text, + tone: outcome.isError ? .error : .success) + } + } + } + } +} + +// MARK: - 文件系统检查 + +private struct VerifyCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + let tools = model.diskTools + Card { + CardHeader(icon: "stethoscope", title: tr("文件系统检查")) { + HStack(spacing: DS.Space.s3) { + Text(tools.lastVerified.map { tr("上次检查:\($0.formatted(.relative(presentation: .named).locale(L10n.locale)))") } ?? tr("尚未检查")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).lineLimit(1) + Button(tools.verifyPhase == .running ? tr("检查中…") : tr("检查")) { tools.verify() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(tools.verifyPhase == .running) + .fixedSize() + } + } + switch tools.verifyPhase { + case .running: + HStack(spacing: DS.Space.s2) { + ProgressView().controlSize(.small) + Text(tr("正在核对启动盘的文件系统结构,通常几秒到几十秒")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + } + case .finished(let result): + VStack(alignment: .leading, spacing: DS.Space.s2) { + StatusBadge(text: result.ok ? tr("状态正常") : tr("发现问题"), tone: result.ok ? .success : .error) + Text(verbatim: result.summary).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + if !result.ok { + Text(tr("请打开系统自带的“磁盘工具”,选择启动盘运行“急救”进行修复")) + .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).fixedSize(horizontal: false, vertical: true) + } + } + case .failed(let message): + InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) + case .idle: + Text(tr("相当于“磁盘工具”里的急救,但只检查不修改:核对启动盘的目录结构、文件分配与快照元数据是否一致。")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize(horizontal: false, vertical: true) + if tools.lastVerified != nil { + StatusBadge(text: tools.lastVerifiedOK ? tr("上次检查正常") : tr("上次检查发现问题"), tone: tools.lastVerifiedOK ? .success : .error) + } + } + } + } +} + +// MARK: - 本地快照 + +private struct SnapshotsCard: View { + @Environment(AppModel.self) private var model + @State private var confirming = false + + var body: some View { + let tools = model.diskTools + Card { + CardHeader(icon: "clock.arrow.circlepath", title: tr("本地快照")) { + HStack(spacing: DS.Space.s3) { + Text(tools.snapshotsLoaded ? tr("\(tools.snapshots.count) 个") : tr("读取中")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).lineLimit(1) + Button(tools.isDeletingSnapshots ? tr("删除中…") : tr("全部删除")) { confirming = true } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(tools.snapshots.isEmpty || tools.isDeletingSnapshots) + .fixedSize() + } + } + Text(tr("Time Machine 在备份之间会先在本机留下快照,它们占的空间计入“可清除”,系统缺空间时会自动删。手动删除不影响已完成的备份。")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize(horizontal: false, vertical: true) + if tools.snapshotsLoaded, tools.snapshots.isEmpty { + Text(tr("现在没有本地快照")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(tools.snapshots.prefix(6)) { snapshot in + InfoRow(label: snapshot.date.map { $0.formatted(date: .abbreviated, time: .shortened) } ?? snapshot.identifier) { + Text(verbatim: snapshot.date.map { $0.formatted(.relative(presentation: .named).locale(L10n.locale)) } ?? "") + } + } + if tools.snapshots.count > 6 { + Text(verbatim: tr("还有 \(tools.snapshots.count - 6) 个")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + if let outcome = tools.snapshotOutcome { + InfoBanner(icon: outcome.isError ? "exclamationmark.triangle" : "checkmark.circle.fill", text: outcome.text, + tone: outcome.isError ? .error : .success) + } + } + .confirmationDialog(tr("删除全部本地快照?"), isPresented: $confirming, titleVisibility: .visible) { + Button(tr("删除"), role: .destructive) { tools.deleteAllSnapshots() } + Button(tr("取消"), role: .cancel) {} + } message: { + Text(tr("需要管理员权限。已完成的 Time Machine 备份不受影响,只是本机上这些快照对应的时间点无法再从本地恢复。")) + } + } +} + +// MARK: - 其他磁盘 + +private struct VolumesCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + let tools = model.diskTools + if !tools.volumes.isEmpty || tools.volumeOutcome != nil { + Card { + CardHeader(icon: "externaldrive", title: tr("其他磁盘"), detail: tr("外置硬盘、U 盘、镜像与网络共享")) + ForEach(tools.volumes) { volume in + VStack(alignment: .leading, spacing: DS.Space.s1) { + HStack(spacing: DS.Space.s2) { + Image(systemName: volume.isLocal ? (volume.isInternal ? "internaldrive" : "externaldrive") : "network") + .font(.system(size: DS.TextSize.base.rawValue)) + .foregroundStyle(DS.Palette.textSecondary) + .frame(width: DS.Size.iconStandalone) + Text(verbatim: volume.name).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1) + Spacer(minLength: DS.Space.s2) + Text(verbatim: tr("可用 \(Format.bytes(volume.available, base: .decimal)) / 共 \(Format.bytes(volume.total, base: .decimal))")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).monospacedDigit() + IconButton(systemName: "magnifyingglass", help: tr("在访达中显示")) { tools.reveal(volume.url) } + if volume.isEjectable || volume.isRemovable || !volume.isLocal { + Button(tools.ejecting.contains(volume.id) ? tr("推出中…") : tr("推出")) { tools.eject(volume) } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(tools.ejecting.contains(volume.id)) + } + } + ProgressTrack(fraction: volume.usedFraction, + color: volume.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary, height: DS.Space.s1) + } + } + if let outcome = tools.volumeOutcome { + InfoBanner(icon: outcome.isError ? "exclamationmark.triangle" : "checkmark.circle.fill", text: outcome.text, + tone: outcome.isError ? .error : .success) + } + } + } } } @@ -35,12 +283,10 @@ private struct CapacityCard: View { Text(verbatim: "%").dsFont(.sm).foregroundStyle(DS.Palette.textSecondary) Spacer() } - ProgressTrack(fraction: disk?.usedFraction ?? 0, - color: (disk?.usedFraction ?? 0) > 0.9 ? DS.Palette.warning : DS.Palette.primary) + SegmentedBar(segments: disk.map(DiskSegments.segments) ?? DiskSegments.placeholder) if let disk { - InfoRow(label: tr("已用"), text: Format.bytes(disk.used, base: .decimal)) - InfoRow(label: tr("可用"), text: Format.bytes(disk.available, base: .decimal)) - Text(tr("可用空间包含系统可以随时清除的缓存,与访达显示一致")) + DiskSegments.legend(disk) + Text(tr("可清除是系统随时可以腾出的缓存;访达显示的“可用”把它算在内")) .dsFont(.xs) .foregroundStyle(DS.Palette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -49,6 +295,41 @@ private struct CapacityCard: View { } } +/// 容量分段:已用(超过 90% 变橙)、可清除、可用,主窗口卡片与菜单栏弹窗共用 +enum DiskSegments { + static func segments(_ disk: DiskUsage) -> [SegmentedBar.Segment] { + var segments = [SegmentedBar.Segment(id: "used", label: tr("已用"), fraction: disk.usedFraction, + color: disk.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary)] + if disk.purgeable > 0 { + segments.append(SegmentedBar.Segment(id: "purgeable", label: tr("可清除"), fraction: disk.purgeableFraction, + color: DS.Palette.secondary)) + } + segments.append(SegmentedBar.Segment(id: "free", label: tr("可用"), fraction: disk.freeFraction, color: DS.Palette.neutral500)) + return segments + } + + static let placeholder = [SegmentedBar.Segment(id: "loading", label: "", fraction: 1, color: DS.Palette.track)] + + @ViewBuilder + static func legend(_ disk: DiskUsage) -> some View { + // 放不下就换行,不压缩每一项 + FlowLayout(spacing: DS.Space.s4) { + ForEach(segments(disk)) { segment in + LegendItem(color: segment.color, label: segment.label, value: Format.bytes(bytes(for: segment.id, in: disk), base: .decimal)) + .fixedSize() + } + } + } + + private static func bytes(for id: String, in disk: DiskUsage) -> UInt64 { + switch id { + case "used": disk.used + case "purgeable": disk.purgeable + default: disk.free + } + } +} + private struct ActivityCard: View { @Environment(AppModel.self) private var model @@ -58,10 +339,12 @@ private struct ActivityCard: View { let writes = store.diskWriteHistory.elements Card { CardHeader(icon: "arrow.up.arrow.down", title: tr("读写速度"), detail: tr("所有磁盘合计")) + // 两组数值各占一半宽度:数字长短变化时位置不动 HStack(spacing: DS.Space.s4) { rate(tr("读取"), store.diskActivity?.readRate, color: DS.NetworkPalette.download) + .frame(maxWidth: .infinity, alignment: .leading) rate(tr("写入"), store.diskActivity?.writeRate, color: DS.NetworkPalette.upload) - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) } // 上半写入、下半读取,与网络流量图同一套配色 MirroredRateChart(upload: writes, download: reads, height: DS.Size.chartHeight * 2) @@ -137,54 +420,184 @@ private struct HealthCard: View { } } - private func tone(_ health: DiskHealth) -> Tone { - if health.criticalWarning != 0 || health.mediaErrors > 0 || health.availableSpare < health.availableSpareThreshold { return .error } - return health.remainingLife < 20 ? .warning : .success + private func tone(_ health: DiskHealth) -> Tone { health.tone } + private func summary(_ health: DiskHealth) -> String { health.summary } +} + +extension DiskHealth { + /// 有严重警告、介质错误或备用空间不足为红色,寿命偏低为橙色 + var tone: Tone { + if criticalWarning != 0 || mediaErrors > 0 || availableSpare < availableSpareThreshold { return .error } + return remainingLife < 20 ? .warning : .success } - private func summary(_ health: DiskHealth) -> String { - if health.criticalWarning != 0 { return tr("磁盘报告了严重警告,建议尽快备份") } - if health.mediaErrors > 0 { return tr("出现介质错误,建议备份") } - if health.availableSpare < health.availableSpareThreshold { return tr("备用空间低于阈值,建议备份") } - return health.remainingLife < 20 ? tr("寿命偏低,注意备份") : tr("状态良好") + var summary: String { + if criticalWarning != 0 { return tr("磁盘报告了严重警告,建议尽快备份") } + if mediaErrors > 0 { return tr("出现介质错误,建议备份") } + if availableSpare < availableSpareThreshold { return tr("备用空间低于阈值,建议备份") } + return remainingLife < 20 ? tr("寿命偏低,注意备份") : tr("状态良好") } } private struct DiskProcessesCard: View { - @Environment(AppModel.self) private var model - var body: some View { - let rows = ProcessRowModel.grouped(model.store.processes) - .compactMap { row in row.disk.map { (row, $0.read + $0.write) } } - .filter { $0.1 > 0 } - .sorted { $0.1 > $1.1 } - .prefix(8) - let peak = rows.first?.1 ?? 1 - Card { CardHeader(icon: "list.bullet.rectangle", title: tr("读写最多的应用"), detail: tr("只含当前用户的进程")) - if rows.isEmpty { - Text(tr("最近没有应用在读写磁盘")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) - } - ForEach(Array(rows), id: \.0.id) { row, total in - VStack(spacing: DS.Space.s1) { - HStack(spacing: DS.Space.s2) { - AppIconCache.shared.image(bundlePath: row.bundlePath) - .resizable() - .frame(width: DS.Size.iconInline, height: DS.Size.iconInline) - Text(verbatim: row.title) - .dsFont(.xs, weight: .medium) - .foregroundStyle(DS.Palette.textPrimary) - .lineLimit(1) - Spacer(minLength: DS.Space.s2) - Text(verbatim: tr("读 \(Format.menuBarRate(row.disk?.read ?? 0)) · 写 \(Format.menuBarRate(row.disk?.write ?? 0))")) - .dsFont(.xs, weight: .medium) - .foregroundStyle(DS.Palette.textPrimary) - .monospacedDigit() - } - ProgressTrack(fraction: total / peak, height: DS.Space.s1) + DiskProcessRows(rowCount: 5) + } + } +} + +/// 读写磁盘最多的应用,主窗口磁盘页与菜单栏弹窗共用 +private struct DiskProcessRows: View { + @Environment(AppModel.self) private var model + let rowCount: Int + + var body: some View { + // 顺序和条形长度按最近十几秒的平均速率,数字显示当前速率:列表不会随每秒的波动跳动 + let grouped = Dictionary(uniqueKeysWithValues: ProcessRowModel.grouped(model.store.processes).map { ($0.id, $0) }) + let rows = model.store.diskRanking.ranked(limit: rowCount) + .compactMap { entry in grouped[entry.id].map { ($0, entry.score) } } + let peak = rows.first?.1 ?? 1 + + if rows.isEmpty { + Text(tr("最近没有应用在读写磁盘")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(Array(rows), id: \.0.id) { row, total in + VStack(spacing: DS.Space.s1) { + HStack(spacing: DS.Space.s2) { + AppIconCache.shared.image(bundlePath: row.bundlePath) + .resizable() + .frame(width: DS.Size.iconInline, height: DS.Size.iconInline) + Text(verbatim: row.title) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(1) + Spacer(minLength: DS.Space.s2) + Text(verbatim: tr("读 \(Format.menuBarRate(row.disk?.read ?? 0)) · 写 \(Format.menuBarRate(row.disk?.write ?? 0))")) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .monospacedDigit() } + ProgressTrack(fraction: total / peak, height: DS.Space.s1, showsTrack: false) } } } } + +// MARK: - 菜单栏弹窗 + +/// 菜单栏“磁盘”项的窄详情:容量、读写速度、SSD 健康、读写最多的应用,区块可在设置里逐个隐藏 +struct DiskPopover: View { + @Environment(AppModel.self) private var model + + var body: some View { + let settings = model.settings + let store = model.store + + DiskHero() + + ForEach(MenuBarItem.disk.popoverSections.filter { settings.isVisible($0) }) { section in + switch section { + case .diskActivity: + let reads = store.diskReadHistory.elements + let writes = store.diskWriteHistory.elements + SectionCard(title: section.title, trailing: { Text(tr("所有磁盘合计")) }) { + HStack(spacing: DS.Space.s3) { + LegendItem(color: Color(nsColor: DS.NetworkPalette.download), label: tr("读取"), + value: store.diskActivity.map { Format.menuBarRate($0.readRate) } ?? "—") + .frame(maxWidth: .infinity, alignment: .leading) + LegendItem(color: Color(nsColor: DS.NetworkPalette.upload), label: tr("写入"), + value: store.diskActivity.map { Format.menuBarRate($0.writeRate) } ?? "—") + .frame(maxWidth: .infinity, alignment: .leading) + } + MirroredRateChart(upload: writes, download: reads, height: DS.Size.chartHeight) + InfoRow(label: tr("60 秒峰值")) { + Text(verbatim: tr("读 \(Format.menuBarRate(reads.max() ?? 0)) · 写 \(Format.menuBarRate(writes.max() ?? 0))")) + } + } + case .diskHealth: + let health = store.diskHealth + SectionCard(title: section.title, + hint: tr("寿命按厂商估算的已用比例计算;写入量越大消耗越快,日常使用通常可用很多年"), + trailing: { Text(verbatim: health?.model ?? "") }) { + if let health { + HStack(spacing: DS.Space.s3) { + RingGauge(fraction: Double(health.remainingLife) / 100, color: health.tone.color, size: DS.Space.s12 + DS.Space.s2) { + Text(verbatim: "\(health.remainingLife)%") + .dsFont(.sm, weight: .semibold) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + } + VStack(alignment: .leading, spacing: DS.Space.s1) { + StatusBadge(text: health.summary, tone: health.tone) + Text(verbatim: tr("剩余寿命 \(health.remainingLife)% · 已写入 \(Format.bytes(health.bytesWritten, base: .decimal))")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .monospacedDigit() + } + Spacer(minLength: 0) + } + if let temperature = health.temperature { + InfoRow(label: tr("温度"), text: Format.temperature(temperature, fahrenheit: settings.useFahrenheit)) + } + InfoRow(label: tr("通电时间"), text: tr("\(health.powerOnHours.formatted()) 小时")) + InfoRow(label: tr("异常断电"), text: health.unsafeShutdowns.formatted()) + } else { + Text(tr("正在读取 SMART 信息;外置磁盘或不支持 NVMe SMART 的磁盘不显示")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + case .diskProcesses: + SectionCard(title: section.title, trailing: { Text(tr("只含当前用户的进程")) }) { + DiskProcessRows(rowCount: 5) + } + default: + EmptyView() + } + } + } +} + +/// 磁盘弹窗顶部:已用百分比、卷名与总容量、可用空间,下面是用量条 +private struct DiskHero: View { + @Environment(AppModel.self) private var model + + var body: some View { + let disk = model.store.disk + let fraction = disk?.usedFraction ?? 0 + let color = fraction > 0.9 ? DS.Palette.warning : DS.Palette.primary + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(alignment: .center, spacing: DS.Space.s3) { + HeroValue(value: disk.map { "\(Int(($0.usedFraction * 100).rounded()))" } ?? "—", unit: "%", size: .xxl) + VStack(alignment: .leading, spacing: DS.Space.s1) { + Text(verbatim: disk?.volumeName ?? tr("启动磁盘")) + .dsFont(.sm, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(1) + Text(verbatim: disk.map { tr("共 \(Format.bytes($0.total, base: .decimal))") } ?? tr("读取中")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } + Spacer(minLength: DS.Space.s2) + if let disk { + VStack(alignment: .trailing, spacing: 0) { + Text(tr("可用")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + Text(verbatim: Format.bytes(disk.available, base: .decimal)) + .dsFont(.base, weight: .semibold) + .monospacedDigit() + .foregroundStyle(color) + } + } + } + SegmentedBar(segments: disk.map(DiskSegments.segments) ?? DiskSegments.placeholder, height: DS.Space.s6) + if let disk { + DiskSegments.legend(disk) + } + } + .help(tr("可清除是系统随时可以腾出的缓存;访达显示的“可用”把它算在内")) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index e0a302b..152c637 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -21,7 +21,9 @@ public struct MainWindowView: View { } VStack(spacing: 0) { + // 页面的滚动视图会自动向上延伸到标题栏下面,与顶栏重叠;顶栏必须在它之上,否则开关收不到点击 PageHeader() + .zIndex(1) Group { switch model.settings.panelTab { case .overview: OverviewPage() @@ -45,6 +47,7 @@ public struct MainWindowView: View { case .settingsGeneral: SettingsTabPage { GeneralSettings() } case .settingsMenuBar: SettingsTabPage { MenuBarSettings() } case .settingsNotifications: SettingsTabPage { NotificationSettings() } + case .settingsAccount: SettingsTabPage { AccountSettings() } case .settingsHelper: SettingsTabPage { HelperSettings() } case .settingsAbout: SettingsTabPage { AboutSettings() } } @@ -121,6 +124,8 @@ private struct MainSidebar: View { switch tab { case .settingsAbout: model.updates.release != nil ? .primary : nil case .settingsHelper: model.helper.isReady && model.helper.isOutdated ? .warning : nil + case .settingsAccount: + if model.sync.conflict != nil { .primary } else if case .failed = model.sync.phase { .warning } else { nil } default: nil } } @@ -162,10 +167,18 @@ private struct PageHeader: View { Chip(text: tr("防休眠已开启"), icon: "cup.and.saucer.fill", tone: .primary) } if tab == .memory { PurgeMemoryButton() } - if let item = tab.menuBarItem { + // 每个监控页都能在这里开关自己的菜单栏项目;温度与风扇页有两项,各带一个小标签 + let items = tab.menuBarItems + if !items.isEmpty { Text(tr("在菜单栏显示")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) - DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }), - label: tr("在菜单栏显示\(item.title)")) + ForEach(items) { item in + if items.count > 1 { + Text(item.popoverTitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }), + label: tr("在菜单栏显示\(item.title)")) + .help(tr("开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置")) + } } } .padding(.horizontal, DS.Space.s3 + DS.Space.s1) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift index 24dcf8b..8c6c687 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift @@ -85,8 +85,9 @@ private struct AppListColumn: View { } } } + // 探针必须放在内容里才找得到这个列表自己的 NSScrollView;挂在外面时接了鼠标会显示一条粗的传统滚动条 + .overlayScrollers() } - .overlayScrollers() } .padding(.vertical, DS.Space.s3) .frame(maxHeight: .infinity, alignment: .top) @@ -199,14 +200,13 @@ private struct AppDetailCard: View { HairlineDivider() HStack(spacing: DS.Space.s3) { - DSToggle(isOn: $uninstaller.removeFromDock, label: tr("从程序坞移除图标")) - Text(tr("同时从程序坞移除图标")).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary) Spacer() Button(uninstaller.isRemoving ? tr("正在移除…") : tr("卸载")) { confirm() } .buttonStyle(DSButtonStyle(kind: .primary)) .disabled(running || uninstaller.isScanning || uninstaller.isRemoving) } - Text(tr("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动")) + // 程序坞里的图标始终一并移除,不单独做开关:留着一个空图标没有意义 + Text(tr("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除")) .dsFont(.xs) .foregroundStyle(DS.Palette.textTertiary) .fixedSize(horizontal: false, vertical: true) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift index 4b3341a..f2b3ceb 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift @@ -25,65 +25,85 @@ struct CPUPopover: View { @Environment(\.isDetailPage) private var isDetailPage var body: some View { - let settings = model.settings - let store = model.store - let cpu = store.cpu - let cores = Double(max(1, store.topology.logicalCores)) - CPUHero() - ForEach(MenuBarItem.cpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in - switch section { - case .cpuHeatmap: - SectionCard(title: section.title, trailing: { HeatLegend() }) { - CoreHeatmap(topology: store.topology, history: store.coreHistory.elements, - columns: isDetailPage ? 60 : 36, - rowHeight: isDetailPage ? DS.Space.s2 : DS.Space.s1 - DS.Size.stroke) - Text(tr("每行一个核心,每列一次采样,最新的在右边")) - .dsFont(.xs) - .foregroundStyle(DS.Palette.textTertiary) - } - case .cpuClusters: - SectionCard(title: section.title) { - ForEach(store.topology.clusters) { cluster in - let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0 - let frequency = store.power?.clusterFrequency[cluster.id].map { " · \(Format.frequency(megahertz: $0))" } ?? "" - ShareRow(label: tr("\(cluster.name) · \(cluster.coreIndices.count) 核\(frequency)"), value: Format.percent(average), - fraction: average, color: DS.Palette.cluster(cluster.id)) - } - if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) { - InfoRow(label: tr("最忙的核心"), text: busiest) - } - } - case .cpuLoadAverage: - SectionCard(title: section.title, trailing: { Text(loadTrend(cpu?.loadAverage ?? [])) }) { - let averages = cpu?.loadAverage ?? [] - ForEach(Array([tr("1 分钟"), tr("5 分钟"), tr("15 分钟")].enumerated()), id: \.offset) { index, label in - let value = averages[safe: index] ?? 0 - ShareRow(label: label, - value: tr("每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))"), - fraction: value / cores, color: loadTone(value / cores).color) - } - Text(tr("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU")) - .dsFont(.xs) - .foregroundStyle(DS.Palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - case .cpuApps: - SectionCard(title: section.title, trailing: { Text("CPU") }) { - AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.cpu > $1.cpu }, - rowCount: isDetailPage ? 10 : 6, metric: .cpu) - } - default: - EmptyView() + if isDetailPage { + // 主窗口更宽:先是最直观的各核心圆环,再是热力图;核心分工与排队程度并排,按应用汇总占满一行 + card(.cpuCores) + card(.cpuHeatmap) + HStack(alignment: .top, spacing: DS.Space.s3) { + card(.cpuClusters) + card(.cpuLoadAverage) + } + card(.cpuApps) + } else { + ForEach(MenuBarItem.cpu.popoverSections.filter { model.settings.isVisible($0) }) { section in + card(section) } } } - private func busiestCore(_ topology: CPUTopology, _ perCore: [Double]) -> String? { - guard let (index, value) = perCore.enumerated().max(by: { $0.element < $1.element }).map({ ($0.offset, $0.element) }), - let cluster = topology.clusters.first(where: { $0.coreIndices.contains(index) }) else { return nil } - return "\(cluster.name) #\(index + 1) · \(Format.percent(value))" + @ViewBuilder + private func card(_ section: PopoverSection) -> some View { + let store = model.store + let cpu = store.cpu + let cores = Double(max(1, store.topology.logicalCores)) + + switch section { + // 各区块的解释都放在悬停提示里(标题旁的 ⓘ 或卡片内容),界面上只留数据 + case .cpuHeatmap: + SectionCard(title: section.title, + hint: tr("每行一个核心(共 \(store.topology.logicalCores) 个),每列一次采样,颜色越深越忙;右侧粗条是此刻的占用"), + trailing: { HeatLegend() }) { + CoreHeatmap(topology: store.topology, history: store.coreHistory.elements, + columns: isDetailPage ? 60 : 36, + rowHeight: isDetailPage ? DS.Space.s2 : DS.Space.s1 - DS.Size.stroke) + } + case .cpuCores: + // 主窗口:每个核心一个圆环,中间写百分比,最直观;弹窗窄,用每核一根柱子的紧凑版(默认关闭,在设置里打开) + if isDetailPage { + SectionCard(title: section.title, hint: tr("此刻各核心的占用;超过 60% 变橙色、85% 变红色")) { + CoreRingGrid(topology: store.topology, perCore: cpu?.perCore ?? []) + } + } else { + SectionCard(title: section.title, hint: tr("每根柱子一个核心,按核心类型着色")) { + CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? [], byCluster: true) + } + } + case .cpuClusters: + SectionCard(title: section.title, hint: clusterExplanation(store.topology), + trailing: { Text(verbatim: store.topology.brand) }) { + ForEach(store.topology.clusters) { cluster in + let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0 + ShareRow(label: tr("\(cluster.name) · \(cluster.coreIndices.count) 核"), value: Format.percent(average), + detail: store.power?.clusterFrequency[cluster.id].map { Format.frequency(megahertz: $0) }, + fraction: average, color: DS.Palette.cluster(cluster.id)) + } + if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) { + InfoRow(label: tr("最忙的核心"), text: busiest) + } + } + case .cpuLoadAverage: + SectionCard(title: section.title, + hint: tr("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU"), + trailing: { Text(loadTrend(cpu?.loadAverage ?? [])) }) { + let averages = cpu?.loadAverage ?? [] + ForEach(Array([tr("1 分钟"), tr("5 分钟"), tr("15 分钟")].enumerated()), id: \.offset) { index, label in + let value = averages[safe: index] ?? 0 + ShareRow(label: label, + value: tr("每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))"), + fraction: value / cores, color: loadTone(value / cores).color) + } + } + case .cpuApps: + SectionCard(title: section.title, + hint: tr("每个应用按一个核心满载为 100% 计算,与活动监视器一致,所以各应用相加可以超过顶部的总占用")) { + AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.cpu > $1.cpu }, + rowCount: isDetailPage ? 10 : 6, metric: .cpu) + } + default: + EmptyView() + } } private func loadTrend(_ averages: [Double]) -> String { @@ -91,11 +111,91 @@ struct CPUPopover: View { let change = averages[0] / averages[2] return change > 1.15 ? tr("负载在上升") : change < 0.85 ? tr("负载在下降") : tr("负载平稳") } + + /// 核心按 1…N 统一编号,与“各核心占用”里的圆环标签一致,括号里注明它属于哪一类 + private func busiestCore(_ topology: CPUTopology, _ perCore: [Double]) -> String? { + guard let (index, value) = perCore.enumerated().max(by: { $0.element < $1.element }).map({ ($0.offset, $0.element) }), + let cluster = topology.clusters.first(where: { $0.coreIndices.contains(index) }) else { return nil } + return tr("核心 \(index + 1)(\(cluster.name))· \(Format.percent(value))") + } + + /// 用人话说明每类核心是干什么的,只有一类核心(Intel)时不显示 + private func clusterExplanation(_ topology: CPUTopology) -> String? { + let roles = topology.clusters.sorted { $0.id < $1.id }.compactMap { cluster in + cluster.role(in: topology).map { tr("\(cluster.name):\($0)") } + } + guard !roles.isEmpty else { return nil } + return tr("\(roles.joined(separator: " · "))。系统会自动分配任务,不用你操心") + } } -/// CPU 顶部:大号占用、状态、与 30 秒前相比的变化、温度余量,下面是走势线和用户 / 系统 / 空闲构成 +extension CPUCluster { + /// 这类核心的分工:最高档最快、最低档最省电、中间档介于两者之间;只有一类核心时没有分工可言 + func role(in topology: CPUTopology) -> String? { + let levels = topology.clusters.count + guard levels > 1 else { return nil } + if id == 0 { return tr("最快,重活优先交给它们") } + if id == levels - 1 { return tr("更省电,负责后台和轻量任务") } + return tr("速度与省电介于两者之间") + } +} + +/// 主窗口里的各核心占用:每个核心一个小圆环,中间写百分比,按核心类型分组, +/// 组名后面用一句话说明这类核心是干什么的;圆环颜色随占用变化,忙的核心一眼能看到 +private struct CoreRingGrid: View { + let topology: CPUTopology + let perCore: [Double] + + private static let ring = DS.Space.s12 + DS.Space.s2 + private static let columns = [GridItem(.adaptive(minimum: DS.Space.s16 + DS.Space.s2), spacing: DS.Space.s2)] + + var body: some View { + // 最快的一类在前 + let clusters = topology.clusters.sorted { $0.id < $1.id } + VStack(alignment: .leading, spacing: DS.Space.s3) { + ForEach(clusters) { cluster in + VStack(alignment: .leading, spacing: DS.Space.s2) { + if clusters.count > 1 { + // 这类核心是干什么的,悬停组名时再说 + HStack(spacing: DS.Space.s1) { + RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(DS.Palette.cluster(cluster.id)) + .frame(width: DS.Size.barHeight, height: DS.Size.barHeight) + Text(verbatim: tr("\(cluster.name) · \(cluster.coreIndices.count) 个")) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + } + .help(optional: cluster.role(in: topology)) + } + LazyVGrid(columns: Self.columns, alignment: .leading, spacing: DS.Space.s2) { + ForEach(cluster.coreIndices, id: \.self) { index in + let value = perCore[safe: index] ?? 0 + VStack(spacing: DS.Space.s1) { + RingGauge(fraction: value, color: loadTone(value).color, size: Self.ring) { + Text(verbatim: Format.percent(value)) + .dsFont(.xs, weight: .semibold) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + } + Text(verbatim: tr("核心 \(index + 1)")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } + .frame(maxWidth: .infinity) + } + } + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(tr("各核心占用")) + } +} + +/// CPU 顶部:大号占用、状态、与 30 秒前相比的变化、CPU 温度(附离 100°C 的余量), +/// 下面是最近 1 / 3 / 5 分钟的走势线(主窗口里更高,带百分比刻度、参考线与时间轴)、峰值和用户 / 系统 / 空闲构成 private struct CPUHero: View { @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage var body: some View { let store = model.store @@ -103,6 +203,11 @@ private struct CPUHero: View { let total = cpu?.total ?? 0 let history = store.cpuTotal.elements let temperature = store.sensors?.temperature(.cpu) + let fahrenheit = model.settings.useFahrenheit + // 走势按真实时间定位:最新一次采样在右边缘,往左铺满所选时长 + let duration = TimeInterval(model.settings.cpuChartSeconds) + let end = store.cpuTimeline.elements.last?.date ?? Date() + let timeline = store.cpuTimeline.elements.filter { $0.date >= end.addingTimeInterval(-duration) } Card(padding: DS.Space.s3, spacing: DS.Space.s2) { HStack(alignment: .center, spacing: DS.Space.s3) { @@ -116,20 +221,47 @@ private struct CPUHero: View { } Spacer(minLength: DS.Space.s2) if let temperature { + let hottest = temperature.maximum VStack(alignment: .trailing, spacing: 0) { - Text(tr("温度余量")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) - Text(verbatim: "\(Int(max(0, 100 - temperature.maximum).rounded()))°C") + Text(tr("CPU 温度")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + Text(verbatim: Format.temperature(hottest, fahrenheit: fahrenheit)) .dsFont(.base, weight: .semibold) .monospacedDigit() - .foregroundStyle(Tone.forTemperature(temperature.maximum).color) - Text(verbatim: tr("当前 \(Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit))")) + .foregroundStyle(Tone.forTemperature(hottest).color) + Text(verbatim: tr("余量 \(headroom(hottest, fahrenheit: fahrenheit))")) .dsFont(.xs) .foregroundStyle(DS.Palette.textTertiary) } - .help(tr("离 100°C 还差多少度;越接近 0,越可能因过热降频")) + .help(tr("核心最高温度;余量是离 100°C 还差多少度,越接近 0 越可能因过热降频")) } } - LineHistoryChart(values: history, height: DS.Space.s8) + if isDetailPage { + HStack(spacing: DS.Space.s2) { + PercentAxis(height: DS.Size.chartHeight * 2) + TimedLineChart(points: timeline, duration: duration, end: end, grid: true, height: DS.Size.chartHeight * 2) + } + HStack { + Text(verbatim: Self.clock(end.addingTimeInterval(-duration))) + Spacer() + Text(verbatim: Self.clock(end.addingTimeInterval(-duration / 2))) + Spacer() + Text(tr("现在")) + } + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .monospacedDigit() + .padding(.leading, PercentAxis.width + DS.Space.s2) + } else { + TimedLineChart(points: timeline, duration: duration, end: end, height: DS.Space.s8) + } + HStack { + ChartDurationPicker() + Spacer() + Text(verbatim: tr("峰值 \(Format.percent(timeline.map(\.value).max() ?? 0))")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .monospacedDigit() + } if let cpu { WaterlineBar(segments: [(cpu.user, DS.Palette.primary), (cpu.system, DS.Palette.secondary)], total: 1, height: DS.Space.s2) @@ -146,6 +278,12 @@ private struct CPUHero: View { total >= 0.85 ? tr("满载") : total >= 0.6 ? tr("繁忙") : total >= 0.25 ? tr("适中") : tr("空闲") } + /// 离 100°C 还差多少度;华氏制下按温差换算(差 45°C 即差 81°F) + private func headroom(_ celsius: Double, fahrenheit: Bool) -> String { + let degrees = max(0, 100 - celsius) + return fahrenheit ? "\(Int((degrees * 9 / 5).rounded()))°F" : "\(Int(degrees.rounded()))°C" + } + /// 与大约 30 秒前(第 15 个采样之前)相比的变化 private func trend(_ history: [Double]) -> String { guard history.count > 15, let now = history.last else { return tr("正在收集走势…") } @@ -154,6 +292,63 @@ private struct CPUHero: View { if abs(delta) < 3 { return tr("与 30 秒前持平") } return delta > 0 ? tr("比 30 秒前高 \(delta)%") : tr("比 30 秒前低 \(-delta)%") } + + /// 时间轴刻度:走势只有几分钟,带秒才分得开;小时固定两位,零点前后宽度不变 + private static func clock(_ date: Date) -> String { + date.formatted(Date.FormatStyle(locale: L10n.locale).hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)) + } +} + +/// 走势时长 1 / 3 / 5 分钟:一行小字标签,选中的用品牌蓝加粗,不另占高度 +private struct ChartDurationPicker: View { + @Environment(AppModel.self) private var model + + var body: some View { + let settings = model.settings + HStack(spacing: DS.Space.s3) { + ForEach(AppSettings.cpuChartOptions, id: \.self) { seconds in + let selected = settings.cpuChartSeconds == seconds + Button { + settings.cpuChartSeconds = seconds + } label: { + Text(verbatim: tr("\(seconds / 60) 分钟")) + .dsFont(.xs, weight: selected ? .semibold : .regular) + .foregroundStyle(selected ? DS.Palette.primary : DS.Palette.textTertiary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? .isSelected : []) + } + } + .help(tr("走势图显示最近多长时间")) + .accessibilityLabel(tr("走势时长")) + } +} + +/// 走势图左侧的 0–100% 刻度,位置与参考线对齐;最上、最下两个标签收进图表范围内,不压到相邻的行 +private struct PercentAxis: View { + static let width = DS.Space.s8 + DS.Space.s2 + let height: CGFloat + + var body: some View { + GeometryReader { proxy in + let inset = DS.Size.chartLine + let usable = proxy.size.height - inset * 2 + let half = DS.TextSize.xs.rawValue / 2 + DS.Size.stroke + ForEach([1.0, 0.75, 0.5, 0.25, 0.0], id: \.self) { fraction in + Text(verbatim: "\(Int(fraction * 100))%") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .monospacedDigit() + .lineLimit(1) + .fixedSize() + .frame(width: Self.width, alignment: .trailing) + .position(x: Self.width / 2, y: min(max(inset + usable * CGFloat(1 - fraction), half), proxy.size.height - half)) + } + } + .frame(width: Self.width, height: height) + .accessibilityHidden(true) + } } // MARK: - 内存 @@ -342,7 +537,7 @@ private struct AppUsageList: View { .monospacedDigit() .foregroundStyle(DS.Palette.textSecondary) } - ProgressTrack(fraction: peak > 0 ? value / peak : 0, color: DS.Palette.primary, height: DS.Space.s1) + ProgressTrack(fraction: peak > 0 ? value / peak : 0, color: DS.Palette.primary, height: DS.Space.s1, showsTrack: false) .padding(.leading, DS.Size.iconInline + DS.Space.s2) } .explainable(.init(app.representative)) @@ -364,12 +559,21 @@ private struct AppUsageList: View { private struct ShareRow: View { let label: String let value: String + /// 跟在数值后面的补充读数(如频率),用次要颜色,不抢主数值 + var detail: String? let fraction: Double let color: Color var body: some View { VStack(spacing: DS.Space.s1) { - InfoRow(label: label, text: value) + InfoRow(label: label) { + if let detail { + Text(verbatim: value) + + Text(verbatim: " · \(detail)").foregroundStyle(DS.Palette.textSecondary).fontWeight(.regular) + } else { + Text(verbatim: value) + } + } ProgressTrack(fraction: fraction, color: color, height: DS.Space.s1 + DS.Space.s1 / 2) } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift index 804817d..f83a12b 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift @@ -27,6 +27,7 @@ struct NetworkPopover: View { case .networkProbe: ProbeSection() case .networkInterface: InterfaceSection() case .networkAddresses: AddressSection() + case .networkPurity: PuritySection() case .networkDNS: DNSSection() case .networkProcesses: NetworkProcessesSection() default: EmptyView() @@ -143,11 +144,28 @@ private struct InterfaceSection: View { @Environment(AppModel.self) private var model var body: some View { - let details = model.network.details + let network = model.network + let details = network.details let physical = details?.physical let totals = model.store.network - SectionCard(title: PopoverSection.networkInterface.title) { + SectionCard(title: PopoverSection.networkInterface.title, trailing: { + // 右上角重置:把上传 / 下载从现在起重新累计,标出起算时间 + if let totals { + if let since = network.trafficBaseline?.date { + Text(verbatim: tr("自 \(since.formatted(Date.FormatStyle(locale: L10n.locale).month().day().hour().minute())) 起")) + } + MiniIconButton(systemName: "arrow.counterclockwise", + help: tr("重置上传与下载统计:从现在起重新累计。重启后自动回到开机后的累计;右键可改回")) { + network.resetTraffic(totals) + } + .contextMenu { + if network.trafficBaseline != nil { + Button(tr("改回开机后累计")) { network.clearTrafficBaseline() } + } + } + } + }) { if let physical { InfoRow(label: tr("接口"), text: tr("\(physical.displayName)(\(physical.bsdName))")) InfoRow(label: tr("状态")) { @@ -166,8 +184,10 @@ private struct InterfaceSection: View { InfoRow(label: tr("VPN / 代理"), text: tr("\(tunnel.name)(\(tunnel.bsdName))")) } if let totals { - InfoRow(label: tr("开机后下载"), text: Format.bytes(totals.totalDownloaded, base: .decimal)) - InfoRow(label: tr("开机后上传"), text: Format.bytes(totals.totalUploaded, base: .decimal)) + let counted = network.trafficTotals(totals) + let sinceReset = network.trafficBaseline != nil + InfoRow(label: sinceReset ? tr("重置后下载") : tr("开机后下载"), text: Format.bytes(counted.download, base: .decimal)) + InfoRow(label: sinceReset ? tr("重置后上传") : tr("开机后上传"), text: Format.bytes(counted.upload, base: .decimal)) } } } @@ -188,14 +208,13 @@ private struct AddressSection: View { let lookup = model.settings.publicIPLookup let publicAddresses = network.publicAddresses - SectionCard(title: PopoverSection.networkAddresses.title, trailing: { - if lookup { - MiniIconButton(systemName: "arrow.clockwise", help: tr("重新查询公网 IP")) { - network.lookUpPublicAddresses() - } - .disabled(network.isLookingUpPublic) + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + // 标题、数据来源字标、IPv4 / IPv6 切换、刷新排在一行;320 宽放不下时字标依次缩小,卡片不会被撑宽 + ViewThatFits(in: .horizontal) { + header(publicAddresses, logoHeight: DS.TextSize.sm.rawValue) + header(publicAddresses, logoHeight: DS.TextSize.xs.rawValue) + header(publicAddresses, logoHeight: DS.TextSize.xs.rawValue - DS.Space.s1 / 2) } - }) { InfoRow(label: tr("本地 IPv4")) { CopyableText(text: physical?.ipv4.first ?? "—") } InfoRow(label: tr("本地 IPv6")) { CopyableText(text: physical?.ipv6.first ?? "—") } InfoRow(label: tr("路由器")) { CopyableText(text: physical?.router ?? "—") } @@ -218,8 +237,14 @@ private struct AddressSection: View { if let organization = publicAddresses?.organization { InfoRow(label: tr("网络运营方")) { Text(verbatim: organization).lineLimit(1).truncationMode(.middle) } } - if let publicAddresses, publicAddresses.countryCode != nil || publicAddresses.asn != nil { - InfoRow(label: tr("数据来源"), text: publicAddresses.source == .localDatabase ? tr("本地 GeoLite2(MaxMind)") : tr("ipinfo.io 在线查询")) + // 原生 / 广播、IP 类型、网络类型做成徽章,一眼看结论;反向解析、住宅概率等细节不在弹窗里占行 + if let publicAddresses, !Self.badges(publicAddresses).isEmpty { + FlowLayout(spacing: DS.Space.s2) { + ForEach(Self.badges(publicAddresses), id: \.text) { badge in + TagBadge(icon: badge.icon, text: badge.text, tone: badge.tone) + } + } + .padding(.top, DS.Space.s1) } } else { InfoRow(label: tr("公网 IP"), text: tr("查询已关闭")) @@ -236,9 +261,249 @@ private struct AddressSection: View { } } + /// 国家 · 省 / 州 · 城市;英文界面优先用英文地名,重复的相邻项只留一个 private func location(_ addresses: PublicAddresses, code: String) -> String { let country = Locale(identifier: L10n.isEnglish ? "en" : "zh-Hans").localizedString(forRegionCode: code) ?? code - return [country, addresses.city].compactMap { $0 }.joined(separator: " · ") + let region = L10n.isEnglish ? (addresses.regionEnglish ?? addresses.region) : addresses.region + let city = L10n.isEnglish ? (addresses.cityEnglish ?? addresses.city) : addresses.city + var parts: [String] = [country] + for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) } + return parts.joined(separator: " · ") + } + + /// 标题行:IP 地址 · 数据来源 ……… [IPv4 | IPv6] ⟳ + private func header(_ addresses: PublicAddresses?, logoHeight: CGFloat) -> some View { + let network = model.network + return HStack(spacing: DS.Space.s2) { + Text(PopoverSection.networkAddresses.title) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.textSecondary) + .lineLimit(1) + .fixedSize() + if let source = sourceAccessory(addresses, logoHeight: logoHeight) { + source + } + Spacer(minLength: 0) + if model.settings.publicIPLookup { + HStack(spacing: DS.Space.s1) { + if network.hasDualStack { + PillSwitch(selection: Binding(get: { network.publicFamily }, set: { network.publicFamily = $0 }), + options: IPFamily.allCases.map { ($0, $0.title) }) + } + RefreshButton(loading: network.isLookingUpPublic, help: tr("强制刷新:忽略缓存,立即重新查询公网 IP 与归属地")) { + network.lookUpPublicAddresses(force: true) + } + } + } + } + } + + /// 标题后的数据来源:CleanIP.io 用它的字标(默认与标题文字同高,点了打开官网),其他来源写名字 + private func sourceAccessory(_ addresses: PublicAddresses?, logoHeight: CGFloat) -> AnyView? { + guard let addresses, addresses.countryCode != nil || addresses.asn != nil else { return nil } + switch addresses.source { + case .online(.cleanIP): + return AnyView(CleanIPLink(height: logoHeight, help: tr("数据来源:cleanip.io,点击打开官网"))) + case .online(let provider): + return AnyView(Text(verbatim: provider.displayName).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).fixedSize()) + case .localDatabase: + return AnyView(Text("GeoLite2").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).fixedSize()) + } + } + + struct Badge: Equatable { + let icon: String + let text: String + let tone: TagBadge.Tone + } + + /// 结论性徽章:原生 / 广播、住宅 / 机房、ASN 的类型 + static func badges(_ addresses: PublicAddresses) -> [Badge] { + var badges: [Badge] = [] + if let isNative = addresses.isNative { + badges.append(isNative ? Badge(icon: "checkmark.shield.fill", text: tr("原生 IP"), tone: .success) + : Badge(icon: "antenna.radiowaves.left.and.right", text: tr("广播 IP"), tone: .warning)) + } + if let ipType = addresses.ipType?.lowercased() { + switch ipType { + case "residential ip": badges.append(Badge(icon: "house.fill", text: tr("住宅 IP"), tone: .success)) + case "datacenter ip", "hosting ip": badges.append(Badge(icon: "server.rack", text: tr("机房 IP"), tone: .warning)) + case "mobile ip": badges.append(Badge(icon: "iphone.radiowaves.left.and.right", text: tr("移动网络 IP"), tone: .success)) + case "business ip": badges.append(Badge(icon: "building.2.fill", text: tr("企业 IP"), tone: .success)) + default: badges.append(Badge(icon: "questionmark.circle", text: addresses.ipType ?? "", tone: .neutral)) + } + } + if let asnType = addresses.asnType { + switch asnType { + case "isp": badges.append(Badge(icon: "wifi.router.fill", text: "ISP", tone: .success)) + case "mobile": badges.append(Badge(icon: "antenna.radiowaves.left.and.right", text: tr("移动运营商"), tone: .success)) + case "business": badges.append(Badge(icon: "building.2.fill", text: tr("企业网络"), tone: .success)) + case "education": badges.append(Badge(icon: "graduationcap.fill", text: tr("教育网"), tone: .success)) + case "government": badges.append(Badge(icon: "building.columns.fill", text: tr("政府"), tone: .success)) + case "hosting": badges.append(Badge(icon: "server.rack", text: tr("机房"), tone: .warning)) + default: break + } + } + return badges + } + + static func sourceLabel(_ source: PublicAddresses.Source) -> String { + switch source { + case .localDatabase: tr("本地 GeoLite2(MaxMind)") + case .online(let provider): tr("\(provider.displayName) 在线查询") + } + } + + static func networkTypeLabel(_ raw: String) -> String { + switch raw.lowercased() { + case "residential", "isp": tr("住宅宽带") + case "business": tr("企业") + case "hosting", "datacenter", "data center": tr("机房") + case "mobile", "cellular": tr("移动网络") + case "education": tr("教育网") + case "government": tr("政府") + default: raw + } + } + + static func ipTypeLabel(_ raw: String) -> String { + switch raw.lowercased() { + case "residential ip": tr("住宅 IP") + case "datacenter ip", "hosting ip": tr("机房 IP") + case "mobile ip": tr("移动网络 IP") + case "business ip": tr("企业 IP") + default: raw + } + } +} + +// MARK: - IP 纯净度 + +/// CleanIP.io 给出的纯净度评分与风险标记;其他数据源没有这一项 +private struct PuritySection: View { + @Environment(AppModel.self) private var model + + /// 标题行里“查看完整报告”链接的三种长度 + enum ReportStyle { case full, short, icon } + + var body: some View { + let settings = model.settings + let network = model.network + let addresses = network.publicAddresses + let hint = tr("纯净度看信誉、来路、邻居与网络类型四项;机房 IP 常见信誉高、来路和类型偏低。数据来自 cleanip.io") + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + // 标题、地址族徽章、置信度、报告链接、刷新按钮排成一行;宽度不够时报告链接依次缩成“完整报告”、只剩图标 + ViewThatFits(in: .horizontal) { + header(addresses, report: .full) + header(addresses, report: .short) + header(addresses, report: .icon) + } + if let purity = addresses?.purity { + // 分数在左、cleanip.io 字标在右,色带在下占满整行 + HStack(spacing: DS.Space.s2) { + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) { + Text(verbatim: "\(purity.score)") + .dsFont(.xxl, weight: .semibold) + .foregroundStyle(DS.Grade.color(for: purity.score)) + .monospacedDigit() + Text(verbatim: purity.grade) + .dsFont(.base, weight: .semibold) + .foregroundStyle(DS.Grade.color(for: purity.score)) + Text(verbatim: "/ 100").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + .help(hint) + Spacer(minLength: DS.Space.s2) + CleanIPLink(height: DS.Size.iconInline, + url: addresses?.reportURL ?? CleanIPLink.site, + help: tr("评分由 cleanip.io 提供,点击查看完整报告")) + } + ScoreBand(score: purity.score).help(hint) + if let risk = addresses?.risk { + InfoRow(label: tr("风险评分")) { + Text(verbatim: "\(risk.score)/100" + (risk.label.map { " · \(Self.riskLabel($0))" } ?? "")) + } + InfoRow(label: tr("风险标记")) { + Text(verbatim: risk.flags.isEmpty ? tr("未检出") : risk.flags.map(Self.flagLabel).joined(separator: "、")) + .foregroundStyle(risk.flags.isEmpty ? DS.Palette.success : DS.Palette.error) + } + } + if let recommendation = purity.recommendation, !L10n.isEnglish { + Text(verbatim: recommendation) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else if !settings.publicIPLookup { + Text(tr("查询已关闭")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } else if settings.geoSource != .cleanIP { + Text(tr("纯净度评分来自 CleanIP.io。在「设置 · 网络」把归属地数据源改为 CleanIP.io 后显示。")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize(horizontal: false, vertical: true) + } else if network.isLookingUpPublic { + Text(tr("正在查询…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } else { + Text(tr("暂时没有拿到评分,稍后会再试")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + } + } + + /// 标题行:IP 纯净度 · [IPv4] · [置信度 92%] · 查看完整报告 ↗ ……… ⟳ + private func header(_ addresses: PublicAddresses?, report: ReportStyle) -> some View { + let network = model.network + // 320 宽的弹窗里这一行很挤:标题与徽章之间只留最小间距,右侧刷新按钮自带留白 + return HStack(spacing: DS.Space.s1) { + Text(PopoverSection.networkPurity.title) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.textSecondary) + .lineLimit(1) + .fixedSize() + HStack(spacing: DS.Space.s1) { + if let family = network.shownFamily { + TagBadge(text: family.title, tone: .primary, compact: true) + } + if let confidence = addresses?.purity?.confidence { + TagBadge(text: tr("置信度 \(confidence)%"), tone: Self.confidenceTone(confidence), compact: true) + } + } + if let url = addresses?.reportURL { + ReportLink(url: url, style: report).padding(.leading, DS.Space.s1) + } + Spacer(minLength: 0) + RefreshButton(loading: network.isLookingUpPublic, help: tr("强制刷新:忽略缓存,立即重新查询公网 IP 与归属地")) { + network.lookUpPublicAddresses(force: true) + } + } + } + + /// 置信度高的绿、中等的灰、低的黄 + static func confidenceTone(_ confidence: Int) -> TagBadge.Tone { + confidence >= 80 ? .success : confidence >= 50 ? .neutral : .warning + } + + static func riskLabel(_ raw: String) -> String { + switch raw.lowercased() { + case "very clean": tr("非常干净") + case "clean": tr("干净") + case "low risk", "low": tr("低风险") + case "medium risk", "medium", "moderate": tr("中等风险") + case "high risk", "high": tr("高风险") + case "very high risk", "critical": tr("极高风险") + default: raw + } + } + + static func flagLabel(_ flag: String) -> String { + switch flag { + case "vpn": "VPN" + case "proxy": tr("代理") + case "residentialProxy": tr("住宅代理") + case "tor": "Tor" + case "relay": tr("中继") + case "datacenter": tr("机房") + case "hosting": tr("托管") + case "abuser": tr("滥用记录") + default: flag + } } } @@ -348,7 +613,7 @@ private struct NetworkProcessesSection: View { Text(tr("上传")).frame(width: DS.Size.valueColumn, alignment: .trailing) } }) { - ProcessList(count: processes.count, rowCount: 8, emptyText: tr("正在统计各进程流量…")) { index in + ProcessList(count: processes.count, rowCount: 5, emptyText: tr("正在统计各进程流量…")) { index in let process = processes[index] HStack(spacing: 0) { ProcessNameLabel(icon: AppIconCache.shared.image(bundlePath: process.appBundlePath), name: process.localizedName) @@ -365,3 +630,33 @@ private struct NetworkProcessesSection: View { } } } + +/// 纯净度标题行里的“查看完整报告”链接;`icon` 只剩一个外链图标 +private struct ReportLink: View { + let url: URL + let style: PuritySection.ReportStyle + @State private var hovering = false + + var body: some View { + let help = tr("在 cleanip.io 查看这个 IP 的完整报告") + if style == .icon { + MiniIconButton(systemName: "arrow.up.forward.square", help: help) { NSWorkspace.shared.open(url) } + } else { + Button { NSWorkspace.shared.open(url) } label: { + HStack(spacing: DS.Space.s1 / 2) { + Text(style == .full ? tr("查看完整报告") : tr("完整报告")) + .dsFont(.xs, weight: .medium) + Image(systemName: "arrow.up.forward.square") + .font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold)) + } + .foregroundStyle(hovering ? DS.Palette.primaryHover : DS.Palette.primary) + .lineLimit(1) + .fixedSize() + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(help) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift index 92463c8..8cc3992 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift @@ -21,6 +21,7 @@ struct PopoverRootView: View { case .memory: MemoryPopover() case .network: NetworkPopover() case .gpu: GPUPopover() + case .disk: DiskPopover() case .temperature, .fan: ThermalPopover(item: item) } } @@ -51,7 +52,9 @@ private struct PopoverHeader: View { .foregroundStyle(DS.Palette.textPrimary) Spacer(minLength: DS.Space.s2) if item == .memory { PurgeMemoryButton() } - IconButton(systemName: "macwindow", help: tr("打开 OpenStats 主窗口")) { model.openMainWindow(nil) } + // 左边的按钮用该指标自己的图标,点进主窗口里它的页面;右边的齿轮进总设置 + let page = PanelTab(item: item) + IconButton(systemName: page.symbol, help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) } IconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() } } } @@ -59,9 +62,13 @@ private struct PopoverHeader: View { // MARK: - 共用组件 -/// 弹窗里的一个区块:小标题 + 内容 +/// 弹窗里的一个区块:小标题 + 内容。 +/// 解释性文字不常驻界面:给 `hint` 后标题旁出现一个很淡的 ⓘ,鼠标悬停在 ⓘ 或卡片内容上才显示说明,看一遍就够的人不用一直看着它 struct SectionCard: View { let title: String + var hint: String? + /// 紧跟标题之后的小附件,例如数据来源的 logo + var titleAccessory: AnyView? @ViewBuilder var trailing: Trailing @ViewBuilder var content: Content @@ -71,6 +78,18 @@ struct SectionCard: View { Text(title) .dsFont(.xs, weight: .semibold) .foregroundStyle(DS.Palette.textSecondary) + .lineLimit(1) + .fixedSize() + if let titleAccessory { + titleAccessory + } + if let hint { + Image(systemName: "info.circle") + .font(.system(size: DS.TextSize.xs.rawValue, weight: .medium)) + .foregroundStyle(DS.Palette.textTertiary) + .help(hint) + .accessibilityLabel(hint) + } Spacer(minLength: DS.Space.s2) trailing .dsFont(.xs) @@ -78,13 +97,22 @@ struct SectionCard: View { .lineLimit(1) } content + .help(optional: hint) } } } extension SectionCard where Trailing == EmptyView { - init(title: String, @ViewBuilder content: () -> Content) { - self.init(title: title, trailing: { EmptyView() }, content: content) + init(title: String, hint: String? = nil, @ViewBuilder content: () -> Content) { + self.init(title: title, hint: hint, trailing: { EmptyView() }, content: content) + } +} + +extension View { + /// 有说明时才挂悬停提示 + @ViewBuilder + func help(optional text: String?) -> some View { + if let text { help(text) } else { self } } } @@ -168,6 +196,27 @@ struct MiniIconButton: View { } } +/// 区块标题栏里的刷新按钮:查询进行中换成系统的转圈,查完自动变回图标 +struct RefreshButton: View { + var loading: Bool + let help: String + let action: () -> Void + + var body: some View { + ZStack { + if loading { + ProgressView() + .controlSize(.small) + .accessibilityLabel(tr("正在查询…")) + } else { + MiniIconButton(systemName: "arrow.clockwise", help: help, action: action) + } + } + .frame(width: DS.Size.segmentHeight, height: DS.Size.segmentHeight) + .animation(DS.Motion.quick, value: loading) + } +} + /// 弹窗顶部的大号数值 struct HeroValue: View { let value: String diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/cleanip-green.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/cleanip-green.svg new file mode 100644 index 0000000..41afc70 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/cleanip-green.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/cleanip-ink.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/cleanip-ink.svg new file mode 100644 index 0000000..e7f46aa --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/cleanip-ink.svg @@ -0,0 +1,3 @@ + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/github.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/github.svg new file mode 100644 index 0000000..bd5366d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/google.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/google.svg new file mode 100644 index 0000000..32fb1d9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/AccountSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/AccountSettings.swift new file mode 100644 index 0000000..914eeae --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/AccountSettings.swift @@ -0,0 +1,236 @@ +import AccountSync +import AppKit +import Localization +import SwiftUI + +/// 设置 · 账号与同步:登录、同步状态、首次登录的二选一、退出与删除 +struct AccountSettings: View { + @Environment(AppModel.self) private var model + @State private var confirmingDelete = false + + var body: some View { + let sync = model.sync + Group { + if let user = sync.user { + signedIn(user: user, sync: sync) + } else { + signedOut(sync: sync) + } + } + .onAppear { sync.pageOpened() } + } + + // MARK: 未登录 + + @ViewBuilder + private func signedOut(sync: SyncController) -> some View { + SettingsGroup(caption: tr("账号")) { + GroupRow(showsDivider: false) { + VStack(alignment: .leading, spacing: DS.Space.s3) { + SettingRow(title: tr("登录以同步设置"), + subtitle: tr("换一台 Mac 登录后,菜单栏、外观、通知与快捷键等偏好会自动恢复。不登录也能使用全部功能。")) { + EmptyView() + } + HStack(spacing: DS.Space.s2) { + ForEach(sync.providers) { provider in + Button { + sync.signIn(with: provider) + } label: { + HStack(spacing: DS.Space.s2) { + ProviderLogo(provider: provider) + Text(tr("使用 \(provider.title) 登录")) + } + } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(sync.phase == .signingIn) + } + if sync.phase == .signingIn { + ProgressView().controlSize(.small) + Button(tr("取消")) { sync.cancelSignIn() }.buttonStyle(DSButtonStyle(kind: .ghost)) + } + } + if case .failed(let message) = sync.phase { + InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) + } + } + } + } + privacyGroup + } + + // MARK: 已登录 + + @ViewBuilder + private func signedIn(user: SyncUser, sync: SyncController) -> some View { + SettingsGroup(caption: tr("账号")) { + GroupRow(showsDivider: false) { + HStack(spacing: DS.Space.s3) { + Avatar(url: user.avatar) + VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { + Text(user.displayName).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary) + Text(accountSubtitle(user)).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + } + Spacer(minLength: DS.Space.s4) + Button(tr("退出登录")) { sync.signOut() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(sync.phase == .syncing) + } + } + } + + SettingsGroup(caption: tr("同步")) { + GroupRow(showsDivider: false) { + SettingRow(title: statusTitle(sync), subtitle: statusSubtitle(sync)) { + Button(sync.phase == .syncing ? tr("正在同步…") : tr("立即同步")) { sync.syncNow() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(sync.phase == .syncing || sync.conflict != nil) + } + } + if let conflict = sync.conflict { + GroupRow { + VStack(alignment: .leading, spacing: DS.Space.s3) { + InfoBanner(icon: "arrow.triangle.2.circlepath", + text: tr("云端已有 \(conflict.remoteDevice ?? tr("另一台 Mac")) 在 \(Self.relative(conflict.remoteUpdatedAt)) 保存的设置,和这台 Mac 上的不一样。要用哪一份?"), + tone: .warning) + HStack(spacing: DS.Space.s2) { + Spacer() + Button(tr("上传本机设置")) { sync.resolveConflict(useRemote: false) } + .buttonStyle(DSButtonStyle(kind: .secondary)) + Button(tr("使用云端设置")) { sync.resolveConflict(useRemote: true) } + .buttonStyle(DSButtonStyle(kind: .primary)) + } + } + } + } + if case .failed(let message) = sync.phase { + GroupRow { InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) } + } + GroupRow { + Text(tr("同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具、历史记录与归属地库留在本机。")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + SettingsGroup(caption: tr("数据")) { + GroupRow(showsDivider: false) { + SettingRow(title: tr("删除云端数据"), subtitle: tr("删除账号、所有登录方式与云端保存的设置;这台 Mac 上的设置保留")) { + Button(tr("删除…")) { confirmingDelete = true } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(sync.phase == .syncing) + .confirmationDialog(tr("删除云端数据?"), isPresented: $confirmingDelete, titleVisibility: .visible) { + Button(tr("删除"), role: .destructive) { sync.deleteAccount() } + Button(tr("取消"), role: .cancel) {} + } message: { + Text(tr("账号与云端设置会立即删除,无法恢复。")) + } + } + } + } + privacyGroup + } + + private var privacyGroup: some View { + SettingsGroup(caption: tr("隐私")) { + GroupRow(showsDivider: false) { + SettingRow(title: tr("云端只保存邮箱、姓名与设置文档"), + subtitle: tr("服务器在 getopenstats.com,不上传任何监控数据;随时可以删除。")) { + Button(tr("隐私政策")) { + if let url = URL(string: "https://getopenstats.com/privacy.html") { NSWorkspace.shared.open(url) } + } + .buttonStyle(DSButtonStyle(kind: .ghost)) + } + } + } + } + + // MARK: 文案 + + private func accountSubtitle(_ user: SyncUser) -> String { + let providers = user.providers.compactMap(SyncProvider.init(rawValue:)).map(\.title).joined(separator: " / ") + let via = providers.isEmpty ? "" : tr("通过 \(providers) 登录") + guard let email = user.email, !email.isEmpty, email != user.displayName else { return via } + return via.isEmpty ? email : "\(email) · \(via)" + } + + private func statusTitle(_ sync: SyncController) -> String { + switch sync.phase { + case .syncing: tr("正在同步") + case .failed: tr("同步失败") + default: sync.conflict != nil ? tr("等待选择") : tr("已开启同步") + } + } + + private func statusSubtitle(_ sync: SyncController) -> String { + guard let date = sync.lastSyncedAt else { return tr("设置改动后 2 秒内上传;启动、唤醒与每 15 分钟检查一次云端") } + return tr("上次同步:\(Self.relative(date))") + } + + private static func relative(_ date: Date) -> String { + date.formatted(.relative(presentation: .named).locale(L10n.locale)) + } +} + +/// 头像:圆形,取不到时用系统人像图标 +private struct Avatar: View { + let url: URL? + + var body: some View { + AsyncImage(url: url) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + } else { + Image(systemName: "person.crop.circle") + .font(.system(size: DS.Space.s6)) + .foregroundStyle(DS.Palette.textTertiary) + } + } + .frame(width: DS.Space.s8, height: DS.Space.s8) + .clipShape(Circle()) + } +} + +/// 登录按钮上的品牌标志,16 pt 行内尺寸。Google 用四色 G;Apple 与 GitHub 的标志本身只有单色版本, +/// 按品牌规范用文字色绘制,浅色模式下为黑、深色模式下为白 +struct ProviderLogo: View { + let provider: SyncProvider + var size: CGFloat = DS.Size.iconInline + + var body: some View { + Group { + switch provider { + case .apple: + Image(systemName: "apple.logo") + .font(.system(size: size - DS.Space.s1 / 2, weight: .medium)) + .foregroundStyle(DS.Palette.textPrimary) + case .github: + if let image = LogoCache.shared.image(named: "github", template: true) { + Image(nsImage: image).resizable().scaledToFit().foregroundStyle(DS.Palette.textPrimary) + } + case .google: + if let image = LogoCache.shared.image(named: "google", template: false) { + Image(nsImage: image).resizable().scaledToFit() + } + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +/// 包内 Resources/Logos 下的 SVG:GitHub 标志来自 Octicons(MIT),Google 标志为其品牌资源 +@MainActor +final class LogoCache { + static let shared = LogoCache() + private var images: [String: NSImage] = [:] + + func image(named name: String, template: Bool) -> NSImage? { + if let image = images[name] { return image } + guard let url = Bundle.module.url(forResource: name, withExtension: "svg", subdirectory: "Logos"), + let image = NSImage(contentsOf: url) else { return nil } + image.isTemplate = template + images[name] = image + return image + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index 663c82d..c71890a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -137,6 +137,15 @@ struct NotificationSettings: View { } } } + if kind == .cpuLoad, settings.enabledAlerts.contains(.cpuLoad) { + GroupRow { + SettingRow(title: tr("占用高于")) { + SegmentedControl(selection: $settings.alertCPULoad, + options: AppSettings.alertLoadOptions.map { ($0, "\($0)%") }) + .frame(width: DS.Size.sidebarWidth + DS.Space.s6) + } + } + } } } @@ -281,7 +290,7 @@ struct MenuBarSettings: View { } } - SettingsGroup(caption: tr("风格")) { + SettingsGroup(caption: tr("整体风格")) { LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible(), spacing: DS.Space.s3)], spacing: DS.Space.s3) { ForEach(MenuBarStyle.allCases) { style in @@ -291,24 +300,33 @@ struct MenuBarSettings: View { } } .padding(DS.Space.s3) + HairlineDivider() + Text(tr("整体风格套用到所有项目。想让某个项目不一样,在下面“显示项目”里给它单独选一种,比如 CPU 用圆环、风扇用数字。")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, DS.Space.s4) + .padding(.vertical, DS.Space.s3) } SettingsGroup(caption: tr("显示项目")) { + GroupRow(showsDivider: false) { + InfoBanner(icon: "hand.draw", + text: tr("菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。")) + } ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in - GroupRow(showsDivider: index > 0) { + GroupRow { VStack(alignment: .leading, spacing: DS.Space.s3) { SettingRow(title: item.title, subtitle: item.subtitle, icon: item.symbol) { - HStack(spacing: DS.Space.s3) { - if settings.isEnabled(item) { - itemStylePicker(item) - } - DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, - set: { settings.setEnabled(item, $0) }), - label: item.title) - } + DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, + set: { settings.setEnabled(item, $0) }), + label: item.title) } - if settings.isEnabled(item), settings.menuBarLayout == .separate { - PopoverSectionPicker(item: item) + if settings.isEnabled(item) { + ItemStyleRow(item: item) + if settings.menuBarLayout == .separate { + PopoverSectionPicker(item: item) + } } } } @@ -324,27 +342,56 @@ struct MenuBarSettings: View { } } - /// 网速有自己的三种样式;其他指标可以单独指定风格,默认跟随整体 - @ViewBuilder - private func itemStylePicker(_ item: MenuBarItem) -> some View { +} + +/// 单个项目自己的菜单栏风格:网速有自己的三种样式;其他项目默认跟随整体,也可以单独选一种。 +/// 旁边用实时数据画出这一项现在在菜单栏里的样子,改了立刻能看到 +private struct ItemStyleRow: View { + @Environment(AppModel.self) private var model + @Environment(\.colorScheme) private var colorScheme + let item: MenuBarItem + + var body: some View { @Bindable var settings = model.settings - if item == .network { - SegmentedControl(selection: $settings.networkStyle, - options: NetworkMenuStyle.allCases.map { ($0, $0.title) }) - .frame(width: DS.Size.sidebarWidth + DS.Space.s6) - } else { - Picker(tr("风格"), selection: Binding(get: { settings.styleOverrides[item] }, - set: { settings.setStyleOverride($0, for: item) })) { - Text(tr("跟随整体")).tag(MenuBarStyle?.none) - Divider() - ForEach(MenuBarStyle.allCases) { style in - Text(style.title).tag(MenuBarStyle?.some(style)) + HStack(spacing: DS.Space.s3) { + Text(tr("菜单栏风格")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize() + if item == .network { + SegmentedControl(selection: $settings.networkStyle, + options: NetworkMenuStyle.allCases.map { ($0, $0.title) }) + .frame(width: DS.Size.sidebarWidth + DS.Space.s6) + } else { + Picker(tr("菜单栏风格"), selection: Binding(get: { settings.styleOverrides[item] }, + set: { settings.setStyleOverride($0, for: item) })) { + Text(tr("跟随整体(\(settings.menuBarStyle.title))")).tag(MenuBarStyle?.none) + Divider() + ForEach(MenuBarStyle.options(for: item)) { style in + Text(style.title).tag(MenuBarStyle?.some(style)) + } } + .pickerStyle(.menu) + .labelsHidden() + .fixedSize() } - .pickerStyle(.menu) - .labelsHidden() - .fixedSize() + preview + Spacer(minLength: 0) } + .padding(.leading, DS.Size.iconStandalone + DS.Space.s3) + } + + /// 这一项按当前风格、实时读数画出来的样子,底色跟随浅色 / 深色外观 + private var preview: some View { + let settings = model.settings + let image = MenuBarRenderer.image(reading: MenuBarReading(model: model), items: [item], + style: { settings.style(for: $0) }, networkStyle: settings.networkStyle, + colorizeHighLoad: settings.colorizeHighLoad, fahrenheit: settings.useFahrenheit) + return Image(nsImage: MenuBarRenderer.preview(image, dark: colorScheme == .dark)) + .padding(.horizontal, DS.Space.s3) + .frame(height: DS.Size.segmentHeight + DS.Space.s1) + .background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.sm)) + .accessibilityLabel(tr("该项目在菜单栏里的实时效果")) } } @@ -529,12 +576,23 @@ struct NetworkSettings: View { SettingsGroup(caption: tr("公网 IP")) { GroupRow(showsDivider: false) { SettingRow(title: tr("查询公网 IP"), - subtitle: model.geo.isAvailable - ? tr("打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求") - : tr("打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询")) { + subtitle: tr("打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求")) { DSToggle(isOn: $settings.publicIPLookup, label: tr("查询公网 IP")) } } + GroupRow { + SettingRow(title: tr("归属地数据源"), subtitle: settings.geoSource.detail) { + Picker(tr("归属地数据源"), selection: $settings.geoSource) { + ForEach(GeoSource.allCases) { source in + Text(source.title).tag(source) + } + } + .pickerStyle(.menu) + .labelsHidden() + .fixedSize() + .disabled(!settings.publicIPLookup) + } + } } GeoDatabaseSettings() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/UpdateViews.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/UpdateViews.swift index 2e1360a..c9fb17c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/UpdateViews.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/UpdateViews.swift @@ -58,7 +58,7 @@ struct ReleaseSummary: View { if let notesHeight { ViewThatFits(in: .vertical) { list.padding(DS.Space.s3) - ScrollView { list.padding(DS.Space.s3) }.overlayScrollers() + ScrollView { list.padding(DS.Space.s3).overlayScrollers() } } .frame(maxHeight: notesHeight) .background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md)) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index e546068..9b43f43 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -13,7 +13,7 @@ enum SnapshotRenderer { let defaults = UserDefaults(suiteName: "OpenStats.snapshot") ?? .standard let settings = AppSettings(defaults: defaults) - settings.menuBarItems = [.cpu, .gpu, .memory, .network, .temperature] + settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature] settings.probeSeconds = 1 // 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档 let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("openstats-snapshot-history.sqlite") diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ActivityRanking.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ActivityRanking.swift new file mode 100644 index 0000000..493c3eb --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ActivityRanking.swift @@ -0,0 +1,36 @@ +import Foundation + +/// 排行榜的平滑打分:给每个条目按指数移动平均记一个分数,列表按分数排序。 +/// 瞬时速率每秒都在变,直接按它排会让条目忽隐忽现、上下乱跳;按最近十几秒的平均排, +/// 一个应用读写完之后还会在榜上停留一会儿慢慢退下去,列表看起来是稳的 +struct ActivityRanking: Equatable { + struct Entry: Equatable { + let id: String + let score: Double + } + + private(set) var scores: [String: Double] = [:] + /// 每次采样保留的旧分数比例;0.75 时约 3 次采样后旧值影响减半 + var decay = 0.75 + /// 分数低于这个值且当前没有活动的条目移出榜单(字节 / 秒) + var floor: Double = 512 + + /// 用本次采样的瞬时值更新分数;没出现在本次采样里的条目按 0 衰减 + mutating func update(_ current: [String: Double]) { + var next: [String: Double] = [:] + for id in Set(scores.keys).union(current.keys) { + let value = current[id] ?? 0 + let score = (scores[id] ?? 0) * decay + value * (1 - decay) + if score >= floor || value > 0 { next[id] = score } + } + scores = next + } + + func ranked(limit: Int) -> [Entry] { + Array(scores.map { Entry(id: $0.key, score: $0.value) } + .sorted { $0.score == $1.score ? $0.id < $1.id : $0.score > $1.score } + .prefix(limit)) + } + + var isEmpty: Bool { scores.isEmpty } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift index 9fc50b1..8e05cb4 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift @@ -8,13 +8,14 @@ import UserNotifications /// 可以发系统通知的状况 public enum AlertKind: String, CaseIterable, Identifiable, Sendable { - case cpuTemperature, memoryPressure, diskSpace, networkDown, batteryHealth, bluetoothBattery + case cpuTemperature, cpuLoad, memoryPressure, diskSpace, networkDown, batteryHealth, bluetoothBattery public var id: String { rawValue } var title: String { switch self { case .cpuTemperature: tr("CPU 过热") + case .cpuLoad: tr("CPU 持续高负载") case .memoryPressure: tr("内存压力严重") case .diskSpace: tr("磁盘空间不足") case .networkDown: tr("网络断开") @@ -26,6 +27,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { var detail: String { switch self { case .cpuTemperature: tr("CPU 温度持续 1 分钟高于设定值") + case .cpuLoad: tr("CPU 总占用持续 1 分钟高于设定值,通常是某个应用卡住或在后台狂跑") case .memoryPressure: tr("内存压力持续 30 秒处于严重,系统开始大量换页") case .diskSpace: tr("启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次") case .networkDown: tr("网络连接中断超过 20 秒,恢复后再提示一次") @@ -37,6 +39,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { var symbol: String { switch self { case .cpuTemperature: "thermometer.high" + case .cpuLoad: "cpu" case .memoryPressure: "memorychip" case .diskSpace: "internaldrive" case .networkDown: "wifi.slash" @@ -49,6 +52,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { var tab: PanelTab { switch self { case .cpuTemperature: .thermal + case .cpuLoad: .cpu case .memoryPressure: .memory case .diskSpace: .disk case .networkDown: .network @@ -59,7 +63,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { /// 状况持续多久才提醒 var sustain: TimeInterval { switch self { - case .cpuTemperature: 60 + case .cpuTemperature, .cpuLoad: 60 case .memoryPressure: 30 case .networkDown: 20 case .diskSpace, .batteryHealth, .bluetoothBattery: 0 @@ -69,7 +73,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { /// 同一状况两次提醒的最短间隔 var cooldown: TimeInterval { switch self { - case .cpuTemperature, .memoryPressure, .networkDown: 30 * 60 + case .cpuTemperature, .cpuLoad, .memoryPressure, .networkDown: 30 * 60 case .diskSpace, .bluetoothBattery: 6 * 60 * 60 case .batteryHealth: 30 * 24 * 60 * 60 } @@ -179,6 +183,12 @@ public final class AlertController: NSObject { fireIfNeeded(.cpuTemperature, isActive: cpu >= limit, now: now, body: tr("CPU 温度 \(Format.temperature(cpu, fahrenheit: settings.useFahrenheit)),已持续超过 1 分钟。可以检查高占用进程,或在“温度与风扇”里提高风扇转速。")) } + if enabled.contains(.cpuLoad), let cpu = store.cpu { + // 只在菜单栏显示时进程列表可能是很久以前采的,不在通知里点名应用,让用户打开详情看实时的 + let limit = Double(settings.alertCPULoad) / 100 + fireIfNeeded(.cpuLoad, isActive: cpu.total >= limit, now: now, + body: tr("CPU 占用 \(Format.percent(cpu.total)),已持续超过 1 分钟。可以打开 CPU 详情,在“按应用汇总”里看看是哪个应用在占用。")) + } if enabled.contains(.memoryPressure), let memory = store.memory { fireIfNeeded(.memoryPressure, isActive: memory.pressure == .critical, now: now, body: tr("可用内存只剩 \(Format.bytes(memory.available)),系统正在压缩和交换内存。关闭不用的应用可以缓解。")) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 7de56c1..2c5c5de 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -25,6 +25,8 @@ public final class AppModel { public let uninstaller = UninstallerController() let startupItems = StartupItemsController() public let history: HistoryRecorder + public let sync: SyncController + let diskTools: DiskToolsController @ObservationIgnored public let hub = MetricsHub() public var isMainWindowVisible = false @@ -54,6 +56,8 @@ public final class AppModel { updates = UpdateController(settings: settings) alerts = AlertController(settings: settings) history = HistoryRecorder(settings: settings, databaseURL: historyURL) + sync = SyncController(settings: settings) + diskTools = DiskToolsController(helper: helper) } /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 @@ -72,10 +76,11 @@ public final class AppModel { demand.network = true let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item } demand.gpu = (window && [.overview, .system].contains(tab)) || menu.contains(.gpu) || showing(.gpu, .gpu) - demand.disk = window && [.overview, .system, .cleaner, .disk].contains(tab) - demand.diskDetail = window && tab == .disk + demand.disk = (window && [.overview, .system, .cleaner, .disk].contains(tab)) || menu.contains(.disk) || popover == .disk + demand.diskDetail = showing(.disk, .disk) demand.battery = (window && [.overview, .system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive demand.processes = (window && [.processes, .overview, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory) + || popover == .disk demand.systemProcesses = window && tab == .processes var groups = Set() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 585ecc9..4de0537 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -1,9 +1,10 @@ import Foundation import Localization +import Metrics import Observation public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { - case cpu, memory, network, gpu, temperature, fan + case cpu, memory, network, gpu, disk, temperature, fan public var id: String { rawValue } @@ -13,6 +14,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .memory: tr("内存") case .network: tr("网络") case .gpu: "GPU" + case .disk: tr("磁盘") case .temperature: tr("CPU 温度") case .fan: tr("风扇转速") } @@ -24,6 +26,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .memory: tr("已用内存占比") case .network: tr("上传与下载速度、IP 地址、DNS") case .gpu: tr("GPU 占用") + case .disk: tr("启动磁盘已用占比") case .temperature: tr("CPU 核心最高温度") case .fan: tr("转速最高的风扇") } @@ -36,6 +39,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .memory: "RAM" case .network: "NET" case .gpu: "GPU" + case .disk: "DISK" case .temperature: "TEMP" case .fan: "FAN" } @@ -48,6 +52,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .memory: tr("内存") case .network: tr("网络") case .gpu: "GPU" + case .disk: tr("磁盘") case .temperature: tr("温度") case .fan: tr("风扇") } @@ -56,10 +61,11 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { /// 点击该项弹出的详情里可以显示的内容,按显示顺序排列 var popoverSections: [PopoverSection] { switch self { - case .cpu: [.cpuHeatmap, .cpuClusters, .cpuLoadAverage, .cpuApps] + case .cpu: [.cpuCores, .cpuHeatmap, .cpuClusters, .cpuLoadAverage, .cpuApps] case .memory: [.memoryWaterline, .memoryCompression, .memoryApps] - case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses] + case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkPurity, .networkDNS, .networkProcesses] case .gpu: [.gpuHistory, .gpuDetails] + case .disk: [.diskActivity, .diskHealth, .diskProcesses] case .temperature: [.thermalSensors, .thermalFans, .thermalPower] case .fan: [.thermalFans, .thermalSensors, .thermalPower] } @@ -74,6 +80,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .memory: "memorychip" case .network: "arrow.up.arrow.down" case .gpu: "square.3.layers.3d" + case .disk: "internaldrive" case .temperature: "thermometer.medium" case .fan: "fan" } @@ -82,17 +89,23 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { /// 详情弹窗里可以单独隐藏的区块 public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { - case cpuHeatmap, cpuClusters, cpuLoadAverage, cpuApps + case cpuHeatmap, cpuCores, cpuClusters, cpuLoadAverage, cpuApps case memoryWaterline, memoryCompression, memoryApps - case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses + case networkHistory, networkProbe, networkInterface, networkAddresses, networkPurity, networkDNS, networkProcesses case gpuHistory, gpuDetails + case diskActivity, diskHealth, diskProcesses case thermalSensors, thermalFans, thermalPower public var id: String { rawValue } + /// 弹窗默认不显示的区块(主窗口详情页始终显示):热力图已经能看出各核心此刻的占用, + /// 每核柱状图给想要一眼看清单个核心的人在设置里打开,避免弹窗默认太长 + static let hiddenByDefault: Set = [.cpuCores] + var title: String { switch self { case .cpuHeatmap: tr("核心热力图") + case .cpuCores: tr("各核心占用") case .cpuClusters: tr("核心分工") case .cpuLoadAverage: tr("排队程度") case .cpuApps, .memoryApps: tr("按应用汇总") @@ -103,9 +116,13 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { case .networkProbe: tr("连接探测") case .networkInterface: tr("接口") case .networkAddresses: tr("IP 地址") + case .networkPurity: tr("IP 纯净度") case .networkDNS: "DNS" case .gpuHistory: tr("使用历史") case .gpuDetails: tr("显卡信息") + case .diskActivity: tr("读写速度") + case .diskHealth: tr("SSD 健康") + case .diskProcesses: tr("读写最多的应用") case .thermalSensors: tr("温度") case .thermalFans: tr("风扇") case .thermalPower: tr("功耗") @@ -158,12 +175,58 @@ public enum ProbeTarget: String, CaseIterable, Identifiable, Sendable { } } -/// 菜单栏风格:一种风格统一套用到所有指标,风格内部的排版保持一致 -public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable { - case stacked, inline, icon, ring, pie, history, meter, dot +/// 公网 IP 归属地的数据源 +public enum GeoSource: String, CaseIterable, Identifiable, Sendable { + case cleanIP, ipapi, dbip, ipinfo, localDatabase public var id: String { rawValue } + var title: String { + switch self { + case .cleanIP: "CleanIP.io" + case .ipapi: "ipapi.is" + case .dbip: "DB-IP" + case .ipinfo: "ipinfo.io" + case .localDatabase: tr("本地 GeoLite2 数据库") + } + } + + var detail: String { + switch self { + case .cleanIP: tr("归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名;把公网 IP 发给 cleanip.io") + case .ipapi: tr("归属地与 ASN;把公网 IP 发给 ipapi.is,匿名额度每天 30 次,同一 IP 的结果记住一天") + case .dbip: tr("只有国家、省 / 州与城市;把公网 IP 发给 db-ip.com") + case .ipinfo: tr("归属地与 ASN;把公网 IP 发给 ipinfo.io,匿名额度很小,容易被限流") + case .localDatabase: tr("在本机的 MaxMind 数据库里查国家、城市与 ASN,不向任何服务发送 IP;数据库未就绪时暂用 CleanIP.io") + } + } + + /// 对应的在线数据源;本地数据库没有 + var provider: GeoProvider? { + switch self { + case .cleanIP: .cleanIP + case .ipapi: .ipapi + case .dbip: .dbip + case .ipinfo: .ipinfo + case .localDatabase: nil + } + } +} + +/// 菜单栏风格:整体选一种套用到所有指标,个别指标可以单独指定(AppSettings.styleOverrides);风格内部的排版保持一致 +public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable { + case stacked, inline, icon, ring, pie, history, line, meter, dot + + public var id: String { rawValue } + + /// 温度、风扇没有百分比,图形类风格画不出来,只给文字类的三种 + static func options(for item: MenuBarItem) -> [MenuBarStyle] { + switch item { + case .temperature, .fan: [.stacked, .inline, .icon] + default: allCases + } + } + var title: String { switch self { case .stacked: tr("双行文字") @@ -172,6 +235,7 @@ public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable { case .ring: tr("圆环") case .pie: tr("饼图") case .history: tr("柱状历史") + case .line: tr("折线历史") case .meter: tr("电量条") case .dot: tr("状态圆点") } @@ -185,6 +249,7 @@ public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable { case .ring: tr("圆环表示当前占用比例") case .pie: tr("饼图表示当前占用比例") case .history: tr("10 根柱子是最近 10 次采样(约 20 秒)的变化") + case .line: tr("折线是最近 30 次采样(约 1 分钟)的走势") case .meter: tr("竖向电量条表示当前占用比例") case .dot: tr("绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)") } @@ -235,13 +300,13 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { /// 主窗口侧边栏的页面 public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case overview, system, history, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller, startupItems - case settingsGeneral, settingsMenuBar, settingsNotifications, settingsHelper, settingsAbout + case settingsGeneral, settingsMenuBar, settingsNotifications, settingsAccount, settingsHelper, settingsAbout public var id: String { rawValue } static let monitors: [PanelTab] = [.overview, .system, .history, .cpu, .gpu, .memory, .disk, .network, .thermal] static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller] - static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsHelper, .settingsAbout] + static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsAccount, .settingsHelper, .settingsAbout] var isSettings: Bool { Self.settings.contains(self) } @@ -267,6 +332,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .settingsGeneral: tr("通用") case .settingsMenuBar: tr("菜单栏") case .settingsNotifications: tr("通知") + case .settingsAccount: tr("账号与同步") case .settingsHelper: tr("辅助工具") case .settingsAbout: tr("关于") } @@ -291,6 +357,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .settingsGeneral: "gearshape" case .settingsMenuBar: "menubar.rectangle" case .settingsNotifications: "bell.badge" + case .settingsAccount: "person.crop.circle" case .settingsHelper: "lock.shield" case .settingsAbout: "info.circle" } @@ -303,17 +370,27 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .gpu: .gpu case .memory: .memory case .network: .network + case .disk: .disk case .thermal: .temperature default: nil } } + /// 页面右上角可以直接开关的菜单栏项目:温度与风扇页有温度、风扇两项 + var menuBarItems: [MenuBarItem] { + switch self { + case .thermal: [.temperature, .fan] + default: menuBarItem.map { [$0] } ?? [] + } + } + init(item: MenuBarItem) { switch item { case .cpu: self = .cpu case .gpu: self = .gpu case .memory: self = .memory case .network: self = .network + case .disk: self = .disk case .temperature, .fan: self = .thermal } } @@ -384,6 +461,10 @@ public final class AppSettings { public var probeTarget: ProbeTarget { didSet { defaults.set(probeTarget.rawValue, forKey: Keys.probeTarget) } } + /// 公网 IP 归属地的数据源 + public var geoSource: GeoSource { + didSet { defaults.set(geoSource.rawValue, forKey: Keys.geoSource) } + } /// 打开网络详情时查询公网 IP(会访问 Cloudflare / ipify) public var publicIPLookup: Bool { didSet { defaults.set(publicIPLookup, forKey: Keys.publicIPLookup) } @@ -404,6 +485,14 @@ public final class AppSettings { public var alertCPUTemperature: Int { didSet { defaults.set(alertCPUTemperature, forKey: Keys.alertCPUTemperature) } } + /// CPU 持续高负载提醒的总占用阈值(百分比) + public var alertCPULoad: Int { + didSet { defaults.set(alertCPULoad, forKey: Keys.alertCPULoad) } + } + /// CPU 详情顶部走势图显示最近多少秒 + public var cpuChartSeconds: Int { + didSet { defaults.set(cpuChartSeconds, forKey: Keys.cpuChartSeconds) } + } /// 界面语言:界面文字立即切换;系统提供的名称(显示器、应用名)在下次启动时跟着切换 public var language: AppLanguage { didSet { @@ -438,6 +527,8 @@ public final class AppSettings { public static let batteryFloorOptions = [10, 20, 30, 40] public static let fanSafetyOptions = [85, 90, 95, 100] public static let alertTemperatureOptions = [85, 90, 95, 100] + public static let alertLoadOptions = [70, 80, 90] + public static let cpuChartOptions = [60, 180, 300] public init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -462,12 +553,14 @@ public final class AppSettings { appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash) menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate - hiddenPopoverSections = Set(defaults.stringArray(forKey: Keys.hiddenPopoverSections)?.compactMap(PopoverSection.init(rawValue:)) ?? []) + hiddenPopoverSections = defaults.stringArray(forKey: Keys.hiddenPopoverSections) + .map { Set($0.compactMap(PopoverSection.init(rawValue:))) } ?? PopoverSection.hiddenByDefault probeEnabled = defaults.object(forKey: Keys.probeEnabled) as? Bool ?? true probeInBackground = defaults.object(forKey: Keys.probeInBackground) as? Bool ?? true probeSeconds = Self.probeOptions.contains(defaults.integer(forKey: Keys.probeSeconds)) ? defaults.integer(forKey: Keys.probeSeconds) : 2 probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare + geoSource = defaults.string(forKey: Keys.geoSource).flatMap(GeoSource.init(rawValue:)) ?? .cleanIP publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity) geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true @@ -481,6 +574,10 @@ public final class AppSettings { enabledAlerts = Set(defaults.stringArray(forKey: Keys.enabledAlerts)?.compactMap(AlertKind.init(rawValue:)) ?? []) alertCPUTemperature = Self.alertTemperatureOptions.contains(defaults.integer(forKey: Keys.alertCPUTemperature)) ? defaults.integer(forKey: Keys.alertCPUTemperature) : 95 + alertCPULoad = Self.alertLoadOptions.contains(defaults.integer(forKey: Keys.alertCPULoad)) + ? defaults.integer(forKey: Keys.alertCPULoad) : 80 + cpuChartSeconds = Self.cpuChartOptions.contains(defaults.integer(forKey: Keys.cpuChartSeconds)) + ? defaults.integer(forKey: Keys.cpuChartSeconds) : 60 } /// 按固定顺序返回已启用的菜单栏项目 @@ -528,6 +625,7 @@ public final class AppSettings { static let probeSeconds = "probeSeconds" static let probeInBackground = "probeInBackground" static let probeTarget = "probeTarget" + static let geoSource = "geoSource" static let publicIPLookup = "publicIPLookup" static let geoIncludeCity = "geoIncludeCity" static let geoAutoUpdate = "geoAutoUpdate" @@ -537,5 +635,7 @@ public final class AppSettings { static let language = "language" static let enabledAlerts = "enabledAlerts" static let alertCPUTemperature = "alertCPUTemperature" + static let alertCPULoad = "alertCPULoad" + static let cpuChartSeconds = "cpuChartSeconds" } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift index f41e9b9..5b74585 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift @@ -15,6 +15,7 @@ enum Log { static let power = Logger(subsystem: subsystem, category: "power") static let network = Logger(subsystem: subsystem, category: "network") static let update = Logger(subsystem: subsystem, category: "update") + static let sync = Logger(subsystem: subsystem, category: "sync") } /// 导出诊断信息:版本与系统、辅助工具状态、主要设置、最近 3 天的运行日志、清理记录与崩溃报告,打成一个 zip。 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DiskToolsController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DiskToolsController.swift new file mode 100644 index 0000000..51b8d51 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/DiskToolsController.swift @@ -0,0 +1,225 @@ +import AppKit +import Cleaner +import Foundation +import HelperShared +import Localization +import Metrics +import Observation + +/// 磁盘页上能动手的部分:文件系统检查、Time Machine 本地快照、其他已装载的磁盘、家目录空间分析 +@MainActor +@Observable +final class DiskToolsController { + struct Outcome: Equatable { + let text: String + let isError: Bool + } + + enum VerifyPhase: Equatable { + case idle + case running + case finished(VolumeVerification) + case failed(String) + } + + enum ScanPhase: Equatable { + case idle + case scanning(SpaceScanProgress) + case finished(SpaceScanResult) + case failed(String) + } + + // MARK: 文件系统检查 + + private(set) var verifyPhase: VerifyPhase = .idle + private(set) var lastVerified: Date? { + didSet { defaults.set(lastVerified, forKey: Keys.lastVerified) } + } + private(set) var lastVerifiedOK: Bool { + didSet { defaults.set(lastVerifiedOK, forKey: Keys.lastVerifiedOK) } + } + + // MARK: 本地快照 + + private(set) var snapshots: [LocalSnapshot] = [] + private(set) var snapshotsLoaded = false + private(set) var isDeletingSnapshots = false + private(set) var snapshotOutcome: Outcome? + + // MARK: 其他磁盘 + + private(set) var volumes: [MountedVolume] = [] + private(set) var ejecting: Set = [] + private(set) var volumeOutcome: Outcome? + + // MARK: 空间占用 + + private(set) var scanPhase: ScanPhase = .idle + private(set) var trashOutcome: Outcome? + @ObservationIgnored private var scanTask: Task? + + @ObservationIgnored private let helper: HelperClient + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private var volumeObservers: [NSObjectProtocol] = [] + + init(helper: HelperClient, defaults: UserDefaults = .standard) { + self.helper = helper + self.defaults = defaults + lastVerified = defaults.object(forKey: Keys.lastVerified) as? Date + lastVerifiedOK = defaults.object(forKey: Keys.lastVerifiedOK) as? Bool ?? true + } + + /// 磁盘页打开时刷新快照与卷宗;接入、拔出磁盘时跟着刷新 + func pageOpened() { + refreshSnapshots() + refreshVolumes() + guard volumeObservers.isEmpty else { return } + let center = NSWorkspace.shared.notificationCenter + for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification] { + volumeObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshVolumes() } + }) + } + } + + // MARK: 检查 + + func verify() { + guard verifyPhase != .running else { return } + verifyPhase = .running + Task { + let result = await Task.detached { VolumeVerifier.verify() }.value + verifyPhase = .finished(result) + lastVerified = result.date + lastVerifiedOK = result.ok + if result.ok { + Log.app.notice("磁盘检查通过") + } else { + Log.app.error("磁盘检查发现问题:\(result.summary, privacy: .public)") + } + } + } + + // MARK: 快照 + + func refreshSnapshots() { + Task { + let list = await Task.detached { LocalSnapshots.list() }.value + snapshots = list + snapshotsLoaded = true + } + } + + /// 删掉全部本地快照:辅助工具够新就走它,否则请求一次管理员授权 + func deleteAllSnapshots() { + guard !isDeletingSnapshots, !snapshots.isEmpty else { return } + let identifiers = snapshots.map(\.identifier).filter(LocalSnapshotCommand.isValidIdentifier) + guard !identifiers.isEmpty else { + snapshotOutcome = Outcome(text: tr("快照名称格式不认识,没有删除"), isError: true) + return + } + isDeletingSnapshots = true + snapshotOutcome = nil + Task { + defer { isDeletingSnapshots = false } + let error: String? + if let version = await helper.remoteProtocolVersion(), version >= 4 { + error = await helper.deleteLocalSnapshots(identifiers: identifiers) + } else { + error = await MaintenanceController.runWithAdministratorPrompt( + shell: LocalSnapshotCommand.shellCommand(identifiers: identifiers), + prompt: tr("OpenStats 需要管理员权限来删除 Time Machine 本地快照。")) + } + if let error { + Log.app.error("删除本地快照失败:\(error, privacy: .public)") + snapshotOutcome = Outcome(text: error, isError: error != MaintenanceController.cancelled) + } else { + Log.app.notice("已删除 \(identifiers.count) 个本地快照") + snapshotOutcome = Outcome(text: tr("已删除 \(identifiers.count) 个快照,腾出的空间稍后会反映在“可清除”里"), isError: false) + } + refreshSnapshots() + } + } + + // MARK: 卷宗 + + func refreshVolumes() { + Task { + volumes = await Task.detached { MountedVolumes.list() }.value + } + } + + func eject(_ volume: MountedVolume) { + guard !ejecting.contains(volume.id) else { return } + ejecting.insert(volume.id) + volumeOutcome = nil + Task { + defer { ejecting.remove(volume.id) } + do { + try await Task.detached { try NSWorkspace.shared.unmountAndEjectDevice(at: volume.url) }.value + volumeOutcome = Outcome(text: tr("已推出“\(volume.name)”"), isError: false) + } catch { + volumeOutcome = Outcome(text: tr("无法推出“\(volume.name)”:\(error.localizedDescription)"), isError: true) + } + refreshVolumes() + } + } + + // MARK: 空间分析 + + func startScan() { + guard scanTask == nil else { return } + scanPhase = .scanning(SpaceScanProgress(items: 0, bytes: 0, current: "")) + trashOutcome = nil + scanTask = Task { + defer { scanTask = nil } + do { + // 用户点了按钮在等结果,不用 utility:那一档的磁盘读取会被系统限速 + let result = try await Task.detached(priority: .userInitiated) { + try SpaceScanner.scan { progress in + Task { @MainActor in self.report(progress) } + } + }.value + scanPhase = .finished(result) + Log.app.notice("空间分析完成:\(result.scannedItems) 个条目") + } catch is CancellationError { + scanPhase = .idle + } catch { + scanPhase = .failed(error.localizedDescription) + } + } + } + + private func report(_ progress: SpaceScanProgress) { + if case .scanning = scanPhase { scanPhase = .scanning(progress) } + } + + func cancelScan() { + scanTask?.cancel() + } + + /// 把扫描结果里的一个文件移到废纸篓,然后从结果里去掉它 + func trash(_ file: SpaceScanResult.File) { + do { + try SpaceScanner.trash(file.url) + trashOutcome = Outcome(text: tr("已把“\(file.name)”移到废纸篓"), isError: false) + if case .finished(let result) = scanPhase { + scanPhase = .finished(SpaceScanResult(root: result.root, folders: result.folders, + largestFiles: result.largestFiles.filter { $0.id != file.id }, + scannedItems: result.scannedItems, totalBytes: result.totalBytes, date: result.date)) + } + Log.app.notice("空间分析:移到废纸篓 \(file.url.path, privacy: .private)") + } catch { + trashOutcome = Outcome(text: tr("无法移到废纸篓:\(error.localizedDescription)"), isError: true) + } + } + + func reveal(_ url: URL) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + private enum Keys { + static let lastVerified = "diskLastVerified" + static let lastVerifiedOK = "diskLastVerifiedOK" + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift index 3e8e3b2..7bd17ff 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift @@ -163,6 +163,10 @@ public final class HelperClient { await call { proxy, reply in proxy.setDNSServers(service: service, servers: servers, reply: reply) } } + func deleteLocalSnapshots(identifiers: [String]) async -> String? { + await call { proxy, reply in proxy.deleteLocalSnapshots(identifiers: identifiers, reply: reply) } + } + /// 已安装的辅助工具的协议版本;旧版本不认识新增的方法,调用前先确认 func remoteProtocolVersion() async -> Int? { refreshStatus() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift index 80a5367..a8b0a34 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift @@ -80,7 +80,7 @@ public final class MaintenanceController { } } - nonisolated private static var cancelled: String { tr("已取消") } + nonisolated static var cancelled: String { tr("已取消") } /// 未安装辅助工具时的回退:通过 AppleScript 请求一次性管理员授权执行命令。 /// 命令只由固定路径与已校验的参数拼成,这里再做 AppleScript 字符串转义 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift index 8a4374b..aea1a94 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift @@ -3,6 +3,17 @@ import Metrics import Observation import SMC +/// 一次采样的时间与数值 +public struct TimedValue: Sendable, Equatable { + public let date: Date + public let value: Double + + public init(date: Date, value: Double) { + self.date = date + self.value = value + } +} + /// 主线程上的最新指标与短历史,只保留界面需要的数据 @MainActor @Observable @@ -14,6 +25,9 @@ public final class MetricsStore { public private(set) var cpu: CPULoad? public private(set) var cpuTotal = History(capacity: historyCapacity) + /// 带时间戳的 CPU 总占用,供可选 1 / 3 / 5 分钟的走势图按时间定位;每秒一次时正好装下 5 分钟 + public private(set) var cpuTimeline = History(capacity: timelineCapacity) + public static let timelineCapacity = 300 /// 每次采样各核心的占用,用于核心热力图 public private(set) var coreHistory = History<[Double]>(capacity: historyCapacity) @@ -34,6 +48,8 @@ public final class MetricsStore { /// 启动时读取一次,确保面板首次打开时布局(是否有电池卡片)就已确定 public private(set) var battery: BatteryStatus? = BatterySampler.sample() public private(set) var processes: [ProcessUsage] = [] + /// 按应用汇总的磁盘读写排行,分数是最近十几秒的平均速率 + private(set) var diskRanking = ActivityRanking() public private(set) var systemCounts: SystemCounts? public private(set) var sensors: SensorReadings? public private(set) var power: PowerReading? @@ -61,6 +77,7 @@ public final class MetricsStore { if let cpu = snapshot.cpu { self.cpu = cpu cpuTotal.append(cpu.total) + cpuTimeline.append(TimedValue(date: snapshot.date, value: cpu.total)) coreHistory.append(cpu.perCore) } if let memory = snapshot.memory { @@ -86,7 +103,14 @@ public final class MetricsStore { } if let disk = snapshot.disk { self.disk = disk } if let battery = snapshot.battery { self.battery = battery } - if let processes = snapshot.processes { self.processes = processes } + if let processes = snapshot.processes { + self.processes = processes + var activity: [String: Double] = [:] + for row in ProcessRowModel.grouped(processes) { + activity[row.id] = row.disk.map { $0.read + $0.write } ?? 0 + } + diskRanking.update(activity) + } if let counts = snapshot.systemCounts { systemCounts = counts } if let sensors = snapshot.sensors { mergeSensors(sensors) } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift index 2f9f3af..a032375 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift @@ -16,10 +16,28 @@ public final class NetworkController { public static let probeCapacity = 120 public private(set) var details: NetworkDetails? - public private(set) var publicAddresses: PublicAddresses? + /// 公网 IP 信息按地址族各存一份:两份都含 IPv4 与 IPv6 地址,归属地、纯净度等是各自那个地址的 + public private(set) var publicResults: [IPFamily: PublicAddresses] = [:] + /// 界面上当前看的是哪一族;只有一族时自动跟着 + public var publicFamily: IPFamily = .v4 + /// 当前地址族的结果;所选那族没有时退到另一族 + public var publicAddresses: PublicAddresses? { + publicResults[publicFamily] ?? publicResults[publicFamily == .v4 ? .v6 : .v4] + } + /// 两族都有归属地结果时才提供切换 + public var hasDualStack: Bool { + publicResults[.v4] != nil && publicResults[.v6] != nil + } + /// 界面上标注的地址族:双栈时显示,单栈不显示 + public var shownFamily: IPFamily? { + guard hasDualStack else { return nil } + return publicResults[publicFamily] != nil ? publicFamily : (publicFamily == .v4 ? .v6 : .v4) + } public private(set) var isLookingUpPublic = false public private(set) var probes = History(capacity: probeCapacity) public private(set) var processes: [NetworkProcessUsage] = [] + /// 各进程流量的平滑排行,列表按它排序而不是按瞬时速率 + @ObservationIgnored private var processRanking = ActivityRanking() @ObservationIgnored private let settings: AppSettings @ObservationIgnored private var probeTask: Task? @@ -37,9 +55,66 @@ public final class NetworkController { @ObservationIgnored private let geo: GeoDatabaseController + /// 上次查到的公网 IP 信息:点开弹窗时先显示它,只有超过 7 天或公网 IP 变了才重新查归属地 + private struct PublicCache: Codable { + var results: [String: PublicAddresses] + var geoDates: [String: Date] + var source: String + } + static let geoCacheLifetime: TimeInterval = 7 * 24 * 60 * 60 + private static let cacheKey = "publicAddressCache3" + + /// 用户按过“重置统计”后的累计流量基线:界面显示 当前累计 − 基线。 + /// 随开机时间一起保存,重启后系统计数器归零、基线作废,自动回到开机后累计 + public struct TrafficBaseline: Codable, Equatable, Sendable { + public var download: UInt64 + public var upload: UInt64 + public var date: Date + var bootTime: TimeInterval? + } + public private(set) var trafficBaseline: TrafficBaseline? + private static let trafficBaselineKey = "trafficBaseline" + private static let bootTime: TimeInterval? = { + var boot = timeval() + var size = MemoryLayout.size + return sysctlbyname("kern.boottime", &boot, &size, nil, 0) == 0 ? TimeInterval(boot.tv_sec) : nil + }() + init(settings: AppSettings, geo: GeoDatabaseController) { self.settings = settings self.geo = geo + if let data = UserDefaults.standard.data(forKey: Self.cacheKey), + let cache = try? JSONDecoder().decode(PublicCache.self, from: data), + cache.source == settings.geoSource.rawValue, settings.publicIPLookup { + publicResults = Dictionary(uniqueKeysWithValues: cache.results.compactMap { key, value in IPFamily(rawValue: key).map { ($0, value) } }) + } + if let data = UserDefaults.standard.data(forKey: Self.trafficBaselineKey), + let baseline = try? JSONDecoder().decode(TrafficBaseline.self, from: data), + baseline.bootTime == Self.bootTime { + trafficBaseline = baseline + } + } + + // MARK: 流量统计 + + /// 从现在起重新累计上传与下载:把当前计数器记成基线 + public func resetTraffic(_ rate: NetworkRate) { + let baseline = TrafficBaseline(download: rate.totalDownloaded, upload: rate.totalUploaded, date: Date(), bootTime: Self.bootTime) + trafficBaseline = baseline + UserDefaults.standard.set(try? JSONEncoder().encode(baseline), forKey: Self.trafficBaselineKey) + } + + /// 改回开机后的累计 + public func clearTrafficBaseline() { + trafficBaseline = nil + UserDefaults.standard.removeObject(forKey: Self.trafficBaselineKey) + } + + /// 扣掉基线后的累计流量;计数器比基线小(接口变动)时按 0 算 + public func trafficTotals(_ rate: NetworkRate) -> (download: UInt64, upload: UInt64) { + guard let baseline = trafficBaseline else { return (rate.totalDownloaded, rate.totalUploaded) } + return (rate.totalDownloaded >= baseline.download ? rate.totalDownloaded - baseline.download : 0, + rate.totalUploaded >= baseline.upload ? rate.totalUploaded - baseline.upload : 0) } // MARK: 统计 @@ -131,7 +206,7 @@ public final class NetworkController { }.value sampler = next guard !Task.isCancelled, let self else { return } - self.processes = usage + self.processes = self.rankedProcesses(usage) tick += 1 try? await Task.sleep(for: .seconds(2)) } @@ -144,27 +219,80 @@ public final class NetworkController { // MARK: 公网 IP - func lookUpPublicAddresses() { + /// 先只取公网地址(Cloudflare,便宜且不限额),再按地址族各查一次归属地:地址没变、结果不满 7 天、 + /// 数据源没换的那一族沿用缓存,否则重新查并写回缓存。`force` 为真时(用户点了刷新)两族都重查 + func lookUpPublicAddresses(force: Bool = false) { guard settings.publicIPLookup, !isLookingUpPublic else { return } - isLookingUpPublic = true + // 有缓存可显示时不转圈,后台悄悄核对 + isLookingUpPublic = publicResults.isEmpty || force let localIPv4 = details?.physical?.ipv4 ?? [] Task { - // 有本地 GeoLite2 时只向外查询公网 IP,归属地与 ASN 在本机查 - let local = geo.isAvailable - var result = await PublicAddressLookup.fetch(includeGeo: !local) - if local, let address = result.ipv4 ?? result.ipv6, let location = geo.locate(address) { - result.countryCode = location.countryCode ?? result.countryCode - result.city = location.city - result.asn = location.asn - result.organization = location.organization - result.source = .localDatabase + let source = settings.geoSource + let provider = source.provider ?? .cleanIP + let useLocal = source == .localDatabase && geo.isAvailable + let base = await PublicAddressLookup.fetch(includeGeo: false, provider: provider) + let cached = Self.loadCache() + let now = Date() + + @MainActor func resolve(_ family: IPFamily, ip: String?) async -> PublicAddresses? { + guard let ip else { return nil } + if !force, let cached, cached.source == source.rawValue, + let old = cached.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip, + let date = cached.geoDates[family.rawValue], now.timeIntervalSince(date) < Self.geoCacheLifetime { + var kept = old + kept.ipv4 = base.ipv4 + kept.ipv6 = base.ipv6 + return kept + } + var result = base + if !useLocal, let info = await PublicAddressLookup.geo(for: ip, provider: provider) { + result.apply(info) + } + // 选了本地 GeoLite2,或在线查不到时,退回本地数据库 + if useLocal || (result.asn == nil && result.city == nil && geo.isAvailable), let location = geo.locate(ip) { + result.countryCode = location.countryCode ?? result.countryCode + result.city = location.city + result.asn = location.asn + result.organization = location.organization + result.source = .localDatabase + } + guard result.asn != nil || result.city != nil || result.purity != nil else { + // 这次没查到,地址又没变,先继续用旧的 + if let old = cached?.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip { return old } + return result + } + return result } - publicAddresses = result + + async let v4 = resolve(.v4, ip: base.ipv4) + async let v6 = resolve(.v6, ip: base.ipv6) + var results: [IPFamily: PublicAddresses] = [:] + if let v4 = await v4 { results[.v4] = v4 } + if let v6 = await v6 { results[.v6] = v6 } + + var dates = cached?.source == source.rawValue ? cached?.geoDates ?? [:] : [:] + for (family, result) in results { + let previous = cached?.results[family.rawValue] + let sameAsCached = previous?.asn == result.asn && previous?.purity == result.purity && previous?.city == result.city + if force || !sameAsCached || dates[family.rawValue] == nil { dates[family.rawValue] = now } + } + Self.saveCache(PublicCache(results: Dictionary(uniqueKeysWithValues: results.map { ($0.key.rawValue, $0.value) }), + geoDates: dates, source: source.rawValue)) + publicResults = results + if publicResults[publicFamily] == nil, let only = publicResults.keys.first { publicFamily = only } lastPublicLookup = (Date(), localIPv4) isLookingUpPublic = false } } + private static func loadCache() -> PublicCache? { + UserDefaults.standard.data(forKey: cacheKey).flatMap { try? JSONDecoder().decode(PublicCache.self, from: $0) } + } + + private static func saveCache(_ cache: PublicCache) { + UserDefaults.standard.set(try? JSONEncoder().encode(cache), forKey: cacheKey) + } + /// 10 分钟内且本地地址未变化时沿用上次结果 private func lookUpPublicAddressesIfStale() { guard settings.publicIPLookup else { return } @@ -174,8 +302,9 @@ public final class NetworkController { } func clearPublicAddresses() { - publicAddresses = nil + publicResults = [:] lastPublicLookup = nil + UserDefaults.standard.removeObject(forKey: Self.cacheKey) } // MARK: 探测 @@ -219,7 +348,39 @@ public final class NetworkController { details.physical?.hardwareAddress = "a4:83:e7:12:34:56" details.dnsServers = details.physical?.manualDNS.isEmpty == false ? details.physical!.manualDNS : ["192.168.1.1"] self.details = details - publicAddresses = PublicAddresses(ipv4: "203.0.113.24", ipv6: nil, countryCode: "CN", - city: "Shanghai", asn: "AS64500", organization: "EXAMPLE NETWORK") + var sample = PublicAddresses(ipv4: "203.0.113.24", ipv6: nil, countryCode: "CN", + city: "上海", region: "上海", asn: "AS64500", organization: "EXAMPLE NETWORK") + sample.cityEnglish = "Shanghai" + sample.networkType = "Residential" + sample.connectionType = "Cable/DSL" + sample.ipType = "Residential IP" + sample.isNative = true + sample.residentialProbability = 96 + sample.purity = PublicAddresses.Purity(score: 94, grade: "A", confidence: 100) + sample.risk = PublicAddresses.Risk(score: 4, label: "Very Clean") + publicResults = [.v4: sample] } } + +extension NetworkController { + /// 用最近十几秒的平均流量给进程排序:刚安静下来的进程还会在榜上停留一会儿,列表不会忽隐忽现 + func rankedProcesses(_ usage: [NetworkProcessUsage]) -> [NetworkProcessUsage] { + var current: [String: Double] = [:] + var byID: [String: NetworkProcessUsage] = [:] + for process in usage { + let key = "\(process.pid)" + current[key] = process.download + process.upload + byID[key] = process + } + processRanking.update(current) + return processRanking.ranked(limit: 5).compactMap { byID[$0.id] } + } +} + +/// 公网地址的地址族 +public enum IPFamily: String, CaseIterable, Identifiable, Sendable, Codable { + case v4, v6 + + public var id: String { rawValue } + public var title: String { self == .v4 ? "IPv4" : "IPv6" } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift new file mode 100644 index 0000000..ced051d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift @@ -0,0 +1,132 @@ +import Foundation +import Localization + +/// 同步到云端的设置文档:只含偏好,不含本机状态(当前页签、辅助工具安装状态、历史库、归属地库)。 +/// 枚举以原始值保存,字段全部可选:旧版本读到新字段会忽略,新版本读到旧文档不会解码失败; +/// 应用时逐项校验,不认识的值跳过 +public struct SettingsDocument: Codable, Equatable, Sendable { + public var schema: Int? = 1 + public var menuBarItems: [String]? + public var menuBarStyle: String? + public var networkStyle: String? + public var styleOverrides: [String: String]? + public var refreshSeconds: Int? + public var colorizeHighLoad: Bool? + public var useFahrenheit: Bool? + public var lidModeBatteryFloor: Int? + public var fanSafetyTemperature: Int? + public var appearance: String? + public var menuBarLayout: String? + public var hiddenPopoverSections: [String]? + public var probeEnabled: Bool? + public var probeInBackground: Bool? + public var probeSeconds: Int? + public var probeTarget: String? + public var geoSource: String? + public var publicIPLookup: Bool? + public var geoIncludeCity: Bool? + public var geoAutoUpdate: Bool? + public var enabledAlerts: [String]? + public var alertCPUTemperature: Int? + public var alertCPULoad: Int? + public var language: String? + public var hotKeys: [String: HotKey]? + public var historyEnabled: Bool? + public var autoCheckUpdates: Bool? + public var cleanPrefersTrash: Bool? + + public init() {} +} + +extension AppSettings { + /// 当前设置导出成文档。读取这里列出的每一项也就登记了观察,SyncController 借此得知设置变化 + public func exportDocument() -> SettingsDocument { + var doc = SettingsDocument() + doc.menuBarItems = menuBarItems.map(\.rawValue).sorted() + doc.menuBarStyle = menuBarStyle.rawValue + doc.networkStyle = networkStyle.rawValue + doc.styleOverrides = Dictionary(uniqueKeysWithValues: styleOverrides.map { ($0.key.rawValue, $0.value.rawValue) }) + doc.refreshSeconds = refreshSeconds + doc.colorizeHighLoad = colorizeHighLoad + doc.useFahrenheit = useFahrenheit + doc.lidModeBatteryFloor = lidModeBatteryFloor + doc.fanSafetyTemperature = fanSafetyTemperature + doc.appearance = appearance.rawValue + doc.menuBarLayout = menuBarLayout.rawValue + doc.hiddenPopoverSections = hiddenPopoverSections.map(\.rawValue).sorted() + doc.probeEnabled = probeEnabled + doc.probeInBackground = probeInBackground + doc.probeSeconds = probeSeconds + doc.probeTarget = probeTarget.rawValue + doc.geoSource = geoSource.rawValue + doc.publicIPLookup = publicIPLookup + doc.geoIncludeCity = geoIncludeCity + doc.geoAutoUpdate = geoAutoUpdate + doc.enabledAlerts = enabledAlerts.map(\.rawValue).sorted() + doc.alertCPUTemperature = alertCPUTemperature + doc.alertCPULoad = alertCPULoad + doc.language = language.rawValue + doc.hotKeys = Dictionary(uniqueKeysWithValues: hotKeys.map { ($0.key.rawValue, $0.value) }) + doc.historyEnabled = historyEnabled + doc.autoCheckUpdates = autoCheckUpdates + doc.cleanPrefersTrash = cleanPrefersTrash + return doc + } + + /// 把文档套用到当前设置。只改有变化的项,避免无谓地触发持久化与界面重建 + public func apply(_ doc: SettingsDocument) { + func assign(_ keyPath: ReferenceWritableKeyPath, _ value: T?) { + guard let value, self[keyPath: keyPath] != value else { return } + self[keyPath: keyPath] = value + } + func option(_ value: Int?, in options: [Int]) -> Int? { + value.flatMap { options.contains($0) ? $0 : nil } + } + + assign(\.menuBarItems, doc.menuBarItems.map { Set($0.compactMap(MenuBarItem.init(rawValue:))) }) + assign(\.menuBarStyle, doc.menuBarStyle.flatMap(MenuBarStyle.init(rawValue:))) + assign(\.networkStyle, doc.networkStyle.flatMap(NetworkMenuStyle.init(rawValue:))) + assign(\.styleOverrides, doc.styleOverrides.map { overrides in + Dictionary(uniqueKeysWithValues: overrides.compactMap { key, value in + guard let item = MenuBarItem(rawValue: key), let style = MenuBarStyle(rawValue: value) else { return nil } + return (item, style) + }) + }) + assign(\.refreshSeconds, option(doc.refreshSeconds, in: Self.refreshOptions)) + assign(\.colorizeHighLoad, doc.colorizeHighLoad) + assign(\.useFahrenheit, doc.useFahrenheit) + assign(\.lidModeBatteryFloor, option(doc.lidModeBatteryFloor, in: Self.batteryFloorOptions)) + assign(\.fanSafetyTemperature, option(doc.fanSafetyTemperature, in: Self.fanSafetyOptions)) + assign(\.appearance, doc.appearance.flatMap(AppearanceMode.init(rawValue:))) + assign(\.menuBarLayout, doc.menuBarLayout.flatMap(MenuBarLayout.init(rawValue:))) + assign(\.hiddenPopoverSections, doc.hiddenPopoverSections.map { Set($0.compactMap(PopoverSection.init(rawValue:))) }) + assign(\.probeEnabled, doc.probeEnabled) + assign(\.probeInBackground, doc.probeInBackground) + assign(\.probeSeconds, option(doc.probeSeconds, in: Self.probeOptions)) + assign(\.probeTarget, doc.probeTarget.flatMap(ProbeTarget.init(rawValue:))) + assign(\.geoSource, doc.geoSource.flatMap(GeoSource.init(rawValue:))) + assign(\.publicIPLookup, doc.publicIPLookup) + assign(\.geoIncludeCity, doc.geoIncludeCity) + assign(\.geoAutoUpdate, doc.geoAutoUpdate) + assign(\.enabledAlerts, doc.enabledAlerts.map { Set($0.compactMap(AlertKind.init(rawValue:))) }) + assign(\.alertCPUTemperature, option(doc.alertCPUTemperature, in: Self.alertTemperatureOptions)) + assign(\.alertCPULoad, option(doc.alertCPULoad, in: Self.alertLoadOptions)) + assign(\.language, doc.language.flatMap(AppLanguage.init(rawValue:))) + assign(\.hotKeys, doc.hotKeys.map { stored in + Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in + HotKeyAction(rawValue: key).map { ($0, value) } + }) + }) + assign(\.historyEnabled, doc.historyEnabled) + assign(\.autoCheckUpdates, doc.autoCheckUpdates) + assign(\.cleanPrefersTrash, doc.cleanPrefersTrash) + } + + /// 出厂设置对应的文档:用一个空的 defaults 域构造一次即可,不会写盘 + public static func defaultDocument() -> SettingsDocument { + let name = "com.openstats.defaults-probe" + let defaults = UserDefaults(suiteName: name) ?? .standard + defaults.removePersistentDomain(forName: name) + return AppSettings(defaults: defaults).exportDocument() + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SyncController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SyncController.swift new file mode 100644 index 0000000..be1cec4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SyncController.swift @@ -0,0 +1,397 @@ +import AccountSync +import AppKit +import AuthenticationServices +import Foundation +import Localization +import Observation + +/// 账号与设置同步:用系统的登录窗口走 GitHub / Google / Apple 授权,令牌存钥匙串; +/// 设置一变就在 2 秒后整份推送,启动、唤醒与每 15 分钟拉一次。云端以最后写入为准, +/// 只有第一次登录且两边都有内容时让用户选一次 +@MainActor +@Observable +public final class SyncController { + public enum Phase: Equatable { + case idle + case signingIn + case syncing + case failed(String) + } + + /// 首次登录时本机与云端设置不同,等用户选择 + public struct Conflict: Equatable { + public let remoteUpdatedAt: Date + public let remoteDevice: String? + } + + public private(set) var phase: Phase = .idle + public private(set) var user: SyncUser? { + didSet { defaults.set(user.flatMap { try? JSONEncoder().encode($0) }, forKey: Keys.user) } + } + public private(set) var lastSyncedAt: Date? { + didSet { defaults.set(lastSyncedAt, forKey: Keys.lastSyncedAt) } + } + public private(set) var conflict: Conflict? + /// 服务器上配置好的登录方式;取不到时全部显示 + public private(set) var providers: [SyncProvider] = SyncProvider.allCases + + public var isSignedIn: Bool { token != nil && user != nil } + + /// 登录窗口挂在哪个窗口上,由 AppController 提供主窗口 + @ObservationIgnored var presentationAnchor: @MainActor () -> NSWindow? = { nil } + + @ObservationIgnored private let settings: AppSettings + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let client: SyncClient + @ObservationIgnored private let tokens: TokenStore + @ObservationIgnored private var token: String? + @ObservationIgnored private var lastSyncedVersion: Int { + didSet { defaults.set(lastSyncedVersion, forKey: Keys.lastSyncedVersion) } + } + /// 上次推送或套用过的文档:本机再次读到同样的内容就不用推,也用来识别套用云端设置引起的回声 + @ObservationIgnored private var lastKnownDocument: SettingsDocument? { + didSet { defaults.set(lastKnownDocument.flatMap { try? JSONEncoder().encode($0) }, forKey: Keys.lastKnownDocument) } + } + @ObservationIgnored private var pendingRemote: RemoteSettings? + @ObservationIgnored private var pushTask: Task? + @ObservationIgnored private var pullTask: Task? + @ObservationIgnored private var authSession: ASWebAuthenticationSession? + @ObservationIgnored private var anchorProvider: AnchorProvider? + @ObservationIgnored private var timer: Timer? + @ObservationIgnored private var observing = false + + static let pushDelay: Duration = .seconds(2) + static let pullInterval: TimeInterval = 15 * 60 + + init(settings: AppSettings, defaults: UserDefaults = .standard, client: SyncClient = SyncClient(), tokens: TokenStore = KeychainTokenStore()) { + self.settings = settings + self.defaults = defaults + self.client = client + self.tokens = tokens + user = defaults.data(forKey: Keys.user).flatMap { try? JSONDecoder().decode(SyncUser.self, from: $0) } + lastSyncedAt = defaults.object(forKey: Keys.lastSyncedAt) as? Date + lastSyncedVersion = defaults.integer(forKey: Keys.lastSyncedVersion) + lastKnownDocument = defaults.data(forKey: Keys.lastKnownDocument).flatMap { try? JSONDecoder().decode(SettingsDocument.self, from: $0) } + } + + /// 启动时调用:已登录就开始观察设置并补一次同步 + public func start() { + token = tokens.read() + guard let token else { + if user != nil { clearLocalSession() } + return + } + if user == nil { + Task { [weak self] in + guard let self, let fetched = try? await self.client.me(token: token) else { return } + self.user = fetched + self.start() + } + return + } + beginObserving() + if lastKnownDocument != settings.exportDocument() { + schedulePush() + } else { + pullSoon() + } + } + + /// 账号页打开时:未登录就刷新可用的登录方式,已登录就拉一次 + func pageOpened() { + if isSignedIn { + pullSoon() + } else { + Task { [weak self] in + guard let self, let fetched = try? await self.client.providers(), !fetched.isEmpty else { return } + self.providers = fetched + } + } + } + + // MARK: 登录 + + public func signIn(with provider: SyncProvider) { + guard phase != .signingIn else { return } + let verifier = PKCE.verifier() + let url = client.loginURL(provider: provider, challenge: PKCE.challenge(for: verifier)) + let anchor = AnchorProvider(anchor: presentationAnchor) + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: SyncClient.callbackScheme) { [weak self] callback, error in + Task { @MainActor in self?.finishSignIn(callback: callback, error: error, verifier: verifier) } + } + session.presentationContextProvider = anchor + // 保留浏览器里已登录的第三方账号,用户不必再输一次密码 + session.prefersEphemeralWebBrowserSession = false + anchorProvider = anchor + authSession = session + phase = .signingIn + Log.sync.notice("开始登录:\(provider.rawValue, privacy: .public)") + if !session.start() { + phase = .failed(tr("无法打开登录窗口")) + authSession = nil + } + } + + public func cancelSignIn() { + authSession?.cancel() + authSession = nil + if phase == .signingIn { phase = .idle } + } + + private func finishSignIn(callback: URL?, error: Error?, verifier: String) { + authSession = nil + anchorProvider = nil + if let error { + let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin + phase = cancelled ? .idle : .failed(error.localizedDescription) + return + } + guard let callback else { + phase = .failed(SyncError.invalidCallback.localizedDescription) + return + } + switch SyncClient.parseCallback(callback) { + case .failure(.cancelled): + phase = .idle + case .failure(let error): + phase = .failed(error.localizedDescription) + case .success(let code): + Task { await exchange(code: code, verifier: verifier) } + } + } + + private func exchange(code: String, verifier: String) async { + do { + let session = try await client.exchange(code: code, verifier: verifier, device: Self.deviceName) + try tokens.write(session.token) + token = session.token + user = session.user + lastSyncedVersion = 0 + lastKnownDocument = nil + conflict = nil + phase = .idle + Log.sync.notice("登录成功:\(session.user.providers.joined(separator: ","), privacy: .public)") + beginObserving() + await reconcileAfterSignIn() + } catch { + Log.sync.error("登录失败:\(error.localizedDescription, privacy: .public)") + phase = .failed(error.localizedDescription) + } + } + + /// 第一次登录:云端没有就上传;本机还是出厂设置就直接用云端;两边都有且不同就问用户 + private func reconcileAfterSignIn() async { + guard let token else { return } + phase = .syncing + do { + let local = settings.exportDocument() + guard let remote = try await client.fetchSettings(token: token, as: SettingsDocument.self) else { + phase = .idle + await push() + return + } + if remote.document == local || local == AppSettings.defaultDocument() { + applyRemote(remote) + } else { + pendingRemote = remote + conflict = Conflict(remoteUpdatedAt: remote.updatedAt, remoteDevice: remote.device) + } + phase = .idle + } catch { + handle(error) + } + } + + public func resolveConflict(useRemote: Bool) { + guard conflict != nil else { return } + conflict = nil + if useRemote, let remote = pendingRemote { + applyRemote(remote) + } else { + Task { await push() } + } + pendingRemote = nil + } + + // MARK: 退出 + + public func signOut() { + if let token { + Task { [client] in try? await client.logout(token: token) } + } + clearLocalSession() + Log.sync.notice("已退出登录") + } + + public func deleteAccount() { + guard let token else { return } + phase = .syncing + Task { + do { + try await client.deleteAccount(token: token) + clearLocalSession() + Log.sync.notice("已删除云端账号") + } catch { + handle(error) + } + } + } + + private func clearLocalSession() { + pushTask?.cancel() + pullTask?.cancel() + pushTask = nil + pullTask = nil + timer?.invalidate() + timer = nil + tokens.delete() + token = nil + user = nil + conflict = nil + pendingRemote = nil + lastSyncedAt = nil + lastSyncedVersion = 0 + lastKnownDocument = nil + observing = false + phase = .idle + } + + // MARK: 同步 + + /// 用户点“立即同步”:有本机改动就推,否则拉 + public func syncNow() { + guard isSignedIn, conflict == nil else { return } + if lastKnownDocument != settings.exportDocument() { + pushTask?.cancel() + pushTask = nil + Task { await push() } + } else { + pullTask?.cancel() + pullTask = nil + Task { await pull() } + } + } + + /// 唤醒或打开账号页时稍后拉一次,合并短时间内的多次触发 + func pullSoon() { + guard isSignedIn, pullTask == nil else { return } + pullTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard let self, !Task.isCancelled else { return } + self.pullTask = nil + await self.pull() + } + } + + private func beginObserving() { + guard !observing else { return } + observing = true + observeSettings() + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: Self.pullInterval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.pullSoon() } + } + timer?.tolerance = 60 + } + + private func observeSettings() { + guard observing else { return } + withObservationTracking { + _ = settings.exportDocument() + } onChange: { [weak self] in + Task { @MainActor in + guard let self, self.observing else { return } + self.schedulePush() + self.observeSettings() + } + } + } + + private func schedulePush() { + pushTask?.cancel() + pushTask = Task { [weak self] in + try? await Task.sleep(for: Self.pushDelay) + guard let self, !Task.isCancelled else { return } + self.pushTask = nil + await self.push() + } + } + + private func push() async { + guard let token, conflict == nil else { return } + let document = settings.exportDocument() + guard document != lastKnownDocument else { return } + phase = .syncing + do { + let receipt = try await client.putSettings(token: token, document: document, device: Self.deviceName) + lastKnownDocument = document + lastSyncedVersion = receipt.version + lastSyncedAt = receipt.updatedAt + phase = .idle + Log.sync.info("已上传设置,版本 \(receipt.version)") + } catch { + handle(error) + } + } + + private func pull() async { + guard let token, conflict == nil, pushTask == nil else { return } + do { + guard let remote = try await client.fetchSettings(token: token, as: SettingsDocument.self) else { return } + if remote.version != lastSyncedVersion { + applyRemote(remote) + Log.sync.info("已套用云端设置,版本 \(remote.version)") + } + if case .failed = phase { phase = .idle } + } catch { + handle(error) + } + } + + private func applyRemote(_ remote: RemoteSettings) { + // 先记下再套用:套用触发的观察回调会发现内容一致,不会再推回去 + lastKnownDocument = remote.document + lastSyncedVersion = remote.version + lastSyncedAt = remote.updatedAt + settings.apply(remote.document) + // 文档里不认识的值被跳过后本机内容可能与云端不完全一致,以本机实际内容为准避免反复推送 + lastKnownDocument = settings.exportDocument() + } + + private func handle(_ error: Error) { + if let syncError = error as? SyncError, syncError == .unauthorized { + Log.sync.error("令牌已失效") + clearLocalSession() + phase = .failed(syncError.localizedDescription) + return + } + Log.sync.error("同步失败:\(error.localizedDescription, privacy: .public)") + phase = .failed(error.localizedDescription) + } + + static var deviceName: String { + Host.current().localizedName ?? "Mac" + } + + private enum Keys { + static let user = "syncUser" + static let lastSyncedAt = "syncLastSyncedAt" + static let lastSyncedVersion = "syncLastSyncedVersion" + static let lastKnownDocument = "syncLastKnownDocument" + } +} + +/// 登录窗口需要一个挂靠的窗口:优先主窗口,其次当前主窗口 +private final class AnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + private let anchor: @MainActor () -> NSWindow? + + init(anchor: @escaping @MainActor () -> NSWindow?) { + self.anchor = anchor + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + MainActor.assumeIsolated { + anchor() ?? NSApp.keyWindow ?? NSApp.windows.first(where: \.isVisible) ?? NSWindow() + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift index 9bad7c1..8997c7a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift @@ -18,7 +18,6 @@ public final class UninstallerController { public private(set) var isRemoving = false public private(set) var outcome: (text: String, isError: Bool)? var chosen: Set = [] - var removeFromDock = true /// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新 private var runningRevision = 0 @ObservationIgnored private var observers: [NSObjectProtocol] = [] @@ -121,8 +120,8 @@ public final class UninstallerController { self.outcome = (tr("没有移动任何文件\(message.map { tr(":\($0)") } ?? "")"), true) return } - var dock = false - if self.removeFromDock { dock = Self.removeDockTile(for: app.url) } + // 应用已经进了废纸篓,程序坞里的图标只会变成问号,一并移除 + let dock = Self.removeDockTile(for: app.url) Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),移到废纸篓 \(movedCount) 项") let partial = movedCount < urls.count ? tr(",\(urls.count - movedCount) 项未能移动") : "" self.outcome = (tr("已将 \(app.name) 与 \(movedCount - 1) 项残留移到废纸篓,约 \(Format.bytes(freed, base: .decimal))\(dock ? tr(",已从程序坞移除") : "")\(partial)。需要时可以在废纸篓里放回。"), diff --git a/Packages/OpenStatsKit/Tests/AccountSyncTests/AccountSyncTests.swift b/Packages/OpenStatsKit/Tests/AccountSyncTests/AccountSyncTests.swift new file mode 100644 index 0000000..4ad4ea2 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/AccountSyncTests/AccountSyncTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import AccountSync + +@Suite struct PKCETests { + @Test func challengeMatchesRFCExample() { + // RFC 7636 附录 B + #expect(PKCE.challenge(for: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") + } + + @Test func verifierIsURLSafeAndUnique() { + let a = PKCE.verifier(), b = PKCE.verifier() + #expect(a != b) + #expect(a.count == 43) + #expect(a.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }) + } +} + +@Suite struct SyncClientTests { + @Test func buildsLoginURL() throws { + let client = SyncClient(baseURL: URL(string: "https://sync.test/api/v1")!) + let url = client.loginURL(provider: .github, challenge: "abc") + #expect(url.absoluteString == "https://sync.test/api/v1/auth/github/start?challenge=abc") + } + + @Test func parsesCallback() { + #expect(SyncClient.parseCallback(URL(string: "openstats://auth/callback?code=xyz")!) == .success("xyz")) + #expect(SyncClient.parseCallback(URL(string: "openstats://auth/callback?error=cancelled")!) == .failure(.cancelled)) + #expect(SyncClient.parseCallback(URL(string: "openstats://auth/callback?error=bad")!) == .failure(.server(0, "bad"))) + #expect(SyncClient.parseCallback(URL(string: "https://evil.test/?code=xyz")!) == .failure(.invalidCallback)) + } + + @Test func decodesRemoteSettings() throws { + struct Doc: Codable, Equatable, Sendable { var refreshSeconds: Int } + let json = #"{"version":3,"updatedAt":"2026-09-15T08:00:00Z","device":"MacBook","document":{"refreshSeconds":5}}"# + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let remote = try decoder.decode(RemoteSettings.self, from: Data(json.utf8)) + #expect(remote.version == 3) + #expect(remote.device == "MacBook") + #expect(remote.document == Doc(refreshSeconds: 5)) + #expect(remote.updatedAt == Date(timeIntervalSince1970: 1_789_459_200)) + } + + @Test func memoryTokenStoreRoundTrips() throws { + let store = MemoryTokenStore() + #expect(store.read() == nil) + try store.write("t") + #expect(store.read() == "t") + store.delete() + #expect(store.read() == nil) + } +} diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/DiskToolsTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/DiskToolsTests.swift new file mode 100644 index 0000000..0b8643c --- /dev/null +++ b/Packages/OpenStatsKit/Tests/CleanerTests/DiskToolsTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import Cleaner + +@Suite struct LocalSnapshotsTests { + @Test func parsesTmutilOutput() { + let output = """ + Snapshots for disk /: + com.apple.TimeMachine.2026-09-14-120000.local + com.apple.TimeMachine.2026-09-15-083015.local + com.apple.os.update-ABC + """ + let snapshots = LocalSnapshots.parse(output) + #expect(snapshots.map(\.identifier) == ["2026-09-15-083015", "2026-09-14-120000"]) + #expect(snapshots.allSatisfy { $0.date != nil }) + #expect(LocalSnapshots.parse("Snapshots for disk /:\n").isEmpty) + } +} + +@Suite struct VolumeVerifierTests { + @Test func interpretsDiskutilOutput() { + let ok = VolumeVerifier.interpret(status: 0, output: "Checking the fsroot tree\nThe volume /dev/rdisk3s1 appears to be OK\nFile system check exit code is 0\n") + #expect(ok.ok) + let bad = VolumeVerifier.interpret(status: 8, output: "Checking the fsroot tree\nerror: btn: invalid key order\nFile system check exit code is 8\n") + #expect(!bad.ok) + #expect(bad.summary.contains("invalid key order")) + } +} + +@Suite struct SpaceScannerTests { + private func scratch() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("openstats-space-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(_ url: URL, bytes: Int) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(repeating: 0xAB, count: bytes).write(to: url) + } + + @Test func sizesFoldersAndFindsLargestFiles() throws { + let root = try scratch() + defer { try? FileManager.default.removeItem(at: root) } + let big = 60_000_000 + try write(root.appendingPathComponent("Movies/holiday.mov"), bytes: big) + try write(root.appendingPathComponent("Documents/notes.txt"), bytes: 1_000) + try write(root.appendingPathComponent("Documents/Photos.photoslibrary/data/1.jpg"), bytes: big) + try write(root.appendingPathComponent("Documents/Photos.photoslibrary/data/2.jpg"), bytes: big) + try write(root.appendingPathComponent("loose.bin"), bytes: big) + + nonisolated(unsafe) var reports = 0 + let result = try SpaceScanner.scan(root: root) { _ in reports += 1 } + #expect(result.folders.first?.name == "Documents") + #expect(result.folders.map(\.name).contains("Movies")) + #expect(result.totalBytes >= UInt64(4 * big)) + #expect(reports > 0) + // 图库按整体算一个,里面的照片不单独出现 + let names = result.largestFiles.map(\.name) + #expect(names.first == "Photos.photoslibrary") + #expect(names.contains("holiday.mov") && names.contains("loose.bin")) + #expect(!names.contains("1.jpg")) + #expect(!names.contains("notes.txt")) + } + + @Test func trashRulesStayInsideHomeAndAwayFromLibrary() { + let home = URL(fileURLWithPath: "/Users/tester") + #expect(SpaceScanner.canTrash(home.appendingPathComponent("Downloads/big.dmg"), home: home)) + #expect(SpaceScanner.canTrash(home.appendingPathComponent("Movies/a/b.mov"), home: home)) + #expect(!SpaceScanner.canTrash(home.appendingPathComponent("Library/Caches/x"), home: home)) + #expect(!SpaceScanner.canTrash(home.appendingPathComponent("Downloads"), home: home)) + #expect(!SpaceScanner.canTrash(home.appendingPathComponent(".docker/data.raw"), home: home)) + #expect(!SpaceScanner.canTrash(URL(fileURLWithPath: "/Applications/Xcode.app"), home: home)) + #expect(!SpaceScanner.canTrash(home.appendingPathComponent("Downloads/../../other/file"), home: home)) + } +} diff --git a/Packages/OpenStatsKit/Tests/HelperSharedTests/LocalSnapshotCommandTests.swift b/Packages/OpenStatsKit/Tests/HelperSharedTests/LocalSnapshotCommandTests.swift new file mode 100644 index 0000000..30ef15e --- /dev/null +++ b/Packages/OpenStatsKit/Tests/HelperSharedTests/LocalSnapshotCommandTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import HelperShared + +@Suite struct LocalSnapshotCommandTests { + @Test func acceptsOnlyDateIdentifiers() { + #expect(LocalSnapshotCommand.isValidIdentifier("2026-09-14-120000")) + #expect(!LocalSnapshotCommand.isValidIdentifier("2026-09-14")) + #expect(!LocalSnapshotCommand.isValidIdentifier("2026-09-14-120000; rm -rf /")) + #expect(!LocalSnapshotCommand.isValidIdentifier("2026-09-14-12000a")) + #expect(LocalSnapshotCommand.shellCommand(identifiers: ["2026-09-14-120000"]) == "/usr/bin/tmutil deletelocalsnapshots 2026-09-14-120000") + } +} diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift index 624fd55..287d93a 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift @@ -172,27 +172,60 @@ struct LiveSamplerTests { } @Suite struct GeoParsingTests { - @Test func parsesIpinfoResponse() throws { - let json = #"{"ip":"82.139.234.155","city":"Frankfurt am Main","country":"DE","org":"AS151338 POLONETWORK LIMITED"}"# - let result = try #require(PublicAddressLookup.parseGeo(Data(json.utf8))) - #expect(result.ipv4 == "82.139.234.155") - #expect(result.ipv6 == nil) - #expect(result.countryCode == "DE") - #expect(result.city == "Frankfurt am Main") - #expect(result.asn == "AS151338") - #expect(result.organization == "POLONETWORK LIMITED") + @Test func parsesIpapiResponse() throws { + let json = #"{"ip":"32.5.140.2","is_bogon":false,"company":"AT&T Global Network Services, LLC","asn":"AS7018 AT&T Enterprises, LLC","city":"New York City","region":"New York","country":"United States","lat":40.7,"lon":-74.0,"timezone":"America/New_York"}"# + let info = try #require(PublicAddressLookup.parseGeo(Data(json.utf8))) + #expect(info.countryCode == "US") + #expect(info.city == "New York City") + #expect(info.region == "New York") + #expect(info.asn == "AS7018") + #expect(info.organization == "AT&T Global Network Services, LLC") + } + + @Test func fallsBackToASNOrganizationAndRejectsBadCountry() throws { + let json = #"{"ip":"2606:4700:4700::1111","asn":"Cloudflare","country":"../../etc"}"# + let info = try #require(PublicAddressLookup.parseGeo(Data(json.utf8))) + #expect(info.countryCode == nil) + #expect(info.asn == nil) + #expect(info.organization == "Cloudflare") + } + + @Test func parsesCleanIPResponse() throws { + let json = #"{"ok":true,"ip":"32.5.140.2","hostname":"host.example.net","geo":{"country":"美国","country_code":"US","country_en":"United States","region":"佛罗里达州","city":"玛丽湖"},"geo_sources":[{"source":"maxmind","country_code":"US"},{"source":"ipinfo","region":"Florida","city":"Lake Mary"}],"network":{"asn":7018,"asn_name":"AT&T Enterprises, LLC","isp":"AT&T Enterprises, LLC","network_type":"Residential","connection_type":"Cable/DSL","native_or_broadcast":"NATIVE"},"risk":{"risk_score":2,"risk_label":"Very Clean","is_vpn":false,"is_proxy":false,"is_datacenter":false,"is_abuser":true},"purity":{"score":98,"grade":"A+","confidence":100,"ip_type":"Residential IP","is_native":true,"residential_probability":98,"recommendation":"该 IP 整体较干净"}}"# + let info = try #require(PublicAddressLookup.parseCleanIP(Data(json.utf8))) + #expect(info.countryCode == "US") + #expect(info.city == "玛丽湖" && info.cityEnglish == "Lake Mary" && info.regionEnglish == "Florida") + #expect(info.asn == "AS7018") + #expect(info.organization == "AT&T Enterprises, LLC") + #expect(info.hostname == "host.example.net") + #expect(info.networkType == "Residential" && info.connectionType == "Cable/DSL") + #expect(info.ipType == "Residential IP" && info.isNative == true && info.residentialProbability == 98) + #expect(info.purity == PublicAddresses.Purity(score: 98, grade: "A+", confidence: 100, recommendation: "该 IP 整体较干净")) + #expect(info.risk == PublicAddresses.Risk(score: 2, label: "Very Clean", flags: ["abuser"])) + #expect(info.reportURL?.absoluteString == "https://cleanip.io/32.5.140.2") + #expect(PublicAddressLookup.parseCleanIP(Data(#"{"ok":false,"error":"bad"}"#.utf8)) == nil) + } + + @Test func parsesDBIPAndIpinfo() throws { + let dbip = try #require(PublicAddressLookup.parseDBIP(Data(#"{"ipAddress":"32.5.140.2","countryCode":"US","stateProv":"New York","city":"New York"}"#.utf8))) + #expect(dbip.countryCode == "US" && dbip.region == "New York" && dbip.city == "New York" && dbip.asn == nil) + let ipinfo = try #require(PublicAddressLookup.parseIpinfo(Data(#"{"ip":"82.139.234.155","city":"Frankfurt am Main","region":"Hesse","country":"DE","org":"AS151338 POLONETWORK LIMITED"}"#.utf8))) + #expect(ipinfo.countryCode == "DE" && ipinfo.city == "Frankfurt am Main") + #expect(ipinfo.asn == "AS151338" && ipinfo.organization == "POLONETWORK LIMITED") + } + + @Test func mapsCountryNames() { + #expect(PublicAddressLookup.countryCode(fromName: "Germany") == "DE") + #expect(PublicAddressLookup.countryCode(fromName: "China") == "CN") + #expect(PublicAddressLookup.countryCode(fromName: "Hong Kong") == "HK") + #expect(PublicAddressLookup.countryCode(fromName: "south korea") == "KR") + #expect(PublicAddressLookup.countryCode(fromName: "Atlantis") == nil) } @Test func rejectsUnsafeCountryCode() { #expect(PublicAddressLookup.validCountryCode("cn") == "CN") #expect(PublicAddressLookup.validCountryCode("../x") == nil) #expect(PublicAddressLookup.validCountryCode("USA") == nil) - let json = #"{"ip":"2606:4700:4700::1111","country":"../../etc","org":"Cloudflare"}"# - let result = PublicAddressLookup.parseGeo(Data(json.utf8)) - #expect(result?.ipv6 == "2606:4700:4700::1111") - #expect(result?.countryCode == nil) - #expect(result?.asn == nil) - #expect(result?.organization == "Cloudflare") } } diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift index 6ce8b00..1f191c2 100644 --- a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift @@ -1,5 +1,5 @@ import Foundation -import Metrics +@testable import Metrics import Testing @testable import OpenStatsUI @@ -21,6 +21,10 @@ private func isolatedDefaults() -> UserDefaults { #expect(settings.panelTab == .overview) #expect(settings.menuBarItems == [.cpu, .memory, .network]) #expect(settings.probeEnabled && settings.probeInBackground && settings.autoCheckUpdates) + // 从未调整过弹窗区块时,默认隐藏的区块生效;走势默认 1 分钟 + #expect(settings.hiddenPopoverSections == PopoverSection.hiddenByDefault) + #expect(!settings.isVisible(.cpuCores) && settings.isVisible(.cpuHeatmap)) + #expect(settings.cpuChartSeconds == 60) } @Test func persistsChanges() { @@ -190,6 +194,43 @@ private func isolatedDefaults() -> UserDefaults { } } +@Suite struct CoreRoleTests { + @Test func describesEachTierInPlainWords() { + let fast = CPUCluster(id: 0, name: "超级核", coreIndices: [12, 13]) + let middle = CPUCluster(id: 1, name: "性能核", coreIndices: [4, 5]) + let slow = CPUCluster(id: 2, name: "能效核", coreIndices: [0, 1]) + let three = CPUTopology(brand: "Apple", logicalCores: 6, clusters: [fast, middle, slow]) + #expect(fast.role(in: three) == "最快,重活优先交给它们") + #expect(middle.role(in: three) == "速度与省电介于两者之间") + #expect(slow.role(in: three) == "更省电,负责后台和轻量任务") + // 只有一类核心(Intel)时没有分工可言 + let single = CPUTopology(brand: "Intel", logicalCores: 8, clusters: [CPUCluster(id: 0, name: "核心", coreIndices: Array(0..<8))]) + #expect(single.clusters[0].role(in: single) == nil) + } +} + +@Suite struct TimedLineChartTests { + @Test func positionsByTimeAndBreaksAtGaps() { + let end = Date(timeIntervalSince1970: 1_000) + let points = [ + TimedValue(date: end.addingTimeInterval(-70), value: 0.9), // 超出时长,不画 + TimedValue(date: end.addingTimeInterval(-60), value: 0), + TimedValue(date: end.addingTimeInterval(-55), value: 1), + TimedValue(date: end.addingTimeInterval(-50), value: 0.5), + TimedValue(date: end.addingTimeInterval(-10), value: 0.5), // 与上一点隔了 40 秒(睡眠),断开 + TimedValue(date: end.addingTimeInterval(-5), value: 0.5), + TimedValue(date: end, value: 0.5), + ] + let segments = TimedLineChart.segments(points, duration: 60, end: end, in: CGSize(width: 120, height: 40)) + #expect(segments.count == 2) + #expect(segments[0].map(\.x) == [0, 10, 20]) + #expect(segments[1].map(\.x) == [100, 110, 120]) + // 0 在底、1 在顶,越大越靠上 + #expect(segments[0][0].y > segments[0][2].y && segments[0][2].y > segments[0][1].y) + #expect(TimedLineChart.segments([], duration: 60, end: end, in: CGSize(width: 120, height: 40)).isEmpty) + } +} + @Suite struct AlertTrackerTests { @Test func firesOnceAfterSustainAndRespectsCooldown() { var tracker = AlertTracker() @@ -250,3 +291,99 @@ private func isolatedDefaults() -> UserDefaults { #expect(HotKey(keyCode: 0x7A, modifiers: [.command], key: "\u{F704}").display == "⌘F1") } } + +@MainActor +@Suite struct SettingsDocumentTests { + @Test func exportsAndAppliesRoundTrip() { + let source = AppSettings(defaults: isolatedDefaults()) + source.menuBarItems = [.gpu, .temperature] + source.menuBarStyle = .ring + source.styleOverrides = [.gpu: .pie] + source.refreshSeconds = 5 + source.useFahrenheit = true + source.hiddenPopoverSections = [.cpuHeatmap, .networkDNS] + source.probeTarget = .aliyun + source.enabledAlerts = [.cpuLoad] + source.alertCPULoad = 90 + source.hotKeys = [.showProcesses: HotKey(keyCode: 35, modifiers: [.command, .option], key: "p")] + source.panelTab = .thermal + + let document = source.exportDocument() + let target = AppSettings(defaults: isolatedDefaults()) + target.panelTab = .cleaner + target.apply(document) + + #expect(target.menuBarItems == [.gpu, .temperature]) + #expect(target.menuBarStyle == .ring) + #expect(target.styleOverrides == [.gpu: .pie]) + #expect(target.refreshSeconds == 5) + #expect(target.useFahrenheit) + #expect(target.hiddenPopoverSections == [.cpuHeatmap, .networkDNS]) + #expect(target.probeTarget == .aliyun) + #expect(target.enabledAlerts == [.cpuLoad]) + #expect(target.alertCPULoad == 90) + #expect(target.hotKeys[.showProcesses]?.keyCode == 35) + // 当前页签属于本机状态,不随文档走 + #expect(target.panelTab == .cleaner) + #expect(target.exportDocument() == document) + } + + @Test func skipsUnknownAndInvalidValues() { + let settings = AppSettings(defaults: isolatedDefaults()) + var document = SettingsDocument() + document.menuBarItems = ["cpu", "hologram"] + document.menuBarStyle = "neon" + document.refreshSeconds = 7 + document.probeTarget = "mars" + document.language = "english" + settings.apply(document) + #expect(settings.menuBarItems == [.cpu]) + #expect(settings.menuBarStyle == .stacked) + #expect(settings.refreshSeconds == 2) + #expect(settings.probeTarget == .cloudflare) + #expect(settings.language == .english) + settings.language = .system + } + + @Test func documentSurvivesJSONWithMissingFields() throws { + let data = Data(#"{"schema":1,"refreshSeconds":3,"futureField":true}"#.utf8) + let document = try JSONDecoder().decode(SettingsDocument.self, from: data) + #expect(document.refreshSeconds == 3) + #expect(document.menuBarItems == nil) + let settings = AppSettings(defaults: isolatedDefaults()) + settings.apply(document) + #expect(settings.refreshSeconds == 3) + #expect(settings.menuBarItems == [.cpu, .memory, .network]) + } + + @Test func freshSettingsEqualDefaultDocument() { + let settings = AppSettings(defaults: isolatedDefaults()) + #expect(settings.exportDocument() == AppSettings.defaultDocument()) + settings.colorizeHighLoad = true + #expect(settings.exportDocument() != AppSettings.defaultDocument()) + } +} + +@Suite struct ActivityRankingTests { + @Test func smoothsAndKeepsRecentlyActiveEntries() { + var ranking = ActivityRanking() + ranking.update(["a": 10_000, "b": 100_000]) + #expect(ranking.ranked(limit: 5).map(\.id) == ["b", "a"]) + // b 突然安静,a 继续:b 的分数衰减但仍在榜上,a 慢慢追上 + for _ in 0..<3 { ranking.update(["a": 10_000]) } + let entries = ranking.ranked(limit: 5) + #expect(entries.map(\.id).contains("b")) + #expect(entries.first?.id == "b") + for _ in 0..<6 { ranking.update(["a": 10_000]) } + #expect(ranking.ranked(limit: 5).first?.id == "a") + // 长时间没有活动后从榜上移除 + for _ in 0..<40 { ranking.update([:]) } + #expect(ranking.isEmpty) + } + + @Test func limitsAndBreaksTiesByID() { + var ranking = ActivityRanking() + ranking.update(["x": 5_000, "y": 5_000, "z": 9_000]) + #expect(ranking.ranked(limit: 2).map(\.id) == ["z", "x"]) + } +} diff --git a/README.md b/README.md index 47235fd..08b8561 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ OpenStats is a macOS menu-bar app that shows what your Mac is doing right now — per-core CPU load, GPU, memory pressure, network speed, disk, battery, temperatures and fans — and lets you act on it: spin the fans up, keep the Mac awake with the lid closed, clear caches. -Everything is read on your own Mac. No account, no telemetry. The only network traffic is optional and on demand: a public-IP lookup when you open network details, and a ping probe to a target you choose. +Everything is read on your own Mac, and there is no telemetry. An account is optional: sign in with GitHub, Google or Apple to sync your preferences to other Macs; the cloud keeps only your email, name and the settings document. Beyond that, the only network traffic is optional and on demand: a public-IP lookup when you open network details, and a ping probe to a target you choose. [Download](https://getopenstats.com/#download) · [Website](https://getopenstats.com) · @@ -47,84 +47,55 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -Latest release **0.2.0** (2026-09-13) · **6** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) +Latest release **0.2.0** (2026-09-13) · **31** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
-
2026-09-13 · Unreleased · 3 added · 3 changed - -**Added** - -- 在线升级:启动时和之后每天检查官网版本清单,发现新版本时弹出版本号与更新摘要,可一键安装、以后再说或跳过此版本;下载后依次核对 sha256、包名与版本、开发者签名团队和 Apple 公证,任何一项不通过都放弃安装;旧版先备份再原地替换,失败自动还原,装完自动重启到新版。「设置 · 关于」与应用菜单里可以手动检查更新。 -- 辅助工具版本检查:启动时核对正在运行的辅助工具版本;旧进程还在运行时先断开让它退出,由系统启动新版本,仍然旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。 -- 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。 +2026-09-15 · Unreleased · 1 changed · 1 fixed · 1 added **Changed** -- 连接探测更省电:打开网络详情时按设置的间隔探测;详情关闭、只在菜单栏显示网速时改为每 10 秒低频探测,并允许系统合并唤醒,也可以在设置里关闭后台探测。 -- 进程页每 2 秒刷新一次(读取全系统进程需要启动 ps),其他页面仍为每秒。 -- 菜单栏图标用 ⌘ 拖动调整的顺序会被记住,重启应用或切换显示项目后保持不变。 +- 「设置 · 菜单栏」的显示项目上方加了一条说明:按住 ⌘ 拖动可以调整菜单栏图标的顺序,新开启的项目由系统安排位置;各页面右上角的开关悬停时也有同样的提示。官网常见问题同步补充。 + +**Fixed** + +- 各监控页右上角的“在菜单栏显示”开关点不动:页面的滚动区域会自动向上延伸到顶栏底下,把开关的点击截走了;现在顶栏盖在滚动区域之上,GPU、磁盘等页面的开关可以正常点击。 + +**Added** + +- 账号与设置同步(设置 · 账号与同步):用 GitHub、Google 或 Apple 登录后,菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限会保存到 getopenstats.com,换一台 Mac 登录即自动恢复。设置改动 2 秒后上传,启动、唤醒与每 15 分钟检查一次云端;第一次登录时本机与云端都有内容且不同,会让你选用哪一份,之后以最后写入为准。登录走系统的授权窗口,令牌存在钥匙串,云端只保存邮箱、姓名与设置文档,可随时退出或删除云端数据。不登录时应用行为不变。
-2026-09-13 · 0.2.0 · 30 added · 12 style · 6 fixed +2026-09-15 · Unreleased · 3 added · 4 changed **Added** -- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。 -- 菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。 -- 网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。 -- 鼠标悬停在菜单栏图标上显示完整读数。 -- 菜单栏布局:每项独立(每个指标一个图标,点击弹出该项的窄详情)或合并为一个(点击打开主窗口)。每个详情里显示哪些区块可以在设置中逐个勾选。 -- 详情弹窗:CPU(负载历史、各核心、处理器信息、高占用进程)、内存(使用历史、内存构成与释放内存、高占用进程)、GPU、温度与风扇(各组温度、风扇转速与快捷模式)。 -- 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。 -- DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。 -- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。 -- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。 -- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。 -- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。 -- IP 归属地改为本地 MaxMind GeoLite2 数据库查询:自己实现 .mmdb 读取器(不依赖第三方库),国家、城市与 ASN 完全离线查;数据库由官网服务器每周二、周五用 MaxMind 账号同步,应用每 3 天检查清单、下载并按 sha256 校验后替换,也可以从文件导入;设置中可选是否包含城市库(约 60 MB);数据库就绪前暂用 ipinfo.io 在线查询,网络详情里标明数据来源。 -- 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。 -- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。 -- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。 -- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 -- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 -- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 -- 按界面需要分级采样:只显示菜单栏时只采集菜单栏用到的指标,打开详情或主窗口时按页面加采,锁屏、屏幕休眠和系统睡眠时暂停。 -- 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。 -- 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。 -- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存、为网络服务设置 DNS 几个固定操作(DNS 地址与服务名在辅助工具内再次校验);运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。 -- 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 -- 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。 -- 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。 -- 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。 -- 设置(主窗口侧边栏的“设置”分组):外观、登录时启动、刷新频率、温度单位、菜单栏布局 / 显示项 / 样式 / 弹窗内容、连接探测与公网 IP 查询、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 -- 统一外观:浅色为白底蓝色、深色为黑底蓝色,主窗口与弹窗共用同一底色;侧边栏、标题栏与页面不分栏着色,红绿灯按钮与页面标题对齐;卡片为浅灰 / 深灰实色。侧边栏的太阳 / 月亮按钮一键切换,也可以跟随系统。 -- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染主窗口各页、详情弹窗、设置和菜单栏的浅色与深色截图,IP 与硬件地址替换为示例值。 +- CPU 走势时长可选 1 / 3 / 5 分钟:走势线上方一行小字切换,弹窗与主窗口共用;曲线按真实采样时间定位,最新一次采样固定在右边缘,睡眠等超过 10 秒没有采样的地方线条断开。 +- 菜单栏新增“磁盘”项目:显示启动磁盘已用占比,八种风格都可用;点击弹出磁盘详情(容量与可用空间、读写速度、SSD 健康、读写最多的应用),区块可在设置里逐个隐藏。主窗口磁盘页右上角同样可以直接开关,“温度与风扇”页右上角现在有温度、风扇两个开关。 +- CPU 详情新增“各核心占用”区块:主窗口里每个核心一个小圆环,中间写百分比,按“核心 1…N”编号并按类型分组,超过 60% 变橙、85% 变红;弹窗里是每核一根柱子的紧凑版,默认关闭,可在“设置 · 菜单栏”的 CPU 弹窗显示里打开。 -**Style** +**Changed** -- 网络详情的流量历史:上传 / 下载峰值移到标题行(“60 秒峰值 ↑ … ↓ …”,箭头按上传绿、下载蓝着色),图表上不再压文字。 -- 设置并入主窗口:侧边栏新增“设置”分组(通用、菜单栏、辅助工具、关于);网络设置(连接探测、公网 IP、IP 归属地数据库)放到“网络”页底部,风扇安全温度放到“温度与风扇”页底部,合盖运行电量下限放到“防休眠”页底部;弹窗齿轮、菜单栏右键“设置…”与 ⌘, 都打开主窗口的通用设置;不再有单独的设置窗口,已有偏好全部保留;导航较多时侧边栏可滚动。 -- 主窗口侧边栏的选中样式:左侧一条两端渐隐的细线,选中项有发光的蓝色光条,切换时带回弹地滑过去,右侧拖出渐淡的蓝色高亮;不再整块填蓝。 -- 仪表盘“快捷开关”改为开关样式:防休眠、合盖运行、散热模式各一行,右侧开关,下面一行小字说明当前状态。 -- 进程表按窗口宽度自动隐藏次要列,窄窗口里名称列不会被挤没。 -- 内存详情的“释放内存”移到标题栏,执行中与完成后在按钮上显示进度和结果;主窗口内存页顶栏同样提供。 -- 主窗口默认 1072×720,可自由调整大小;滚动条改为浮层样式,只在滚动时出现。 -- 菜单栏字号缩小并统一:两行布局为 7pt 标签加 10pt 数值,单行布局 11pt,网速两行 9pt;每种风格内部只用一套字号,指标之间留出更宽的间距。 -- 柱状历史去掉满高的灰色底槽,只保留一条淡基线,负载起伏更清楚。 -- 面板加宽为三列布局,高度按内容自动计算,常见屏幕上不需要滚动。 -- 更换应用图标,面板左上角和关于页使用同一图标。 -- 首次启动默认在菜单栏显示 CPU、内存与网速。 +- 卸载应用:去掉“同时从程序坞移除图标”开关,卸载时始终移除程序坞图标;应用列表的滚动条改为只在滚动时显示的细条(之前接了鼠标会显示一条粗的传统滚动条),升级说明里的滚动条同样修正。 +- 主窗口的 CPU 走势图加上 0–100% 刻度与起点、中点、现在三个时间标签。 +- CPU 详情的解释性文字不再常驻:热力图怎么看、每类核心是干什么的、平均负载怎么理解、按应用汇总为什么会超过 100%,都收进区块标题旁的 ⓘ,鼠标悬停 ⓘ 或卡片内容时才显示,界面上只留数据。 +- 核心类型说人话:核心分工的悬停说明写明每类核心是干什么的(最快的处理重活、更省电的负责后台和轻量任务,系统自动分配);各核心占用的分组标题、热力图的行标签都带上核心数量,热力图说明写明“每行一个核心(共 N 个),颜色越深越忙”;最忙的核心改为“核心 7(性能核)”这种统一编号。 -**Fixed** +
-- 深色菜单栏上 CPU、RAM 小标签颜色过深,几乎看不清。 -- 面板打开后高度会再调整一次、内容轻微回弹,看起来在抖动;面板打开时点击菜单栏图标关闭,会立刻又被打开。 -- 风扇被其他程序设为手动模式时,误显示为由 OpenStats 控制。 -- 风扇转速、PID 等数字出现千分位分隔符,例如 1,350 RPM。 -- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。 -- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。 +
+2026-09-14 · Unreleased · 2 added · 2 changed + +**Added** + +- 系统通知新增“CPU 持续高负载”:总占用持续 1 分钟高于设定值(70% / 80% / 90%)时提醒,点通知打开 CPU 详情。 +- 菜单栏风格新增“折线历史”:最近 30 次采样(约 1 分钟)的走势折线,线下填淡色;设置里有示例预览。 + +**Changed** + +- CPU 详情重新整理:顶部直接显示 CPU 温度(下方附离 100°C 的余量,不再把余量当主数值),走势线下标出最近 60 秒的峰值;核心热力图右侧加一根粗条画此刻各核心的占用;核心分工标出芯片型号,频率跟在占用后面用次要颜色显示;按应用汇总注明“以单核满载为 100%”,解释各应用相加为什么会超过顶部的总占用。 +- 主窗口的 CPU 页:走势线加高并带 25% / 50% / 75% 参考线,核心分工与排队程度并排显示,不再整页单列拉长。
@@ -247,7 +218,7 @@ Settings → Network: - **Public IP**: when you open network details, one request to Cloudflare `1.1.1.1` (ipify as fallback) for your public address, cached for 10 minutes. Region and ASN are looked up on the Mac in a MaxMind GeoLite2 database downloaded from `getopenstats.com` and verified by sha256; until it is downloaded, - `ipinfo.io` answers instead. + `ipapi.is` answers instead (anonymous, queried directly from each Mac, cached per IP for a day). - **Connection probe**: an ICMP ping to the target you pick (Cloudflare, Google, Alibaba Cloud, Tencent or your router) every 1, 2 or 5 seconds, only while the network item is in the menu bar or network details are open. diff --git a/README.zh-CN.md b/README.zh-CN.md index 716d269..b8060ba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@ OpenStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各核心 CPU 负载、GPU、内存压力、 网速、磁盘、电池、温度和风扇,并且可以直接处理:给风扇提速、合盖后继续运行、清理缓存。 -所有数据都在你自己的 Mac 上读取。无需注册账号,没有统计上报。只有两项可关闭的网络功能会联网:打开网络详情时查询公网 IP,以及定时 ping 你选择的探测目标。 +所有数据都在你自己的 Mac 上读取,没有统计上报。账号是可选的:用 GitHub、Google 或 Apple 登录后可以把偏好设置同步到其他 Mac,云端只保存邮箱、姓名与设置文档。此外只有两项可关闭的网络功能会联网:打开网络详情时查询公网 IP,以及定时 ping 你选择的探测目标。 [下载](https://getopenstats.com/#download) · [官网](https://getopenstats.com) · @@ -46,84 +46,55 @@ brew install --cask gentpan/tap/openstats -最新版本 **0.2.0**(2026-09-13) · 开发中 **6** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +最新版本 **0.2.0**(2026-09-13) · 开发中 **31** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
-2026-09-13 · 未发布 · 新增 3 · 调整 3 - -**新增** - -- 在线升级:启动时和之后每天检查官网版本清单,发现新版本时弹出版本号与更新摘要,可一键安装、以后再说或跳过此版本;下载后依次核对 sha256、包名与版本、开发者签名团队和 Apple 公证,任何一项不通过都放弃安装;旧版先备份再原地替换,失败自动还原,装完自动重启到新版。「设置 · 关于」与应用菜单里可以手动检查更新。 -- 辅助工具版本检查:启动时核对正在运行的辅助工具版本;旧进程还在运行时先断开让它退出,由系统启动新版本,仍然旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。 -- 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。 +2026-09-15 · 未发布 · 调整 1 · 修复 1 · 新增 1 **调整** -- 连接探测更省电:打开网络详情时按设置的间隔探测;详情关闭、只在菜单栏显示网速时改为每 10 秒低频探测,并允许系统合并唤醒,也可以在设置里关闭后台探测。 -- 进程页每 2 秒刷新一次(读取全系统进程需要启动 ps),其他页面仍为每秒。 -- 菜单栏图标用 ⌘ 拖动调整的顺序会被记住,重启应用或切换显示项目后保持不变。 +- 「设置 · 菜单栏」的显示项目上方加了一条说明:按住 ⌘ 拖动可以调整菜单栏图标的顺序,新开启的项目由系统安排位置;各页面右上角的开关悬停时也有同样的提示。官网常见问题同步补充。 + +**修复** + +- 各监控页右上角的“在菜单栏显示”开关点不动:页面的滚动区域会自动向上延伸到顶栏底下,把开关的点击截走了;现在顶栏盖在滚动区域之上,GPU、磁盘等页面的开关可以正常点击。 + +**新增** + +- 账号与设置同步(设置 · 账号与同步):用 GitHub、Google 或 Apple 登录后,菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限会保存到 getopenstats.com,换一台 Mac 登录即自动恢复。设置改动 2 秒后上传,启动、唤醒与每 15 分钟检查一次云端;第一次登录时本机与云端都有内容且不同,会让你选用哪一份,之后以最后写入为准。登录走系统的授权窗口,令牌存在钥匙串,云端只保存邮箱、姓名与设置文档,可随时退出或删除云端数据。不登录时应用行为不变。
-2026-09-13 · 0.2.0 · 新增 30 · 样式 12 · 修复 6 +2026-09-15 · 未发布 · 新增 3 · 调整 4 **新增** -- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。 -- 菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。 -- 网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。 -- 鼠标悬停在菜单栏图标上显示完整读数。 -- 菜单栏布局:每项独立(每个指标一个图标,点击弹出该项的窄详情)或合并为一个(点击打开主窗口)。每个详情里显示哪些区块可以在设置中逐个勾选。 -- 详情弹窗:CPU(负载历史、各核心、处理器信息、高占用进程)、内存(使用历史、内存构成与释放内存、高占用进程)、GPU、温度与风扇(各组温度、风扇转速与快捷模式)。 -- 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。 -- DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。 -- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。 -- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。 -- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。 -- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。 -- IP 归属地改为本地 MaxMind GeoLite2 数据库查询:自己实现 .mmdb 读取器(不依赖第三方库),国家、城市与 ASN 完全离线查;数据库由官网服务器每周二、周五用 MaxMind 账号同步,应用每 3 天检查清单、下载并按 sha256 校验后替换,也可以从文件导入;设置中可选是否包含城市库(约 60 MB);数据库就绪前暂用 ipinfo.io 在线查询,网络详情里标明数据来源。 -- 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。 -- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。 -- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。 -- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 -- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 -- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 -- 按界面需要分级采样:只显示菜单栏时只采集菜单栏用到的指标,打开详情或主窗口时按页面加采,锁屏、屏幕休眠和系统睡眠时暂停。 -- 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。 -- 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。 -- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存、为网络服务设置 DNS 几个固定操作(DNS 地址与服务名在辅助工具内再次校验);运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。 -- 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 -- 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。 -- 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。 -- 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。 -- 设置(主窗口侧边栏的“设置”分组):外观、登录时启动、刷新频率、温度单位、菜单栏布局 / 显示项 / 样式 / 弹窗内容、连接探测与公网 IP 查询、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 -- 统一外观:浅色为白底蓝色、深色为黑底蓝色,主窗口与弹窗共用同一底色;侧边栏、标题栏与页面不分栏着色,红绿灯按钮与页面标题对齐;卡片为浅灰 / 深灰实色。侧边栏的太阳 / 月亮按钮一键切换,也可以跟随系统。 -- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染主窗口各页、详情弹窗、设置和菜单栏的浅色与深色截图,IP 与硬件地址替换为示例值。 +- CPU 走势时长可选 1 / 3 / 5 分钟:走势线上方一行小字切换,弹窗与主窗口共用;曲线按真实采样时间定位,最新一次采样固定在右边缘,睡眠等超过 10 秒没有采样的地方线条断开。 +- 菜单栏新增“磁盘”项目:显示启动磁盘已用占比,八种风格都可用;点击弹出磁盘详情(容量与可用空间、读写速度、SSD 健康、读写最多的应用),区块可在设置里逐个隐藏。主窗口磁盘页右上角同样可以直接开关,“温度与风扇”页右上角现在有温度、风扇两个开关。 +- CPU 详情新增“各核心占用”区块:主窗口里每个核心一个小圆环,中间写百分比,按“核心 1…N”编号并按类型分组,超过 60% 变橙、85% 变红;弹窗里是每核一根柱子的紧凑版,默认关闭,可在“设置 · 菜单栏”的 CPU 弹窗显示里打开。 -**样式** +**调整** -- 网络详情的流量历史:上传 / 下载峰值移到标题行(“60 秒峰值 ↑ … ↓ …”,箭头按上传绿、下载蓝着色),图表上不再压文字。 -- 设置并入主窗口:侧边栏新增“设置”分组(通用、菜单栏、辅助工具、关于);网络设置(连接探测、公网 IP、IP 归属地数据库)放到“网络”页底部,风扇安全温度放到“温度与风扇”页底部,合盖运行电量下限放到“防休眠”页底部;弹窗齿轮、菜单栏右键“设置…”与 ⌘, 都打开主窗口的通用设置;不再有单独的设置窗口,已有偏好全部保留;导航较多时侧边栏可滚动。 -- 主窗口侧边栏的选中样式:左侧一条两端渐隐的细线,选中项有发光的蓝色光条,切换时带回弹地滑过去,右侧拖出渐淡的蓝色高亮;不再整块填蓝。 -- 仪表盘“快捷开关”改为开关样式:防休眠、合盖运行、散热模式各一行,右侧开关,下面一行小字说明当前状态。 -- 进程表按窗口宽度自动隐藏次要列,窄窗口里名称列不会被挤没。 -- 内存详情的“释放内存”移到标题栏,执行中与完成后在按钮上显示进度和结果;主窗口内存页顶栏同样提供。 -- 主窗口默认 1072×720,可自由调整大小;滚动条改为浮层样式,只在滚动时出现。 -- 菜单栏字号缩小并统一:两行布局为 7pt 标签加 10pt 数值,单行布局 11pt,网速两行 9pt;每种风格内部只用一套字号,指标之间留出更宽的间距。 -- 柱状历史去掉满高的灰色底槽,只保留一条淡基线,负载起伏更清楚。 -- 面板加宽为三列布局,高度按内容自动计算,常见屏幕上不需要滚动。 -- 更换应用图标,面板左上角和关于页使用同一图标。 -- 首次启动默认在菜单栏显示 CPU、内存与网速。 +- 卸载应用:去掉“同时从程序坞移除图标”开关,卸载时始终移除程序坞图标;应用列表的滚动条改为只在滚动时显示的细条(之前接了鼠标会显示一条粗的传统滚动条),升级说明里的滚动条同样修正。 +- 主窗口的 CPU 走势图加上 0–100% 刻度与起点、中点、现在三个时间标签。 +- CPU 详情的解释性文字不再常驻:热力图怎么看、每类核心是干什么的、平均负载怎么理解、按应用汇总为什么会超过 100%,都收进区块标题旁的 ⓘ,鼠标悬停 ⓘ 或卡片内容时才显示,界面上只留数据。 +- 核心类型说人话:核心分工的悬停说明写明每类核心是干什么的(最快的处理重活、更省电的负责后台和轻量任务,系统自动分配);各核心占用的分组标题、热力图的行标签都带上核心数量,热力图说明写明“每行一个核心(共 N 个),颜色越深越忙”;最忙的核心改为“核心 7(性能核)”这种统一编号。 -**修复** +
-- 深色菜单栏上 CPU、RAM 小标签颜色过深,几乎看不清。 -- 面板打开后高度会再调整一次、内容轻微回弹,看起来在抖动;面板打开时点击菜单栏图标关闭,会立刻又被打开。 -- 风扇被其他程序设为手动模式时,误显示为由 OpenStats 控制。 -- 风扇转速、PID 等数字出现千分位分隔符,例如 1,350 RPM。 -- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。 -- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。 +
+2026-09-14 · 未发布 · 新增 2 · 调整 2 + +**新增** + +- 系统通知新增“CPU 持续高负载”:总占用持续 1 分钟高于设定值(70% / 80% / 90%)时提醒,点通知打开 CPU 详情。 +- 菜单栏风格新增“折线历史”:最近 30 次采样(约 1 分钟)的走势折线,线下填淡色;设置里有示例预览。 + +**调整** + +- CPU 详情重新整理:顶部直接显示 CPU 温度(下方附离 100°C 的余量,不再把余量当主数值),走势线下标出最近 60 秒的峰值;核心热力图右侧加一根粗条画此刻各核心的占用;核心分工标出芯片型号,频率跟在占用后面用次要颜色显示;按应用汇总注明“以单核满载为 100%”,解释各应用相加为什么会超过顶部的总占用。 +- 主窗口的 CPU 页:走势线加高并带 25% / 50% / 75% 参考线,核心分工与排队程度并排显示,不再整页单列拉长。
@@ -163,7 +134,7 @@ brew install --cask gentpan/tap/openstats

菜单栏

**详情弹窗**——每个指标一个菜单栏图标,点击弹出该项的窄详情,显示哪些区块可在设置里勾选;按 `Esc` 关闭。 -- **CPU**:占用与状态、与 30 秒前的变化、温度余量;核心热力图;核心分工;排队程度(每核平均负载与趋势);按应用汇总。 +- **CPU**:占用与状态、与 30 秒前的变化、CPU 温度与余量、1 / 3 / 5 分钟走势;核心热力图与此刻各核心占用;核心分工与频率;排队程度(每核平均负载与趋势);按应用汇总。 - **内存**:还可用多少与压力走势;内存水位条;压缩省下的内存与交换区读写;按应用汇总。 - **网络**:流量历史;连接探测格子(延迟、抖动、丢包);接口、Wi-Fi 信号、VPN / 代理;本地与公网 IPv4 / IPv6, 归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。 @@ -224,7 +195,7 @@ brew install --cask gentpan/tap/openstats 只有两项可选功能会联网,都可以在“设置 → 网络”中关闭: -- **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地与 ASN 在本机的 MaxMind GeoLite2 数据库里查询;数据库从 `getopenstats.com` 下载并按 sha256 校验,下载前才临时改用 `ipinfo.io` 在线查询。 +- **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地与 ASN 在本机的 MaxMind GeoLite2 数据库里查询;数据库从 `getopenstats.com` 下载并按 sha256 校验,下载前才临时改用 `ipapi.is` 的匿名接口在线查询:由每台 Mac 自己直接查、不经过我们的服务器,同一个公网 IP 的结果记住一天。 - **连接探测**:每 1、2 或 5 秒向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。 用 Apple 智能解释进程完全在本机完成,进程信息不会离开这台 Mac。 diff --git a/project.yml b/project.yml index 4faedf1..3561b4d 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: SWIFT_VERSION: "6.0" MARKETING_VERSION: "0.2.0" # 构建号四位数字,每次 make build 加一;营销版本只在发布时用 Scripts/version.sh patch / minor 修改 - CURRENT_PROJECT_VERSION: "0003" + CURRENT_PROJECT_VERSION: "0045" MACOSX_DEPLOYMENT_TARGET: "14.0" # 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md) CODE_SIGN_STYLE: Manual @@ -50,6 +50,11 @@ targets: LSUIElement: true LSApplicationCategoryType: public.app-category.utilities NSHumanReadableCopyright: © 2026 OpenStats + # 账号登录完成后浏览器跳回 openstats://auth/callback + CFBundleURLTypes: + - CFBundleURLName: com.openstats.app.auth + CFBundleURLSchemes: [openstats] + CFBundleTypeRole: Viewer settings: base: PRODUCT_NAME: OpenStats