diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a406e45..885cd57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,9 @@ jobs: - uses: actions/checkout@v7 # CommandLineTools alone cannot expand SwiftUI's @Observable / @Entry - # macros — the plugins ship only with full Xcode. Liquid Glass needs the - # macOS 26 SDK, so pick the newest Xcode on the image rather than the - # default one or a pinned version that a future image may not carry. + # macros — the plugins ship only with full Xcode. Pick the newest Xcode + # on the image rather than the default one or a pinned version that a + # future image may not carry. - name: Select Xcode run: | ls -d /Applications/Xcode*.app @@ -41,9 +41,9 @@ jobs: test -f "$APP/Contents/Resources/AppIcon.icns" codesign --verify --verbose "$APP" - # Smoke test: the panel is a menu-bar popover with no window to drive in - # CI, so render it off-screen instead. A view that traps on layout fails - # here rather than the first time a user clicks the menu-bar item. + # Smoke test: there is no menu bar or window to drive in CI, so render + # every main-window page, popover and settings section off-screen. A view + # that traps on layout fails here rather than the first time a user opens it. - name: Render UI snapshots run: | build/DerivedData/Build/Products/Release/OpenStats.app/Contents/MacOS/OpenStats --snapshot "$PWD/snapshots" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 24cf8f8..c77b278 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,8 +19,7 @@ hosting SwiftUI, no third-party dependencies. The Xcode project is generated fro ## Build -**Xcode 26 or later is required** — the macOS 26 SDK provides Liquid Glass, and -CommandLineTools does not ship the SwiftUI macro plugins. +**Xcode 26 or later is required** — CommandLineTools does not ship the SwiftUI macro plugins. ```bash brew install xcodegen @@ -95,15 +94,39 @@ 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. -## Panel +## Menu bar, popovers and main window -A borderless, non-activating `NSPanel` under the status item, hosting SwiftUI inside -`NSGlassEffectView` on macOS 26+ (`NSVisualEffectView` before). The SwiftUI tree is created on -open and destroyed on close, so a hidden panel costs nothing. Height comes from measuring a -flat, scroll-free copy of the same view; placeholders keep that height stable until data -arrives, and the window is resized without animation because animating it makes SwiftUI -re-lay out every frame. Charts are drawn with `Canvas`, not Swift Charts. +`MenuBarController` owns the status items. In the *separate* layout every enabled metric gets +its own `NSStatusItem` (created in reverse so they read left to right) and opens a 320 pt +popover for that metric; in the *combined* layout a single item opens the main window. -`--snapshot ` renders every panel tab, settings section and the menu bar in light and +Popovers are borderless, non-activating `NSPanel`s. The SwiftUI tree is created on open and +destroyed on close, so a hidden popover costs nothing. Height comes from measuring a flat, +scroll-free copy of the same view; placeholders keep that height stable until data arrives, and +the window is resized without animation because animating it makes SwiftUI re-lay out every +frame. Charts are drawn with `Canvas`, not Swift Charts. + +The main window reuses the popover content with `isDetailPage` set (all sections, taller charts) +next to the dashboard and tool pages. Windows use a transparent, full-size-content title bar with +an empty compact toolbar, so the traffic lights sit on the same ground colour as the sidebar and +line up with the 40 pt page header; the app switches to a regular activation policy while a window +is open and back to accessory when all are closed. + +## Network details + +`NetworkController` runs only while it is needed: + +- **Connection probe** — an unprivileged `SOCK_DGRAM` ICMP echo (`ConnectivityProbe`), matched on + sequence number and a random payload token because the kernel rewrites the identifier. +- **Interface and addresses** — `SCDynamicStore` / `SCPreferences` for the primary and physical + service, `getifaddrs` for addresses, CoreWLAN for signal and rate. +- **Public IP** — ipinfo.io for address, region and ASN; Cloudflare trace and ipify for IPv6 and + fallback. 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 + service name and every address; without the helper, a one-off administrator prompt runs the + same fixed command. + +`--snapshot ` renders every main-window page, popover, settings section and the menu bar in light and dark, through real `NSHostingView`s in off-screen windows — `ImageRenderer` washes out pages that contain bitmaps. diff --git a/Assets/readme/activity.svg b/Assets/readme/activity.svg index f78b5ee..0b575b0 100644 --- a/Assets/readme/activity.svg +++ b/Assets/readme/activity.svg @@ -1,194 +1,200 @@ -6 commits in the last 26 weeks +0 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-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 -6 commits on 2026-09-13 +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 -6 commits in the last 26 weeks · through 2026-09-13 +0 commits in the last 26 weeks · through 2026-09-12 Less diff --git a/Assets/readme/activity.zh.svg b/Assets/readme/activity.zh.svg index 11bb1e7..01538f2 100644 --- a/Assets/readme/activity.zh.svg +++ b/Assets/readme/activity.zh.svg @@ -1,194 +1,200 @@ -近 26 周共 6 次提交 +近 26 周共 0 次提交 -4月 -5月 -6月 -7月 -8月 -9月 +4月 +5月 +6月 +7月 +8月 +9月 一 三 五 -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:6 次提交 +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 次提交 -近 26 周共 6 次提交 · 截至 2026-09-13 +近 26 周共 0 次提交 · 截至 2026-09-12 少 diff --git a/CHANGELOG.md b/CHANGELOG.md index 075e0d3..1100135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,20 +13,25 @@ - 菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。 - 网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。 - 鼠标悬停在菜单栏图标上显示完整读数。 -- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 +- 菜单栏布局:每项独立(每个指标一个图标,点击弹出该项的窄详情)或合并为一个(点击打开主窗口)。每个详情里显示哪些区块可以在设置中逐个勾选。 +- 详情弹窗: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 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 -- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。 +- 按界面需要分级采样:只显示菜单栏时只采集菜单栏用到的指标,打开详情或主窗口时按页面加采,锁屏、屏幕休眠和系统睡眠时暂停。 - 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。 - 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。 -- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作;运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。 +- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存、为网络服务设置 DNS 几个固定操作(DNS 地址与服务名在辅助工具内再次校验);运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。 - 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 - 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。 - 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。 - 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。 -- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 -- 面板提供浅色、深色两套实色主题,顶部的太阳 / 月亮按钮一键切换,也可以跟随系统;液态玻璃背景改为可选(macOS 26 起)。 -- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。 +- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏布局 / 显示项 / 样式 / 弹窗内容、连接探测与公网 IP 查询、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 +- 统一外观:浅色为白底蓝色、深色为黑底蓝色,主窗口、设置窗口与弹窗共用同一底色;侧边栏、标题栏与页面不分栏着色,红绿灯按钮与页面标题对齐;卡片为浅灰 / 深灰实色。侧边栏的太阳 / 月亮按钮一键切换,也可以跟随系统。 +- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染主窗口各页、详情弹窗、设置和菜单栏的浅色与深色截图,IP 与硬件地址替换为示例值。 #### 样式 diff --git a/Helper/main.swift b/Helper/main.swift index 1e49277..da042ec 100644 --- a/Helper/main.swift +++ b/Helper/main.swift @@ -2,7 +2,7 @@ // OpenStatsHelper // // 以 root 运行的辅助工具,由 SMAppService.daemon 注册。 -// 只做固定的几件事:设置风扇转速、切换“合盖不睡眠”、刷新 DNS、释放内存, +// 只做固定的几件事:设置风扇转速、切换“合盖不睡眠”、刷新 DNS、释放内存、设置 DNS 服务器, // 并在客户端断开时恢复风扇与睡眠设置。 // @@ -111,6 +111,24 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto queue.async { [self] in reply(run(.purgeMemory)) } } + func setDNSServers(service: String, servers: [String], reply: @escaping @Sendable (String?) -> Void) { + queue.async { [self] in + guard DNSConfiguration.isValidServiceName(service), servers.count <= DNSConfiguration.maxServers, + servers.allSatisfy(DNSConfiguration.isValidAddress) else { return reply("无效的 DNS 参数") } + // 服务名必须是系统里已有的网络服务 + let list = runTool("/usr/sbin/networksetup", ["-listallnetworkservices"]) + let names = list.output.split(whereSeparator: \.isNewline).dropFirst() + .map { $0.hasPrefix("*") ? String($0.dropFirst()) : String($0) } + guard list.status == 0, names.contains(service) else { return reply("找不到网络服务“\(service)”") } + + let result = runTool("/usr/sbin/networksetup", DNSConfiguration.networksetupArguments(service: service, servers: servers)) + guard result.status == 0 else { + return reply("networksetup 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))") + } + reply(run(.flushDNS)) + } + } + // MARK: 状态恢复 private func connectionClosed(_ id: ObjectIdentifier) { diff --git a/Makefile b/Makefile index 5723da6..e887a87 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ SIGN_ID ?= $(shell security find-identity -v -p codesigning 2>/dev/null | grep TEAM_ID := $(shell echo '$(SIGN_ID)' | sed -nE 's/.*\(([A-Z0-9]+)\)$$/\1/p') ifneq ($(strip $(TEAM_ID)),) # Release 加安全时间戳,公证要求如此,证书过期后签名依然有效 -SIGN_FLAGS := CODE_SIGN_IDENTITY="$(SIGN_ID)" DEVELOPMENT_TEAM=$(TEAM_ID) \ +SIGN_FLAGS := CODE_SIGN_IDENTITY="$(SIGN_ID)" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=$(TEAM_ID) \ $(if $(filter Release,$(CONFIG)),OTHER_CODE_SIGN_FLAGS=--timestamp CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO) endif diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift index 40b51f3..5d49946 100644 --- a/Packages/OpenStatsKit/Package.swift +++ b/Packages/OpenStatsKit/Package.swift @@ -17,9 +17,11 @@ let package = Package( .target(name: "Metrics", dependencies: ["SMC"]), .target(name: "HelperShared"), .target(name: "Cleaner"), - .target(name: "OpenStatsUI", dependencies: ["Metrics", "SMC", "HelperShared", "Cleaner"]), + .target(name: "OpenStatsUI", dependencies: ["Metrics", "SMC", "HelperShared", "Cleaner"], + resources: [.copy("Resources/Flags")]), .testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]), .testTarget(name: "CleanerTests", dependencies: ["Cleaner"]), + .testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]), ], swiftLanguageModes: [.v6] ) diff --git a/Packages/OpenStatsKit/Sources/HelperShared/DNSConfiguration.swift b/Packages/OpenStatsKit/Sources/HelperShared/DNSConfiguration.swift new file mode 100644 index 0000000..50d2d90 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/HelperShared/DNSConfiguration.swift @@ -0,0 +1,68 @@ +import Darwin +import Foundation + +/// 常用公共 DNS。只用 IPv4 地址:没有 IPv6 的网络里写入 IPv6 服务器会拖慢解析 +public enum DNSPreset: String, CaseIterable, Identifiable, Sendable { + case automatic, cloudflare, google, tencent, aliyun + + public var id: String { rawValue } + + public var title: String { + switch self { + case .automatic: "自动" + case .cloudflare: "Cloudflare" + case .google: "Google" + case .tencent: "腾讯 DNSPod" + case .aliyun: "阿里云" + } + } + + public var servers: [String] { + switch self { + case .automatic: [] + case .cloudflare: ["1.1.1.1", "1.0.0.1"] + case .google: ["8.8.8.8", "8.8.4.4"] + case .tencent: ["119.29.29.29", "182.254.116.116"] + case .aliyun: ["223.5.5.5", "223.6.6.6"] + } + } + + /// 当前手动设置的地址与哪个预设一致;无手动地址即“自动” + public static func matching(_ servers: [String]) -> DNSPreset? { + allCases.first { $0.servers == servers } + } +} + +public enum DNSConfiguration { + public static let maxServers = 6 + + /// 只接受数字形式的 IPv4 / IPv6 地址 + public static func isValidAddress(_ text: String) -> Bool { + var v4 = in_addr() + var v6 = in6_addr() + return inet_pton(AF_INET, text, &v4) == 1 || inet_pton(AF_INET6, text, &v6) == 1 + } + + /// 把用户输入(逗号、空格或换行分隔)拆成地址列表;有无效地址时返回 nil + public static func parse(_ input: String) -> [String]? { + let parts = input.split { $0 == "," || $0 == "," || $0.isWhitespace }.map(String.init) + guard parts.count <= maxServers, parts.allSatisfy(isValidAddress) else { return nil } + return parts + } + + /// 服务名来自系统,可能包含空格或引号;只拒绝控制字符 + public static func isValidServiceName(_ name: String) -> Bool { + !name.isEmpty && name.count <= 128 && !name.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) } + } + + public static func networksetupArguments(service: String, servers: [String]) -> [String] { + ["-setdnsservers", service] + (servers.isEmpty ? ["Empty"] : servers) + } + + /// 未安装辅助工具时通过管理员授权执行的 shell 命令。服务名按单引号转义,地址已校验为纯数字形式 + public static func shellCommand(service: String, servers: [String]) -> String { + let quoted = "'" + service.replacingOccurrences(of: "'", with: "'\\''") + "'" + let arguments = ["-setdnsservers", quoted] + (servers.isEmpty ? ["Empty"] : servers) + return (["/usr/sbin/networksetup"] + arguments).joined(separator: " ") + } +} diff --git a/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift index 1dce024..f4c7462 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 = 2 + public static let protocolVersion = 3 /// 辅助工具对调用方的签名要求:与辅助工具自身同一团队签名的 OpenStats。 /// ad-hoc 签名的开发构建没有团队,只能校验 bundle identifier。 @@ -41,6 +41,8 @@ public enum CodeSigningInfo { func setSleepDisabled(_ disabled: Bool, reply: @escaping @Sendable (String?) -> Void) func flushDNSCache(reply: @escaping @Sendable (String?) -> Void) func purgeMemory(reply: @escaping @Sendable (String?) -> Void) + /// 为指定网络服务设置 DNS;`servers` 为空表示恢复自动获取。辅助工具会校验服务名与每个地址 + func setDNSServers(service: String, servers: [String], reply: @escaping @Sendable (String?) -> Void) } /// 需要管理员权限的系统维护命令。辅助工具与未安装辅助工具时的授权回退共用同一份固定命令 diff --git a/Packages/OpenStatsKit/Sources/Metrics/ConnectivityProbe.swift b/Packages/OpenStatsKit/Sources/Metrics/ConnectivityProbe.swift new file mode 100644 index 0000000..5b7d407 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/ConnectivityProbe.swift @@ -0,0 +1,101 @@ +import Darwin +import Foundation + +/// 用 ICMP 回显(ping)测量到目标地址的往返延迟。 +/// macOS 允许普通用户创建 SOCK_DGRAM 类型的 ICMP 套接字,不需要 root,也不经过 shell。 +public enum ConnectivityProbe { + /// 发送一次回显请求,返回往返毫秒数;超时或不可达时返回 nil。调用会阻塞,最长 `timeout`。 + public static func ping(_ address: String, sequence: UInt16, timeout: TimeInterval) -> Double? { + var v4 = in_addr() + var v6 = in6_addr() + if inet_pton(AF_INET, address, &v4) == 1 { + var target = sockaddr_in() + target.sin_len = UInt8(MemoryLayout.size) + target.sin_family = sa_family_t(AF_INET) + target.sin_addr = v4 + return withUnsafeBytes(of: &target) { raw in + send(family: AF_INET, proto: IPPROTO_ICMP, target: raw, sequence: sequence, timeout: timeout) + } + } + if inet_pton(AF_INET6, address, &v6) == 1 { + var target = sockaddr_in6() + target.sin6_len = UInt8(MemoryLayout.size) + target.sin6_family = sa_family_t(AF_INET6) + target.sin6_addr = v6 + return withUnsafeBytes(of: &target) { raw in + send(family: AF_INET6, proto: IPPROTO_ICMPV6, target: raw, sequence: sequence, timeout: timeout) + } + } + return nil + } + + private static let echoRequest: [Int32: UInt8] = [AF_INET: 8, AF_INET6: 128] + private static let echoReply: [Int32: UInt8] = [AF_INET: 0, AF_INET6: 129] + private static let payloadSize = 16 + + private static func send(family: Int32, proto: Int32, target: UnsafeRawBufferPointer, + sequence: UInt16, timeout: TimeInterval) -> Double? { + let socketFD = socket(family, SOCK_DGRAM, proto) + guard socketFD >= 0 else { return nil } + defer { close(socketFD) } + + // 内核会改写标识符,所以用序号 + 随机令牌匹配应答 + let token = UInt32.random(in: .min ... .max) + var packet = [UInt8](repeating: 0, count: 8 + payloadSize) + packet[0] = echoRequest[family] ?? 8 + packet[6] = UInt8(sequence >> 8) + packet[7] = UInt8(sequence & 0xFF) + withUnsafeBytes(of: token.bigEndian) { packet.replaceSubrange(8..<12, with: $0) } + if family == AF_INET { + // ICMPv6 的校验和由内核填写 + let sum = checksum(packet) + packet[2] = UInt8(sum >> 8) + packet[3] = UInt8(sum & 0xFF) + } + + let start = clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + let sent = target.withMemoryRebound(to: sockaddr.self) { address in + sendto(socketFD, packet, packet.count, 0, address.baseAddress, socklen_t(target.count)) + } + guard sent == packet.count else { return nil } + + let deadline = start + UInt64(timeout * 1_000_000_000) + var buffer = [UInt8](repeating: 0, count: 512) + while true { + let now = clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + guard now < deadline else { return nil } + var descriptor = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0) + let waitMilliseconds = Int32(max(1, (deadline - now) / 1_000_000)) + guard poll(&descriptor, 1, waitMilliseconds) > 0 else { return nil } + let count = recv(socketFD, &buffer, buffer.count, 0) + guard count > 0 else { return nil } + if matches(buffer, count: count, family: family, sequence: sequence, token: token) { + return Double(clock_gettime_nsec_np(CLOCK_UPTIME_RAW) - start) / 1_000_000 + } + } + } + + static func matches(_ buffer: [UInt8], count: Int, family: Int32, sequence: UInt16, token: UInt32) -> Bool { + // IPv4 的 DGRAM 套接字在 macOS 上会带回 IP 头 + var offset = 0 + if family == AF_INET, count > 0, buffer[0] >> 4 == 4 { + offset = Int(buffer[0] & 0x0F) * 4 + } + guard count >= offset + 12, buffer[offset] == echoReply[family] else { return false } + let replySequence = UInt16(buffer[offset + 6]) << 8 | UInt16(buffer[offset + 7]) + let replyToken = buffer[(offset + 8)..<(offset + 12)].reduce(UInt32(0)) { $0 << 8 | UInt32($1) } + return replySequence == sequence && replyToken == token + } + + static func checksum(_ bytes: [UInt8]) -> UInt16 { + var sum: UInt32 = 0 + var index = 0 + while index + 1 < bytes.count { + sum += UInt32(bytes[index]) << 8 | UInt32(bytes[index + 1]) + index += 2 + } + if index < bytes.count { sum += UInt32(bytes[index]) << 8 } + while sum >> 16 != 0 { sum = (sum & 0xFFFF) + (sum >> 16) } + return ~UInt16(sum) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/NetworkDetails.swift b/Packages/OpenStatsKit/Sources/Metrics/NetworkDetails.swift new file mode 100644 index 0000000..ed50799 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/NetworkDetails.swift @@ -0,0 +1,195 @@ +import CoreWLAN +import Darwin +import Foundation +import SystemConfiguration + +public struct WiFiLink: Sendable, Equatable { + /// macOS 14 起读取网络名需要定位权限,未授权时为 nil + public var ssid: String? + public var rssi: Int + public var noise: Int + public var transmitRate: Double +} + +/// 实际承载流量的物理网卡(Wi-Fi、有线网卡等) +public struct PhysicalInterface: Sendable, Equatable { + public var bsdName: String + public var displayName: String + public var serviceID: String + /// “网络”设置里的服务名,修改 DNS 时使用 + public var serviceName: String + public var hardwareAddress: String? + public var isUp: Bool + public var ipv4: [String] + public var ipv6: [String] + public var router: String? + /// 在该服务上手动设置的 DNS;为空表示使用 DHCP / 路由器下发的地址 + public var manualDNS: [String] + public var wifi: WiFiLink? +} + +/// 默认路由走 VPN / 代理隧道时的服务名与接口(例如 Surge、utun6) +public struct TunnelInterface: Sendable, Equatable { + public var name: String + public var bsdName: String +} + +public struct NetworkDetails: Sendable, Equatable { + public var physical: PhysicalInterface? + public var tunnel: TunnelInterface? + /// 系统当前实际使用的 DNS 服务器 + public var dnsServers: [String] +} + +public enum NetworkDetailsReader { + private static let tunnelPrefixes = ["utun", "ipsec", "ppp", "tun", "tap"] + + static func isTunnel(_ bsdName: String) -> Bool { + tunnelPrefixes.contains { bsdName.hasPrefix($0) } + } + + /// 读取接口、地址与 DNS。只读 SystemConfiguration 与 getifaddrs,不需要权限。 + public static func read() -> NetworkDetails { + guard let store = SCDynamicStoreCreate(nil, "OpenStats" as CFString, nil, nil) else { + return NetworkDetails(physical: nil, tunnel: nil, dnsServers: []) + } + let globalIPv4 = value(store, "State:/Network/Global/IPv4") + let globalIPv6 = value(store, "State:/Network/Global/IPv6") + let primaryBSD = (globalIPv4?["PrimaryInterface"] ?? globalIPv6?["PrimaryInterface"]) as? String + let primaryService = (globalIPv4?["PrimaryService"] ?? globalIPv6?["PrimaryService"]) as? String + let dnsServers = value(store, "State:/Network/Global/DNS")?["ServerAddresses"] as? [String] ?? [] + + let preferences = SCPreferencesCreate(nil, "OpenStats" as CFString, nil) + let addresses = interfaceAddresses() + + var tunnel: TunnelInterface? + if let primaryBSD, isTunnel(primaryBSD) { + tunnel = TunnelInterface(name: primaryService.flatMap { serviceName(preferences, $0) } ?? "VPN", bsdName: primaryBSD) + } + + let physical = physicalService(store: store, preferences: preferences, primaryBSD: primaryBSD, + primaryService: primaryService) + .map { service -> PhysicalInterface in + let ipv4State = value(store, "State:/Network/Service/\(service.id)/IPv4") + let manualDNS = value(store, "Setup:/Network/Service/\(service.id)/DNS")?["ServerAddresses"] as? [String] ?? [] + return PhysicalInterface( + bsdName: service.bsdName, + displayName: service.displayName, + serviceID: service.id, + serviceName: service.name, + hardwareAddress: service.hardwareAddress, + isUp: addresses[service.bsdName]?.isUp ?? false, + ipv4: addresses[service.bsdName]?.ipv4 ?? [], + ipv6: addresses[service.bsdName]?.ipv6 ?? [], + router: ipv4State?["Router"] as? String, + manualDNS: manualDNS, + wifi: service.isWiFi ? wifiLink(service.bsdName) : nil) + } + return NetworkDetails(physical: physical, tunnel: tunnel, dnsServers: dnsServers) + } + + // MARK: 服务 + + private struct Service { + let id: String + let name: String + let bsdName: String + let displayName: String + let hardwareAddress: String? + let isWiFi: Bool + } + + /// 默认路由直接走物理网卡时就是它;走隧道时,按“网络”设置里的服务顺序取第一个已联网的物理服务 + private static func physicalService(store: SCDynamicStore, preferences: SCPreferences?, + primaryBSD: String?, primaryService: String?) -> Service? { + guard let preferences, let set = SCNetworkSetCopyCurrent(preferences) else { return nil } + let services = SCNetworkSetCopyServices(set) as? [SCNetworkService] ?? [] + let order = SCNetworkSetGetServiceOrder(set) as? [String] ?? [] + let ranked = services.sorted { lhs, rhs in + let left = (SCNetworkServiceGetServiceID(lhs) as String?).flatMap { order.firstIndex(of: $0) } ?? Int.max + let right = (SCNetworkServiceGetServiceID(rhs) as String?).flatMap { order.firstIndex(of: $0) } ?? Int.max + return left < right + } + + let candidates: [Service] = ranked.compactMap { service in + guard SCNetworkServiceGetEnabled(service), + let interface = SCNetworkServiceGetInterface(service), + let bsdName = SCNetworkInterfaceGetBSDName(interface) as String?, + let id = SCNetworkServiceGetServiceID(service) as String?, + !isTunnel(bsdName) else { return nil } + let type = SCNetworkInterfaceGetInterfaceType(interface) as String? + return Service(id: id, + name: SCNetworkServiceGetName(service) as String? ?? bsdName, + bsdName: bsdName, + displayName: SCNetworkInterfaceGetLocalizedDisplayName(interface) as String? ?? bsdName, + hardwareAddress: SCNetworkInterfaceGetHardwareAddressString(interface) as String?, + isWiFi: type == (kSCNetworkInterfaceTypeIEEE80211 as String)) + } + if let primaryService, let match = candidates.first(where: { $0.id == primaryService }) { return match } + if let primaryBSD, let match = candidates.first(where: { $0.bsdName == primaryBSD }) { return match } + return candidates.first { value(store, "State:/Network/Service/\($0.id)/IPv4") != nil } + ?? candidates.first { value(store, "State:/Network/Service/\($0.id)/IPv6") != nil } + } + + private static func serviceName(_ preferences: SCPreferences?, _ id: String) -> String? { + guard let preferences, let service = SCNetworkServiceCopy(preferences, id as CFString) else { return nil } + return SCNetworkServiceGetName(service) as String? + } + + private static func value(_ store: SCDynamicStore, _ key: String) -> [String: Any]? { + SCDynamicStoreCopyValue(store, key as CFString) as? [String: Any] + } + + private static func wifiLink(_ bsdName: String) -> WiFiLink? { + guard let interface = CWWiFiClient.shared().interface(withName: bsdName), interface.powerOn() else { return nil } + let rssi = interface.rssiValue() + guard rssi != 0 else { return nil } + return WiFiLink(ssid: interface.ssid(), rssi: rssi, noise: interface.noiseMeasurement(), + transmitRate: interface.transmitRate()) + } + + // MARK: 地址 + + private struct Addresses { + var isUp = false + var ipv4: [String] = [] + var ipv6: [String] = [] + } + + /// 按接口汇总 IPv4 与 IPv6 地址;IPv6 只保留全局与唯一本地地址,不含 fe80 链路本地地址 + private static func interfaceAddresses() -> [String: Addresses] { + var result: [String: Addresses] = [:] + var head: UnsafeMutablePointer? + guard getifaddrs(&head) == 0, let first = head else { return result } + defer { freeifaddrs(head) } + + for pointer in sequence(first: first, next: { $0.pointee.ifa_next }) { + let entry = pointer.pointee + guard let address = entry.ifa_addr else { continue } + let name = String(cString: entry.ifa_name) + var info = result[name] ?? Addresses() + if entry.ifa_flags & UInt32(IFF_UP) != 0, entry.ifa_flags & UInt32(IFF_RUNNING) != 0 { info.isUp = true } + + switch Int32(address.pointee.sa_family) { + case AF_INET: + if let text = numericHost(address) { info.ipv4.append(text) } + case AF_INET6: + if let text = numericHost(address), !text.lowercased().hasPrefix("fe80") { + info.ipv6.append(text) + } + default: + break + } + result[name] = info + } + return result + } + + private static func numericHost(_ address: UnsafeMutablePointer) -> String? { + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let length = socklen_t(address.pointee.sa_len) + guard getnameinfo(address, length, &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { return nil } + // 去掉 IPv6 的作用域后缀(%en0) + return String(nullTerminated: host).split(separator: "%").first.map(String.init) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/NetworkProcessSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/NetworkProcessSampler.swift new file mode 100644 index 0000000..6636a7d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/NetworkProcessSampler.swift @@ -0,0 +1,91 @@ +import Darwin +import Foundation + +public struct NetworkProcessUsage: Sendable, Equatable, Identifiable { + public var id: Int32 { pid } + public let pid: Int32 + public let name: String + public let appBundlePath: String? + public var download: Double // 字节 / 秒 + public var upload: Double +} + +/// 各进程的网络速率。系统没有公开接口,读取 /usr/bin/nettop 的累计字节数并与上次比较。 +/// 只在网络详情打开时运行,每次约 40 ms。 +public struct NetworkProcessSampler: Sendable { + private var previous: [Int32: (download: UInt64, upload: UInt64)] = [:] + private var previousTime: UInt64 = 0 + + public init() {} + + public mutating func sample(limit: Int = 8) -> [NetworkProcessUsage] { + guard let output = Self.runNettop() else { return [] } + let now = clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + let seconds = previousTime > 0 ? Double(now - previousTime) / 1_000_000_000 : 0 + let totals = Self.parse(output) + + var results: [NetworkProcessUsage] = [] + if seconds > 0 { + for (pid, entry) in totals { + guard let before = previous[pid] else { continue } + let download = entry.download >= before.download ? Double(entry.download - before.download) / seconds : 0 + let upload = entry.upload >= before.upload ? Double(entry.upload - before.upload) / seconds : 0 + guard download + upload > 0 else { continue } + let meta = ProcessSampler.meta(for: pid) + results.append(NetworkProcessUsage(pid: pid, name: meta.bundle.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent } ?? meta.name, + appBundlePath: meta.bundle, download: download, upload: upload)) + } + } + previous = totals.mapValues { ($0.download, $0.upload) } + previousTime = now + + // 同一应用的多个进程合并为一行 + var merged: [String: NetworkProcessUsage] = [:] + for usage in results { + let key = usage.appBundlePath ?? "\(usage.pid)" + if var existing = merged[key] { + existing.download += usage.download + existing.upload += usage.upload + merged[key] = existing + } else { + merged[key] = usage + } + } + return merged.values + .sorted { $0.download + $0.upload > $1.download + $1.upload } + .prefix(limit) + .map { $0 } + } + + /// 解析 `nettop -P -L 1 -x -J bytes_in,bytes_out` 的 CSV:`进程名.PID,入字节,出字节,` + static func parse(_ output: String) -> [Int32: (name: String, download: UInt64, upload: UInt64)] { + var result: [Int32: (name: String, download: UInt64, upload: UInt64)] = [:] + for line in output.split(whereSeparator: \.isNewline).dropFirst() { + let columns = line.split(separator: ",", omittingEmptySubsequences: false) + guard columns.count >= 3, + let dot = columns[0].lastIndex(of: "."), + let pid = Int32(columns[0][columns[0].index(after: dot)...]), + let download = UInt64(columns[1]), + let upload = UInt64(columns[2]) else { continue } + result[pid] = (String(columns[0][.. String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop") + process.arguments = ["-P", "-L", "1", "-n", "-x", "-J", "bytes_in,bytes_out"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return nil + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return process.terminationStatus == 0 ? String(decoding: data, as: UTF8.self) : nil + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift index 2d85050..e5b60d0 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift @@ -7,7 +7,7 @@ public struct ProcessSampler { let startTime: UInt64 } - private struct Meta { + struct Meta { let name: String let path: String? let bundle: String? @@ -68,7 +68,7 @@ public struct ProcessSampler { return Array(results.prefix(limit)) } - private static func meta(for pid: pid_t) -> Meta { + static func meta(for pid: pid_t) -> Meta { var pathBuffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) let pathLength = proc_pidpath(pid, &pathBuffer, UInt32(pathBuffer.count)) let path = pathLength > 0 ? String(nullTerminated: pathBuffer) : nil diff --git a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift new file mode 100644 index 0000000..3194ffe --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift @@ -0,0 +1,142 @@ +import Foundation + +public struct PublicAddresses: Sendable, Equatable { + public var ipv4: String? + public var ipv6: String? + /// ISO 3166 两位国家 / 地区代码(大写),例如 “CN” + public var countryCode: String? + public var city: String? + /// 自治系统号,例如 “AS4134” + public var asn: String? + /// 网络所属组织(英文),例如 “CHINANET-BACKBONE” + public var organization: String? + + public init(ipv4: String? = nil, ipv6: String? = nil, countryCode: String? = nil, + city: String? = nil, asn: String? = nil, organization: String? = nil) { + self.ipv4 = ipv4 + self.ipv6 = ipv6 + self.countryCode = countryCode + self.city = city + self.asn = asn + self.organization = organization + } +} + +/// 查询公网 IP 与归属地。只在用户打开网络详情时请求,不携带任何本机信息。 +/// 归属地与 ASN 来自 ipinfo.io;IPv6 地址与失败时的回退使用 Cloudflare trace(直连 IP,不依赖 DNS)与 ipify。 +public enum PublicAddressLookup { + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 5 + configuration.timeoutIntervalForResource = 8 + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpShouldSetCookies = false + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) + }() + + public static func fetch() async -> PublicAddresses { + 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 + + 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 } + return result + } + + // MARK: 归属地 + + /// ipinfo.io 的响应:`country` 是两位代码,`org` 形如 “AS151338 POLONETWORK LIMITED” + struct GeoResponse: Decodable { + let ip: String? + let city: String? + let country: 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 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 + } + } + return result + } + + /// 只接受两位字母,之后会用作国旗文件名 + static func validCountryCode(_ code: String) -> String? { + let upper = code.uppercased() + guard upper.count == 2, upper.unicodeScalars.allSatisfy({ ("A"..."Z").contains($0) }) else { return nil } + return upper + } + + private static func nonEmpty(_ text: String) -> String? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + // MARK: 回退 + + private static func lookup(trace: String, fallback: String) async -> (ip: String?, country: String?) { + if let data = await get(trace), let body = String(data: data, encoding: .utf8) { + let fields = parseTrace(body) + if let ip = fields["ip"] { return (ip, fields["loc"].flatMap(validCountryCode)) } + } + if let data = await get(fallback), let body = String(data: data, encoding: .utf8) { + let ip = body.trimmingCharacters(in: .whitespacesAndNewlines) + if isAddress(ip) { return (ip, nil) } + } + return (nil, nil) + } + + 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 + } + + static func parseTrace(_ body: String) -> [String: String] { + var fields: [String: String] = [:] + for line in body.split(whereSeparator: \.isNewline) { + let parts = line.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + fields[String(parts[0])] = String(parts[1]) + } + if let ip = fields["ip"], !isAddress(ip) { fields["ip"] = nil } + return fields + } + + static func isAddress(_ text: String) -> Bool { + var v4 = in_addr() + var v6 = in6_addr() + return inet_pton(AF_INET, text, &v4) == 1 || inet_pton(AF_INET6, text, &v6) == 1 + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index b1c6560..f163f72 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -3,10 +3,10 @@ import Metrics import SwiftUI @MainActor -public final class AppController: NSObject, NSApplicationDelegate { +public final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { private let model = AppModel() - private var statusItem: NSStatusItem! - private var panel: StatusPanel! + private var menuBar: MenuBarController! + private var mainWindow: MainWindowController! private var settingsWindow: NSWindow? private var workspaceObservers: [NSObjectProtocol] = [] @@ -15,10 +15,17 @@ public final class AppController: NSObject, NSApplicationDelegate { } public func applicationDidFinishLaunching(_ notification: Notification) { - setUpStatusItem() - setUpPanel() + NSApp.mainMenu = MainMenu.make() + menuBar = MenuBarController(model: model) + mainWindow = MainWindowController(model: model) + mainWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() } + menuBar.update() model.openSettings = { [weak self] in self?.showSettings() } + model.openMainWindow = { [weak self] tab in + self?.menuBar.dismissPopovers() + self?.mainWindow.show(tab: tab) + } model.quit = { NSApp.terminate(nil) } model.helper.refreshStatus() model.helper.onReconnect = { [weak self] in @@ -30,22 +37,29 @@ public final class AppController: NSObject, NSApplicationDelegate { observeWorkspace() observeModel() + observeProbeSettings() applyAppearance() + model.network.updateProbing(networkShown: model.networkShown) - // 开发调试:启动后展开并固定面板,便于测量面板打开时的资源占用 - if CommandLine.arguments.contains("--show-panel") { + // 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口 + let arguments = CommandLine.arguments + if let index = arguments.firstIndex(of: "--show-panel") { + let item = arguments.dropFirst(index + 1).first.flatMap(MenuBarItem.init(rawValue:)) ?? .network DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.togglePanel() - self?.panel.isPinned = true + self?.menuBar.pinsNextPopover = true + self?.menuBar.togglePopover(item) } } + if arguments.contains("--show-window") { + mainWindow.show(tab: nil) + } let model = self.model Task { [weak self] in await model.hub.update(model.demand) await model.hub.start { [weak self] snapshot in model.handle(snapshot) - self?.refreshStatusItem() + self?.menuBar.refreshImages() } } } @@ -57,115 +71,100 @@ public final class AppController: NSObject, NSApplicationDelegate { } } - // MARK: 菜单栏 - - private func setUpStatusItem() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - guard let button = statusItem.button else { return } - button.target = self - button.action = #selector(statusItemClicked(_:)) - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) - button.imagePosition = .imageOnly - refreshStatusItem() - } - - private func refreshStatusItem() { - statusItem.button?.image = MenuBarRenderer.image(for: model) - // 悬停时显示完整读数 - statusItem.button?.toolTip = MenuBarReading(model: model) - .tooltip(items: model.settings.orderedMenuBarItems, fahrenheit: model.settings.useFahrenheit) - } - - @objc private func statusItemClicked(_ sender: NSStatusBarButton) { - if NSApp.currentEvent?.type == .rightMouseUp { - showContextMenu() - } else { - togglePanel() - } - } - - private func togglePanel() { - guard let button = statusItem.button, let window = button.window else { return } - let frame = window.convertToScreen(button.convert(button.bounds, to: nil)) - panel.toggle(below: frame, on: window.screen) - } - - private func showContextMenu() { - panel.dismiss() - let menu = NSMenu() - menu.addItem(withTitle: "打开面板", action: #selector(openPanelFromMenu), keyEquivalent: "").target = self - menu.addItem(withTitle: "设置…", action: #selector(openSettingsFromMenu), keyEquivalent: ",").target = self - menu.addItem(.separator()) - menu.addItem(withTitle: "退出 OpenStats", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") - statusItem.menu = menu - statusItem.button?.performClick(nil) - statusItem.menu = nil - } - - @objc private func openPanelFromMenu() { togglePanel() } - @objc private func openSettingsFromMenu() { showSettings() } - - // MARK: 面板 - - private func setUpPanel() { - let model = self.model - panel = StatusPanel(content: { PanelRootView().environment(model) }, - measuring: { PanelRootView().environment(model).environment(\.isSnapshot, true) }, - usesGlass: { model.settings.panelGlass }) - panel.onVisibilityChange = { [weak self] visible in - guard let self else { return } - self.model.isPanelVisible = visible - self.statusItem.button?.highlight(visible) - if visible { self.model.helper.refreshStatus() } - } + /// 再次打开应用(访达或启动台)时显示主窗口 + public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { mainWindow.show(tab: nil) } + return true } // MARK: 设置窗口 private func showSettings() { - panel.dismiss() + menuBar.dismissPopovers() if settingsWindow == nil { let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: DS.Size.settingsWidth, height: DS.Size.settingsHeight), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false) window.title = "OpenStats 设置" + WindowChrome.apply(to: window) window.isReleasedWhenClosed = false + window.delegate = self window.contentView = NSHostingView(rootView: SettingsView().environment(model)) window.center() settingsWindow = window } NSApp.activate() settingsWindow?.makeKeyAndOrderFront(nil) + updateActivationPolicy() + } + + public func windowWillClose(_ notification: Notification) { + DispatchQueue.main.async { [weak self] in self?.updateActivationPolicy() } + } + + /// 有窗口打开时显示在程序坞与 ⌘Tab 里,全部关闭后回到仅菜单栏 + private func updateActivationPolicy() { + let hasWindow = mainWindow.isVisible || settingsWindow?.isVisible == true + let policy: NSApplication.ActivationPolicy = hasWindow ? .regular : .accessory + guard NSApp.activationPolicy() != policy else { return } + NSApp.setActivationPolicy(policy) + if hasWindow { NSApp.activate() } } // MARK: 观察 - /// 设置或面板状态变化时,更新采样范围并重绘菜单栏 + /// 设置或可见内容变化时,更新采样范围、网络探测并重绘菜单栏 private func observeModel() { withObservationTracking { _ = model.demand _ = model.settings.menuBarItems + _ = model.settings.menuBarLayout _ = model.settings.menuBarStyle _ = model.settings.networkStyle _ = model.settings.styleOverrides _ = model.settings.colorizeHighLoad _ = model.settings.useFahrenheit + _ = model.settings.hiddenPopoverSections _ = model.keepAwake.isActive _ = model.settings.appearance - _ = model.settings.panelTab + _ = model.networkShown + _ = model.isNetworkDetailVisible + _ = model.settings.probeEnabled } onChange: { [weak self] in Task { @MainActor in guard let self else { return } await self.model.hub.update(self.model.demand) self.applyAppearance() - self.panel.refreshHeight() - self.refreshStatusItem() + self.menuBar.update() + self.menuBar.refreshPopoverHeight() + self.model.network.updateProbing(networkShown: self.model.networkShown) + self.model.network.setDetailVisible(self.model.isNetworkDetailVisible) self.observeModel() } } } + /// 探测目标、间隔变化时清空历史重新探测;关闭公网 IP 查询时清除已显示的结果 + private func observeProbeSettings() { + withObservationTracking { + _ = model.settings.probeTarget + _ = model.settings.probeSeconds + _ = model.settings.publicIPLookup + } 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.lookUpPublicAddresses() + } + self.observeProbeSettings() + } + } + } + private func applyAppearance() { switch model.settings.appearance { case .system: NSApp.appearance = nil @@ -174,7 +173,7 @@ public final class AppController: NSObject, NSApplicationDelegate { } } - /// 屏幕休眠、系统睡眠、切换用户时暂停采样;唤醒后重新下发风扇设置 + /// 屏幕休眠、系统睡眠、切换用户时暂停采样与探测;唤醒后重新下发风扇设置 private func observeWorkspace() { let center = NSWorkspace.shared.notificationCenter let pauses: [Notification.Name] = [NSWorkspace.screensDidSleepNotification, @@ -187,7 +186,8 @@ public final class AppController: NSObject, NSApplicationDelegate { workspaceObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } - self.panel.dismiss() + self.menuBar.dismissPopovers() + self.model.network.setPaused(true, networkShown: self.model.networkShown) Task { await self.model.hub.setPaused(true) } } }) @@ -196,6 +196,7 @@ public final class AppController: NSObject, NSApplicationDelegate { workspaceObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } + self.model.network.setPaused(false, networkShown: self.model.networkShown) Task { await self.model.hub.setPaused(false) await self.model.fans.reapply() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainMenu.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainMenu.swift new file mode 100644 index 0000000..2d629a3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainMenu.swift @@ -0,0 +1,41 @@ +import AppKit + +/// 应用菜单。仅菜单栏运行时不显示,但仍负责 ⌘C / ⌘V / ⌘W 等快捷键 +@MainActor +enum MainMenu { + static func make() -> NSMenu { + let main = NSMenu() + + let app = NSMenu(title: "OpenStats") + app.addItem(withTitle: "关于 OpenStats", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + app.addItem(.separator()) + app.addItem(withTitle: "隐藏 OpenStats", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + app.addItem(.separator()) + app.addItem(withTitle: "退出 OpenStats", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + add(app, to: main) + + let edit = NSMenu(title: "编辑") + edit.addItem(withTitle: "撤销", action: Selector(("undo:")), keyEquivalent: "z") + edit.addItem(withTitle: "重做", action: Selector(("redo:")), keyEquivalent: "Z") + edit.addItem(.separator()) + edit.addItem(withTitle: "剪切", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + edit.addItem(withTitle: "拷贝", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + edit.addItem(withTitle: "粘贴", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + edit.addItem(withTitle: "全选", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + add(edit, to: main) + + let window = NSMenu(title: "窗口") + window.addItem(withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + window.addItem(withTitle: "关闭", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + add(window, to: main) + NSApp.windowsMenu = window + + return main + } + + private static func add(_ submenu: NSMenu, to main: NSMenu) { + let item = NSMenuItem(title: submenu.title, action: nil, keyEquivalent: "") + item.submenu = submenu + main.addItem(item) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift new file mode 100644 index 0000000..429c08a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift @@ -0,0 +1,89 @@ +import AppKit +import SwiftUI + +/// 主窗口。打开时应用出现在程序坞与 ⌘Tab 中,关闭后回到仅菜单栏运行 +@MainActor +final class MainWindowController: NSObject, NSWindowDelegate { + private let model: AppModel + private var window: NSWindow? + var onVisibilityChange: ((Bool) -> Void)? + + init(model: AppModel) { + self.model = model + super.init() + } + + var isVisible: Bool { window?.isVisible == true } + + func show(tab: PanelTab?) { + if let tab { model.settings.panelTab = tab } + let window = self.window ?? makeWindow() + self.window = window + if !window.isVisible { + window.setFrame(initialFrame(for: window), display: false) + } + NSApp.activate() + window.makeKeyAndOrderFront(nil) + model.isMainWindowVisible = true + onVisibilityChange?(true) + } + + private func makeWindow() -> NSWindow { + let width = DS.Size.settingsSidebar + DS.Size.panelWidth + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: DS.Size.panelMinHeight * 2), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false) + window.title = "OpenStats" + WindowChrome.apply(to: window) + window.isReleasedWhenClosed = false + window.delegate = self + window.contentMinSize = NSSize(width: width, height: DS.Size.panelMinHeight * 1.5) + window.setFrameAutosaveName("OpenStatsMainWindow") + window.contentView = NSHostingView(rootView: MainWindowView().environment(model)) + return window + } + + /// 首次打开时按仪表盘内容高度确定窗口大小,之后沿用用户调整过的尺寸 + private func initialFrame(for window: NSWindow) -> NSRect { + let visible = (window.screen ?? NSScreen.main)?.visibleFrame ?? .zero + if window.frame.height > DS.Size.panelMinHeight * 2, visible.intersects(window.frame) { + return window.frame + } + let measuring = NSHostingView(rootView: MainWindowView().environment(model).environment(\.isSnapshot, true)) + let height = min(measuring.fittingSize.height, visible.height - DS.Space.s12) + let size = NSSize(width: window.frame.width, height: max(height, DS.Size.panelMinHeight * 1.5)) + return NSRect(x: visible.midX - size.width / 2, y: visible.midY - size.height / 2, + width: size.width, height: size.height) + } + + func windowWillClose(_ notification: Notification) { + model.isMainWindowVisible = false + onVisibilityChange?(false) + } + + func windowDidMiniaturize(_ notification: Notification) { + model.isMainWindowVisible = false + } + + func windowDidDeminiaturize(_ notification: Notification) { + model.isMainWindowVisible = true + } +} + +/// OpenStats 窗口的统一外观:透明标题栏,内容铺满整个窗口,底色与页面一致 +@MainActor +enum WindowChrome { + static func apply(to window: NSWindow) { + window.styleMask.insert(.fullSizeContentView) + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.backgroundColor = .dsBackground + // 空的紧凑工具栏让标题栏与顶栏同高(40pt),红绿灯按钮与页面标题对齐 + let toolbar = NSToolbar(identifier: "OpenStats.\(window.title)") + toolbar.showsBaselineSeparator = false + window.toolbar = toolbar + window.toolbarStyle = .unifiedCompact + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift new file mode 100644 index 0000000..e19ae8b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift @@ -0,0 +1,140 @@ +import AppKit +import SwiftUI + +/// 菜单栏图标:每项独立时一个指标一个图标,点击弹出该项详情;合并时只有一个图标,点击打开主窗口 +@MainActor +final class MenuBarController: NSObject { + private let model: AppModel + private var items: [(key: MenuBarItem?, statusItem: NSStatusItem)] = [] + private var panels: [MenuBarItem: StatusPanel] = [:] + private var layoutSignature: [MenuBarItem?] = [] + + /// 开发调试:启动后展开并固定某个弹窗 + var pinsNextPopover = false + + init(model: AppModel) { + self.model = model + super.init() + } + + // MARK: 图标 + + /// 布局或显示项目变化时重建;否则只重绘图片 + func update() { + let settings = model.settings + let enabled = settings.orderedMenuBarItems + // 没有任何项目时保留一个图标,确保仍能打开主窗口 + let signature: [MenuBarItem?] = settings.menuBarLayout == .separate && !enabled.isEmpty ? enabled : [nil] + if signature != layoutSignature { + rebuild(signature) + } + refreshImages() + } + + private func rebuild(_ signature: [MenuBarItem?]) { + dismissPopovers() + for entry in items { NSStatusBar.system.removeStatusItem(entry.statusItem) } + items = [] + panels = panels.filter { key, _ in signature.contains(key) } + layoutSignature = signature + + // 新建的状态栏图标出现在已有图标左侧,倒序创建才能保持从左到右的顺序 + for key in signature.reversed() { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + button.target = self + button.action = #selector(statusItemClicked(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + button.imagePosition = .imageOnly + } + items.insert((key, statusItem), at: 0) + } + } + + func refreshImages() { + let settings = model.settings + let reading = MenuBarReading(model: model) + for (index, entry) in items.enumerated() { + let itemsToDraw = entry.key.map { [$0] } ?? settings.orderedMenuBarItems + var entryReading = reading + // 防休眠标记只画在最左侧的图标里 + entryReading.keepAwake = reading.keepAwake && index == 0 + entry.statusItem.button?.image = MenuBarRenderer.image(reading: entryReading, + items: itemsToDraw, + style: { settings.style(for: $0) }, + networkStyle: settings.networkStyle, + colorizeHighLoad: settings.colorizeHighLoad, + fahrenheit: settings.useFahrenheit) + entry.statusItem.button?.toolTip = reading.tooltip(items: itemsToDraw, fahrenheit: settings.useFahrenheit) + } + } + + // MARK: 点击 + + @objc private func statusItemClicked(_ sender: NSStatusBarButton) { + guard let entry = items.first(where: { $0.statusItem.button === sender }) else { return } + if NSApp.currentEvent?.type == .rightMouseUp { + showContextMenu(for: entry.statusItem) + } else if let key = entry.key { + togglePopover(key, button: sender) + } else { + model.openMainWindow(nil) + } + } + + func togglePopover(_ item: MenuBarItem, button: NSStatusBarButton? = nil) { + guard let button = button ?? items.first(where: { $0.key == item })?.statusItem.button, + let window = button.window else { return } + for (key, panel) in panels where key != item { panel.dismiss() } + let frame = window.convertToScreen(button.convert(button.bounds, to: nil)) + let panel = self.panel(for: item) + panel.toggle(below: frame, on: window.screen) + if pinsNextPopover, panel.isVisible { + panel.isPinned = true + pinsNextPopover = false + } + } + + func dismissPopovers() { + for panel in panels.values { panel.dismiss() } + } + + func refreshPopoverHeight() { + for panel in panels.values { panel.refreshHeight() } + } + + private func panel(for item: MenuBarItem) -> StatusPanel { + if let panel = panels[item] { return panel } + let model = self.model + let panel = StatusPanel(width: DS.Size.popoverWidth, + content: { PopoverRootView(item: item).environment(model) }, + measuring: { PopoverRootView(item: item).environment(model).environment(\.isSnapshot, true) }) + panel.onVisibilityChange = { [weak self] visible in + guard let self else { return } + if visible { + self.model.openPopover = item + self.model.helper.refreshStatus() + } else if self.model.openPopover == item { + self.model.openPopover = nil + } + self.items.first { $0.key == item }?.statusItem.button?.highlight(visible) + } + panels[item] = panel + return panel + } + + private func showContextMenu(for statusItem: NSStatusItem) { + dismissPopovers() + let menu = NSMenu() + menu.addItem(withTitle: "打开 OpenStats", action: #selector(openMainWindow), keyEquivalent: "").target = self + menu.addItem(withTitle: "设置…", action: #selector(openSettings), keyEquivalent: ",").target = self + menu.addItem(.separator()) + menu.addItem(withTitle: "退出 OpenStats", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + statusItem.menu = menu + statusItem.button?.performClick(nil) + statusItem.menu = nil + } + + @objc private func openMainWindow() { model.openMainWindow(nil) } + @objc private func openSettings() { model.openSettings() } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift index 8b6c29a..fc62cc7 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift @@ -1,7 +1,7 @@ import AppKit import SwiftUI -/// 菜单栏下方的玻璃浮动面板。点击面板外部、按 Esc 或切换应用时关闭。 +/// 菜单栏下方的浮动详情弹窗。点击外部、按 Esc 或切换应用时关闭。 /// 高度按内容自动计算,屏幕放得下就不出现滚动。 @MainActor final class StatusPanel: NSPanel { @@ -19,14 +19,13 @@ final class StatusPanel: NSPanel { private let makeContent: () -> NSView /// 平铺布局(不含滚动容器)的视图,只用来测量内容的自然高度 private let makeMeasuringContent: () -> NSView - private let usesGlass: () -> Bool + private let width: CGFloat - init(content: @escaping () -> Content, measuring: @escaping () -> Measuring, - usesGlass: @escaping () -> Bool) { - self.usesGlass = usesGlass + init(width: CGFloat, content: @escaping () -> Content, measuring: @escaping () -> Measuring) { + self.width = width makeContent = { NSHostingView(rootView: content()) } makeMeasuringContent = { NSHostingView(rootView: measuring()) } - super.init(contentRect: NSRect(x: 0, y: 0, width: DS.Size.panelWidth, height: DS.Size.panelMinHeight), + super.init(contentRect: NSRect(x: 0, y: 0, width: width, height: DS.Size.panelMinHeight), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true) @@ -60,7 +59,7 @@ final class StatusPanel: NSPanel { let content = makeContent() setFrame(targetFrame(), display: false) content.frame = NSRect(origin: .zero, size: frame.size) - contentView = GlassBackdrop.make(containing: content, cornerRadius: DS.Radius.xl, glass: usesGlass()) + contentView = PanelContainer.make(containing: content, cornerRadius: DS.Radius.xl) alphaValue = 0 makeKeyAndOrderFront(nil) @@ -72,7 +71,7 @@ final class StatusPanel: NSPanel { onVisibilityChange?(true) } - /// 切换标签页后重新计算高度,保持顶部对齐。 + /// 内容区块变化后重新计算高度,保持顶部对齐。 /// 直接设置而不做窗口动画:动画期间 SwiftUI 会逐帧重排,看起来像抖动 func refreshHeight() { guard isVisible else { return } @@ -122,9 +121,9 @@ final class StatusPanel: NSPanel { let natural = makeMeasuringContent().fittingSize.height let height = min(max(natural, DS.Size.panelMinHeight), available) - var x = anchor.midX - DS.Size.panelWidth / 2 - x = min(max(x, visible.minX + margin), visible.maxX - DS.Size.panelWidth - margin) - return NSRect(x: x, y: top - height, width: DS.Size.panelWidth, height: height) + var x = anchor.midX - width / 2 + x = min(max(x, visible.minX + margin), visible.maxX - width - margin) + return NSRect(x: x, y: top - height, width: width, height: height) } // MARK: 事件 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift index ee69edb..324e264 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift @@ -194,3 +194,82 @@ struct CoreClusterBars: View { .accessibilityLabel("各核心负载") } } + +/// 上下镜像的流量图:上半部分上传、下半部分下载,各自按自己的峰值缩放 +struct MirroredRateChart: View { + let upload: [Double] + let download: [Double] + var capacity: Int = MetricsStore.historyCapacity + var height: CGFloat = DS.Size.chartHeight + + /// 峰值太小时按 64 KB/s 缩放,避免空闲时的噪声被放大成满格 + static let floor = 64.0 * 1024 + + var body: some View { + Canvas { context, size in + let middle = size.height / 2 + let half = CGSize(width: size.width, height: middle) + let series: [([Double], Color, Bool)] = [ + (upload, Color(nsColor: DS.NetworkPalette.upload), true), + (download, Color(nsColor: DS.NetworkPalette.download), false), + ] + for (values, color, above) in series { + let peak = max(values.max() ?? 0, Self.floor) + guard var line = LineHistoryChart.path(values: values, capacity: capacity, maxValue: peak, in: half) else { continue } + if !above { + // 下半部分:向下翻转 + line = line.applying(CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: middle * 2)) + } + var area = line + area.addLine(to: CGPoint(x: size.width, y: middle)) + area.addLine(to: CGPoint(x: line.boundingRect.minX, y: middle)) + area.closeSubpath() + context.fill(area, with: .color(color.opacity(0.14))) + context.stroke(line, with: .color(color), style: StrokeStyle(lineWidth: DS.Size.chartLine, lineJoin: .round)) + } + context.fill(Path(CGRect(x: 0, y: middle - DS.Size.stroke / 2, width: size.width, height: DS.Size.stroke)), + with: .color(DS.Palette.border)) + } + .frame(height: height) + .accessibilityHidden(true) + } +} + +/// 连接探测格子:按时间从左到右、从上到下排列,最新的一格在末尾。 +/// 绿色正常,橙色延迟偏高,红色超时,灰色尚无数据 +struct ProbeGrid: View { + let samples: [ProbeSample] + var columns = 20 + var rows = 3 + var slowThreshold: Double = 200 + + var body: some View { + let capacity = columns * rows + let recent = Array(samples.suffix(capacity)) + Canvas { context, size in + let gap = DS.Space.s1 / 2 + let cellWidth = (size.width - gap * CGFloat(columns - 1)) / CGFloat(columns) + let cellHeight = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows) + let offset = capacity - recent.count + for index in 0..= slowThreshold ? DS.Palette.warning : DS.Palette.success + } else { + color = DS.Palette.error + } + context.fill(Path(roundedRect: rect, cornerRadius: DS.Radius.sm / 2), with: .color(color)) + } + } + // 格子保持接近正方形,宽度随容器变化 + .aspectRatio(CGFloat(columns) / CGFloat(rows), contentMode: .fit) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement() + .accessibilityLabel("连接探测历史") + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift index 61f1383..68cb8f1 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift @@ -4,6 +4,8 @@ import SwiftUI /// ImageRenderer 无法渲染 ScrollView 等 AppKit 承载的控件,截图模式下改用平铺布局。 extension EnvironmentValues { @Entry var isSnapshot = false + /// 在主窗口里显示指标详情:显示全部区块,图表更高 + @Entry var isDetailPage = false } // MARK: - 卡片 @@ -20,8 +22,7 @@ struct Card: View { .padding(padding) // 在等高行里撑满高度,背景随之延伸 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md)) - .overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)) + .background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.lg)) } } @@ -180,7 +181,7 @@ private struct DSButtonBody: View { private var background: Color { switch kind { case .primary: active ? DS.Palette.primaryHover : DS.Palette.primary - case .secondary: active ? DS.Palette.surfaceHover : DS.Palette.surface + case .secondary: active ? DS.Palette.surfaceHover : DS.Palette.elevated case .ghost: active ? DS.Palette.surfaceHover : .clear } } @@ -196,10 +197,10 @@ struct IconButton: View { Button(action: action) { Image(systemName: systemName) .font(.system(size: DS.TextSize.sm.rawValue, weight: .medium)) - .foregroundStyle(hovering ? DS.Palette.textPrimary : DS.Palette.textSecondary) + .foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textSecondary) .frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight) - .dsGlass(in: Circle(), interactive: true) - .contentShape(Circle()) + .background(hovering ? DS.Palette.surfaceHover : .clear, in: RoundedRectangle(cornerRadius: DS.Radius.md)) + .contentShape(Rectangle()) } .buttonStyle(.plain) .onHover { hovering = $0 } @@ -208,6 +209,41 @@ struct IconButton: View { } } +// MARK: - 侧边栏 + +struct SidebarButton: View { + let title: String + let symbol: String + let isSelected: Bool + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: DS.Space.s2) { + Image(systemName: symbol) + .font(.system(size: DS.TextSize.sm.rawValue, weight: .medium)) + .frame(width: DS.Size.iconStandalone) + Text(title).dsFont(.sm, weight: isSelected ? .semibold : .regular) + Spacer() + } + .foregroundStyle(isSelected ? DS.Palette.onPrimary : DS.Palette.textPrimary) + .padding(.horizontal, DS.Space.s2) + .frame(height: DS.Size.controlHeight) + .background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var background: Color { + if isSelected { return DS.Palette.primary } + return hovering ? DS.Palette.surfaceHover : .clear + } +} + // MARK: - 分段控件 struct SegmentedControl: View { @@ -230,7 +266,7 @@ struct SegmentedControl: View { .background { if selected { RoundedRectangle(cornerRadius: DS.Radius.md - DS.Space.s1) - .fill(DS.Palette.surface) + .fill(DS.Palette.elevated) .dsShadow(DS.Shadow.level1) } } @@ -417,7 +453,7 @@ struct DSSlider: View { RoundedRectangle(cornerRadius: DS.Radius.sm).fill(DS.Palette.primary) .frame(width: x + knob / 2, height: DS.Space.s1 + DS.Space.s1 / 2) Circle() - .fill(DS.Palette.surface) + .fill(DS.Palette.elevated) .overlay(Circle().strokeBorder(DS.Palette.primary, lineWidth: DS.Size.chartLine)) .frame(width: knob, height: knob) .dsShadow(DS.Shadow.level1) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/FlagImage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/FlagImage.swift new file mode 100644 index 0000000..607aa32 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/FlagImage.swift @@ -0,0 +1,42 @@ +import AppKit +import SwiftUI + +/// 国旗:使用包内的 SVG(flag-icons,MIT),不使用 emoji +struct FlagImage: View { + let countryCode: String + var height: CGFloat = DS.Space.s3 + + var body: some View { + if let image = FlagCache.shared.image(for: countryCode) { + Image(nsImage: image) + .resizable() + .aspectRatio(4 / 3, contentMode: .fit) + .frame(height: height) + .clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm / 2)) + .overlay(RoundedRectangle(cornerRadius: DS.Radius.sm / 2).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)) + .accessibilityHidden(true) + } + } +} + +@MainActor +final class FlagCache { + static let shared = FlagCache() + private var images: [String: NSImage] = [:] + private var missing: Set = [] + + func image(for countryCode: String) -> NSImage? { + let code = countryCode.lowercased() + // 只接受两位字母,避免拼出包外路径 + guard code.count == 2, code.unicodeScalars.allSatisfy({ ("a"..."z").contains($0) }) else { return nil } + if let image = images[code] { return image } + guard !missing.contains(code) else { return nil } + guard let url = Bundle.module.url(forResource: code, withExtension: "svg", subdirectory: "Flags"), + let image = NSImage(contentsOf: url) else { + missing.insert(code) + return nil + } + images[code] = image + return image + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift deleted file mode 100644 index d703472..0000000 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift +++ /dev/null @@ -1,66 +0,0 @@ -import AppKit -import SwiftUI - -// 液态玻璃:macOS 26 起使用系统 Liquid Glass,更早的系统退回毛玻璃材质。 -// 截图模式无法捕获窗口背后的模糊,统一改用实色。 - -extension View { - @ViewBuilder - func dsGlass(in shape: S, interactive: Bool = false, tint: Color? = nil) -> some View { - if #available(macOS 26.0, *) { - glassEffect(interactive ? Glass.regular.tint(tint).interactive() : Glass.regular.tint(tint), in: shape) - } else { - background(.ultraThinMaterial, in: shape) - } - } -} - -/// 面板窗口的底板,SwiftUI 内容放在其中。 -/// 默认是圆角裁切的普通容器,由 SwiftUI 画实色背景;开启玻璃时使用系统液态玻璃或毛玻璃 -@MainActor -enum GlassBackdrop { - /// 容器必须从内容的尺寸开始:若从 0 开始,窗口把它撑满时自动调整会把内容再放大一倍 - static func make(containing content: NSView, cornerRadius: CGFloat, glass: Bool) -> NSView { - guard glass else { - let container = NSView(frame: content.frame) - container.wantsLayer = true - container.layer?.cornerRadius = cornerRadius - container.layer?.cornerCurve = .continuous - container.layer?.masksToBounds = true - content.autoresizingMask = [.width, .height] - container.addSubview(content) - return container - } - if #available(macOS 26.0, *) { - let glass = NSGlassEffectView(frame: content.frame) - glass.cornerRadius = cornerRadius - glass.style = .regular - glass.contentView = content - return glass - } - let effect = NSVisualEffectView(frame: content.frame) - effect.material = .popover - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = cornerRadius - effect.layer?.masksToBounds = true - content.frame = effect.bounds - content.autoresizingMask = [.width, .height] - effect.addSubview(content) - return effect - } -} - -/// 设置窗口侧边栏的半透明背景 -struct SidebarMaterial: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.material = .sidebar - view.blendingMode = .behindWindow - view.state = .followsWindowActiveState - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} -} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/PanelContainer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/PanelContainer.swift new file mode 100644 index 0000000..959028c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/PanelContainer.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI + +/// 弹窗窗口的底板:圆角裁切的普通容器,背景由 SwiftUI 内容绘制 +@MainActor +enum PanelContainer { + /// 容器必须从内容的尺寸开始:若从 0 开始,窗口把它撑满时自动调整会把内容再放大一倍 + static func make(containing content: NSView, cornerRadius: CGFloat) -> NSView { + let container = NSView(frame: content.frame) + container.wantsLayer = true + container.layer?.cornerRadius = cornerRadius + container.layer?.cornerCurve = .continuous + container.layer?.masksToBounds = true + content.autoresizingMask = [.width, .height] + container.addSubview(content) + return container + } +} + +/// 内容铺满标题栏后,标题栏区域由 SwiftUI 接收点击;放在顶栏背后,按住空白处可以拖动窗口 +struct WindowDragArea: NSViewRepresentable { + final class DragView: NSView { + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { + window?.performZoom(nil) + } else { + window?.performDrag(with: event) + } + } + } + + func makeNSView(context: Context) -> DragView { DragView() } + func updateNSView(_ nsView: DragView, context: Context) {} +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index 8b5d5f6..51284de 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -24,10 +24,12 @@ extension DS { static let textTertiary = Color.dynamic(light: 0x9CA3AF, dark: 0x6B7280) static let onPrimary = Color.dynamic(light: 0xFFFFFF, dark: 0xFFFFFF) - // 中性色(背景为实色,截图与不支持玻璃效果时使用) - static let background = Color.dynamic(light: 0xF8F9FA, dark: 0x111315) - // 卡片与控件使用半透明色,叠在液态玻璃上仍能透出背景 - static let surface = Color.dynamic(light: 0xFFFFFF, lightAlpha: 0.72, dark: 0x2A2D33, darkAlpha: 0.55) + // OpenStats 的统一配色:浅色为白底蓝色,深色为黑底蓝色。 + // 窗口、侧边栏、标题栏与弹窗共用同一底色,卡片用浅灰 / 深灰实色区分层级,不加边框 + static let background = Color(nsColor: NSColor.dsBackground) + static let surface = Color.dynamic(light: 0xF3F4F6, dark: 0x17181B) + /// 卡片上的控件底色(分段选中块、次要按钮、输入框) + static let elevated = Color.dynamic(light: 0xFFFFFF, dark: 0x26282D) static let surfaceHover = Color.dynamic(light: 0x000000, lightAlpha: 0.05, dark: 0xFFFFFF, darkAlpha: 0.07) 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) @@ -50,6 +52,9 @@ extension Color { } extension NSColor { + /// 窗口底色,AppKit 窗口背景与 SwiftUI 页面共用 + static let dsBackground = NSColor.dynamic(light: 0xFFFFFF, dark: 0x0B0B0D) + static func dynamic(light: UInt32, lightAlpha: CGFloat = 1, dark: UInt32, darkAlpha: CGFloat = 1) -> NSColor { NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua @@ -90,7 +95,7 @@ extension DS { static let sm: CGFloat = 4 static let md: CGFloat = 8 static let lg: CGFloat = 12 - static let xl: CGFloat = 16 // 玻璃面板 + static let xl: CGFloat = 16 // 弹窗 } enum Size { @@ -108,10 +113,13 @@ extension DS { static let stroke: CGFloat = 1 static let chartLine: CGFloat = 1.5 static let panelWidth: CGFloat = 640 + /// 菜单栏单项详情弹窗的宽度 + static let popoverWidth: CGFloat = 320 static let panelMinHeight: CGFloat = 320 static let panelGap: CGFloat = 4 - static let tabsWidth: CGFloat = 300 - /// 三列卡片的单列宽度:(面板宽 - 两侧内边距 - 两个列间距) / 3 + /// 窗口顶栏高度,与紧凑工具栏的标题栏同高,红绿灯按钮与页面标题在同一水平线上 + static let windowHeader: CGFloat = 40 + /// 窄控件的固定宽度(分段控件等) static let tileWidth: CGFloat = (panelWidth - Space.s3 * 4) / 3 static let settingsWidth: CGFloat = 720 static let settingsHeight: CGFloat = 520 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/CleanerPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/CleanerPage.swift index a8bd406..64bbe5d 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/CleanerPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/CleanerPage.swift @@ -8,9 +8,9 @@ struct CleanerPage: View { var body: some View { PageScroll { - EqualHeightRow { - SummaryCard().frame(maxWidth: .infinity) - MaintenanceCard().frame(width: DS.Size.tileWidth) + WeightedRow(weights: [2, 1]) { + SummaryCard() + MaintenanceCard() } if model.cleaner.needsFullDiskAccess { InfoBanner(icon: "lock.shield", text: "部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。", tone: .warning) { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift new file mode 100644 index 0000000..d3cab8b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -0,0 +1,148 @@ +import AppKit +import Metrics +import SwiftUI + +/// 主窗口:侧边栏、标题栏与页面在同一张底色上,不分栏着色,也不画分隔线。 +/// 左侧切换页面,右侧是仪表盘、各指标详情与工具页;窗口宽高都可调整 +public struct MainWindowView: View { + @Environment(AppModel.self) private var model + @Environment(\.isSnapshot) private var isSnapshot + + public init() {} + + public var body: some View { + HStack(spacing: 0) { + MainSidebar() + .frame(width: DS.Size.settingsSidebar) + .frame(maxHeight: .infinity) + .background(alignment: .top) { + WindowDragArea().frame(height: DS.Size.windowHeader) + } + + VStack(spacing: 0) { + PageHeader() + Group { + switch model.settings.panelTab { + case .overview: OverviewPage() + case .cpu: DetailPage { CPUPopover() } + case .gpu: DetailPage { GPUPopover() } + case .memory: DetailPage { MemoryPopover() } + case .network: DetailPage { NetworkPopover() } + case .thermal: ThermalPage() + case .processes: ProcessesPage() + case .keepAwake: KeepAwakePage() + case .cleaner: CleanerPage() + } + } + .frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top) + } + .frame(minWidth: DS.Size.panelWidth, maxWidth: .infinity) + .frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top) + } + .frame(width: isSnapshot ? DS.Size.settingsSidebar + DS.Size.panelWidth : nil) + .fixedSize(horizontal: false, vertical: isSnapshot) + .background(DS.Palette.background) + // 内容延伸到透明标题栏下方,由顶栏高度留出红绿灯按钮的位置 + .ignoresSafeArea() + } +} + +/// 指标详情页:复用菜单栏弹窗的内容,显示全部区块 +private struct DetailPage: View { + @ViewBuilder var content: Content + + var body: some View { + PageScroll { content } + .environment(\.isDetailPage, true) + } +} + +private struct MainSidebar: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: DS.Space.s1) { + group("监控", PanelTab.monitors) + group("工具", PanelTab.tools) + + Spacer(minLength: DS.Space.s3) + + HStack(spacing: DS.Space.s1) { + ThemeToggle() + IconButton(systemName: "gearshape", help: "设置") { model.openSettings() } + Spacer(minLength: 0) + IconButton(systemName: "power", help: "退出 OpenStats") { model.quit() } + } + } + .padding(.horizontal, DS.Space.s3) + // 顶部留出窗口红绿灯按钮的位置 + .padding(.top, DS.Size.windowHeader + DS.Space.s2) + .padding(.bottom, DS.Space.s3) + } + + @ViewBuilder + private func group(_ title: String, _ tabs: [PanelTab]) -> some View { + Text(title) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textTertiary) + .padding(.horizontal, DS.Space.s2) + .padding(.top, DS.Space.s2) + ForEach(tabs) { tab in + SidebarButton(title: tab.title, symbol: tab.symbol, isSelected: model.settings.panelTab == tab) { + model.settings.panelTab = tab + } + } + } +} + +private struct PageHeader: View { + @Environment(AppModel.self) private var model + + var body: some View { + let settings = model.settings + let tab = settings.panelTab + + HStack(spacing: DS.Space.s3) { + Text(tab.title) + .dsFont(.base, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + Spacer(minLength: DS.Space.s3) + if model.keepAwake.isActive { + Chip(text: "防休眠已开启", icon: "cup.and.saucer.fill", tone: .primary) + } + if let item = tab.menuBarItem { + Text("在菜单栏显示").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }), + label: "在菜单栏显示\(item.title)") + } + } + .padding(.horizontal, DS.Space.s3 + DS.Space.s1) + .frame(height: DS.Size.windowHeader) + .background(WindowDragArea()) + } +} + +/// 在浅色与深色之间切换;当前跟随系统时,切到与系统相反的那一种 +private struct ThemeToggle: View { + @Environment(AppModel.self) private var model + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let isDark = colorScheme == .dark + IconButton(systemName: isDark ? "sun.max" : "moon", help: isDark ? "切换到浅色" : "切换到深色") { + model.settings.appearance = isDark ? .light : .dark + } + } +} + +struct AppGlyph: View { + var size: CGFloat = DS.Size.controlHeight + + var body: some View { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .interpolation(.high) + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift index df338ef..e9547fc 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift @@ -9,37 +9,62 @@ struct OverviewPage: View { var body: some View { PageScroll { HealthHeader() - EqualHeightRow { - CPUTile().frame(maxWidth: .infinity) - GPUTile().frame(maxWidth: .infinity) - MemoryTile().frame(maxWidth: .infinity) + WeightedRow { + CPUTile() + GPUTile() + MemoryTile() } - EqualHeightRow { - DiskTile().frame(maxWidth: .infinity) - NetworkTile().frame(maxWidth: .infinity) - FanTile().frame(maxWidth: .infinity) + WeightedRow { + DiskTile() + NetworkTile() + FanTile() } - EqualHeightRow { - CoreLoadCard().frame(maxWidth: .infinity) - if model.store.battery != nil { - BatteryCard().frame(width: DS.Size.tileWidth) - } + // 窄卡片与上方单列同宽:宽卡占两列 + WeightedRow(weights: model.store.battery != nil ? [2, 1] : [1]) { + CoreLoadCard() + if model.store.battery != nil { BatteryCard() } } - EqualHeightRow { - TopProcessesCard().frame(maxWidth: .infinity) - QuickActionsCard().frame(width: DS.Size.tileWidth) + WeightedRow(weights: [2, 1]) { + TopProcessesCard() + QuickActionsCard() } } } } -/// 一行等高卡片 -struct EqualHeightRow: View { - @ViewBuilder var content: Content +/// 一行卡片:按权重分配宽度(窗口变宽时同比放大),高度取最高的一张,其余撑满 +struct WeightedRow: Layout { + var weights: [CGFloat] + var spacing: CGFloat - var body: some View { - HStack(alignment: .top, spacing: DS.Space.s3) { content } - .fixedSize(horizontal: false, vertical: true) + init(weights: [CGFloat] = [], spacing: CGFloat = DS.Space.s3) { + self.weights = weights + self.spacing = spacing + } + + private func widths(total: CGFloat, count: Int) -> [CGFloat] { + guard count > 0 else { return [] } + let resolved = (0.. CGSize { + let width = proposal.width ?? DS.Size.panelWidth + let columnWidths = widths(total: width, count: subviews.count) + let height = zip(subviews, columnWidths).map { $0.sizeThatFits(ProposedViewSize(width: $1, height: nil)).height }.max() ?? 0 + return CGSize(width: width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX + for (subview, width) in zip(subviews, widths(total: bounds.width, count: subviews.count)) { + subview.place(at: CGPoint(x: x, y: bounds.minY), proposal: ProposedViewSize(width: width, height: bounds.height)) + x += width + spacing + } } } @@ -258,7 +283,6 @@ private struct FanTile: View { var body: some View { let fans = model.store.sensors?.fans ?? [] let fastest = model.store.fastestFan - let isManual = fans.contains(where: \.isManual) // 以最高转速为 100%,比“最低到最高”的区间比例更直观 let average = fans.isEmpty ? 0 : fans.map { $0.maximum > 0 ? $0.current / $0.maximum : 0 }.reduce(0, +) / Double(fans.count) @@ -279,7 +303,7 @@ private struct FanTile: View { Text(verbatim: "RPM").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary) } - Text(fanStatus(fans: fans, isManual: isManual)) + Text(fanStatusText(fans: fans, mode: model.fans.mode)) .dsFont(.xs) .foregroundStyle(DS.Palette.textSecondary) .frame(height: DS.Size.tileChart / 2, alignment: .center) @@ -296,15 +320,6 @@ private struct FanTile: View { } } -extension FanTile { - /// 固件只记录“手动 / 自动”,无法区分是谁设置的:OpenStats 未下发时即为其他程序(如 Stats、Mole) - fileprivate func fanStatus(fans: [FanState], isManual: Bool) -> String { - if fans.isEmpty { return "未检测到风扇" } - guard isManual else { return "由 macOS 调节" } - return model.fans.mode == .automatic ? "其他程序手动控制" : "OpenStats 控制中" - } -} - // MARK: - 核心负载 private struct CoreLoadCard: View { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift deleted file mode 100644 index 1f3f303..0000000 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift +++ /dev/null @@ -1,109 +0,0 @@ -import AppKit -import Metrics -import SwiftUI - -public struct PanelRootView: View { - @Environment(AppModel.self) private var model - @Environment(\.isSnapshot) private var isSnapshot - - public init() {} - - public var body: some View { - VStack(spacing: 0) { - PanelHeader() - .padding(DS.Space.s3) - - HairlineDivider() - - Group { - switch model.settings.panelTab { - case .overview: OverviewPage() - case .processes: ProcessesPage() - case .thermal: ThermalPage() - case .keepAwake: KeepAwakePage() - case .cleaner: CleanerPage() - } - } - .frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top) - } - .frame(width: DS.Size.panelWidth) - .frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top) - .fixedSize(horizontal: false, vertical: isSnapshot) - .background { - // 开启液态玻璃时由窗口底板提供背景,否则画浅色 / 深色实色背景 - if isSnapshot || !model.settings.panelGlass { - RoundedRectangle(cornerRadius: DS.Radius.xl).fill(DS.Palette.background) - } - } - .overlay { - RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke) - } - } -} - -private struct PanelHeader: View { - @Environment(AppModel.self) private var model - - var body: some View { - @Bindable var settings = model.settings - - HStack(spacing: DS.Space.s3) { - AppGlyph() - VStack(alignment: .leading, spacing: 0) { - Text("OpenStats") - .dsFont(.base, weight: .semibold) - .foregroundStyle(DS.Palette.textPrimary) - Text("状态栏监控") - .dsFont(.xs) - .foregroundStyle(DS.Palette.textSecondary) - } - .fixedSize() - - Spacer(minLength: DS.Space.s3) - - SegmentedControl(selection: $settings.panelTab, - options: PanelTab.allCases.map { ($0, $0.title) }) - .frame(width: DS.Size.tabsWidth) - - Spacer(minLength: DS.Space.s3) - - HStack(spacing: DS.Space.s1) { - if model.keepAwake.isActive { - Image(systemName: "cup.and.saucer.fill") - .font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold)) - .foregroundStyle(DS.Palette.primary) - .frame(width: DS.Size.controlHeight) - .help("防休眠已开启") - } - ThemeToggle() - IconButton(systemName: "gearshape", help: "设置") { model.openSettings() } - IconButton(systemName: "power", help: "退出 OpenStats") { model.quit() } - } - } - } -} - -/// 在浅色与深色之间切换;当前跟随系统时,切到与系统相反的那一种 -private struct ThemeToggle: View { - @Environment(AppModel.self) private var model - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - let isDark = colorScheme == .dark - IconButton(systemName: isDark ? "sun.max" : "moon", help: isDark ? "切换到浅色" : "切换到深色") { - model.settings.appearance = isDark ? .light : .dark - } - } -} - -struct AppGlyph: View { - var size: CGFloat = DS.Size.controlHeight - - var body: some View { - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .interpolation(.high) - .frame(width: size, height: size) - .accessibilityHidden(true) - } -} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift index 1a6eee7..20ba6ce 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift @@ -139,10 +139,14 @@ final class AppIconCache { private var cache: [String: CGImage] = [:] func image(for process: ProcessUsage) -> Image { - let key = process.appBundlePath ?? "unix-executable" + image(bundlePath: process.appBundlePath) + } + + func image(bundlePath: String?) -> Image { + let key = bundlePath ?? "unix-executable" if let cached = cache[key] { return Image(decorative: cached, scale: 2) } - let icon = process.appBundlePath.map { NSWorkspace.shared.icon(forFile: $0) } + let icon = bundlePath.map { NSWorkspace.shared.icon(forFile: $0) } ?? NSWorkspace.shared.icon(for: .unixExecutable) var rect = NSRect(x: 0, y: 0, width: Self.pixelSize, height: Self.pixelSize) guard let cgImage = icon.cgImage(forProposedRect: &rect, context: nil, hints: nil) else { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift new file mode 100644 index 0000000..ecf2602 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift @@ -0,0 +1,378 @@ +import AppKit +import Metrics +import SMC +import SwiftUI + +private let heroGauge = DS.Space.s12 + DS.Space.s2 + +extension View { + /// 详情图表高度:弹窗里紧凑,主窗口里放大 + @MainActor + func detailChartHeight(_ isDetailPage: Bool) -> CGFloat { + isDetailPage ? DS.Size.chartHeight * 3 : DS.Size.chartHeight + } +} + +private func loadTone(_ value: Double) -> Tone { + value >= 0.85 ? .error : value >= 0.6 ? .warning : .primary +} + +// MARK: - CPU + +struct CPUPopover: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let settings = model.settings + let store = model.store + let cpu = store.cpu + let temperature = store.sensors?.temperature(.cpu) + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(spacing: DS.Space.s3) { + RingGauge(fraction: cpu?.total ?? 0, color: loadTone(cpu?.total ?? 0).color, size: heroGauge) { + Text(verbatim: cpu.map { Format.percent($0.total) } ?? "—") + .dsFont(.sm, weight: .semibold) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + } + VStack(alignment: .leading, spacing: DS.Space.s1) { + LegendItem(color: DS.Palette.primary, label: "用户", value: cpu.map { Format.percent($0.user) } ?? "—") + LegendItem(color: DS.Palette.secondary, label: "系统", value: cpu.map { Format.percent($0.system) } ?? "—") + LegendItem(color: DS.Palette.neutral300, label: "空闲", + value: cpu.map { Format.percent(max(0, 1 - $0.total)) } ?? "—") + } + Spacer(minLength: 0) + if let temperature { + Chip(text: Format.temperature(temperature.maximum, fahrenheit: settings.useFahrenheit), + icon: "thermometer.medium", tone: Tone.forTemperature(temperature.maximum)) + } + } + } + + ForEach(MenuBarItem.cpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in + switch section { + case .cpuHistory: + SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) { + BarHistoryChart(values: store.cpuTotal.elements, capacity: isDetailPage ? 60 : 40, height: detailChartHeight(isDetailPage)) + } + case .cpuCores: + SectionCard(title: section.title) { + CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? []) + } + case .cpuDetails: + SectionCard(title: section.title) { + InfoRow(label: "处理器", text: store.topology.brand) + InfoRow(label: "核心", text: coreSummary(store.topology)) + InfoRow(label: "负载平均", text: cpu.map { loadAverage($0.loadAverage) } ?? "—") + if let boot = store.system.bootDate { + InfoRow(label: "已运行", text: Format.uptime(since: boot)) + } + } + case .cpuProcesses: + SectionCard(title: section.title, trailing: { Text("CPU") }) { + let processes = store.processes + ProcessList(count: processes.count) { index in + let process = processes[index] + HStack(spacing: DS.Space.s2) { + ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName) + Text(verbatim: "\((process.cpu * 100).formatted(.number.precision(.fractionLength(1))))%") + .dsFont(.xs, weight: .medium) + .monospacedDigit() + .foregroundStyle(DS.Palette.textSecondary) + } + } + } + default: + EmptyView() + } + } + } + + private func coreSummary(_ topology: CPUTopology) -> String { + let clusters = topology.clusters.map { "\($0.coreIndices.count) \($0.name)" }.joined(separator: " + ") + return clusters.isEmpty ? "\(topology.logicalCores) 核" : "\(topology.logicalCores) 核(\(clusters))" + } + + private func loadAverage(_ values: [Double]) -> String { + values.prefix(3).map { $0.formatted(.number.precision(.fractionLength(2))) }.joined(separator: " · ") + } +} + +// MARK: - 内存 + +struct MemoryPopover: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let settings = model.settings + let store = model.store + let memory = store.memory + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(spacing: DS.Space.s3) { + RingGauge(fraction: memory?.usedFraction ?? 0, color: pressureTone(memory?.pressure).color, size: heroGauge) { + Text(verbatim: memory.map { Format.percent($0.usedFraction) } ?? "—") + .dsFont(.sm, weight: .semibold) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + } + VStack(alignment: .leading, spacing: DS.Space.s1) { + HeroValue(value: memory.map { Format.bytes($0.used) } ?? "—", unit: memory.map { "/ \(Format.bytes($0.total))" }, size: .lg) + StatusBadge(text: "内存压力 \(memory?.pressure.title ?? "—")", tone: pressureTone(memory?.pressure)) + } + Spacer(minLength: 0) + } + } + + ForEach(MenuBarItem.memory.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in + switch section { + case .memoryHistory: + SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) { + LineHistoryChart(values: store.memoryHistory.elements, height: detailChartHeight(isDetailPage)) + } + case .memoryBreakdown: + SectionCard(title: section.title) { + if let memory { + MemoryBar(memory: memory) + LegendRow(color: DS.Palette.primary, label: "App 内存", value: Format.bytes(memory.app)) + LegendRow(color: DS.Palette.secondary, label: "联动内存", value: Format.bytes(memory.wired)) + LegendRow(color: DS.Palette.warning, label: "被压缩", value: Format.bytes(memory.compressed)) + LegendRow(color: DS.Palette.neutral300, label: "缓存文件", value: Format.bytes(memory.cached)) + LegendRow(color: DS.Palette.track, label: "交换空间", value: Format.bytes(memory.swapUsed)) + } + PurgeMemoryRow() + } + case .memoryProcesses: + SectionCard(title: section.title, trailing: { Text("内存") }) { + let processes = store.processes.sorted { $0.memory > $1.memory } + ProcessList(count: processes.count) { index in + let process = processes[index] + HStack(spacing: DS.Space.s2) { + ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName) + Text(verbatim: Format.bytes(process.memory)) + .dsFont(.xs, weight: .medium) + .monospacedDigit() + .foregroundStyle(DS.Palette.textSecondary) + } + } + } + default: + EmptyView() + } + } + } + + private func pressureTone(_ pressure: MemoryPressure?) -> Tone { + switch pressure { + case .normal: .success + case .warning: .warning + case .critical: .error + case nil: .neutral + } + } +} + +private struct LegendRow: View { + let color: Color + let label: String + let value: String + + var body: some View { + HStack(spacing: DS.Space.s2) { + RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(color) + .frame(width: DS.Size.barHeight, height: DS.Size.barHeight) + Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + Spacer(minLength: DS.Space.s2) + Text(verbatim: value).dsFont(.xs, weight: .medium).monospacedDigit().foregroundStyle(DS.Palette.textPrimary) + } + } +} + +/// App / 联动 / 压缩三段堆叠条,剩余部分为可用 +private struct MemoryBar: View { + let memory: MemoryUsage + + var body: some View { + GeometryReader { proxy in + let total = max(1, Double(memory.total)) + let parts: [(UInt64, Color)] = [(memory.app, DS.Palette.primary), (memory.wired, DS.Palette.secondary), + (memory.compressed, DS.Palette.warning)] + HStack(spacing: DS.Size.stroke) { + ForEach(Array(parts.enumerated()), id: \.offset) { _, part in + Rectangle().fill(part.1) + .frame(width: max(0, proxy.size.width * Double(part.0) / total - DS.Size.stroke)) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + .background(DS.Palette.track) + .clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm)) + } + .frame(height: DS.Space.s3) + } +} + +private struct PurgeMemoryRow: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let maintenance = model.maintenance + HStack(spacing: DS.Space.s2) { + Button(maintenance.running == .purgeMemory ? "正在释放…" : "释放内存") { + Task { await maintenance.run(.purgeMemory) } + } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(maintenance.running != nil) + if let outcome = maintenance.outcomes[.purgeMemory] { + Text(outcome.text) + .dsFont(.xs) + .foregroundStyle(outcome.isError ? DS.Palette.error : DS.Palette.textSecondary) + .lineLimit(2) + } + Spacer(minLength: 0) + } + } +} + +// MARK: - GPU + +struct GPUPopover: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let settings = model.settings + let store = model.store + let gpu = store.gpu + let temperature = store.sensors?.temperature(.gpu) + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(spacing: DS.Space.s3) { + RingGauge(fraction: gpu?.utilization ?? 0, color: loadTone(gpu?.utilization ?? 0).color, size: heroGauge) { + Text(verbatim: gpu.map { Format.percent($0.utilization) } ?? "—") + .dsFont(.sm, weight: .semibold) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + } + VStack(alignment: .leading, spacing: DS.Space.s1) { + Text(verbatim: gpu?.name ?? "GPU") + .dsFont(.sm, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(1) + Text(verbatim: gpu?.coreCount.map { "\($0) 核图形处理器" } ?? "图形处理器") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } + Spacer(minLength: 0) + if let temperature { + Chip(text: Format.temperature(temperature.maximum, fahrenheit: settings.useFahrenheit), + icon: "thermometer.medium", tone: Tone.forTemperature(temperature.maximum)) + } + } + } + + ForEach(MenuBarItem.gpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in + switch section { + case .gpuHistory: + SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) { + LineHistoryChart(values: store.gpuHistory.elements, color: DS.Palette.secondary, height: detailChartHeight(isDetailPage)) + } + case .gpuDetails: + SectionCard(title: section.title) { + InfoRow(label: "型号", text: gpu?.name ?? "—") + InfoRow(label: "核心数", text: gpu?.coreCount.map(String.init) ?? "—") + InfoRow(label: "平均温度", text: temperature.map { Format.temperature($0.average, fahrenheit: settings.useFahrenheit) } ?? "—") + InfoRow(label: "最高温度", text: temperature.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—") + } + default: + EmptyView() + } + } + } +} + +// MARK: - 温度与风扇 + +struct ThermalPopover: View { + let item: MenuBarItem + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let settings = model.settings + let store = model.store + let cpu = store.sensors?.temperature(.cpu) + let fans = store.sensors?.fans ?? [] + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(alignment: .center, spacing: DS.Space.s3) { + VStack(alignment: .leading, spacing: DS.Space.s1) { + Text("CPU 最高").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + HeroValue(value: cpu.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—") + } + .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: DS.Space.s1) { + Text("风扇").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + HeroValue(value: store.fastestFan.map { Int($0.current).formatted() } ?? "—", unit: fans.isEmpty ? nil : "RPM") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + ForEach(item.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in + switch section { + case .thermalSensors: + SectionCard(title: section.title, trailing: { Text("最高 / 平均") }) { + let temperatures = store.sensors?.temperatures ?? [] + if temperatures.isEmpty { + Text("正在读取传感器…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(temperatures) { summary in + VStack(spacing: DS.Space.s1) { + InfoRow(label: summary.group.title) { + Text(verbatim: "\(Format.temperature(summary.maximum, fahrenheit: settings.useFahrenheit)) / \(Format.temperature(summary.average, fahrenheit: settings.useFahrenheit))") + } + ProgressTrack(fraction: summary.maximum / DS.Thermal.scaleMax, + color: Tone.forTemperature(summary.maximum).color, + height: DS.Space.s1) + } + } + } + case .thermalFans: + SectionCard(title: section.title, trailing: { Text(fanStatusText(fans: fans, mode: model.fans.mode)) }) { + if fans.isEmpty { + Text("未检测到风扇").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } else { + ForEach(fans) { fan in + VStack(spacing: DS.Space.s1) { + InfoRow(label: "风扇 \(fan.id + 1)", text: Format.rpm(fan.current)) + ProgressTrack(fraction: fan.maximum > 0 ? fan.current / fan.maximum : 0, height: DS.Space.s1) + } + } + HStack(spacing: DS.Space.s1) { + ForEach([FanController.Mode.automatic, .cooling, .maximum]) { mode in + ChipButton(title: mode.title, isSelected: model.fans.mode == mode) { + model.requestFanMode(mode) + } + } + } + } + } + default: + EmptyView() + } + } + } +} + +/// 固件只记录“手动 / 自动”,无法区分是谁设置的:OpenStats 未下发时即为其他程序(如 Stats、Mole) +@MainActor +func fanStatusText(fans: [FanState], mode: FanController.Mode) -> String { + if fans.isEmpty { return "未检测到风扇" } + guard fans.contains(where: \.isManual) else { return "由 macOS 调节" } + return mode == .automatic ? "其他程序手动控制" : "OpenStats 控制中" +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift new file mode 100644 index 0000000..15cef7d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift @@ -0,0 +1,359 @@ +import AppKit +import HelperShared +import Metrics +import SwiftUI + +struct NetworkPopover: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let settings = model.settings + let store = model.store + let rate = store.network + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(spacing: DS.Space.s3) { + RateHero(title: "下载", bytesPerSecond: rate?.downloadBytesPerSecond, color: DS.NetworkPalette.download) + Rectangle().fill(DS.Palette.border).frame(width: DS.Size.stroke, height: DS.Space.s8) + RateHero(title: "上传", bytesPerSecond: rate?.uploadBytesPerSecond, color: DS.NetworkPalette.upload) + } + } + + ForEach(MenuBarItem.network.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in + switch section { + case .networkHistory: TrafficHistorySection() + case .networkProbe: ProbeSection() + case .networkInterface: InterfaceSection() + case .networkAddresses: AddressSection() + case .networkDNS: DNSSection() + case .networkProcesses: NetworkProcessesSection() + default: EmptyView() + } + } + } +} + +private struct RateHero: View { + let title: String + let bytesPerSecond: Double? + let color: NSColor + + var body: some View { + let parts = (bytesPerSecond.map { Format.menuBarRate($0) } ?? "— KB/s").split(separator: " ", maxSplits: 1).map(String.init) + VStack(alignment: .leading, spacing: DS.Space.s1) { + HeroValue(value: parts.first ?? "—", unit: parts.count > 1 ? parts[1] : nil) + HStack(spacing: DS.Space.s1) { + Circle().fill(Color(nsColor: color)).frame(width: DS.Size.barHeight, height: DS.Size.barHeight) + Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - 流量历史 + +private struct TrafficHistorySection: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + let store = model.store + let upload = store.uploadHistory.elements + let download = store.downloadHistory.elements + + SectionCard(title: PopoverSection.networkHistory.title, trailing: { Text("最近 60 秒") }) { + MirroredRateChart(upload: upload, download: download, + height: isDetailPage ? DS.Size.chartHeight * 3 : DS.Size.chartHeight + DS.Space.s6) + .overlay(alignment: .topLeading) { + peakLabel("峰值 ↑", upload) + } + .overlay(alignment: .bottomLeading) { + peakLabel("峰值 ↓", download) + } + } + } + + private func peakLabel(_ prefix: String, _ values: [Double]) -> some View { + Text(verbatim: "\(prefix) \(Format.menuBarRate(max(values.max() ?? 0, MirroredRateChart.floor)))") + .dsFont(.xs) + .monospacedDigit() + .foregroundStyle(DS.Palette.textTertiary) + } +} + +// MARK: - 连接探测 + +private struct ProbeSection: View { + @Environment(AppModel.self) private var model + @Environment(\.isDetailPage) private var isDetailPage + + var body: some View { + @Bindable var settings = model.settings + let network = model.network + + SectionCard(title: PopoverSection.networkProbe.title, trailing: { + if settings.probeEnabled { + Text(verbatim: "\(network.probeAddress ?? "—") · 每 \(settings.probeSeconds) 秒") + } + }) { + if settings.probeEnabled { + ProbeGrid(samples: network.probes.elements, columns: isDetailPage ? 40 : 20, rows: 3) + HStack(spacing: DS.Space.s3) { + stat("延迟", network.latency.map(milliseconds) ?? "—") + stat("抖动", network.jitter.map(milliseconds) ?? "—") + stat("丢包", network.lossRate.map { Format.percent($0) } ?? "—", + tone: (network.lossRate ?? 0) > 0.05 ? .error : .neutral) + } + } else { + HStack { + Text("定时 ping 一个地址,记录网络是否通畅").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + Spacer(minLength: DS.Space.s2) + Button("开启") { settings.probeEnabled = true } + .buttonStyle(DSButtonStyle(kind: .ghost)) + } + } + } + } + + private func milliseconds(_ value: Double) -> String { + value < 10 ? "\(value.formatted(.number.precision(.fractionLength(1)))) ms" : "\(Int(value.rounded())) ms" + } + + private func stat(_ label: String, _ value: String, tone: Tone = .neutral) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + Text(verbatim: value) + .dsFont(.sm, weight: .semibold) + .monospacedDigit() + .foregroundStyle(tone == .neutral ? DS.Palette.textPrimary : tone.color) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - 接口 + +private struct InterfaceSection: View { + @Environment(AppModel.self) private var model + + var body: some View { + let details = model.network.details + let physical = details?.physical + let totals = model.store.network + + SectionCard(title: PopoverSection.networkInterface.title) { + if let physical { + InfoRow(label: "接口", text: "\(physical.displayName)(\(physical.bsdName))") + InfoRow(label: "状态") { + StatusBadge(text: physical.isUp ? "已连接" : "未连接", tone: physical.isUp ? .success : .error) + } + InfoRow(label: "物理地址") { CopyableText(text: physical.hardwareAddress ?? "—") } + if let wifi = physical.wifi { + if let ssid = wifi.ssid { InfoRow(label: "网络名称", text: ssid) } + InfoRow(label: "信号强度", text: "\(wifi.rssi) dBm · \(signalQuality(wifi.rssi))") + InfoRow(label: "传输速率", text: "\(Int(wifi.transmitRate)) Mbps") + } + } else { + Text(details == nil ? "正在读取…" : "未连接网络").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + if let tunnel = details?.tunnel { + InfoRow(label: "VPN / 代理", text: "\(tunnel.name)(\(tunnel.bsdName))") + } + if let totals { + InfoRow(label: "开机后下载", text: Format.bytes(totals.totalDownloaded, base: .decimal)) + InfoRow(label: "开机后上传", text: Format.bytes(totals.totalUploaded, base: .decimal)) + } + } + } + + private func signalQuality(_ rssi: Int) -> String { + rssi >= -50 ? "极好" : rssi >= -60 ? "良好" : rssi >= -70 ? "一般" : "较弱" + } +} + +// MARK: - 地址 + +private struct AddressSection: View { + @Environment(AppModel.self) private var model + + var body: some View { + let network = model.network + let physical = network.details?.physical + let lookup = model.settings.publicIPLookup + let publicAddresses = network.publicAddresses + + SectionCard(title: PopoverSection.networkAddresses.title, trailing: { + if lookup { + MiniIconButton(systemName: "arrow.clockwise", help: "重新查询公网 IP") { + network.lookUpPublicAddresses() + } + .disabled(network.isLookingUpPublic) + } + }) { + InfoRow(label: "本地 IPv4") { CopyableText(text: physical?.ipv4.first ?? "—") } + InfoRow(label: "本地 IPv6") { CopyableText(text: physical?.ipv6.first ?? "—") } + InfoRow(label: "路由器") { CopyableText(text: physical?.router ?? "—") } + if lookup { + InfoRow(label: "公网 IPv4") { publicValue(publicAddresses?.ipv4, loading: network.isLookingUpPublic) } + InfoRow(label: "公网 IPv6") { publicValue(publicAddresses?.ipv6, loading: network.isLookingUpPublic) } + if let publicAddresses, let code = publicAddresses.countryCode { + InfoRow(label: "归属地") { + HStack(spacing: DS.Space.s2) { + FlagImage(countryCode: code) + Text(verbatim: location(publicAddresses, code: code)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + if let asn = publicAddresses?.asn { + InfoRow(label: "ASN") { CopyableText(text: asn) } + } + if let organization = publicAddresses?.organization { + InfoRow(label: "网络运营方") { Text(verbatim: organization).lineLimit(1).truncationMode(.middle) } + } + } else { + InfoRow(label: "公网 IP", text: "查询已关闭") + } + } + } + + @ViewBuilder + private func publicValue(_ value: String?, loading: Bool) -> some View { + if loading && value == nil { + Text("查询中…").foregroundStyle(DS.Palette.textTertiary) + } else { + CopyableText(text: value ?? "—") + } + } + + private func location(_ addresses: PublicAddresses, code: String) -> String { + let country = Locale(identifier: "zh-Hans").localizedString(forRegionCode: code) ?? code + return [country, addresses.city].compactMap { $0 }.joined(separator: " · ") + } +} + +// MARK: - DNS + +private struct DNSSection: View { + @Environment(AppModel.self) private var model + @State private var editingManual = false + @State private var manualText = "" + + var body: some View { + let network = model.network + let maintenance = model.maintenance + let physical = network.details?.physical + let manual = physical?.manualDNS ?? [] + let matched = DNSPreset.matching(manual) + + SectionCard(title: PopoverSection.networkDNS.title, trailing: { + if let physical { Text(verbatim: physical.serviceName) } + }) { + InfoRow(label: "正在使用") { + Text(verbatim: network.details.map { $0.dnsServers.isEmpty ? "—" : $0.dnsServers.joined(separator: "\n") } ?? "—") + } + InfoRow(label: "配置方式", text: manual.isEmpty ? "自动(由路由器分配)" : matched?.title ?? "手动") + + if let tunnel = network.details?.tunnel { + InfoBanner(icon: "info.circle", text: "流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。", tone: .neutral) + } + + if let physical { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: DS.Space.s1), count: 3), spacing: DS.Space.s1) { + ForEach(DNSPreset.allCases) { preset in + ChipButton(title: preset.title, isSelected: !editingManual && matched == preset) { + editingManual = false + apply(preset.servers, service: physical.serviceName) + } + } + ChipButton(title: "手动", isSelected: editingManual || (!manual.isEmpty && matched == nil)) { + manualText = manual.joined(separator: ", ") + editingManual.toggle() + } + } + .disabled(maintenance.isApplyingDNS) + + if editingManual { + HStack(spacing: DS.Space.s2) { + TextField("例如 1.1.1.1, 8.8.8.8", text: $manualText) + .textFieldStyle(.plain) + .dsFont(.xs) + .padding(.horizontal, DS.Space.s2) + .frame(height: DS.Size.controlHeight) + .background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md)) + .overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.stroke)) + .onSubmit { applyManual(service: physical.serviceName) } + Button("应用") { applyManual(service: physical.serviceName) } + .buttonStyle(DSButtonStyle(kind: .primary)) + .disabled(DNSConfiguration.parse(manualText)?.isEmpty != false) + } + } + } + + HStack(spacing: DS.Space.s2) { + Button(maintenance.running == .flushDNS ? "正在刷新…" : "刷新 DNS 缓存") { + Task { await maintenance.run(.flushDNS) } + } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(maintenance.running != nil) + Spacer(minLength: 0) + } + + if maintenance.isApplyingDNS { + Text("正在修改 DNS…").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + } else if let outcome = maintenance.dnsOutcome ?? maintenance.outcomes[.flushDNS] { + Text(outcome.text) + .dsFont(.xs) + .foregroundStyle(outcome.isError ? DS.Palette.error : DS.Palette.success) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func applyManual(service: String) { + guard let servers = DNSConfiguration.parse(manualText), !servers.isEmpty else { return } + editingManual = false + apply(servers, service: service) + } + + private func apply(_ servers: [String], service: String) { + Task { + await model.maintenance.setDNSServers(servers, service: service) + await model.network.refreshDetails() + } + } +} + +// MARK: - 进程 + +private struct NetworkProcessesSection: View { + @Environment(AppModel.self) private var model + + var body: some View { + let processes = model.network.processes + + SectionCard(title: PopoverSection.networkProcesses.title, trailing: { + HStack(spacing: 0) { + Text("下载").frame(width: DS.Size.valueColumn, alignment: .trailing) + Text("上传").frame(width: DS.Size.valueColumn, alignment: .trailing) + } + }) { + ProcessList(count: processes.count, emptyText: "正在统计各进程流量…") { index in + let process = processes[index] + HStack(spacing: 0) { + ProcessNameLabel(icon: AppIconCache.shared.image(bundlePath: process.appBundlePath), name: process.name) + Text(verbatim: Format.menuBarRate(process.download)) + .frame(width: DS.Size.valueColumn, alignment: .trailing) + Text(verbatim: Format.menuBarRate(process.upload)) + .frame(width: DS.Size.valueColumn, alignment: .trailing) + } + .dsFont(.xs) + .monospacedDigit() + .foregroundStyle(DS.Palette.textSecondary) + } + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift new file mode 100644 index 0000000..d499a31 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift @@ -0,0 +1,233 @@ +import AppKit +import Metrics +import SwiftUI + +/// 点击菜单栏单个指标弹出的窄详情。区块可在设置中逐个隐藏 +struct PopoverRootView: View { + let item: MenuBarItem + @Environment(AppModel.self) private var model + @Environment(\.isSnapshot) private var isSnapshot + + var body: some View { + VStack(spacing: 0) { + PopoverHeader(item: item) + .padding(.horizontal, DS.Space.s3) + .padding(.vertical, DS.Space.s2) + + PageScroll { + switch item { + case .cpu: CPUPopover() + case .memory: MemoryPopover() + case .network: NetworkPopover() + case .gpu: GPUPopover() + case .temperature, .fan: ThermalPopover(item: item) + } + } + .frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top) + } + .frame(width: DS.Size.popoverWidth) + .frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top) + .fixedSize(horizontal: false, vertical: isSnapshot) + .background(DS.Palette.background, in: RoundedRectangle(cornerRadius: DS.Radius.xl)) + .overlay { + RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke) + } + } +} + +private struct PopoverHeader: View { + let item: MenuBarItem + @Environment(AppModel.self) private var model + + var body: some View { + HStack(spacing: DS.Space.s2) { + Image(systemName: item.symbol) + .font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold)) + .foregroundStyle(DS.Palette.textSecondary) + .frame(width: DS.Size.iconStandalone) + Text(item.popoverTitle) + .dsFont(.base, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + Spacer(minLength: DS.Space.s2) + IconButton(systemName: "macwindow", help: "打开 OpenStats 主窗口") { model.openMainWindow(nil) } + IconButton(systemName: "gearshape", help: "设置") { model.openSettings() } + } + } +} + +// MARK: - 共用组件 + +/// 弹窗里的一个区块:小标题 + 内容 +struct SectionCard: View { + let title: String + @ViewBuilder var trailing: Trailing + @ViewBuilder var content: Content + + var body: some View { + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(spacing: DS.Space.s2) { + Text(title) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.textSecondary) + Spacer(minLength: DS.Space.s2) + trailing + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .lineLimit(1) + } + content + } + } +} + +extension SectionCard where Trailing == EmptyView { + init(title: String, @ViewBuilder content: () -> Content) { + self.init(title: title, trailing: { EmptyView() }, content: content) + } +} + +/// 左侧标签、右侧数值的一行 +struct InfoRow: View { + let label: String + @ViewBuilder var value: Value + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s3) { + Text(label) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize() + Spacer(minLength: 0) + value + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .monospacedDigit() + .multilineTextAlignment(.trailing) + } + } +} + +extension InfoRow where Value == Text { + init(label: String, text: String) { + self.label = label + self.value = Text(verbatim: text) + } +} + +/// 点击拷贝的数值(IP、MAC 地址) +struct CopyableText: View { + let text: String + @State private var copied = false + @State private var hovering = false + + var body: some View { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + copied = true + Task { + try? await Task.sleep(for: .seconds(1.2)) + copied = false + } + } label: { + Text(verbatim: copied ? "已拷贝" : text) + .foregroundStyle(copied ? DS.Palette.success : hovering ? DS.Palette.primary : DS.Palette.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help("点击拷贝") + .disabled(text == "—") + } +} + +/// 区块标题栏里的小图标按钮 +struct MiniIconButton: View { + let systemName: String + let help: String + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold)) + .foregroundStyle(hovering ? DS.Palette.textPrimary : DS.Palette.textSecondary) + .frame(width: DS.Size.segmentHeight, height: DS.Size.segmentHeight) + .background(hovering ? DS.Palette.surfaceHover : .clear, in: RoundedRectangle(cornerRadius: DS.Radius.sm)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(help) + .accessibilityLabel(help) + } +} + +/// 弹窗顶部的大号数值 +struct HeroValue: View { + let value: String + var unit: String? + var size: DS.TextSize = .xl + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) { + Text(verbatim: value) + .dsFont(size, weight: .semibold) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + if let unit { + Text(verbatim: unit).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary) + } + } + .lineLimit(1) + } +} + +/// 固定行数的进程列表,数据不足时用占位行,弹窗高度保持不变 +struct ProcessList: View { + let count: Int + var rowCount = 5 + var emptyText = "正在统计…" + @ViewBuilder var row: (Int) -> Row + + var body: some View { + if count == 0 { + Text(emptyText) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: Self.rowHeight) + ForEach(1.. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ae.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ae.svg new file mode 100644 index 0000000..651ac85 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ae.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/af.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/af.svg new file mode 100644 index 0000000..4dbe455 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/af.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ag.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ag.svg new file mode 100644 index 0000000..243c3d8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ag.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ai.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ai.svg new file mode 100644 index 0000000..9c2ea33 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ai.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/al.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/al.svg new file mode 100644 index 0000000..e85d95f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/al.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/am.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/am.svg new file mode 100644 index 0000000..99fa4dc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/am.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ao.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ao.svg new file mode 100644 index 0000000..b73b1ec --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/aq.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/aq.svg new file mode 100644 index 0000000..c7e3536 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/aq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ar.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ar.svg new file mode 100644 index 0000000..c753da1 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ar.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/arab.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/arab.svg new file mode 100644 index 0000000..9ef079f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/arab.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/as.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/as.svg new file mode 100644 index 0000000..82459de --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/as.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/asean.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/asean.svg new file mode 100644 index 0000000..189ae02 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/asean.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/at.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/at.svg new file mode 100644 index 0000000..9d2775c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/au.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/au.svg new file mode 100644 index 0000000..96e8076 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/au.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/aw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/aw.svg new file mode 100644 index 0000000..413b7c4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/aw.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ax.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ax.svg new file mode 100644 index 0000000..0584d71 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ax.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/az.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/az.svg new file mode 100644 index 0000000..3557522 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/az.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ba.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ba.svg new file mode 100644 index 0000000..93bd9cf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ba.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bb.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bb.svg new file mode 100644 index 0000000..cecd5cc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bd.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bd.svg new file mode 100644 index 0000000..16b794d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/be.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/be.svg new file mode 100644 index 0000000..ac706a0 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/be.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bf.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bf.svg new file mode 100644 index 0000000..4713822 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bf.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bg.svg new file mode 100644 index 0000000..af2d0d0 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bh.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bh.svg new file mode 100644 index 0000000..7a2ea54 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bi.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bi.svg new file mode 100644 index 0000000..a4434a9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bi.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bj.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bj.svg new file mode 100644 index 0000000..0846724 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bj.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bl.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bl.svg new file mode 100644 index 0000000..f84cbba --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bm.svg new file mode 100644 index 0000000..f43a5eb --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bm.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bn.svg new file mode 100644 index 0000000..f544c25 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bn.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bo.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bo.svg new file mode 100644 index 0000000..7658e3f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bo.svg @@ -0,0 +1,673 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bq.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bq.svg new file mode 100644 index 0000000..0e6bc76 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/br.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/br.svg new file mode 100644 index 0000000..719a763 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/br.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bs.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bs.svg new file mode 100644 index 0000000..5cc918e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bt.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bt.svg new file mode 100644 index 0000000..20aef3a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bt.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bv.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bv.svg new file mode 100644 index 0000000..40e16d9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bw.svg new file mode 100644 index 0000000..3435608 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/by.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/by.svg new file mode 100644 index 0000000..948784f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/by.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bz.svg new file mode 100644 index 0000000..d81b16c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/bz.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ca.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ca.svg new file mode 100644 index 0000000..c9b23b4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ca.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cc.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cc.svg new file mode 100644 index 0000000..a42dec6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cc.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cd.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cd.svg new file mode 100644 index 0000000..b9cf528 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cefta.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cefta.svg new file mode 100644 index 0000000..f748d08 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cefta.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cf.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cf.svg new file mode 100644 index 0000000..a6cd367 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cg.svg new file mode 100644 index 0000000..f5a0e42 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cg.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ch.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ch.svg new file mode 100644 index 0000000..b42d670 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ch.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ci.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ci.svg new file mode 100644 index 0000000..e400f0c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ci.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ck.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ck.svg new file mode 100644 index 0000000..18e547b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ck.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cl.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cl.svg new file mode 100644 index 0000000..5b3c72f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cm.svg new file mode 100644 index 0000000..70adc8b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cn.svg new file mode 100644 index 0000000..10d3489 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/co.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/co.svg new file mode 100644 index 0000000..ebd0a0f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/co.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cp.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cp.svg new file mode 100644 index 0000000..b8aa9cf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cr.svg new file mode 100644 index 0000000..5a409ee --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cu.svg new file mode 100644 index 0000000..053c9ee --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cu.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cv.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cv.svg new file mode 100644 index 0000000..aec8994 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cw.svg new file mode 100644 index 0000000..bb0ece2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cw.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cx.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cx.svg new file mode 100644 index 0000000..3a83c23 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cy.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cy.svg new file mode 100644 index 0000000..ee4b0c7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cz.svg new file mode 100644 index 0000000..7913de3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/cz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/de.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/de.svg new file mode 100644 index 0000000..71aa2d2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/de.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dg.svg new file mode 100644 index 0000000..dfee2bb --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dg.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dj.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dj.svg new file mode 100644 index 0000000..9b00a82 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dj.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dk.svg new file mode 100644 index 0000000..563277f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dm.svg new file mode 100644 index 0000000..5aa9cea --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dm.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/do.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/do.svg new file mode 100644 index 0000000..6de2b26 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/do.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dz.svg new file mode 100644 index 0000000..5ff29a7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/dz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eac.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eac.svg new file mode 100644 index 0000000..59d02d2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eac.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ec.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ec.svg new file mode 100644 index 0000000..88c50bf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ec.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ee.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ee.svg new file mode 100644 index 0000000..8b98c2c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ee.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eg.svg new file mode 100644 index 0000000..88e32b3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eg.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eh.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eh.svg new file mode 100644 index 0000000..6aec728 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eh.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/er.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/er.svg new file mode 100644 index 0000000..48a13b4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/er.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-ct.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-ct.svg new file mode 100644 index 0000000..4d85911 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-ct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-ga.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-ga.svg new file mode 100644 index 0000000..573ca45 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-ga.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-pv.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-pv.svg new file mode 100644 index 0000000..63c19f4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es-pv.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es.svg new file mode 100644 index 0000000..a296ebf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/es.svg @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/et.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/et.svg new file mode 100644 index 0000000..3f99be4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/et.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eu.svg new file mode 100644 index 0000000..b0874c1 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/eu.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fi.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fi.svg new file mode 100644 index 0000000..470be2d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fj.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fj.svg new file mode 100644 index 0000000..332ae61 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fj.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fk.svg new file mode 100644 index 0000000..a0dace8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fk.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fm.svg new file mode 100644 index 0000000..c1b7c97 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fm.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fo.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fo.svg new file mode 100644 index 0000000..f802d28 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fr.svg new file mode 100644 index 0000000..4110e59 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/fr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ga.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ga.svg new file mode 100644 index 0000000..76edab4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ga.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-eng.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-eng.svg new file mode 100644 index 0000000..12e3b67 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-eng.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-nir.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-nir.svg new file mode 100644 index 0000000..e22190a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-nir.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-sct.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-sct.svg new file mode 100644 index 0000000..f50cd32 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-sct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-wls.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-wls.svg new file mode 100644 index 0000000..d7f5791 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb-wls.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb.svg new file mode 100644 index 0000000..7991383 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gd.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gd.svg new file mode 100644 index 0000000..b3d250d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gd.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ge.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ge.svg new file mode 100644 index 0000000..ab08a9a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gf.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gf.svg new file mode 100644 index 0000000..f8fe94c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gg.svg new file mode 100644 index 0000000..f8216c8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gh.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gh.svg new file mode 100644 index 0000000..5c3e3e6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gi.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gi.svg new file mode 100644 index 0000000..a5d7570 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gi.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gl.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gl.svg new file mode 100644 index 0000000..eb5a52e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gm.svg new file mode 100644 index 0000000..8fe9d66 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gm.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gn.svg new file mode 100644 index 0000000..40d6ad4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gn.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gp.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gp.svg new file mode 100644 index 0000000..ee55c4b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gq.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gq.svg new file mode 100644 index 0000000..64c8eb2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gq.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gr.svg new file mode 100644 index 0000000..599741e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gr.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gs.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gs.svg new file mode 100644 index 0000000..29db9b9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gs.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gt.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gt.svg new file mode 100644 index 0000000..7df9df5 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gt.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gu.svg new file mode 100644 index 0000000..3b95219 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gu.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gw.svg new file mode 100644 index 0000000..d470bac --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gy.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gy.svg new file mode 100644 index 0000000..569fb56 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/gy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hk.svg new file mode 100644 index 0000000..4fd55bc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hk.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hm.svg new file mode 100644 index 0000000..815c482 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hn.svg new file mode 100644 index 0000000..11fde67 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hn.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hr.svg new file mode 100644 index 0000000..dde825c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hr.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ht.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ht.svg new file mode 100644 index 0000000..8e8efc4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ht.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hu.svg new file mode 100644 index 0000000..baddf7f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/hu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ic.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ic.svg new file mode 100644 index 0000000..81e6ee2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/id.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/id.svg new file mode 100644 index 0000000..3b7c8fc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/id.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ie.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ie.svg new file mode 100644 index 0000000..049be14 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ie.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/il.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/il.svg new file mode 100644 index 0000000..f43be7e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/il.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/im.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/im.svg new file mode 100644 index 0000000..fe6a59a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/im.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/in.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/in.svg new file mode 100644 index 0000000..bc47d74 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/in.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/io.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/io.svg new file mode 100644 index 0000000..3058f7d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/io.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/iq.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/iq.svg new file mode 100644 index 0000000..8044514 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/iq.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ir.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ir.svg new file mode 100644 index 0000000..8c6d516 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ir.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/is.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/is.svg new file mode 100644 index 0000000..a6588af --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/is.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/it.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/it.svg new file mode 100644 index 0000000..20a8bfd --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/it.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/je.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/je.svg new file mode 100644 index 0000000..70a8754 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/je.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jm.svg new file mode 100644 index 0000000..269df03 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jo.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jo.svg new file mode 100644 index 0000000..d6f927d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jp.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jp.svg new file mode 100644 index 0000000..cc1c181 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/jp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ke.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ke.svg new file mode 100644 index 0000000..3a67ca3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ke.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kg.svg new file mode 100644 index 0000000..e26db95 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kh.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kh.svg new file mode 100644 index 0000000..a7d52f2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kh.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ki.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ki.svg new file mode 100644 index 0000000..fda03f3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ki.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/km.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/km.svg new file mode 100644 index 0000000..414d65e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/km.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kn.svg new file mode 100644 index 0000000..47fe64d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kp.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kp.svg new file mode 100644 index 0000000..ad1b713 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kp.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kr.svg new file mode 100644 index 0000000..6947eab --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kr.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kw.svg new file mode 100644 index 0000000..3dd89e9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ky.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ky.svg new file mode 100644 index 0000000..aeaa7e0 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ky.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kz.svg new file mode 100644 index 0000000..2fac45b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/kz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/la.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/la.svg new file mode 100644 index 0000000..6aea6b7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/la.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lb.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lb.svg new file mode 100644 index 0000000..bde2581 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lb.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lc.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lc.svg new file mode 100644 index 0000000..bb25654 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/li.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/li.svg new file mode 100644 index 0000000..7a4d183 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/li.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lk.svg new file mode 100644 index 0000000..cbd660a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lk.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lr.svg new file mode 100644 index 0000000..e482ab9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lr.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ls.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ls.svg new file mode 100644 index 0000000..a7c01a9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ls.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lt.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lt.svg new file mode 100644 index 0000000..90ec5d2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lu.svg new file mode 100644 index 0000000..cc12206 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lv.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lv.svg new file mode 100644 index 0000000..6a9e75e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/lv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ly.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ly.svg new file mode 100644 index 0000000..1eaa51e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ly.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ma.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ma.svg new file mode 100644 index 0000000..7ce56ef --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ma.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mc.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mc.svg new file mode 100644 index 0000000..9cb6c9e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/md.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/md.svg new file mode 100644 index 0000000..e9ba506 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/md.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/me.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/me.svg new file mode 100644 index 0000000..297888c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/me.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mf.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mf.svg new file mode 100644 index 0000000..6305edc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mg.svg new file mode 100644 index 0000000..5fa2d24 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mh.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mh.svg new file mode 100644 index 0000000..7b9f490 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mk.svg new file mode 100644 index 0000000..4f5cae7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ml.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ml.svg new file mode 100644 index 0000000..6f6b716 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mm.svg new file mode 100644 index 0000000..42b4dee --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mm.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mn.svg new file mode 100644 index 0000000..6a38a71 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mo.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mo.svg new file mode 100644 index 0000000..f638b6c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mp.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mp.svg new file mode 100644 index 0000000..26bfa22 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mp.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mq.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mq.svg new file mode 100644 index 0000000..b221951 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mr.svg new file mode 100644 index 0000000..d859972 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ms.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ms.svg new file mode 100644 index 0000000..4367505 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ms.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mt.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mt.svg new file mode 100644 index 0000000..5d5d7c8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mt.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mu.svg new file mode 100644 index 0000000..82d7a3b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mu.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mv.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mv.svg new file mode 100644 index 0000000..10450f9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mw.svg new file mode 100644 index 0000000..137ff87 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mx.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mx.svg new file mode 100644 index 0000000..e3ec2bc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mx.svg @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/my.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/my.svg new file mode 100644 index 0000000..115f864 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/my.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mz.svg new file mode 100644 index 0000000..0f94c3a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/mz.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/na.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/na.svg new file mode 100644 index 0000000..35b9f78 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/na.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nc.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nc.svg new file mode 100644 index 0000000..fa15551 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ne.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ne.svg new file mode 100644 index 0000000..39a82b8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ne.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nf.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nf.svg new file mode 100644 index 0000000..fd61b25 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nf.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ng.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ng.svg new file mode 100644 index 0000000..81eb35f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ng.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ni.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ni.svg new file mode 100644 index 0000000..e4861f5 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ni.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nl.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nl.svg new file mode 100644 index 0000000..e90f5b0 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/no.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/no.svg new file mode 100644 index 0000000..a5f2a15 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/no.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/np.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/np.svg new file mode 100644 index 0000000..6242856 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/np.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nr.svg new file mode 100644 index 0000000..ff394c4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nr.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nu.svg new file mode 100644 index 0000000..4067baf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nu.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nz.svg new file mode 100644 index 0000000..935d8a7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/nz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/om.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/om.svg new file mode 100644 index 0000000..4f1461a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/om.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pa.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pa.svg new file mode 100644 index 0000000..8dc03bc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pa.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pc.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pc.svg new file mode 100644 index 0000000..5202d6d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pc.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pe.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pe.svg new file mode 100644 index 0000000..33e6cfd --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pf.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pf.svg new file mode 100644 index 0000000..bea0354 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pg.svg new file mode 100644 index 0000000..7b7e77a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ph.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ph.svg new file mode 100644 index 0000000..b910e24 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ph.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pk.svg new file mode 100644 index 0000000..4ddc19f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pk.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pl.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pl.svg new file mode 100644 index 0000000..0fa5145 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pl.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pm.svg new file mode 100644 index 0000000..19a9330 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pm.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pn.svg new file mode 100644 index 0000000..209ea71 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pn.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pr.svg new file mode 100644 index 0000000..ec51831 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ps.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ps.svg new file mode 100644 index 0000000..362d435 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pt.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pt.svg new file mode 100644 index 0000000..2767cd4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pt.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pw.svg new file mode 100644 index 0000000..9f89c5f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/pw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/py.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/py.svg new file mode 100644 index 0000000..abccd87 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/py.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/qa.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/qa.svg new file mode 100644 index 0000000..901f3fa --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/qa.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/re.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/re.svg new file mode 100644 index 0000000..64e788e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/re.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ro.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ro.svg new file mode 100644 index 0000000..fda0f7b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ro.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/rs.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/rs.svg new file mode 100644 index 0000000..6d4f74d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/rs.svg @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ru.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ru.svg new file mode 100644 index 0000000..cf24301 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ru.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/rw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/rw.svg new file mode 100644 index 0000000..06e26ae --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/rw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sa.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sa.svg new file mode 100644 index 0000000..596cf48 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sa.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sb.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sb.svg new file mode 100644 index 0000000..6066f94 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sb.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sc.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sc.svg new file mode 100644 index 0000000..9a46b36 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sd.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sd.svg new file mode 100644 index 0000000..12818b4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sd.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/se.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/se.svg new file mode 100644 index 0000000..8ba745a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/se.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sg.svg new file mode 100644 index 0000000..c4dd4ac --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-ac.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-ac.svg new file mode 100644 index 0000000..c43b301 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-ac.svg @@ -0,0 +1,689 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-hl.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-hl.svg new file mode 100644 index 0000000..2150bf6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-hl.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-ta.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-ta.svg new file mode 100644 index 0000000..ba39063 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh-ta.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh.svg new file mode 100644 index 0000000..7aba0ae --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/si.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/si.svg new file mode 100644 index 0000000..1bbdd94 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/si.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sj.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sj.svg new file mode 100644 index 0000000..bb2799c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sj.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sk.svg new file mode 100644 index 0000000..676018e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sk.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sl.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sl.svg new file mode 100644 index 0000000..a07baf7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sm.svg new file mode 100644 index 0000000..e41d2f7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sm.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sn.svg new file mode 100644 index 0000000..7c0673d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/so.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/so.svg new file mode 100644 index 0000000..a581ac6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/so.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sr.svg new file mode 100644 index 0000000..5e71c40 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ss.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ss.svg new file mode 100644 index 0000000..b257aa0 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ss.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/st.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/st.svg new file mode 100644 index 0000000..1294bcb --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/st.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sv.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sv.svg new file mode 100644 index 0000000..cbc674a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sv.svg @@ -0,0 +1,593 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sx.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sx.svg new file mode 100644 index 0000000..ac78561 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sx.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sy.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sy.svg new file mode 100644 index 0000000..97c05cf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sz.svg new file mode 100644 index 0000000..eb538e4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/sz.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tc.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tc.svg new file mode 100644 index 0000000..1258971 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tc.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/td.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/td.svg new file mode 100644 index 0000000..fa3bd92 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/td.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tf.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tf.svg new file mode 100644 index 0000000..fba2335 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tg.svg new file mode 100644 index 0000000..9d6ea6c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tg.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/th.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/th.svg new file mode 100644 index 0000000..1e93a61 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/th.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tj.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tj.svg new file mode 100644 index 0000000..f8c9a03 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tj.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tk.svg new file mode 100644 index 0000000..05d3e86 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tl.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tl.svg new file mode 100644 index 0000000..3d0701a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tm.svg new file mode 100644 index 0000000..4154ed7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tm.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tn.svg new file mode 100644 index 0000000..5735c19 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/to.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/to.svg new file mode 100644 index 0000000..d072337 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/to.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tr.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tr.svg new file mode 100644 index 0000000..b96da21 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tt.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tt.svg new file mode 100644 index 0000000..bc24938 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tv.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tv.svg new file mode 100644 index 0000000..675210e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tv.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tw.svg new file mode 100644 index 0000000..57fd98b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tw.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tz.svg new file mode 100644 index 0000000..a2cfbca --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/tz.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ua.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ua.svg new file mode 100644 index 0000000..a339eb1 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ua.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ug.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ug.svg new file mode 100644 index 0000000..520eee5 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ug.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/um.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/um.svg new file mode 100644 index 0000000..9e9edda --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/um.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/un.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/un.svg new file mode 100644 index 0000000..632bbb4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/un.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/us.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/us.svg new file mode 100644 index 0000000..9cfd0c9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/us.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/uy.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/uy.svg new file mode 100644 index 0000000..62c36f8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/uy.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/uz.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/uz.svg new file mode 100644 index 0000000..0ccca1b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/uz.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/va.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/va.svg new file mode 100644 index 0000000..3e297d6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/va.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vc.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vc.svg new file mode 100644 index 0000000..f26c2d8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ve.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ve.svg new file mode 100644 index 0000000..314e7f5 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ve.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vg.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vg.svg new file mode 100644 index 0000000..ac90088 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vg.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vi.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vi.svg new file mode 100644 index 0000000..d88d68f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vi.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vn.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vn.svg new file mode 100644 index 0000000..7e4bac8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vu.svg new file mode 100644 index 0000000..326d29e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/vu.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/wf.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/wf.svg new file mode 100644 index 0000000..054c57d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/wf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ws.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ws.svg new file mode 100644 index 0000000..0e758a7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ws.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/xk.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/xk.svg new file mode 100644 index 0000000..0e8958d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/xk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/xx.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/xx.svg new file mode 100644 index 0000000..9333be3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/xx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ye.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ye.svg new file mode 100644 index 0000000..1c9e6d6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/ye.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/yt.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/yt.svg new file mode 100644 index 0000000..e7776b3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/yt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/za.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/za.svg new file mode 100644 index 0000000..d563adb --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/za.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/zm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/zm.svg new file mode 100644 index 0000000..360f37a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/zm.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/zw.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/zw.svg new file mode 100644 index 0000000..93aac4f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/zw.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift index 3adfd7c..320ca37 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift @@ -3,7 +3,7 @@ import HelperShared import SwiftUI enum SettingsSection: String, CaseIterable, Identifiable { - case general, menuBar, thermal, helper, about + case general, menuBar, network, thermal, helper, about var id: String { rawValue } @@ -11,6 +11,7 @@ enum SettingsSection: String, CaseIterable, Identifiable { switch self { case .general: "通用" case .menuBar: "菜单栏" + case .network: "网络" case .thermal: "散热与防休眠" case .helper: "辅助工具" case .about: "关于" @@ -21,6 +22,7 @@ enum SettingsSection: String, CaseIterable, Identifiable { switch self { case .general: "gearshape" case .menuBar: "menubar.rectangle" + case .network: "network" case .thermal: "fan" case .helper: "lock.shield" case .about: "info.circle" @@ -45,33 +47,44 @@ public struct SettingsView: View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: DS.Space.s1) { ForEach(SettingsSection.allCases) { item in - SidebarItem(section: item, isSelected: item == section) { section = item } + SidebarButton(title: item.title, symbol: item.symbol, isSelected: item == section) { section = item } } Spacer() } - .padding(DS.Space.s3) + .padding(.horizontal, DS.Space.s3) + .padding(.top, DS.Size.windowHeader + DS.Space.s2) + .padding(.bottom, DS.Space.s3) .frame(width: DS.Size.settingsSidebar) .frame(maxHeight: .infinity) - .background { - if isSnapshot { DS.Palette.background } else { SidebarMaterial() } + .background(alignment: .top) { + WindowDragArea().frame(height: DS.Size.windowHeader) } - Rectangle().fill(DS.Palette.border).frame(width: DS.Size.stroke) - - SettingsPage(title: section.title) { - switch section { - case .general: GeneralSettings() - case .menuBar: MenuBarSettings() - case .thermal: ThermalSettings() - case .helper: HelperSettings() - case .about: AboutSettings() + VStack(alignment: .leading, spacing: 0) { + Text(section.title) + .dsFont(.base, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + .padding(.horizontal, DS.Space.s3 + DS.Space.s1) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: DS.Size.windowHeader) + .background(WindowDragArea()) + SettingsPage { + switch section { + case .general: GeneralSettings() + case .menuBar: MenuBarSettings() + case .network: NetworkSettings() + case .thermal: ThermalSettings() + case .helper: HelperSettings() + case .about: AboutSettings() + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(Color(nsColor: .windowBackgroundColor)) } .frame(width: DS.Size.settingsWidth, height: isSnapshot ? nil : DS.Size.settingsHeight) .fixedSize(horizontal: false, vertical: isSnapshot) + .background(DS.Palette.background) + .ignoresSafeArea() .onAppear { model.refreshLaunchAtLogin() model.helper.refreshStatus() @@ -79,54 +92,23 @@ public struct SettingsView: View { } } -private struct SidebarItem: View { - let section: SettingsSection - let isSelected: Bool - let action: () -> Void - @State private var hovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: DS.Space.s2) { - Image(systemName: section.symbol) - .font(.system(size: DS.TextSize.sm.rawValue, weight: .medium)) - .frame(width: DS.Size.iconStandalone) - Text(section.title).dsFont(.sm, weight: isSelected ? .semibold : .regular) - Spacer() - } - .foregroundStyle(isSelected ? DS.Palette.primary : DS.Palette.textPrimary) - .padding(.horizontal, DS.Space.s2) - .frame(height: DS.Size.controlHeight) - .background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - } - - private var background: Color { - if isSelected { return DS.Palette.primary.opacity(0.1) } - return hovering ? DS.Palette.surfaceHover : .clear - } -} - private struct SettingsPage: View { - let title: String @ViewBuilder var content: Content @Environment(\.isSnapshot) private var isSnapshot var body: some View { let stack = VStack(alignment: .leading, spacing: DS.Space.s4) { - Text(title).dsFont(.xl, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) content } - .padding(DS.Space.s6) + .padding(.horizontal, DS.Space.s3 + DS.Space.s1) + .padding(.bottom, DS.Space.s6) .frame(maxWidth: .infinity, alignment: .leading) if isSnapshot { stack } else { ScrollView { stack } + .scrollIndicators(.automatic) } } } @@ -143,8 +125,7 @@ private struct SettingsGroup: View { VStack(alignment: .leading, spacing: 0) { content } - .background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md)) - .overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)) + .background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.lg)) } } } @@ -180,11 +161,6 @@ private struct GeneralSettings: View { .frame(width: DS.Size.settingsSidebar + DS.Space.s12) } } - GroupRow { - SettingRow(title: "液态玻璃背景", subtitle: "面板背景半透明,透出桌面;关闭时为浅色或深色实色背景") { - DSToggle(isOn: $settings.panelGlass, label: "液态玻璃背景") - } - } GroupRow { SettingRow(title: "登录时启动", subtitle: model.launchAtLoginError ?? "开机后自动在菜单栏显示 OpenStats") { DSToggle(isOn: Binding(get: { model.launchAtLoginEnabled }, @@ -193,7 +169,7 @@ private struct GeneralSettings: View { } } GroupRow { - SettingRow(title: "刷新频率", subtitle: "面板收起时菜单栏的采样间隔;展开面板时固定为 1 秒") { + SettingRow(title: "刷新频率", subtitle: "只显示菜单栏时的采样间隔;打开弹窗或主窗口时固定为 1 秒") { SegmentedControl(selection: $settings.refreshSeconds, options: AppSettings.refreshOptions.map { ($0, "\($0) 秒") }) .frame(width: DS.Size.settingsSidebar + DS.Space.s12) @@ -219,6 +195,19 @@ private struct MenuBarSettings: View { MenuBarPreview() + SettingsGroup(caption: "布局") { + GroupRow(showsDivider: false) { + SettingRow(title: "菜单栏图标", + subtitle: settings.menuBarLayout == .separate + ? "每个指标一个图标,点击弹出该项详情" + : "所有指标合成一个图标,点击打开主窗口") { + SegmentedControl(selection: $settings.menuBarLayout, + options: MenuBarLayout.allCases.map { ($0, $0.title) }) + .frame(width: DS.Size.settingsSidebar + DS.Space.s6) + } + } + } + SettingsGroup(caption: "风格") { LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible(), spacing: DS.Space.s3)], spacing: DS.Space.s3) { @@ -234,14 +223,19 @@ private struct MenuBarSettings: View { SettingsGroup(caption: "显示项目") { ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in GroupRow(showsDivider: index > 0) { - SettingRow(title: item.title, subtitle: item.subtitle, icon: item.symbol) { - HStack(spacing: DS.Space.s3) { - if settings.isEnabled(item) { - itemStylePicker(item) + 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) } } } @@ -281,6 +275,59 @@ private struct MenuBarSettings: View { } } +/// 选择该项详情弹窗里显示哪些区块 +private struct PopoverSectionPicker: View { + @Environment(AppModel.self) private var model + let item: MenuBarItem + + var body: some View { + let settings = model.settings + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s3) { + Text("弹窗显示") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize() + FlowLayout(spacing: DS.Space.s1) { + ForEach(item.popoverSections) { section in + let visible = settings.isVisible(section) + SectionToggleChip(title: section.title, isOn: visible) { + settings.setVisible(section, !visible) + } + } + } + } + .padding(.leading, DS.Size.iconStandalone + DS.Space.s3) + } +} + +private struct SectionToggleChip: View { + let title: String + let isOn: Bool + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: DS.Space.s1) { + Image(systemName: isOn ? "checkmark" : "plus") + .font(.system(size: DS.TextSize.xs.rawValue - DS.Space.s1 / 2, weight: .bold)) + Text(title).dsFont(.xs, weight: .medium) + } + .foregroundStyle(isOn ? DS.Palette.primary : DS.Palette.textSecondary) + .padding(.horizontal, DS.Space.s2) + .frame(height: DS.Size.segmentHeight) + .background(isOn ? DS.Palette.primary.opacity(0.12) : hovering ? DS.Palette.surfaceHover : .clear, + in: RoundedRectangle(cornerRadius: DS.Radius.sm)) + .overlay(RoundedRectangle(cornerRadius: DS.Radius.sm) + .strokeBorder(isOn ? DS.Palette.primary.opacity(0.4) : DS.Palette.border, lineWidth: DS.Size.stroke)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .accessibilityAddTraits(isOn ? .isSelected : []) + } +} + private struct StyleCard: View { @Environment(AppModel.self) private var model @Environment(\.colorScheme) private var colorScheme @@ -362,6 +409,55 @@ private struct MenuBarPreview: View { } } +// MARK: - 网络 + +private struct NetworkSettings: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var settings = model.settings + + SettingsGroup(caption: "连接探测") { + GroupRow(showsDivider: false) { + SettingRow(title: "定时探测网络", subtitle: "用 ping 测量延迟与丢包,在网络详情里以格子显示;只在菜单栏显示网络项或打开网络详情时运行") { + DSToggle(isOn: $settings.probeEnabled, label: "定时探测网络") + } + } + GroupRow { + SettingRow(title: "探测间隔") { + SegmentedControl(selection: $settings.probeSeconds, + options: AppSettings.probeOptions.map { ($0, "\($0) 秒") }) + .frame(width: DS.Size.settingsSidebar) + } + } + .disabled(!settings.probeEnabled) + GroupRow { + SettingRow(title: "探测目标", subtitle: "国内网络建议选阿里云或腾讯;选路由器只检测本地连接") { + Picker("探测目标", selection: $settings.probeTarget) { + ForEach(ProbeTarget.allCases) { target in + Text(target.title).tag(target) + } + } + .pickerStyle(.menu) + .labelsHidden() + .fixedSize() + } + } + .disabled(!settings.probeEnabled) + } + + SettingsGroup(caption: "公网 IP") { + GroupRow(showsDivider: false) { + SettingRow(title: "查询公网 IP", subtitle: "打开网络详情时向 ipinfo.io 查询公网地址、归属地与 ASN,IPv6 与回退使用 Cloudflare / ipify;10 分钟内不重复请求") { + DSToggle(isOn: $settings.publicIPLookup, label: "查询公网 IP") + } + } + } + + InfoBanner(icon: "lock.shield", text: "修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。", tone: .neutral) + } +} + // MARK: - 散热与防休眠 private struct ThermalSettings: View { @@ -408,10 +504,11 @@ private struct HelperSettings: View { } GroupRow { VStack(alignment: .leading, spacing: DS.Space.s2) { - Text("它只做三件事").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary) + Text("它只做这几件事").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary) CapabilityLine(text: "设置风扇目标转速,或恢复系统自动控制") CapabilityLine(text: "开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep)") - CapabilityLine(text: "应用退出或断开连接时,自动恢复以上两项设置") + CapabilityLine(text: "刷新 DNS 缓存、释放内存、为网络服务设置 DNS 服务器") + CapabilityLine(text: "应用退出或断开连接时,自动恢复风扇与睡眠设置") } } GroupRow { @@ -496,7 +593,7 @@ private struct AboutSettings: View { } } GroupRow { - Text("轻量的 macOS 菜单栏系统监控:CPU、GPU、内存、网络、温度、风扇与防休眠。") + Text("轻量的 macOS 菜单栏系统监控:CPU、GPU、内存、网络、温度、风扇、防休眠与清理。") .dsFont(.sm) .foregroundStyle(DS.Palette.textSecondary) } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index a58de9e..b874905 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -12,8 +12,11 @@ enum SnapshotRenderer { let defaults = UserDefaults(suiteName: "OpenStats.snapshot") ?? .standard let settings = AppSettings(defaults: defaults) settings.menuBarItems = [.cpu, .gpu, .memory, .network, .temperature] + settings.probeSeconds = 1 let model = AppModel(settings: settings) - model.isPanelVisible = true + model.isMainWindowVisible = true + model.network.updateProbing(networkShown: true) + model.network.setDetailVisible(true) // 采集约 12 秒的真实数据,让历史曲线有内容 var demand = MetricsDemand() @@ -30,6 +33,9 @@ enum SnapshotRenderer { await model.hub.start { snapshot in model.handle(snapshot) } try? await Task.sleep(for: .seconds(12)) await model.hub.stop() + model.network.setDetailVisible(false) + model.network.updateProbing(networkShown: false) + model.network.maskForSnapshot() // 清理页展示真实扫描结果(只读,不删除任何文件) model.cleaner.scan() while model.cleaner.isBusy { try? await Task.sleep(for: .milliseconds(200)) } @@ -39,8 +45,12 @@ enum SnapshotRenderer { NSApp.appearance = appearance for tab in PanelTab.allCases { settings.panelTab = tab - write(PanelRootView(), model: model, appearance: appearance, - to: outputDirectory.appendingPathComponent("panel-\(tab.rawValue)-\(suffix).png")) + write(MainWindowView(), model: model, appearance: appearance, + to: outputDirectory.appendingPathComponent("window-\(tab.rawValue)-\(suffix).png")) + } + for item in MenuBarItem.allCases where item != .fan { + write(PopoverRootView(item: item), model: model, appearance: appearance, + to: outputDirectory.appendingPathComponent("popover-\(item.rawValue)-\(suffix).png")) } for section in SettingsSection.allCases { write(SettingsView(section: section), model: model, appearance: appearance, @@ -75,6 +85,7 @@ enum SnapshotRenderer { let window = NSWindow(contentRect: CGRect(origin: CGPoint(x: -10_000, y: -10_000), size: size), styleMask: .borderless, backing: .buffered, defer: false) window.appearance = appearance + window.backgroundColor = .dsBackground window.isReleasedWhenClosed = false window.contentView = hosting window.orderFrontRegardless() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index dc20fc5..7605223 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -15,13 +15,17 @@ public final class AppModel { public let keepAwake: KeepAwakeController public let cleaner: CleanerController public let maintenance: MaintenanceController + public let network: NetworkController @ObservationIgnored public let hub = MetricsHub() - public var isPanelVisible = false + public var isMainWindowVisible = false + /// 当前打开的菜单栏详情弹窗 + public var openPopover: MenuBarItem? public private(set) var launchAtLoginEnabled = SMAppService.mainApp.status == .enabled public private(set) var launchAtLoginError: String? @ObservationIgnored var openSettings: () -> Void = {} + @ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in } @ObservationIgnored var quit: () -> Void = {} public init(settings: AppSettings = AppSettings()) { @@ -34,32 +38,48 @@ public final class AppModel { keepAwake = KeepAwakeController(helper: helper, settings: settings) cleaner = CleanerController(settings: settings) maintenance = MaintenanceController(helper: helper) + network = NetworkController(settings: settings) } - /// 根据当前可见内容决定采集范围 + /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 var demand: MetricsDemand { var demand = MetricsDemand() let tab = settings.panelTab - let panel = isPanelVisible + let window = isMainWindowVisible + let popover = openPopover let menu = settings.menuBarItems + let thermalPopover = popover == .temperature || popover == .fan - demand.interval = panel ? .seconds(1) : .seconds(settings.refreshSeconds) + demand.interval = window || popover != nil ? .seconds(1) : .seconds(settings.refreshSeconds) demand.memory = true demand.network = true - demand.gpu = (panel && tab == .overview) || menu.contains(.gpu) - demand.disk = panel && (tab == .overview || tab == .cleaner) - demand.battery = (panel && (tab == .overview || tab == .keepAwake)) || keepAwake.lidClosedActive - demand.processes = panel && (tab == .processes || tab == .overview) + let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item } + demand.gpu = (window && tab == .overview) || menu.contains(.gpu) || showing(.gpu, .gpu) + demand.disk = window && (tab == .overview || tab == .cleaner) + demand.battery = (window && (tab == .overview || tab == .keepAwake)) || keepAwake.lidClosedActive + demand.processes = (window && [.processes, .overview].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory) var groups = Set() - if panel && tab == .overview { groups.formUnion([.cpu, .gpu]) } - if panel && tab == .thermal { groups.formUnion(TemperatureGroup.allCases) } - if menu.contains(.temperature) || fans.mode != .automatic { groups.insert(.cpu) } + if window && tab == .overview { groups.formUnion([.cpu, .gpu]) } + if (window && tab == .thermal) || thermalPopover { groups.formUnion(TemperatureGroup.allCases) } + if menu.contains(.temperature) || fans.mode != .automatic || showing(.cpu, .cpu) { groups.insert(.cpu) } + if showing(.gpu, .gpu) { groups.insert(.gpu) } demand.temperatures = groups - demand.fans = (panel && (tab == .thermal || tab == .overview)) || menu.contains(.fan) || fans.mode != .automatic + demand.fans = (window && (tab == .thermal || tab == .overview)) || menu.contains(.fan) + || fans.mode != .automatic || thermalPopover return demand } + /// 连接探测在菜单栏显示网络项或网络详情打开时运行 + var networkShown: Bool { + settings.menuBarItems.contains(.network) || isNetworkDetailVisible + } + + /// 网络详情(接口、公网 IP、进程流量)正在显示 + var isNetworkDetailVisible: Bool { + openPopover == .network || (isMainWindowVisible && settings.panelTab == .network) + } + func refreshLaunchAtLogin() { launchAtLoginEnabled = SMAppService.mainApp.status == .enabled } @@ -78,10 +98,11 @@ public final class AppModel { refreshLaunchAtLogin() } - /// 概览页快捷切换风扇:未安装辅助工具时跳到散热页引导安装 + /// 快捷切换风扇:未安装辅助工具时打开散热页引导安装 func requestFanMode(_ mode: FanController.Mode) { guard helper.isReady else { settings.panelTab = .thermal + if !isMainWindowVisible { openMainWindow(.thermal) } return } Task { await fans.select(mode) } @@ -91,6 +112,7 @@ public final class AppModel { func requestLidMode(_ enabled: Bool) { guard helper.isReady else { settings.panelTab = .keepAwake + if !isMainWindowVisible { openMainWindow(.keepAwake) } return } Task { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 80b8e7d..9048fb8 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -10,7 +10,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { switch self { case .cpu: "CPU" case .memory: "内存" - case .network: "网速" + case .network: "网络" case .gpu: "GPU" case .temperature: "CPU 温度" case .fan: "风扇转速" @@ -21,7 +21,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { switch self { case .cpu: "总占用" case .memory: "已用内存占比" - case .network: "上传与下载速度" + case .network: "上传与下载速度、IP 地址、DNS" case .gpu: "GPU 占用" case .temperature: "CPU 核心最高温度" case .fan: "转速最高的风扇" @@ -40,6 +40,30 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { } } + /// 详情弹窗标题 + var popoverTitle: String { + switch self { + case .cpu: "CPU" + case .memory: "内存" + case .network: "网络" + case .gpu: "GPU" + case .temperature: "温度" + case .fan: "风扇" + } + } + + /// 点击该项弹出的详情里可以显示的内容,按显示顺序排列 + var popoverSections: [PopoverSection] { + switch self { + case .cpu: [.cpuHistory, .cpuCores, .cpuDetails, .cpuProcesses] + case .memory: [.memoryHistory, .memoryBreakdown, .memoryProcesses] + case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses] + case .gpu: [.gpuHistory, .gpuDetails] + case .temperature: [.thermalSensors, .thermalFans] + case .fan: [.thermalFans, .thermalSensors] + } + } + /// 可以单独指定风格的指标(网速有自己的样式) var supportsStyleOverride: Bool { self != .network } @@ -55,6 +79,82 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { } } +/// 详情弹窗里可以单独隐藏的区块 +public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { + case cpuHistory, cpuCores, cpuDetails, cpuProcesses + case memoryHistory, memoryBreakdown, memoryProcesses + case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses + case gpuHistory, gpuDetails + case thermalSensors, thermalFans + + public var id: String { rawValue } + + var title: String { + switch self { + case .cpuHistory: "负载历史" + case .cpuCores: "核心负载" + case .cpuDetails: "处理器信息" + case .cpuProcesses, .memoryProcesses, .networkProcesses: "高占用进程" + case .memoryHistory: "使用历史" + case .memoryBreakdown: "内存构成" + case .networkHistory: "流量历史" + case .networkProbe: "连接探测" + case .networkInterface: "接口" + case .networkAddresses: "IP 地址" + case .networkDNS: "DNS" + case .gpuHistory: "使用历史" + case .gpuDetails: "显卡信息" + case .thermalSensors: "温度" + case .thermalFans: "风扇" + } + } +} + +/// 菜单栏布局 +public enum MenuBarLayout: String, CaseIterable, Identifiable, Sendable { + /// 每个指标一个图标,点击弹出该项详情 + case separate + /// 所有指标合成一个图标,点击打开主窗口 + case combined + + public var id: String { rawValue } + + var title: String { + switch self { + case .separate: "每项独立" + case .combined: "合并为一个" + } + } +} + +/// 连接探测的目标 +public enum ProbeTarget: String, CaseIterable, Identifiable, Sendable { + case cloudflare, google, aliyun, tencent, gateway + + public var id: String { rawValue } + + var title: String { + switch self { + case .cloudflare: "Cloudflare(1.1.1.1)" + case .google: "Google(8.8.8.8)" + case .aliyun: "阿里云(223.5.5.5)" + case .tencent: "腾讯(119.29.29.29)" + case .gateway: "路由器(网关)" + } + } + + /// 网关地址随网络变化,由调用方传入 + func address(router: String?) -> String? { + switch self { + case .cloudflare: "1.1.1.1" + case .google: "8.8.8.8" + case .aliyun: "223.5.5.5" + case .tencent: "119.29.29.29" + case .gateway: router + } + } +} + /// 菜单栏风格:一种风格统一套用到所有指标,风格内部的排版保持一致 public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable { case stacked, inline, icon, ring, pie, history, meter, dot @@ -116,20 +216,64 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { } } +/// 主窗口侧边栏的页面 public enum PanelTab: String, CaseIterable, Identifiable, Sendable { - case overview, processes, thermal, keepAwake, cleaner + case overview, cpu, gpu, memory, network, thermal, processes, keepAwake, cleaner public var id: String { rawValue } + static let monitors: [PanelTab] = [.overview, .cpu, .gpu, .memory, .network, .thermal] + static let tools: [PanelTab] = [.processes, .keepAwake, .cleaner] + var title: String { switch self { - case .overview: "概览" + case .overview: "仪表盘" + case .cpu: "CPU" + case .gpu: "GPU" + case .memory: "内存" + case .network: "网络" + case .thermal: "温度与风扇" case .processes: "进程" - case .thermal: "散热" case .keepAwake: "防休眠" case .cleaner: "清理" } } + + var symbol: String { + switch self { + case .overview: "square.grid.2x2" + case .cpu: "cpu" + case .gpu: "square.3.layers.3d" + case .memory: "memorychip" + case .network: "network" + case .thermal: "fan" + case .processes: "list.bullet.rectangle" + case .keepAwake: "cup.and.saucer" + case .cleaner: "sparkles" + } + } + + /// 页面对应的菜单栏项目,页面右上角可直接开关 + var menuBarItem: MenuBarItem? { + switch self { + case .cpu: .cpu + case .gpu: .gpu + case .memory: .memory + case .network: .network + case .thermal: .temperature + default: nil + } + } + + init(item: MenuBarItem) { + switch item { + case .cpu: self = .cpu + case .gpu: self = .gpu + case .memory: self = .memory + case .network: self = .network + case .temperature, .fan: self = .thermal + } + } } /// UserDefaults 持久化的偏好设置 @@ -177,9 +321,25 @@ public final class AppSettings { public var appearance: AppearanceMode { didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } } - /// 面板使用液态玻璃(半透明)背景;关闭时为浅色 / 深色实色背景 - public var panelGlass: Bool { - didSet { defaults.set(panelGlass, forKey: Keys.panelGlass) } + public var menuBarLayout: MenuBarLayout { + didSet { defaults.set(menuBarLayout.rawValue, forKey: Keys.menuBarLayout) } + } + /// 用户在详情弹窗里隐藏的区块 + public var hiddenPopoverSections: Set { + didSet { defaults.set(hiddenPopoverSections.map(\.rawValue).sorted(), forKey: Keys.hiddenPopoverSections) } + } + public var probeEnabled: Bool { + didSet { defaults.set(probeEnabled, forKey: Keys.probeEnabled) } + } + public var probeSeconds: Int { + didSet { defaults.set(probeSeconds, forKey: Keys.probeSeconds) } + } + public var probeTarget: ProbeTarget { + didSet { defaults.set(probeTarget.rawValue, forKey: Keys.probeTarget) } + } + /// 打开网络详情时查询公网 IP(会访问 Cloudflare / ipify) + public var publicIPLookup: Bool { + didSet { defaults.set(publicIPLookup, forKey: Keys.publicIPLookup) } } /// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间) public var cleanPrefersTrash: Bool { @@ -187,6 +347,7 @@ public final class AppSettings { } public static let refreshOptions = [1, 2, 3, 5] + public static let probeOptions = [1, 2, 5] public static let batteryFloorOptions = [10, 20, 30, 40] public static let fanSafetyOptions = [85, 90, 95, 100] @@ -212,7 +373,13 @@ public final class AppSettings { panelTab = defaults.string(forKey: Keys.panelTab).flatMap(PanelTab.init(rawValue:)) ?? .overview appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash) - panelGlass = defaults.bool(forKey: Keys.panelGlass) + menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate + hiddenPopoverSections = Set(defaults.stringArray(forKey: Keys.hiddenPopoverSections)?.compactMap(PopoverSection.init(rawValue:)) ?? []) + probeEnabled = defaults.object(forKey: Keys.probeEnabled) 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 + publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true } /// 按固定顺序返回已启用的菜单栏项目 @@ -231,6 +398,12 @@ public final class AppSettings { styleOverrides[item] = style } + func isVisible(_ section: PopoverSection) -> Bool { !hiddenPopoverSections.contains(section) } + + func setVisible(_ section: PopoverSection, _ visible: Bool) { + if visible { hiddenPopoverSections.remove(section) } else { hiddenPopoverSections.insert(section) } + } + func setEnabled(_ item: MenuBarItem, _ enabled: Bool) { if enabled { menuBarItems.insert(item) } else { menuBarItems.remove(item) } } @@ -248,6 +421,11 @@ public final class AppSettings { static let panelTab = "panelTab" static let appearance = "appearance" static let cleanPrefersTrash = "cleanPrefersTrash" - static let panelGlass = "panelGlass" + static let menuBarLayout = "menuBarLayout" + static let hiddenPopoverSections = "hiddenPopoverSections" + static let probeEnabled = "probeEnabled" + static let probeSeconds = "probeSeconds" + static let probeTarget = "probeTarget" + static let publicIPLookup = "publicIPLookup" } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift index d50c1db..c1503f9 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift @@ -116,6 +116,23 @@ public final class HelperClient { } } + func setDNSServers(service: String, servers: [String]) async -> String? { + await call { proxy, reply in proxy.setDNSServers(service: service, servers: servers, reply: reply) } + } + + /// 已安装的辅助工具的协议版本;旧版本不认识新增的方法,调用前先确认 + func remoteProtocolVersion() async -> Int? { + refreshStatus() + guard isReady else { return nil } + let connection = ensureConnection() + return await withCheckedContinuation { (continuation: CheckedContinuation) in + let once = ResumeOnceValue(continuation) + let proxy = connection.remoteObjectProxyWithErrorHandler { _ in once.resume(nil) } as? OpenStatsHelperProtocol + guard let proxy else { return once.resume(nil) } + proxy.protocolVersion { once.resume($0) } + } + } + /// 退出应用时使用:同步恢复风扇与睡眠设置,每步最多等待 1 秒 func restoreDefaultsSynchronously() { guard isReady else { return } @@ -166,16 +183,18 @@ public final class HelperClient { } } -/// XPC 的错误回调和正常回调可能都会触发,保证 continuation 只恢复一次 -private final class ResumeOnce: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation? +private typealias ResumeOnce = ResumeOnceValue - init(_ continuation: CheckedContinuation) { +/// XPC 的错误回调和正常回调可能都会触发,保证 continuation 只恢复一次 +private final class ResumeOnceValue: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { self.continuation = continuation } - func resume(_ value: String?) { + func resume(_ value: Value) { lock.lock() let pending = continuation continuation = nil diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift index c9c1d93..f1c4ed1 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift @@ -3,7 +3,7 @@ import HelperShared import Metrics import Observation -/// 刷新 DNS、释放内存:已安装辅助工具时直接执行,否则弹出系统管理员授权 +/// 刷新 DNS、释放内存、设置 DNS 服务器:已安装辅助工具时直接执行,否则弹出系统管理员授权 @MainActor @Observable public final class MaintenanceController { @@ -14,6 +14,8 @@ public final class MaintenanceController { public private(set) var running: MaintenanceCommand? public private(set) var outcomes: [MaintenanceCommand: Outcome] = [:] + public private(set) var isApplyingDNS = false + public private(set) var dnsOutcome: Outcome? @ObservationIgnored private let helper: HelperClient @@ -28,7 +30,10 @@ public final class MaintenanceController { let before = MemorySampler().sample() helper.refreshStatus() - let error = helper.isReady ? await helper.run(command) : await Self.runWithAdministratorPrompt(command) + let prompt = command == .flushDNS ? "OpenStats 需要管理员权限来刷新 DNS 缓存。" : "OpenStats 需要管理员权限来释放内存。" + let error = helper.isReady + ? await helper.run(command) + : await Self.runWithAdministratorPrompt(shell: command.shellCommand, prompt: prompt) if let error { outcomes[command] = Outcome(text: error, isError: error != Self.cancelled) @@ -44,12 +49,41 @@ public final class MaintenanceController { } } + /// 为网络服务设置 DNS(空数组为恢复自动),完成后刷新 DNS 缓存 + func setDNSServers(_ servers: [String], service: String) async { + guard !isApplyingDNS else { return } + guard DNSConfiguration.isValidServiceName(service), servers.count <= DNSConfiguration.maxServers, + servers.allSatisfy(DNSConfiguration.isValidAddress) else { + dnsOutcome = Outcome(text: "DNS 地址格式不正确", isError: true) + return + } + isApplyingDNS = true + dnsOutcome = nil + defer { isApplyingDNS = false } + + let error: String? + if let version = await helper.remoteProtocolVersion(), version >= 3 { + error = await helper.setDNSServers(service: service, servers: servers) + } else { + // killall 在 mDNSResponder 恰好重启时可能返回非 0,不视为失败 + let shell = DNSConfiguration.shellCommand(service: service, servers: servers) + + " && /usr/bin/dscacheutil -flushcache && (/usr/bin/killall -HUP mDNSResponder || true)" + error = await Self.runWithAdministratorPrompt(shell: shell, prompt: "OpenStats 需要管理员权限来修改“\(service)”的 DNS。") + } + if let error { + dnsOutcome = Outcome(text: error, isError: error != Self.cancelled) + } else { + dnsOutcome = Outcome(text: servers.isEmpty ? "已恢复自动获取 DNS" : "DNS 已设置为 \(servers.joined(separator: "、"))", isError: false) + } + } + nonisolated private static let cancelled = "已取消" - /// 未安装辅助工具时的回退:通过 AppleScript 请求一次性管理员授权,执行固定命令 - private static func runWithAdministratorPrompt(_ command: MaintenanceCommand) async -> String? { - let prompt = command == .flushDNS ? "OpenStats 需要管理员权限来刷新 DNS 缓存。" : "OpenStats 需要管理员权限来释放内存。" - let script = "do shell script \"\(command.shellCommand)\" with administrator privileges with prompt \"\(prompt)\"" + /// 未安装辅助工具时的回退:通过 AppleScript 请求一次性管理员授权执行命令。 + /// 命令只由固定路径与已校验的参数拼成,这里再做 AppleScript 字符串转义 + private static func runWithAdministratorPrompt(shell: String, prompt: String) async -> String? { + let escape = { (text: String) in text.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") } + let script = "do shell script \"\(escape(shell))\" with administrator privileges with prompt \"\(escape(prompt))\"" return await Task.detached { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift new file mode 100644 index 0000000..743043c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift @@ -0,0 +1,197 @@ +import Foundation +import HelperShared +import Metrics +import Observation + +/// 一次连接探测:latency 为往返毫秒数,nil 表示超时或不可达 +public struct ProbeSample: Sendable, Equatable { + public let latency: Double? +} + +/// 网络详情:接口与地址、公网 IP、连接探测、各进程流量与 DNS 配置 +@MainActor +@Observable +public final class NetworkController { + /// 主窗口显示 120 次,弹窗显示最近 60 次 + public static let probeCapacity = 120 + + public private(set) var details: NetworkDetails? + public private(set) var publicAddresses: PublicAddresses? + public private(set) var isLookingUpPublic = false + public private(set) var probes = History(capacity: probeCapacity) + public private(set) var processes: [NetworkProcessUsage] = [] + + @ObservationIgnored private let settings: AppSettings + @ObservationIgnored private var probeTask: Task? + @ObservationIgnored private var detailTask: Task? + @ObservationIgnored private var probedAddress: String? + @ObservationIgnored private var lastPublicLookup: (date: Date, localIPv4: [String])? + @ObservationIgnored private var isPaused = false + @ObservationIgnored private var wantsDetail = false + + init(settings: AppSettings) { + self.settings = settings + } + + // MARK: 统计 + + /// 最近一次成功探测的延迟 + var latency: Double? { probes.elements.last?.latency } + + /// 抖动:相邻两次成功探测的延迟差的平均值 + var jitter: Double? { + let values = probes.elements.suffix(20).compactMap(\.latency) + guard values.count > 2 else { return nil } + let differences = zip(values.dropFirst(), values).map { abs($0 - $1) } + return differences.reduce(0, +) / Double(differences.count) + } + + var lossRate: Double? { + let recent = probes.elements + guard !recent.isEmpty else { return nil } + return Double(recent.filter { $0.latency == nil }.count) / Double(recent.count) + } + + var probeAddress: String? { + settings.probeTarget.address(router: details?.physical?.router) + } + + // MARK: 生命周期 + + /// 菜单栏显示网络项或网络详情打开时持续探测 + func updateProbing(networkShown: Bool) { + let shouldRun = settings.probeEnabled && networkShown && !isPaused + if shouldRun, probeTask == nil { + startProbing() + } else if !shouldRun { + probeTask?.cancel() + probeTask = nil + } + } + + /// 探测目标或间隔变化时重新开始,历史清空避免混入不同目标的数据 + func restartProbing() { + guard probeTask != nil else { return } + probeTask?.cancel() + probeTask = nil + probes = History(capacity: Self.probeCapacity) + startProbing() + } + + func setPaused(_ paused: Bool, networkShown: Bool) { + isPaused = paused + updateProbing(networkShown: networkShown) + applyDetailState() + } + + /// 网络详情打开时每 2 秒刷新接口信息与进程流量,关闭后停止 + func setDetailVisible(_ visible: Bool) { + wantsDetail = visible + applyDetailState() + } + + private func applyDetailState() { + let shouldRun = wantsDetail && !isPaused + guard shouldRun != (detailTask != nil) else { return } + detailTask?.cancel() + detailTask = nil + // 关闭时保留上次的进程列表,再次打开时不会先闪一下空白 + guard shouldRun else { return } + detailTask = Task { [weak self] in + var sampler = NetworkProcessSampler() + var tick = 0 + while !Task.isCancelled { + // 接口信息每 4 秒读一次,进程流量每 2 秒 + if tick % 2 == 0 { + let details = await Task.detached { NetworkDetailsReader.read() }.value + guard !Task.isCancelled, let self else { return } + if details != self.details { self.details = details } + if tick == 0 { self.lookUpPublicAddressesIfStale() } + } + let (usage, next) = await Task.detached { [sampler] in + var copy = sampler + let usage = copy.sample() + return (usage, copy) + }.value + sampler = next + guard !Task.isCancelled, let self else { return } + self.processes = usage + tick += 1 + try? await Task.sleep(for: .seconds(2)) + } + } + } + + func refreshDetails() async { + details = await Task.detached { NetworkDetailsReader.read() }.value + } + + // MARK: 公网 IP + + func lookUpPublicAddresses() { + guard settings.publicIPLookup, !isLookingUpPublic else { return } + isLookingUpPublic = true + let localIPv4 = details?.physical?.ipv4 ?? [] + Task { + let result = await PublicAddressLookup.fetch() + publicAddresses = result + lastPublicLookup = (Date(), localIPv4) + isLookingUpPublic = false + } + } + + /// 10 分钟内且本地地址未变化时沿用上次结果 + private func lookUpPublicAddressesIfStale() { + guard settings.publicIPLookup else { return } + let localIPv4 = details?.physical?.ipv4 ?? [] + if let last = lastPublicLookup, Date().timeIntervalSince(last.date) < 600, last.localIPv4 == localIPv4 { return } + lookUpPublicAddresses() + } + + func clearPublicAddresses() { + publicAddresses = nil + lastPublicLookup = nil + } + + // MARK: 探测 + + private func startProbing() { + probeTask = Task { [weak self] in + var sequence: UInt16 = 0 + let clock = ContinuousClock() + while !Task.isCancelled { + guard let self else { return } + let interval = Double(self.settings.probeSeconds) + if self.details == nil { await self.refreshDetails() } + let started = clock.now + guard let address = self.probeAddress else { + try? await Task.sleep(for: .seconds(interval)) + continue + } + sequence &+= 1 + let current = sequence + let latency = await Task.detached { + ConnectivityProbe.ping(address, sequence: current, timeout: min(interval, 1.5)) + }.value + guard !Task.isCancelled else { return } + self.probes.append(ProbeSample(latency: latency)) + try? await Task.sleep(until: started + .seconds(interval), tolerance: .milliseconds(100), clock: clock) + } + } + } + + // MARK: 截图 + + /// 截图时换成文档示例地址,不暴露本机的 IP 与硬件地址 + func maskForSnapshot() { + guard var details else { return } + details.physical?.ipv4 = ["192.168.1.20"] + details.physical?.ipv6 = ["2001:db8::20"] + details.physical?.router = "192.168.1.1" + 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") + } +} diff --git a/Packages/OpenStatsKit/Tests/HelperSharedTests/DNSConfigurationTests.swift b/Packages/OpenStatsKit/Tests/HelperSharedTests/DNSConfigurationTests.swift new file mode 100644 index 0000000..30ca414 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/HelperSharedTests/DNSConfigurationTests.swift @@ -0,0 +1,36 @@ +import HelperShared +import Testing + +@Suite struct DNSConfigurationTests { + @Test func acceptsNumericAddressesOnly() { + #expect(DNSConfiguration.isValidAddress("1.1.1.1")) + #expect(DNSConfiguration.isValidAddress("2606:4700:4700::1111")) + #expect(!DNSConfiguration.isValidAddress("dns.google")) + #expect(!DNSConfiguration.isValidAddress("1.1.1.1; rm -rf /")) + #expect(!DNSConfiguration.isValidAddress("999.1.1.1")) + } + + @Test func parsesUserInput() { + #expect(DNSConfiguration.parse("1.1.1.1, 8.8.8.8") == ["1.1.1.1", "8.8.8.8"]) + #expect(DNSConfiguration.parse("223.5.5.5,223.6.6.6\n2400:3200::1") == ["223.5.5.5", "223.6.6.6", "2400:3200::1"]) + #expect(DNSConfiguration.parse("1.1.1.1 example.com") == nil) + #expect(DNSConfiguration.parse(Array(repeating: "1.1.1.1", count: 7).joined(separator: " ")) == nil) + } + + @Test func matchesPresets() { + #expect(DNSPreset.matching([]) == .automatic) + #expect(DNSPreset.matching(["1.1.1.1", "1.0.0.1"]) == .cloudflare) + #expect(DNSPreset.matching(["1.0.0.1", "1.1.1.1"]) == nil) + } + + @Test func buildsArgumentsWithoutShell() { + #expect(DNSConfiguration.networksetupArguments(service: "Wi-Fi", servers: []) == ["-setdnsservers", "Wi-Fi", "Empty"]) + #expect(DNSConfiguration.networksetupArguments(service: "USB LAN", servers: ["8.8.8.8"]) == ["-setdnsservers", "USB LAN", "8.8.8.8"]) + } + + @Test func quotesServiceNameForShell() { + #expect(DNSConfiguration.shellCommand(service: "Bob's LAN", servers: ["1.1.1.1"]) + == "/usr/sbin/networksetup -setdnsservers 'Bob'\\''s LAN' 1.1.1.1") + #expect(!DNSConfiguration.isValidServiceName("Wi-Fi\nrm")) + } +} diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift index b738fe3..cc217c0 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift @@ -126,3 +126,70 @@ struct LiveSamplerTests { #expect(disk.available <= disk.total) } } + +@Suite struct NetworkParsingTests { + @Test func parsesNettopOutput() { + let output = """ + ,bytes_in,bytes_out, + launchd.1,0,0, + Google Chrome H.4521,120127,95191, + broken line + """ + let parsed = NetworkProcessSampler.parse(output) + #expect(parsed.count == 2) + #expect(parsed[4521]?.name == "Google Chrome H") + #expect(parsed[4521]?.download == 120_127) + #expect(parsed[4521]?.upload == 95_191) + } + + @Test func parsesCloudflareTrace() { + let fields = PublicAddressLookup.parseTrace("fl=123\nip=203.0.113.24\nloc=CN\n") + #expect(fields["ip"] == "203.0.113.24") + #expect(fields["loc"] == "CN") + #expect(PublicAddressLookup.parseTrace("ip=")["ip"] == nil) + } + + @Test func matchesEchoReplyBySequenceAndToken() { + // 20 字节 IPv4 头 + ICMP 回显应答(标识符被内核改写为 0x8e84) + var reply = [UInt8](repeating: 0, count: 20) + reply[0] = 0x45 + reply += [0, 0, 0, 0, 0x8E, 0x84, 0x00, 0x07, 0xDE, 0xAD, 0xBE, 0xEF] + #expect(ConnectivityProbe.matches(reply, count: reply.count, family: AF_INET, sequence: 7, token: 0xDEADBEEF)) + #expect(!ConnectivityProbe.matches(reply, count: reply.count, family: AF_INET, sequence: 8, token: 0xDEADBEEF)) + #expect(!ConnectivityProbe.matches(reply, count: reply.count, family: AF_INET, sequence: 7, token: 1)) + } + + @Test func computesInternetChecksum() { + let packet: [UInt8] = [8, 0, 0, 0, 0x12, 0x34, 0, 1] + let sum = ConnectivityProbe.checksum(packet) + var filled = packet + filled[2] = UInt8(sum >> 8) + filled[3] = UInt8(sum & 0xFF) + #expect(ConnectivityProbe.checksum(filled) == 0) + } +} + +@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 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/README.md b/README.md index 17fbc36..989fa6a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,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, no network access at all. +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. [Download](https://github.com/gentpan/OpenStats/releases/latest) · [Website](https://getopenstats.com) · @@ -38,7 +38,7 @@ OpenStats has not had a release yet. The first one will be a `.dmg` on certificate and notarized by Apple, followed by a Homebrew cask. Until then, build it from source — see [Build & run](#build--run). -Requires macOS 14 (Sonoma) or later; the panel uses Liquid Glass on macOS 26 and later. +Requires macOS 14 (Sonoma) or later. Developed and tested on Apple Silicon. The interface is in Simplified Chinese. ## Recent updates @@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -Not released yet · **29** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) +Not released yet · **34** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
-
2026-09-13 · Unreleased · 18 added · 5 style · 6 fixed +2026-09-13 · Unreleased · 23 added · 5 style · 6 fixed **Added** @@ -57,20 +57,25 @@ Not released yet · **29** changes in development · [full changelog](CHANGELOG. - 菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。 - 网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。 - 鼠标悬停在菜单栏图标上显示完整读数。 -- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 +- 菜单栏布局:每项独立(每个指标一个图标,点击弹出该项的窄详情)或合并为一个(点击打开主窗口)。每个详情里显示哪些区块可以在设置中逐个勾选。 +- 详情弹窗: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 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 -- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。 +- 按界面需要分级采样:只显示菜单栏时只采集菜单栏用到的指标,打开详情或主窗口时按页面加采,锁屏、屏幕休眠和系统睡眠时暂停。 - 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。 - 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。 -- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作;运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。 +- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存、为网络服务设置 DNS 几个固定操作(DNS 地址与服务名在辅助工具内再次校验);运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。 - 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 - 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。 - 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。 - 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。 -- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 -- 面板提供浅色、深色两套实色主题,顶部的太阳 / 月亮按钮一键切换,也可以跟随系统;液态玻璃背景改为可选(macOS 26 起)。 -- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。 +- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏布局 / 显示项 / 样式 / 弹窗内容、连接探测与公网 IP 查询、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 +- 统一外观:浅色为白底蓝色、深色为黑底蓝色,主窗口、设置窗口与弹窗共用同一底色;侧边栏、标题栏与页面不分栏着色,红绿灯按钮与页面标题对齐;卡片为浅灰 / 深灰实色。侧边栏的太阳 / 月亮按钮一键切换,也可以跟随系统。 +- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染主窗口各页、详情弹窗、设置和菜单栏的浅色与深色截图,IP 与硬件地址替换为示例值。 **Style** @@ -128,20 +133,21 @@ Not released yet · **29** changes in development · [full changelog](CHANGELOG.

Menu bar

-**Panel** — click the menu-bar item. -- **Overview.** A health score with badges for the chip, memory, macOS version, uptime and - model; then CPU history as bars, GPU as a line, memory, disk, network and fans in three - columns; per-core load grouped by core type (super, performance, efficiency); battery as a - ring; the busiest processes and quick switches. -- **Processes.** Sorted by CPU or memory; helper processes are shown under their app. -- **Thermal.** CPU, GPU, memory, battery and palm-rest temperatures, and the fans. -- **Keep awake.** Keep the display on, keep only the system awake, or keep running with the - lid closed — for a set time or indefinitely. -- **Clean.** Scan, review, clean. See [Cleanup](#cleanup). +**Detail popovers** — each metric gets its own menu-bar item; click it for a narrow popover. +Choose which sections each popover shows in Settings. `Esc` closes it. +- **CPU.** Load history, per-core load grouped by core type, processor info, busiest processes. +- **Memory.** Usage history, breakdown with one-click purge, busiest processes. +- **Network.** Traffic history; a connection-probe grid (latency, jitter, loss); interface, + Wi-Fi signal, VPN / proxy; local and public IPv4 / IPv6 with a flag, region and ASN; DNS flush + and one-click switching to Cloudflare, Google, Tencent, Alibaba Cloud or manual servers; + per-process traffic. +- **GPU**, **Temperature & fans.** History, sensor groups, fan speeds and quick modes. -The panel sizes itself to its content and needs no scrolling on common screens. `Esc` -closes it. Light and dark themes, switched from the panel's header or following the system; -Liquid Glass is optional. +**Main window** — a sidebar with Dashboard, CPU, GPU, Memory, Network, Temperature & fans, +Processes, Keep awake and Clean; resizable in both directions. See [Cleanup](#cleanup). + +White and blue in light mode, black and blue in dark mode — one look across windows and popovers, +switched with one click or following the system.

Thermal @@ -187,13 +193,22 @@ unclean exit restores them at the next boot. ## Your data -OpenStats makes **no network connections**. Metrics come from the kernel (`host_processor_info`, -`host_statistics64`, `sysctl`), IOKit and the SMC on your own Mac. Preferences live in the -app's user defaults and contain nothing personal. There is no analytics and no telemetry. +Metrics come from the kernel (`host_processor_info`, `host_statistics64`, `sysctl`), IOKit and the +SMC on your own Mac. Preferences live in the app's user defaults and contain nothing personal. +There is no analytics and no telemetry. + +OpenStats only touches the network for two optional features, both of which can be turned off in +Settings → Network: + +- **Public IP**: when you open network details, one request to `ipinfo.io` for your public address, + region and ASN (IPv6 and fallback via Cloudflare `1.1.1.1` / ipify), cached for 10 minutes. +- **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. ## Build & run -Requires macOS 14+, **Xcode 26 or later** (the macOS 26 SDK for Liquid Glass; CommandLineTools +Requires macOS 14+, **Xcode 26 or later** (CommandLineTools alone lacks the SwiftUI macro plugins) and [XcodeGen](https://github.com/yonaskolb/XcodeGen). ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index a242b61..7296e09 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,7 +17,7 @@ OpenStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各核心 CPU 负载、GPU、内存压力、 网速、磁盘、电池、温度和风扇,并且可以直接处理:给风扇提速、合盖后继续运行、清理缓存。 -所有数据都在你自己的 Mac 上读取。无需注册账号,没有统计上报,完全不联网。 +所有数据都在你自己的 Mac 上读取。无需注册账号,没有统计上报。只有两项可关闭的网络功能会联网:打开网络详情时查询公网 IP,以及定时 ping 你选择的探测目标。 [下载](https://github.com/gentpan/OpenStats/releases/latest) · [官网](https://getopenstats.com) · @@ -36,7 +36,7 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ 提供 `.dmg`,使用 Developer ID 证书签名并经过 Apple 公证,之后提供 Homebrew cask。 在此之前请从源码构建,见[构建与运行](#构建与运行)。 -需要 macOS 14(Sonoma)或更高版本;macOS 26 起面板使用液态玻璃。在 Apple Silicon 上开发和测试。 +需要 macOS 14(Sonoma)或更高版本。在 Apple Silicon 上开发和测试。 界面为简体中文。 ## 最近更新 @@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ -尚未发布正式版本 · 开发中 **29** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +尚未发布正式版本 · 开发中 **34** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)

-2026-09-13 · 未发布 · 新增 18 · 样式 5 · 修复 6 +2026-09-13 · 未发布 · 新增 23 · 样式 5 · 修复 6 **新增** @@ -55,20 +55,25 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ - 菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。 - 网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。 - 鼠标悬停在菜单栏图标上显示完整读数。 -- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 +- 菜单栏布局:每项独立(每个指标一个图标,点击弹出该项的窄详情)或合并为一个(点击打开主窗口)。每个详情里显示哪些区块可以在设置中逐个勾选。 +- 详情弹窗: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 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 -- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。 +- 按界面需要分级采样:只显示菜单栏时只采集菜单栏用到的指标,打开详情或主窗口时按页面加采,锁屏、屏幕休眠和系统睡眠时暂停。 - 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。 - 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。 -- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作;运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。 +- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存、为网络服务设置 DNS 几个固定操作(DNS 地址与服务名在辅助工具内再次校验);运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。 - 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 - 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。 - 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。 - 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。 -- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 -- 面板提供浅色、深色两套实色主题,顶部的太阳 / 月亮按钮一键切换,也可以跟随系统;液态玻璃背景改为可选(macOS 26 起)。 -- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。 +- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏布局 / 显示项 / 样式 / 弹窗内容、连接探测与公网 IP 查询、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 +- 统一外观:浅色为白底蓝色、深色为黑底蓝色,主窗口、设置窗口与弹窗共用同一底色;侧边栏、标题栏与页面不分栏着色,红绿灯按钮与页面标题对齐;卡片为浅灰 / 深灰实色。侧边栏的太阳 / 月亮按钮一键切换,也可以跟随系统。 +- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染主窗口各页、详情弹窗、设置和菜单栏的浅色与深色截图,IP 与硬件地址替换为示例值。 **样式** @@ -124,16 +129,17 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/

菜单栏

-**面板**——点击菜单栏图标打开。 -- **概览**:健康评分,芯片、内存、系统版本、运行时长、机型徽章;CPU 柱状历史、GPU 折线、内存、 - 磁盘、网络、风扇三列卡片;按超级核、性能核、能效核分组的核心负载;电池环形图;高占用进程和快捷开关。 -- **进程**:按 CPU 或内存排序,辅助进程归并到所属应用。 -- **散热**:CPU、GPU、内存、电池、掌托温度,以及风扇。 -- **防休眠**:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可定时或不限时。 -- **清理**:扫描、预览、清理,见[清理](#清理)。 +**详情弹窗**——每个指标一个菜单栏图标,点击弹出该项的窄详情,显示哪些区块可在设置里勾选;按 `Esc` 关闭。 +- **CPU**:负载历史、按超级核 / 性能核 / 能效核分组的核心负载、处理器信息、高占用进程。 +- **内存**:使用历史、内存构成与一键释放、高占用进程。 +- **网络**:流量历史;连接探测格子(延迟、抖动、丢包);接口、Wi-Fi 信号、VPN / 代理;本地与公网 IPv4 / IPv6, + 归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。 +- **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。 -面板高度按内容自动计算,常见屏幕上不需要滚动;按 `Esc` 关闭。提供浅色、深色两套主题, -可在面板顶部一键切换或跟随系统;液态玻璃背景可选。 +**主窗口**——左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理,宽高都可调整。 +仪表盘有健康评分、芯片与系统徽章、三列指标卡片、核心负载、电池、高占用进程和快捷开关;清理见[清理](#清理)。 + +浅色为白底蓝色、深色为黑底蓝色,窗口与弹窗统一外观,可一键切换或跟随系统。

散热 @@ -171,12 +177,17 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ ## 你的数据 -OpenStats **不发起任何网络连接**。指标来自本机的内核接口(`host_processor_info`、`host_statistics64`、 -`sysctl`)、IOKit 与 SMC。偏好设置保存在应用自己的 user defaults 中,不含个人信息。没有任何统计与上报。 +指标来自本机的内核接口(`host_processor_info`、`host_statistics64`、`sysctl`)、IOKit 与 SMC。 +偏好设置保存在应用自己的 user defaults 中,不含个人信息。没有任何统计与上报。 + +只有两项可选功能会联网,都可以在“设置 → 网络”中关闭: + +- **公网 IP**:打开网络详情时向 `ipinfo.io` 请求一次公网地址、归属地与 ASN(IPv6 与回退使用 Cloudflare `1.1.1.1` / ipify),10 分钟内不重复请求。 +- **连接探测**:每 1、2 或 5 秒向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。 ## 构建与运行 -需要 macOS 14+、**Xcode 26 或更高版本**(液态玻璃需要 macOS 26 SDK;仅有 CommandLineTools 时缺少 SwiftUI 宏插件)以及 +需要 macOS 14+、**Xcode 26 或更高版本**(仅有 CommandLineTools 时缺少 SwiftUI 宏插件)以及 [XcodeGen](https://github.com/yonaskolb/XcodeGen)。 ```bash diff --git a/Scripts/sync_changelog.py b/Scripts/sync_changelog.py index 72c9947..2dfa180 100644 --- a/Scripts/sync_changelog.py +++ b/Scripts/sync_changelog.py @@ -7,6 +7,7 @@ Rewrites, from CHANGELOG.md and `git log`: - the "recent updates" block in README.md and README.zh-CN.md, between and ; +- the same block, as HTML, in web/index.html; - Assets/readme/activity.svg and activity.zh.svg, 26 weeks of commits. Run it after every CHANGELOG edit. Standard library only, so it runs on a @@ -15,6 +16,7 @@ stock Mac and in CI. Adapted from QuotaBar's script of the same name. import argparse import datetime +import html import math import pathlib import re @@ -26,6 +28,8 @@ RECENT_DAYS = 3 WEEKS = 26 KIND_EN = {"新增": "Added", "样式": "Style", "修复": "Fixed"} +WEB_ITEMS_PER_DAY = 8 +CHANGELOG_URL = "https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md" # ── CHANGELOG.md ────────────────────────────────────────────────────────── @@ -155,6 +159,40 @@ def replace_block(path, block, start="", end="", + "", + '

'] + for release, day in day_entries(releases)[:RECENT_DAYS]: + label = release["version"] or "未发布" + items = [(g["kind"], item) for g in day["groups"] for item in g["items"]] + lines.append('
') + lines.append(f'
' + f'{html.escape(label)}
') + lines.append(f'

{html.escape(counts(day, False))}

') + lines.append('
    ') + for kind, item in items[:WEB_ITEMS_PER_DAY]: + lines.append(f'
  • {html.escape(kind)}{inline_html(item)}
  • ') + lines.append("
") + if len(items) > WEB_ITEMS_PER_DAY: + lines.append(f' 还有 {len(items) - WEB_ITEMS_PER_DAY} 项 →') + lines.append("
") + lines.append("
") + lines.append("") + return "\n".join(lines[:1] + [indent + line for line in lines[1:]]) + + # ── Commit calendar ─────────────────────────────────────────────────────── def commit_days(): @@ -243,6 +281,8 @@ def main(): changed.append("README.md") if replace_block(ROOT / "README.zh-CN.md", readme_block(releases, en=False)): changed.append("README.zh-CN.md") + if replace_block(ROOT / "web" / "index.html", web_block(releases)): + changed.append("web/index.html") # The calendar runs through yesterday by default: a finished day does not # change, so running this again after today's commits leaves the chart # alone rather than redrawing it with every commit that records the redraw. diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 8a92838..f25f042 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -1,5 +1,12 @@ # 第三方声明 +## flag-icons + +`Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/` 中的国旗 SVG 来自 +[lipis/flag-icons](https://github.com/lipis/flag-icons),MIT License,Copyright (c) 2013 Panayiotis Lipiridis。 + +## exelban/stats + OpenStats 的以下部分移植自 [exelban/stats](https://github.com/exelban/stats): - `Packages/OpenStatsKit/Sources/SMC/SMCConnection.swift`:SMC 参数结构体布局与读写流程