398 lines
29 KiB
Markdown
398 lines
29 KiB
Markdown
# XStats architecture
|
||
|
||
macOS 14+ menu-bar system monitor. Swift 6 language mode, AppKit status item and panels
|
||
hosting SwiftUI. The Xcode project is generated from `project.yml` by XcodeGen;
|
||
the app's testable components live in the local Swift package. For directory layout, build, test,
|
||
and release instructions, see [DEVELOPMENT.md](DEVELOPMENT.md).
|
||
|
||
## Sampling
|
||
|
||
`MetricsHub` is an actor that runs one loop. `AppModel.demand` describes what is on screen —
|
||
which menu-bar items are enabled, whether the panel is open and on which tab — and the hub
|
||
samples only that: CPU always; memory and network cheaply; GPU, disk, processes, sensors and
|
||
fans only when a visible surface needs them. Disk is read at most every 30 s, battery every
|
||
10 s. The loop pauses on screen sleep, system sleep and session switch.
|
||
|
||
The sandboxed WidgetKit extension samples CPU, memory, disk, and battery through `Metrics`
|
||
independently, so it remains useful when the app is not running.
|
||
|
||
Things that are easy to get wrong and are handled on purpose:
|
||
|
||
- `host_processor_info` returns kernel-allocated memory; it is `vm_deallocate`d every sample.
|
||
- `getifaddrs`' `if_data` counters are 32-bit and wrap at 4 GiB; network uses
|
||
`NET_RT_IFLIST2` and `if_data64`.
|
||
- Memory follows Activity Monitor: app memory is `internal − purgeable` pages; the page size
|
||
comes from `host_page_size` (16 KB on Apple Silicon).
|
||
- Process CPU time and wall time are both in mach absolute units, so their ratio needs no
|
||
timebase conversion.
|
||
- Core types come from `hw.perflevelN`; logical CPUs are numbered from the lowest
|
||
performance level up.
|
||
|
||
## SMC
|
||
|
||
Temperatures are not hard-coded per chip. On first use the sampler enumerates every SMC key
|
||
once (≈3,800 keys in about 10 ms on an M5 Max), groups `Tp*`/`Te*` as CPU, `Tg*` as GPU,
|
||
`Tm*` as memory, `TB*` as battery and `Ts0P`/`Ts1P` as palm rest, keeps keys whose first
|
||
reading is plausible, and samples at most 12 per group.
|
||
|
||
Fans are read without privileges. Writing needs root and goes through the helper. On M5
|
||
the mode key (`F0md`) accepts a direct write; M1–M4 first need `Ftst=1` and a pause while
|
||
`thermalmonitord` lets go. The firmware records only manual or automatic, not who set it, so
|
||
a fan in manual mode that XStats did not set is shown as controlled by another program.
|
||
|
||
## Helper
|
||
|
||
Protocol 5 removes the identifier-only ad-hoc authentication fallback. Ad-hoc apps refuse helper
|
||
registration and unregister any previously registered helper without connecting to it. Fan and
|
||
lid-closed controls require team signing; maintenance operations keep their existing admin-prompt fallback.
|
||
Readiness requires an authenticated version handshake, not just an enabled SMAppService registration.
|
||
For older helpers, the client unregisters the service before re-registering the current bundle and
|
||
verifying its version. Unregister/handshake failures leave privileged calls disabled and surface an error.
|
||
|
||
Registered with `SMAppService.daemon`; `RunAtLoad` so that an unclean exit is repaired at
|
||
boot. The XPC interface is a fixed list of operations — no arbitrary commands — and each
|
||
connection gets `setCodeSigningRequirement`, derived from the helper's own signing team rather
|
||
than a hard-coded team ID. State that must be undone (manual fans, disabled sleep) is persisted
|
||
to `/Library/Application Support/XStats/helper-state.plist` and
|
||
reverted when the last client disconnects or at the next start. The helper exits after 30 s
|
||
without clients.
|
||
|
||
## Cleanup
|
||
|
||
Every rule lists candidate items; every item passes `SafetyGuard` twice — at scan and again
|
||
right before deletion, because apps start in between.
|
||
|
||
- Deny by default: an item must sit strictly inside an allow-listed root under the home
|
||
folder, never be the root itself, contain no `..` or control characters, and still pass
|
||
after symlinks are resolved (resolution can only reject, never allow).
|
||
- Under `Application Support`, only folders named like caches (`Code Cache`, `GPUCache`,
|
||
`CacheStorage`, …) are allowed.
|
||
- Any path component matching a protected keyword (keychains, password managers, VPNs,
|
||
cookies, history, …) is rejected.
|
||
- Reverse-DNS cache folders whose app is running are skipped; browser rules are blocked while
|
||
the browser runs; items modified in the last two minutes count as in use.
|
||
|
||
Regenerable caches are deleted outright; user files go to the Trash. Each action is appended
|
||
to `~/Library/Logs/XStats/cleanup.log` as a JSON line.
|
||
|
||
## Disk tools
|
||
|
||
The disk page is also where users act on the disk. `DiskToolsController` (`XStatsUI/State`) fronts four
|
||
pieces of read-mostly logic in `Cleaner/DiskTools.swift`, each testable without the UI:
|
||
|
||
- `SpaceScanner` walks a root (the home folder) once, sums every top-level entry and keeps the largest files;
|
||
packages (`.app`, `.photoslibrary`, …) count as one item. It never follows symlinks and skips folders it
|
||
cannot read. `canTrash` allows Trash only for files inside the home folder, outside `Library`, with no hidden
|
||
path component, checked again after resolving symlinks; everything else only gets "reveal in Finder".
|
||
- `VolumeVerifier` runs `diskutil verifyVolume /` (read-only, no privileges) and reduces the output to OK /
|
||
problem plus the offending line.
|
||
- `LocalSnapshots` parses `tmutil listlocalsnapshots /`. Deleting goes through the helper
|
||
(`deleteLocalSnapshots`, protocol version 4) and falls back to a one-off administrator prompt; both paths only
|
||
accept identifiers shaped like `2026-09-14-120000` (`HelperShared/LocalSnapshotCommand`).
|
||
- `MountedVolumes` lists browsable non-root volumes; ejecting uses `NSWorkspace`.
|
||
|
||
## Menu bar, popovers and main window
|
||
|
||
`MenuBarController` owns the status items. In the *separate* layout every enabled metric gets
|
||
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 `CombinedPopoverView`: a status
|
||
overview with one row per enabled metric, plus tabs that switch to each metric's full popover content
|
||
(`PopoverDetail`, shared with the separate layout). While a detail tab is showing, `AppModel.openPopover`
|
||
is set to that metric so sampling matches the standalone popover.
|
||
|
||
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.
|
||
|
||
## AI process explanations
|
||
|
||
`ProcessExplainer` uses only the on-device Apple Intelligence model. It checks framework and
|
||
macOS support before exposing the action, then checks model availability at request time. If
|
||
the device, system setting or model is unavailable, the explanation card shows the reason and
|
||
does not call another provider. Successful requests stream local output and can be cancelled.
|
||
The model receives process identity, path, signer and resource usage; nothing is sent to a CLI
|
||
or network service. Model response instructions follow the resolved application language.
|
||
## Calendar
|
||
|
||
`CalendarMenuBarController` owns a separate, opt-in status item, independent of the metrics
|
||
layout and `MetricsHub`. It reuses `StatusPanel`, updates the displayed date every minute and
|
||
on day/time-zone changes or wake, and stops its timer when disabled. `CalendarPopover` uses a
|
||
six-week Gregorian grid (1900–2100) and month/year navigation. Clicking a day opens an almanac
|
||
page in the same panel; returning preserves the selected date and month. `CalendarAlmanac`
|
||
loads Tyme's daily advice, deities and hour fortunes only for the selected day and caches the
|
||
value model by civil-date ID. The twelve-hour grid uses early Zi through Hai, excluding the
|
||
next day's late Zi entry at 23:00; each cell exposes its exact time range in a tooltip. Browsing
|
||
history preserves the selection across midnight; a selection on today follows the new day.
|
||
|
||
`CalendarEngine` is the sole adapter for the pinned MIT-licensed Tyme4Swift 1.5.0 dependency.
|
||
All Tyme calls run on MainActor because upstream exposes mutable static tables; only value
|
||
models leave the adapter. Civil dates use the system time zone and calendar day arithmetic,
|
||
not 24-hour intervals. Tibetan conversion is guarded to the upstream Gregorian coverage
|
||
1951-01-08 through 2051-02-11. Holiday data currently ends in 2026; unknown years show a notice.
|
||
Plum-rain dates describe traditional calendar rules, not observed or forecast weather.
|
||
|
||
Calendar enablement, visible features and week start persist through `AppSettings` and the
|
||
optional fields in `SettingsDocument`. Older backups preserve those settings. Additional
|
||
calendars default off and appear only in selected-day details. There is no event access or
|
||
network request at runtime. Use `--snapshot <directory> --calendar-only` for deterministic
|
||
light/dark calendar screenshots without starting unrelated samplers or scans.
|
||
|
||
## AI usage and quotas
|
||
|
||
The local Codex provider reads JSONL rollouts from CODEX_HOME (default ~/.codex),
|
||
including sessions and archived_sessions. It never reads credentials or calls quota APIs;
|
||
independent quota providers do that only while the master AI Usage switch is enabled.
|
||
Parsing runs in a background actor pinned to a dedicated serial executor, so a cold scan (tens of
|
||
seconds over a multi-gigabyte log directory) never occupies a Swift concurrency cooperative thread
|
||
and cannot stall per-second metric sampling. It uses bounded line buffers and a SQLite checkpoint store at
|
||
~/Library/Application Support/XStats/ai-usage.sqlite. Each source/path row atomically stores file
|
||
identity, size, mtime, newline-aligned byte offset and Codable parser/aggregate state. The preview
|
||
state is stored only when the final line is still unterminated, so the common case keeps one copy.
|
||
Rows whose files were not seen in a completed scan are pruned, which covers rollouts moved to
|
||
archived_sessions, deleted project directories and superseded parser namespaces. Unchanged
|
||
files reuse stored results even after restart. Appends validate prefix/boundary hashes and read
|
||
only the suffix; truncation, replacement, parser namespace or time-zone changes rebuild that file.
|
||
An unterminated final line is previewed separately from committed state, so completing it cannot
|
||
double-count usage. No raw log lines, prompts or credentials are persisted. Directory metadata is
|
||
still enumerated on each scheduled refresh; only currently discovered files contribute to totals.
|
||
The dashboard defaults to the last 365 local-calendar days by model, and shows input/output tokens, cache hit
|
||
rate, record count, daily totals and model ranking. The menu bar shows today's token total.
|
||
Cached input is a subset of input; reasoning is a subset of output, so neither is added twice.
|
||
Rows are aggregated by day and model before leaving the provider, and ordered by day then model, so
|
||
`ModelTokenUsage.id` stays unique and an unchanged corpus produces an equal report.
|
||
Duplicate session IDs and unchanged cumulative snapshots are excluded. Child replayed history
|
||
only seeds the cumulative baseline until the first `task_started`, which always follows that
|
||
history; `started_at` is not compared against the session creation time, because a subagent's
|
||
`started_at` is its parent turn's start and recent Codex builds omit the field entirely.
|
||
Unknown models remain unknown.
|
||
Local logs cannot reliably identify the paying account or usage on other devices. No cost,
|
||
subscription limits or HTTP success rates are inferred from them. The separate quota
|
||
snapshot instead queries Codex `wham/usage` and Claude `api/oauth/usage` using CLI OAuth tokens.
|
||
It maps five-hour and seven-day windows, plus Claude's model-specific weekly windows, without
|
||
combining them with local token totals. Credential discovery is repeated on each refresh; Codex
|
||
reads `auth.json` under CODEX_HOME or the standard locations, while Claude reads its CLI credential
|
||
file or a non-interactive Keychain item. Tokens are not persisted by XStats. Each provider's last
|
||
successful quota snapshot is stored in a separate table of `ai-usage.sqlite`, without credentials.
|
||
The controller restores it before the first network request and labels it with its fetch time.
|
||
Requests use isolated URL sessions and reject redirects so bearer tokens cannot be forwarded.
|
||
Missing or expired credentials clear that provider's quota display; transient errors keep the last
|
||
successful result marked stale across restarts. Authentication failures or removal of a manual
|
||
account delete that provider's cached snapshot. Both quota readers stop when AI Usage is disabled;
|
||
disabled providers are hidden while their cached snapshot remains available on re-enable.
|
||
Codex and Claude Code each have an optional, independently configured Sub2API source, tried only
|
||
when that provider's direct quota fetch fails. Each HTTPS base URL, admin email and account ID remain
|
||
in local preferences outside settings backups; passwords use separate Keychain items. The fallback
|
||
signs in to its configured server, reads the account record to verify its platform (`openai` or
|
||
`anthropic`), then reads `/api/v1/admin/accounts/{id}/usage` with `source=active&force=false`.
|
||
If the detailed endpoint is unavailable, the account's `extra` fields provide coarser five-hour and
|
||
weekly windows. Claude may also expose Sonnet and Fable weekly windows. Credentials and quota results
|
||
are never sent to XStats servers; the UI marks fallback results as Sub2API.
|
||
When one provider has a subscription snapshot, the menu bar AI reading shows its name and remaining
|
||
percentage. It prefers the weekly window (or five-hour window if weekly is unavailable); the tooltip
|
||
shows the chosen window and reset time without crowding the 22-point menu bar.
|
||
When both sources are enabled and either has quota data, the item shows a compact named reading
|
||
for each source; one without quota data shows a dash. The tooltip lists reset times and missing
|
||
data by source. Without any subscription snapshot it keeps the local daily Token reading.
|
||
The AI menu bar item follows the global or per-item style. Ring, pie, meter, and dot styles
|
||
show remaining quota beside its percentage for each available source; a source without quota
|
||
shows a dash rather than a zero gauge. Since quota history is not stored, global history or
|
||
line styles use a current-value ring for this item.
|
||
Icon style uses the bundled OpenAI and Claude logo shapes for the corresponding quota source,
|
||
including a dash when that source has no quota; aggregated local-only tokens keep the generic AI icon.
|
||
The quota detail card presents remaining allowance (not used allowance) and, when the server
|
||
provides a reset date, a separate bar for elapsed time in that five-hour or seven-day window.
|
||
Allowance turns red as it runs low; the reset bar turns green as reset approaches and updates
|
||
while the view is open.
|
||
The UI follows CC Switch's filter/summary/trend/model-table organization, implemented in SwiftUI.
|
||
The desktop toolbar groups source controls and actions. In the compact menu-bar popover, its header
|
||
owns refresh and AI Usage settings; the content shows a source switch only when multiple sources
|
||
are enabled. Token totals use compact notation with
|
||
exact hover/VoiceOver values. Cache details are
|
||
disclosed on demand. AI usage settings open in a separate window so users can copy account details
|
||
from another app without closing the form. Unsaved Sub2API fields stay in memory for the current
|
||
app session across page changes; passwords are not written to preferences. Since source data is aggregated
|
||
by day, all three activity modes cover the same trailing 365 days: daily uses one cell per day;
|
||
weekly uses a seven-cell-high bar per calendar week; cumulative sums usage from the window start
|
||
through the selected week and uses the same bar grid. Zero weeks have no lit cells. Partial weeks
|
||
include only days inside the window through today. The mode switch lives in the Token activity
|
||
header, not in a date-range toolbar. Summary and model ranking follow the mode: daily means today,
|
||
weekly means the current calendar week, and cumulative means the current calendar month (all through
|
||
now, using the local calendar). The heatmap independently keeps its 365-day window. Pointer hover
|
||
immediately shows exact daily/weekly/cumulative counts above the chart; the same label is available
|
||
to accessibility. The narrow popover lays cells out at their design size and scrolls the year grid
|
||
horizontally, initially at the recent end; the wide panel shrinks cells to fit instead.
|
||
Numbers and dates follow the app language rather than the system locale.
|
||
Pointer selection fades only the selected control background (180 ms); keyboard selection and
|
||
background data refresh do not animate the data layout. Reduced motion uses the existing quick fade.
|
||
|
||
Claude Code is a second local provider. It scans projects/ recursively (including subagents) under
|
||
CLAUDE_CONFIG_DIR when set, otherwise ~/.claude and XDG_CONFIG_HOME/claude (default ~/.config/claude).
|
||
A checkpoint keeps every assistant event for its file, because the retention window is recomputed from
|
||
the current date on each fetch: a stored cutoff would freeze at the first scan and keep aged-out events
|
||
forever instead of dropping them.
|
||
Assistant message IDs are deduplicated across files: completed messages win over partial snapshots,
|
||
then the greater output count wins. Claude input is normalized as fresh input + cache reads + cache
|
||
creation; nested 5-minute/1-hour creation counts are a fallback, never added to the aggregate twice.
|
||
Independent local source switches enable Codex and Claude Code (both default on under the master
|
||
opt-in). Disabled sources are not scanned or included in cached reports/menu totals, and are hidden
|
||
from the source selector. Disabling the selected source resets the filter to all enabled sources.
|
||
Both switches may be off, in which case polling stops and the page offers the source settings.
|
||
The menu bar totals enabled sources for today; enabling the menu-bar item also turns scanning on,
|
||
since that item is the most natural place to discover the feature.
|
||
Refreshes are queued rather than dropped: a refresh arriving while another is in flight runs after it,
|
||
so rebuilding the polling cadence cannot mistake a skipped call for a completed initial refresh and
|
||
then sleep out the whole interval. Stopping cancels the in-flight scan. These settings are local and not synced through WebDAV.
|
||
Cache creation is shown separately but already included in the input total. Claude credentials,
|
||
account data, Cowork containers and subscription limits remain outside the local scanner's scope;
|
||
the quota reader has no access to session-log content.
|
||
|
||
## 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** — Cloudflare trace (ipify as fallback) for the address only. Location, ASN, network
|
||
type, native / broadcast and the cleanliness score come from `cleanip.io/cli?json=1`, queried directly
|
||
from each Mac by `PublicAddressLookup`. The endpoint only reports the caller's own address, so
|
||
`AddressFamilyRequest` opens one Network.framework connection pinned to IPv4 and one pinned to IPv6
|
||
(URLSession cannot choose the family) and speaks plain HTTP/1.1 over TLS; when a pinned connection
|
||
cannot be set up it falls back to URLSession and keeps the answer only if it is for the same family.
|
||
If cleanip.io sees a different exit than Cloudflare (split-routing proxies), its address is shown.
|
||
Results are cached per IP for an hour in memory and for a week on disk (failed lookups are not
|
||
cached), and a 429 stops further calls until the next UTC day. Country
|
||
codes are validated before being used as flag file names. `server/geoip/` still holds the systemd
|
||
timer that syncs MaxMind GeoLite2 onto `getopenstats.com/geoip/` (account and key in
|
||
`/etc/openstats/maxmind.env` on the server); the app no longer downloads those files, they are kept
|
||
for other uses.
|
||
- **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.
|
||
|
||
## Battery and Bluetooth
|
||
|
||
The battery item reuses `BatterySampler` (IOKit power sources, sampled every 10 s while the item is in the
|
||
menu bar or its popover / page is open). `BatteryPopover` is both the popover and the main-window page
|
||
(`DetailPage`). The 24-hour charge curve comes from the history database, which gained a `battery` column
|
||
(migrated with `ALTER TABLE` on first open); `HistoryRecorder.loadBattery()` queries it independently of
|
||
the History page's range. Macs without a battery show only Bluetooth devices, and the menu-bar segment
|
||
falls back to the Bluetooth device with the lowest battery.
|
||
|
||
`BluetoothController` (`XStatsUI/State`) wraps `BluetoothBatteryReader`, which shells out to
|
||
`system_profiler` and takes a second or two, so it polls only on demand: every minute while the battery
|
||
popover / page or the System page is open, every five minutes when the menu bar needs it (the
|
||
low-battery hint or a Mac without a battery), otherwise not at all. `AppModel.bluetoothDemand` derives
|
||
that from the same visibility state as `demand`.
|
||
|
||
## Online updates
|
||
|
||
`UpdateController` posts the current version and a hashed random installation ID at launch and daily.
|
||
China-region locales prefer `https://x-stats.china.12306.work/api/v1/update/check`; other locales prefer
|
||
`https://xstats-apps.12306.work/api/v1/update/check`. Failures fall back serially to the other endpoint,
|
||
and the first success stops further requests so one check is not reported twice. Requests use the stable
|
||
`XStats/<app-version> (macOS <system-version>)` User-Agent format for regional routing and diagnostics.
|
||
The original 32-byte random value is generated with `SecRandomCopyBytes` and remains in the app's local
|
||
`UserDefaults`; it is not a credential, is excluded from WebDAV settings sync, and never invokes Keychain
|
||
authorization. No hardware serial number is used. The response has the same release manifest fields previously read from the static appcast (version,
|
||
date, notes taken from `CHANGELOG.md` by `scripts/appcast.py`, zip URL, sha256 and size). An
|
||
update is installed only after: sha256 matches, the zip holds exactly one `.app`, its bundle ID and
|
||
version match, `SecStaticCodeCheckValidity` passes with a requirement pinned to the running app's
|
||
team, and `spctl --assess` accepts it (notarized). The old bundle is renamed into a same-volume
|
||
temporary folder, the new one moved into place (restored on failure; an administrator prompt is
|
||
used when the folder is not writable), and a detached shell waits for the process to exit before
|
||
reopening the app. After an update the old helper may still be running; the app unregisters an outdated
|
||
helper, re-registers the bundled version, and verifies the protocol before privileged calls resume.
|
||
|
||
`server/api` is one Go program. Fiber exposes `POST /api/v1/update/check`, authenticated
|
||
`PUT /api/v1/releases/current`, and the aggregate `GET /stats` dashboard. GORM uses
|
||
`github.com/libtnb/sqlite` with WAL and one database connection so concurrent checks cannot compete for
|
||
SQLite's single writer. Each installation row stores only the SHA-256 installation ID, current version,
|
||
first/last check times and check count; request IPs and monitoring data are not persisted. The release
|
||
endpoint requires `XSTATS_RELEASE_TOKEN`. `scripts/publish_release.sh` uploads the dmg and zip to
|
||
object storage with `mc`, verifies each one by re-reading it from the CDN, creates the GitHub Release
|
||
that carries the dmg for manual downloads, and only then submits the generated appcast through
|
||
`scripts/publish_api.py` to both regional services. The manifest lands last, so an installed app never
|
||
sees a version whose package is not yet in place.
|
||
|
||
## WebDAV settings sync
|
||
|
||
Account login, OAuth callbacks and the old account backend have been removed.
|
||
Sync only needs the user's WebDAV server and does not run at startup, on wake or on settings changes.
|
||
|
||
WebDAVSync uses Basic authentication over HTTPS and reads/writes xstats-settings.json in an
|
||
existing directory via GET/PUT. The temporary URLSession does not share cookies or credentials,
|
||
uses normal certificate validation, refuses redirects, and streams downloads with a 1 MB limit.
|
||
GET requires HTTP 200; PUT accepts 200, 201 or 204. Writes are not retried automatically.
|
||
See [WebDAV PUT semantics](https://www.rfc-editor.org/rfc/rfc4918#section-9.7).
|
||
|
||
SyncController runs on the main actor and allows one request at a time. Upload requires confirmation
|
||
and overwrites the remote file without merging. Download validates a SettingsBackup envelope
|
||
(format, version, timestamp and SettingsDocument schema), then waits for confirmation before applying
|
||
the existing per-field validation. Cancellation and failures do not change local preferences.
|
||
|
||
SettingsDocument remains an explicit allowlist. WebDAV credentials, connection settings, local page,
|
||
helper state, monitoring data and history are excluded. Directory URL and username are saved separately
|
||
in local defaults. Passwords use the file-based login Keychain service work.12306.xstats.webdav,
|
||
with a separate account hash for each endpoint and username. Password storage must succeed before
|
||
new connection details are committed. JSON backups are not additionally encrypted at rest.
|
||
|
||
Configure every Mac separately. This version does not create remote directories, poll in the background,
|
||
merge concurrent edits, or use iCloud. A missing remote file requires an initial upload.
|
||
|
||
## Power, disk and history
|
||
|
||
- System, adapter and battery power come from SMC `PSTR`, `PDTR`, `PPBR`. GPU power comes from the
|
||
IOReport *Energy Model* `GPU Energy` counter; CPU energy counters on recent chips barely update,
|
||
so CPU power is not shown. Cluster frequencies are residency-weighted averages of *CPU Complex
|
||
Performance States*, using the `voltage-states*-sram` tables of `pmgr` (Hz on older chips, MHz on
|
||
newer ones). Each IOReport sample costs about 5 ms of CPU, so it runs at most every 2 s and only
|
||
while a page shows it.
|
||
- Disk activity diffs `IOBlockStorageDriver` statistics; SSD health reads the NVMe SMART log
|
||
through the system `NVMeSMARTLib` plug-in (no root).
|
||
- `HistoryRecorder` folds each sample into a per-minute record (averages, CPU and temperature
|
||
peaks, worst memory pressure) and writes it to `history.sqlite`; records older than 8 days are
|
||
pruned hourly. Queries bucket by 1, 5 or 30 minutes and charts break lines across gaps.
|
||
|
||
## Localization
|
||
|
||
Source strings are Simplified Chinese. `scripts/l10n_wrap.py` wraps every Chinese literal in
|
||
`tr(...)` (skipping logger calls, `case` patterns and multi-line strings) and lists the keys.
|
||
`tr` returns the source for Simplified Chinese. English uses the Swift tables; Traditional Chinese,
|
||
Japanese, Korean, German, Spanish, French and Arabic load bundled TSV catalogs from Localization/Resources.
|
||
Exact keys take priority, followed by templates with {} or numbered placeholders. Captured strings
|
||
are translated recursively; inserted values are not reinterpreted as placeholders. Missing entries
|
||
fall back to English, then the source. Translation tables are immutable; global language and caches
|
||
share a lock because background collectors also call tr.
|
||
|
||
The language picker uses a searchable native popover, native language names, a selected checkmark,
|
||
and keyboard navigation. Search accepts Chinese, English, native names and language codes.
|
||
Automatic detection walks global AppleLanguages in preference order, distinguishing zh-Hans from
|
||
zh-Hant/TW/HK/MO and falling back to English if no supported language is found. Existing system,
|
||
chinese and english preference values and WebDAV settings remain readable.
|
||
|
||
Changing language rebuilds SwiftUI roots and AppKit menu/window titles. Each SwiftUI window and popover
|
||
receives the selected locale and layout direction; Arabic uses right-to-left layout and isolates
|
||
interpolated paths/numbers with Unicode directional isolates. Dates use L10n.locale. For remote geographic
|
||
data available only in Chinese/English, other languages use English names; country names use the locale.
|
||
Some macOS-provided names follow the application's AppleLanguages on the next launch.
|
||
|
||
The nine supported languages are zh-Hans, zh-Hant, ja, ko, en, de, es, fr and ar. Snapshots accept these
|
||
codes through --snapshot <dir> --language <code>, include the language picker, and record missing
|
||
translations in untranslated.txt. New interface strings must be added to all catalogs; coverage and
|
||
placeholder checks accompany the localization tests.
|
||
|
||
`--snapshot <dir>` 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.
|