diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index dd88e31..a270411 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible Toolbox problem without exposing private paths or files. +description: Report a reproducible Orven problem without exposing private paths or files. title: "[Bug]: " labels: - bug @@ -9,13 +9,13 @@ body: value: | Thanks for reporting a reproducible problem. For a security issue, use [private vulnerability reporting](https://github.com/thangldw/toolbox/security/advisories/new) instead. - Do not paste private paths, filenames, file contents, account data, credentials, signing material, or an unredacted Toolbox export. + Do not paste private paths, filenames, file contents, account data, credentials, signing material, or an unredacted Orven export. - type: input id: toolbox-version attributes: - label: Toolbox version + label: Orven version description: Use the version shown in Settings or the GitHub release tag. - placeholder: v2.0.0 + placeholder: v3.0.0 validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 5f07330..038bfcc 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Propose a user problem for Toolbox to solve safely and locally. +description: Propose a user problem for Orven to solve safely and locally. title: "[Feature]: " labels: - enhancement @@ -7,7 +7,7 @@ body: - type: markdown attributes: value: | - Start with the user problem, not an implementation. Toolbox prioritizes local processing, explicit review, bounded scope, and recoverable actions. + Start with the user problem, not an implementation. Orven prioritizes local processing, explicit review, bounded scope, and recoverable actions. - type: textarea id: problem attributes: @@ -18,7 +18,7 @@ body: - type: dropdown id: workflow attributes: - label: Closest Toolbox workflow + label: Closest Orven workflow options: - Storage - Projects diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a4a953b..83498ac 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ -# Toolbox pull request +# Orven pull request [English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d09e142..1e1c6d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: run: python3 tests/check_diagrams.py docs/diagrams/*.html toolbox: - name: Toolbox 2.0 + name: Orven macOS runs-on: macos-15 timeout-minutes: 25 defaults: @@ -51,14 +51,15 @@ jobs: ./scripts/test_storage.sh ./scripts/test_changes.sh ./scripts/test_app.sh + ./scripts/test_monitoring.sh ./scripts/lint_localizations.swift - name: Release build run: swift build -c release - name: Assemble app bundle run: | ./scripts/build_app.sh - test -x dist/Toolbox.app/Contents/MacOS/Toolbox - test -f dist/Toolbox.app/Contents/Resources/en.lproj/Localizable.strings - test -f dist/Toolbox.app/Contents/Resources/vi.lproj/Localizable.strings + test -x dist/Orven.app/Contents/MacOS/Toolbox + test -f dist/Orven.app/Contents/Resources/en.lproj/Localizable.strings + test -f dist/Orven.app/Contents/Resources/vi.lproj/Localizable.strings plutil -lint Resources/Info.plist Resources/en.lproj/*.strings Resources/vi.lproj/*.strings - codesign --verify --deep --strict dist/Toolbox.app + codesign --verify --deep --strict dist/Orven.app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0019923..6f5bafc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - "v2.0.0" + - "v3.0.0" workflow_dispatch: concurrency: @@ -26,7 +26,7 @@ jobs: with: ref: ${{ github.ref }} - name: Require the supported release tag - run: test "$GITHUB_REF" = "refs/tags/v2.0.0" + run: test "$GITHUB_REF" = "refs/tags/v3.0.0" - name: Format run: swift format lint --recursive --parallel Sources Tests Package.swift - name: XCTest @@ -37,11 +37,12 @@ jobs: ./scripts/test_storage.sh ./scripts/test_changes.sh ./scripts/test_app.sh + ./scripts/test_monitoring.sh ./scripts/lint_localizations.swift - name: Universal release contract run: | ./scripts/build_universal.sh - ./Tests/Distribution/release_contract.sh dist/Toolbox.app + ./Tests/Distribution/release_contract.sh dist/Orven.app - name: Public release contracts working-directory: . run: | @@ -49,7 +50,7 @@ jobs: bash tests/launch_assets.sh release: - name: Build and publish unsigned stable release + name: Build and publish ad-hoc Orven release needs: validate runs-on: macos-15 timeout-minutes: 45 @@ -64,7 +65,7 @@ jobs: with: ref: ${{ github.ref }} - - name: Build and verify exact unsigned artifacts + - name: Build and verify exact ad-hoc artifacts run: | ./scripts/build_universal.sh ./scripts/build_dmg.sh @@ -75,10 +76,10 @@ jobs: GH_TOKEN: ${{ github.token }} run: | gh release create "$GITHUB_REF_NAME" \ - dist/Toolbox-2.0.0.dmg \ - dist/Toolbox-2.0.0.dmg.sha256 \ + dist/Orven-3.0.0.dmg \ + dist/Orven-3.0.0.dmg.sha256 \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ - --notes-file ../../docs/launch/toolbox-2.0.0.md \ + --notes-file ../../docs/launch/orven-3.0.0.md \ --latest \ - --title "Toolbox 2.0.0" + --title "Orven 3.0.0" diff --git a/.gitignore b/.gitignore index 85fab95..7247096 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,6 @@ NamiQuant-Claude-Design.html # Local isolated worktrees /.worktrees/ + +# Private machine verification is local evidence, never a release asset. +docs/monitoring/evidence/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae523f..f5581fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,222 +1,43 @@ -# Changelog - -[English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) +# Orven changelog ## English -All notable changes to Toolbox are documented here. Releases follow [Semantic Versioning](https://semver.org/). Historical tags through `v1.4.0` contain the standalone Diskora and Changeora applications; 2.0 and later ship one Toolbox application. - -### Toolbox 2.0.0 — 2026-08-25 - -- Promoted Toolbox 2.0.0 to the stable product channel while retaining explicit unnotarized-distribution disclosure. -- Added a first-launch HTML guide inside the DMG and matching onboarding guidance for safe Gatekeeper approval. -- Kept Homebrew unavailable and prohibited instructions that disable Gatekeeper or strip quarantine attributes. - -### Toolbox 2.0.0 Beta 1 — 2026-08-25 - -- Published tag `v2.0.0-beta.1` as an explicitly unnotarized public beta with manual Gatekeeper approval instructions. -- Replaced the standalone applications with one six-destination Toolbox GUI. -- Added user-selected project artifact cleanup for seven developer ecosystems. -- Added drag-and-drop Install Trace with persisted before/after evidence and interrupted-session recovery. -- Added versioned evidence/activity stores, conflict-safe Recovery, and copy-then-verify legacy migration. -- Revalidated every mutation against canonical roots and exact allowlisted developer commands. -- Added onboarding, Settings, scan-only schedule migration, isolated update checks, and English/Vietnamese parity gates. -- Consolidated CI, packaging, documentation, and release ownership under `apps/toolbox`. - -### Toolbox 1.4.0 — 2026-08-02 - -This release contains Diskora 1.3.0 and Changeora 1.4.0. - -- Added an in-app English/Vietnamese language selector to both applications. -- Made English the default while preserving Vietnamese as a first-class interface language. -- Persisted language preferences locally and refreshed the full interface when changed. -- Localized navigation, actions, safety explanations, scan states, errors, and About panels. -- Added packaged macOS localization resources and release checks for both languages. - -### Toolbox 1.3.0 — 2026-08-02 - -This release contains Diskora 1.2.0 and Changeora 1.3.0. - -#### Diskora 1.2.0 +### Orven 3.0.0 -- Added Undo Center with original and Trash locations, conflict-safe restore, and recovery status. -- Added explainable cleanup confidence: Safe, Review, or Dangerous. -- Improved application artifact discovery using Bundle ID, package receipts, launch services, login/background items, and containers. -- Rebuilt duplicate scanning as size grouping → partial SHA-256 → full SHA-256, with bounded I/O and phase progress. -- Rebuilt similar-photo detection around pairwise clustering, resolution, sharpness, file size, and capture metadata. -- Added allowlisted cleanup through official tools such as `simctl`, Docker, Homebrew, npm, and pip. -- Added scheduled scan-and-notify. Scheduled work never deletes files automatically. +Orven is the new product name and ring identity for Toolbox. The native interface adds live monitoring, hardware profiles and available mapped sensors, Sleep Inspector, Wi-Fi/LAN observations and explicit diagnostics, optional health history/baselines, sensor recording/exports, smart alerts, session connection changes, app dossiers, growth/workspace tools and local reports. Existing installer evidence, reviewed storage actions and recovery remain. -#### Changeora 1.3.0 +### Compatibility and trust -- Combined FSEvents with before/after snapshots to retain deep filesystem changes. -- Added coverage for login items, background tasks, package receipts, extensions, configuration profiles, browser extensions, and shell/PATH changes. -- Added application attribution using Bundle ID, Team ID, code-signing metadata, names, and time proximity. -- Added explainable risk scoring for every detected change. -- Added clean-baseline comparison and comparison between any two sessions. -- Added redacted JSON and Markdown support-report exports. -- Added uninstall observation to help identify remaining artifacts. +Bundle ID, executable/module identifiers and Application Support/Toolbox remain compatible. English, Vietnamese, Japanese and Simplified Chinese are packaged. The explicitly authorized distribution is ad-hoc signed and not notarized. No Homebrew, firewall, fan control, automatic CPU cap/suspension, exclusive snapshot-byte accounting or calibrated CPU-Z/HWMonitor parity is claimed. -#### Repository +### Validation and known limits -- Updated both applications to build 3. -- Expanded unit and smoke coverage for restore, phased hashing, FSEvents, and baseline persistence. -- Rebuilt all documentation in English, Vietnamese, and Japanese. - -### Toolbox 1.1.0 — 2026-08-02 - -- Made Diskora cleanup recoverable through Trash. -- Tightened application-leftover matching. -- Expanded Changeora snapshot coverage. -- Restored unit-test and continuous-integration infrastructure. - -### Toolbox 1.0.0 — 2026-07-26 - -- Consolidated Diskora and Changeora in one repository. -- Established local-first processing, explicit previews, and recoverable operations. +Tests, source/locale checks, universal packaging and exact release artifact verification are recorded in [readiness](docs/production/RELEASE-READINESS.md). Current foreground profiling, physical Intel/macOS 13/Ethernet, full VoiceOver, Japanese critical-copy review and native notification delivery remain unverified. Earlier resource deviations are not a current-package pass. Prior Toolbox releases remain in [GitHub release history](https://github.com/thangldw/toolbox/releases). ## Tiếng Việt -Mọi thay đổi đáng chú ý của Toolbox được ghi tại đây. Các release tuân theo [Semantic Versioning](https://semver.org/). Tag lịch sử đến `v1.4.0` chứa hai ứng dụng độc lập Diskora và Changeora; từ 2.0 trở đi chỉ ship một ứng dụng Toolbox. - -### Toolbox 2.0.0 — 2026-08-25 - -- Đưa Toolbox 2.0.0 lên kênh stable của sản phẩm, đồng thời giữ công bố rõ bản phân phối chưa notarize. -- Thêm hướng dẫn HTML ngay trong DMG và hướng dẫn onboarding tương ứng để phê duyệt qua Gatekeeper an toàn. -- Tiếp tục không cung cấp Homebrew và không hướng dẫn tắt Gatekeeper hoặc xóa quarantine attribute. - -### Toolbox 2.0.0 Beta 1 — 2026-08-25 - -- Publish tag `v2.0.0-beta.1` dưới dạng public beta chưa notarize, kèm hướng dẫn người dùng tự phê duyệt qua Gatekeeper. -- Thay hai ứng dụng độc lập bằng một GUI Toolbox với sáu destination. -- Thêm dọn artifact project theo root người dùng chọn cho bảy hệ sinh thái developer. -- Thêm Install Trace kéo-thả với bằng chứng trước/sau và khôi phục phiên bị gián đoạn. -- Thêm evidence/activity store có version, Recovery không ghi đè và migration copy-then-verify. -- Revalidate mọi mutation theo canonical root và developer command allowlist chính xác. -- Thêm onboarding, Settings, migrate lịch chỉ-quét, update check tách biệt và gate English/Tiếng Việt. -- Hợp nhất CI, packaging, tài liệu và release vào `apps/toolbox`. - -### Toolbox 1.4.0 — 2026-08-02 - -Bản phát hành này gồm Diskora 1.3.0 và Changeora 1.4.0. - -- Thêm bộ chọn English/Tiếng Việt ngay trong cả hai ứng dụng. -- Dùng English làm mặc định và giữ Tiếng Việt là ngôn ngữ giao diện đầy đủ. -- Lưu lựa chọn ngôn ngữ cục bộ và cập nhật toàn bộ giao diện khi thay đổi. -- Bản địa hóa navigation, thao tác, giải thích an toàn, trạng thái quét, lỗi và About panel. -- Đóng gói tài nguyên localization macOS và bổ sung kiểm tra release cho cả hai ngôn ngữ. +### Orven 3.0.0 -### Toolbox 1.3.0 — 2026-08-02 +Orven là tên và ring identity mới của Toolbox. Native UI bổ sung live monitoring, hardware profile/sensor mapped khả dụng, Sleep Inspector, Wi-Fi/LAN/diagnostics theo nút, health history/baseline tùy chọn, sensor recording/export, smart alerts, connection changes trong phiên, app dossiers/growth/workspace/local reports. Giữ installer evidence, storage actions có review và recovery. -Bản phát hành này gồm Diskora 1.2.0 và Changeora 1.3.0. +### Compatibility và trust -#### Diskora 1.2.0 +Bundle ID/executable/module/Application Support/Toolbox giữ tương thích. Đóng gói Anh/Việt/Nhật/Trung giản thể. Distribution đã được chọn ký ad-hoc/chưa notarized. Không Homebrew/firewall/fan control/automatic CPU cap-suspension/exclusive snapshot bytes/calibrated CPU-Z/HWMonitor parity. -- Thêm Undo Center lưu vị trí gốc và vị trí trong Trash, khôi phục an toàn khi trùng tên và hiển thị trạng thái phục hồi. -- Thêm mức tin cậy có giải thích: An toàn, Cần xem lại hoặc Nguy hiểm. -- Nâng cấp nhận diện dữ liệu ứng dụng bằng Bundle ID, package receipt, launch service, login/background item và container. -- Xây lại duplicate scanner theo chuỗi kích thước → SHA-256 một phần → SHA-256 đầy đủ, giới hạn I/O và hiển thị tiến độ theo giai đoạn. -- Xây lại Similar Photos bằng phân cụm từng cặp, độ phân giải, độ nét, kích thước và metadata ngày chụp. -- Thêm cleanup qua danh sách lệnh chính thức được cho phép như `simctl`, Docker, Homebrew, npm và pip. -- Thêm lịch quét và thông báo; tác vụ theo lịch không bao giờ tự xóa. +### Validation và giới hạn -#### Changeora 1.3.0 - -- Kết hợp FSEvents với snapshot trước/sau để giữ lại thay đổi sâu trong filesystem. -- Theo dõi thêm login item, background task, package receipt, extension, configuration profile, browser extension và thay đổi shell/PATH. -- Quy nguồn thay đổi về ứng dụng bằng Bundle ID, Team ID, chữ ký, tên và thời điểm. -- Thêm risk score có lý do cho từng thay đổi. -- Thêm baseline máy sạch và so sánh hai phiên bất kỳ. -- Export JSON và báo cáo hỗ trợ Markdown đã ẩn đường dẫn cá nhân. -- Theo dõi uninstall để phát hiện artifact còn sót. - -#### Repository - -- Nâng cả hai ứng dụng lên build 3. -- Mở rộng unit test và smoke test cho khôi phục, hashing theo giai đoạn, FSEvents và baseline. -- Viết lại toàn bộ tài liệu bằng tiếng Anh, tiếng Việt và tiếng Nhật. - -### Toolbox 1.1.0 — 2026-08-02 - -- Chuyển cleanup của Diskora qua Trash để có thể khôi phục. -- Siết nhận diện dữ liệu ứng dụng còn sót. -- Mở rộng phạm vi snapshot của Changeora. -- Khôi phục unit test và continuous integration. - -### Toolbox 1.0.0 — 2026-07-26 - -- Hợp nhất Diskora và Changeora trong một repository. -- Thiết lập nguyên tắc local-first, xem trước rõ ràng và ưu tiên thao tác có thể phục hồi. +[Readiness](docs/production/RELEASE-READINESS.md) ghi tests/source/locale/universal/artifact checks. Foreground profiling/IntelmacOS 13Ethernet vật lý/full VoiceOver/Japanese copy/OS notification chưa proof. Resource deviations cũ không proofpackage mới. Release Toolbox cũ giữ trong [GitHubhistory](https://github.com/thangldw/toolbox/releases). ## 日本語 -Toolbox の主な変更をこの file に記録します。Release は [Semantic Versioning](https://semver.org/) に従います。`v1.4.0` までの historical tag は standalone Diskora/Changeora application を含み、2.0 以降は一つの Toolbox application を ship します。 - -### Toolbox 2.0.0 — 2026-08-25 - -- Toolbox 2.0.0 を stable product channel に移し、未 notarize distribution の明示的 disclosure を維持しました。 -- DMG 内に first-launch HTML guide を追加し、安全な Gatekeeper approval のための同等の onboarding guidance を追加しました。 -- Homebrew を引き続き提供せず、Gatekeeper の無効化または quarantine attribute の削除を案内しない方針を維持しました。 - -### Toolbox 2.0.0 Beta 1 — 2026-08-25 - -- `v2.0.0-beta.1` を未 notarize の public beta として公開し、Gatekeeper の手動許可手順を追加しました。 -- Standalone app を一つの 6 destination Toolbox GUI に統合しました。 -- User 選択 root 内で 7 ecosystem の project artifact cleanup を追加しました。 -- Drag-and-drop Install Trace、前後 evidence、interrupted session recovery を追加しました。 -- Versioned evidence/activity store、上書きしない Recovery、copy-then-verify migration を追加しました。 -- 全 mutation に canonical root validation と exact command allowlist を適用しました。 -- Onboarding、Settings、scan-only schedule migration、isolated update check、英語/ベトナム語 gate を追加しました。 -- CI、packaging、documentation、release を `apps/toolbox` に統合しました。 - -### Toolbox 1.4.0 — 2026-08-02 - -このリリースには Diskora 1.3.0 と Changeora 1.4.0 が含まれます。 - -- 両アプリに English / Tiếng Việt のアプリ内言語 selector を追加しました。 -- English を既定にし、ベトナム語も完全な UI 言語として維持します。 -- 言語設定をローカル保存し、変更時に UI 全体を更新します。 -- Navigation、操作、安全性の説明、scan status、error、About panel を localization 対象にしました。 -- macOS localization resource を app bundle に含め、両言語の release check を追加しました。 - -### Toolbox 1.3.0 — 2026-08-02 - -このリリースには Diskora 1.2.0 と Changeora 1.3.0 が含まれます。 - -#### Diskora 1.2.0 - -- 元の場所と Trash 内の場所を記録し、競合を避けて復元できる Undo Center を追加しました。 -- 「安全・要確認・危険」の根拠付き信頼度を追加しました。 -- Bundle ID、package receipt、launch service、login/background item、container を使うアプリ artifact 検出に改善しました。 -- 重複スキャンをサイズ → partial SHA-256 → full SHA-256 の段階処理に変更し、I/O 制限と詳細 progress を追加しました。 -- 類似写真を pairwise clustering、解像度、鮮明度、サイズ、撮影 metadata で比較する方式に変更しました。 -- `simctl`、Docker、Homebrew、npm、pip など、許可リスト内の公式コマンドによる cleanup を追加しました。 -- 自動削除を行わない定期 scan-and-notify を追加しました。 - -#### Changeora 1.3.0 - -- FSEvents と前後 snapshot を組み合わせ、深い階層の変更も保持できるようにしました。 -- Login item、background task、package receipt、extension、configuration profile、browser extension、shell/PATH を監視対象に追加しました。 -- Bundle ID、Team ID、署名情報、名前、時刻から変更元アプリを推定します。 -- 検出した変更ごとに説明可能な risk score を追加しました。 -- クリーン baseline と任意の 2 session 比較を追加しました。 -- 個人パスを秘匿した JSON と Markdown support report の export を追加しました。 -- Uninstall を監視し、残存 artifact を検出できるようにしました。 - -#### Repository +### Orven 3.0.0 -- 両アプリを build 3 に更新しました。 -- Restore、段階的 hash、FSEvents、baseline 保存の unit/smoke test を拡充しました。 -- 全ドキュメントを英語、ベトナム語、日本語で再構成しました。 +OrvenはToolboxの新しい名前とring identityです。Native UIにlive monitoring、hardware profile/available mapped sensor、Sleep Inspector、Wi-Fi/LANと明示diagnostics、optional health history/baseline、sensor record/export、smart alert、session connection change、appdossier/growth/workspace/local reportを追加。Installer evidence、reviewed storage action/recoveryを維持。 -### Toolbox 1.1.0 — 2026-08-02 +### Compatibilityとtrust -- Diskora cleanup を Trash 経由で復元可能にしました。 -- アプリ残存データの判定を厳密化しました。 -- Changeora snapshot の監視範囲を拡張しました。 -- Unit test と continuous integration を復元しました。 +Bundle ID/executable/module/ApplicationSupportToolboxを互換維持。英語・ベトナム語・日本語・簡体字中国語を同梱。配布は明示承認されたad-hoc/unnotarized。Homebrew/firewall/fan control/autoCPU cap-suspension/exclusive snapshot bytes/calibratedCPU-Z/HWMonitorparityの主張なし。 -### Toolbox 1.0.0 — 2026-07-26 +### Validationと制限 -- Diskora と Changeora を 1 つの repository に統合しました。 -- Local-first、明示的 preview、復元可能な操作を基本方針にしました。 +[Readiness](docs/production/RELEASE-READINESS.md)にtests/source/locale/universal/artifactcheckを記録。Currentforeground/physicalIntelmacOS 13Ethernet/full VoiceOver/Japanese copy/OS notification未検証、以前resourceはnewpackagepassではありません。旧Toolboxreleaseは[GitHubhistory](https://github.com/thangldw/toolbox/releases)に保持。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66cc2b3..0753971 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to Toolbox +# Contributing to Orven [English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) @@ -8,7 +8,7 @@ Open an issue for behavior or policy changes whose scope is not already agreed. Work on a focused branch, keep each commit reviewable, and use short imperative messages such as `feat: add conflict-safe restore`. Add a failing regression before implementation when practical, make the smallest source change, and update affected documentation in the same pull request. -Preserve Toolbox's local-first safety contract. Mutations require explicit foreground review, exact canonical targets, immediate revalidation, and Trash-backed recovery whenever macOS permits. Never broaden deletion with globs, unresolved variables, name-only matching, symlink escape, or inferred project artifacts. New process execution must use exact executable and argument allowlists. New network behavior requires an explicit privacy design and must not transmit scan or device data. +Preserve Orven's local-first safety contract. Mutations require explicit foreground review, exact canonical targets, immediate revalidation, and Trash-backed recovery whenever macOS permits. Never broaden deletion with globs, unresolved variables, name-only matching, symlink escape, or inferred project artifacts. New process execution must use exact executable and argument allowlists. New network behavior requires an explicit privacy design and must not transmit scan or device data. Do not commit build output, personal reports, credentials, signing identities, notarization material, or private sample data. Do not silently change release assets or convert historical records into current product claims. @@ -23,6 +23,7 @@ swift test ./scripts/test_storage.sh ./scripts/test_changes.sh ./scripts/test_app.sh +./scripts/test_monitoring.sh ./scripts/lint_localizations.swift swift build -c release ./scripts/build_app.sh @@ -32,9 +33,9 @@ Command Line Tools can run the smoke gates and release build; `swift test` requi ### Localization -Tracked Toolbox Markdown must contain complete `## English`, `## Tiếng Việt`, and `## 日本語` sections in that order. English is canonical for commands, identifiers, paths, endpoints, tags, SHAs, and checksums; Vietnamese and Japanese must retain the same requirements and limitations rather than summarize them. +Tracked Orven Markdown must contain complete `## English`, `## Tiếng Việt`, and `## 日本語` sections in that order. English is canonical for commands, identifiers, paths, endpoints, tags, SHAs, and checksums; Vietnamese and Japanese must retain the same requirements and limitations rather than summarize them. -The shipped GUI supports English and Vietnamese. Add every new user-facing string to both localization resources, preserve exact UI labels where documentation refers to them, and run `./scripts/lint_localizations.swift`. Do not translate source identifiers, commands, filenames, URLs, bundle identifiers, or evidence values. +The shipped GUI supports English, Vietnamese, Japanese and Simplified Chinese. Add every new user-facing string to all four localization resources, preserve exact UI labels where documentation refers to them, and run `./scripts/lint_localizations.swift`. Do not translate source identifiers, commands, filenames, URLs, bundle identifiers, or evidence values. ### Accessibility @@ -50,7 +51,7 @@ Reviewers check behavior against source and executable tests first, then workflo Mở issue cho thay đổi behavior hoặc policy chưa được thống nhất scope. Làm việc trên branch tập trung, giữ từng commit có thể review và dùng message mệnh lệnh ngắn như `feat: add conflict-safe restore`. Thêm regression đang fail trước implementation khi phù hợp, thực hiện thay đổi source nhỏ nhất và cập nhật tài liệu bị ảnh hưởng trong cùng pull request. -Giữ contract an toàn local-first của Toolbox. Mutation cần foreground review rõ ràng, canonical target chính xác, revalidation ngay trước thao tác và recovery qua Trash khi macOS cho phép. Không mở rộng phạm vi xóa bằng glob, biến chưa resolve, name-only matching, symlink escape hoặc artifact project suy luận. Process execution mới phải dùng executable/argument allowlist chính xác. Network behavior mới cần privacy design rõ ràng và không được truyền scan hoặc device data. +Giữ contract an toàn local-first của Orven. Mutation cần foreground review rõ ràng, canonical target chính xác, revalidation ngay trước thao tác và recovery qua Trash khi macOS cho phép. Không mở rộng phạm vi xóa bằng glob, biến chưa resolve, name-only matching, symlink escape hoặc artifact project suy luận. Process execution mới phải dùng executable/argument allowlist chính xác. Network behavior mới cần privacy design rõ ràng và không được truyền scan hoặc device data. Không commit build output, report cá nhân, credential, signing identity, notarization material hoặc sample data riêng tư. Không âm thầm thay release asset hoặc biến historical record thành current product claim. @@ -65,6 +66,7 @@ swift test ./scripts/test_storage.sh ./scripts/test_changes.sh ./scripts/test_app.sh +./scripts/test_monitoring.sh ./scripts/lint_localizations.swift swift build -c release ./scripts/build_app.sh @@ -74,9 +76,9 @@ Command Line Tools chạy được smoke gate và release build; `swift test` tr ### Localization -Markdown được track của Toolbox phải có đầy đủ section `## English`, `## Tiếng Việt`, `## 日本語` theo thứ tự đó. English là canonical cho command, identifier, path, endpoint, tag, SHA và checksum; bản Vietnamese/Japanese phải giữ cùng requirement/limitation thay vì tóm tắt. +Markdown được track của Orven phải có đầy đủ section `## English`, `## Tiếng Việt`, `## 日本語` theo thứ tự đó. English là canonical cho command, identifier, path, endpoint, tag, SHA và checksum; bản Vietnamese/Japanese phải giữ cùng requirement/limitation thay vì tóm tắt. -GUI được ship hỗ trợ English và Vietnamese. Thêm mọi user-facing string mới vào cả hai localization resource, giữ UI label chính xác khi tài liệu tham chiếu và chạy `./scripts/lint_localizations.swift`. Không dịch source identifier, command, filename, URL, bundle identifier hoặc evidence value. +GUI hỗ trợ English, Vietnamese, Japanese và Simplified Chinese. Thêm mọi user-facing string mới vào cả bốn localization resource, giữ UI label chính xác khi tài liệu tham chiếu và chạy `./scripts/lint_localizations.swift`. Không dịch source identifier, command, filename, URL, bundle identifier hoặc evidence value. ### Accessibility @@ -92,7 +94,7 @@ Reviewer đối chiếu behavior với source và executable test trước, sau Scope が未合意の behavior または policy change は issue を作成します。Focused branch で作業し、各 commit を review 可能に保ち、`feat: add conflict-safe restore` のような短い imperative message を使います。可能な場合は implementation 前に failing regression を追加し、最小の source change を行い、影響する documentation を同じ pull request で更新します。 -Toolbox の local-first safety contract を保持します。Mutation には明示的 foreground review、exact canonical target、action 直前の revalidation、macOS が許す場合の Trash-backed recovery が必要です。Glob、未解決変数、name-only matching、symlink escape、推測した project artifact で削除範囲を広げません。新しい process execution は exact executable/argument allowlist を使います。新しい network behavior は明示的 privacy design を必要とし、scan/device data を送信してはいけません。 +Orven の local-first safety contract を保持します。Mutation には明示的 foreground review、exact canonical target、action 直前の revalidation、macOS が許す場合の Trash-backed recovery が必要です。Glob、未解決変数、name-only matching、symlink escape、推測した project artifact で削除範囲を広げません。新しい process execution は exact executable/argument allowlist を使います。新しい network behavior は明示的 privacy design を必要とし、scan/device data を送信してはいけません。 Build output、personal report、credential、signing identity、notarization material、private sample data を commit しません。Release asset を黙って置換したり、historical record を current product claim に変えたりしません。 @@ -107,6 +109,7 @@ swift test ./scripts/test_storage.sh ./scripts/test_changes.sh ./scripts/test_app.sh +./scripts/test_monitoring.sh ./scripts/lint_localizations.swift swift build -c release ./scripts/build_app.sh @@ -116,9 +119,9 @@ Command Line Tools では smoke gate と release build を実行できます。 ### Localization -追跡済み Toolbox Markdown は `## English`、`## Tiếng Việt`、`## 日本語` の完全な section をこの順序で持ちます。Command、identifier、path、endpoint、tag、SHA、checksum は English を canonical とし、Vietnamese/Japanese は summary ではなく同じ requirement と limitation を保持します。 +追跡済み Orven Markdown は `## English`、`## Tiếng Việt`、`## 日本語` の完全な section をこの順序で持ちます。Command、identifier、path、endpoint、tag、SHA、checksum は English を canonical とし、Vietnamese/Japanese は summary ではなく同じ requirement と limitation を保持します。 -公開 GUI は English と Vietnamese をサポートします。新しい user-facing string は両方の localization resource に追加し、documentation が参照する exact UI label を保持して `./scripts/lint_localizations.swift` を実行します。Source identifier、command、filename、URL、bundle identifier、evidence value は翻訳しません。 +公開 GUI は英語・ベトナム語・日本語・簡体字中国語をサポートします。新しい user-facing string は4言語の localization resource に追加し、documentation が参照する exact UI label を保持して `./scripts/lint_localizations.swift` を実行します。Source identifier、command、filename、URL、bundle identifier、evidence value は翻訳しません。 ### Accessibility diff --git a/DESIGN.md b/DESIGN.md index 18a6e21..8c3cef4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,93 +1,69 @@ --- -version: alpha +version: production-candidate omitted: - section: colors - reason: Canonical palette is owned by the portfolio css/tokens.css and is not duplicated here. + reason: Canonical palette is owned by native OrvenTheme.swift and adapted by scripts/sync_brand_tokens.py. - section: typography - reason: Type and multilingual font recipes inherit the canonical site-shell.css entry. + reason: Portfolio site-shell.css owns shared type; Japanese and Chinese route stacks are explicit in orven-page.css. - section: spacing - reason: Existing portfolio spacing tokens remain the runtime source of truth. + reason: Shared portfolio controls retain their tokens; route composition owns responsive spacing. - section: rounded - reason: Shared control and logo shapes inherit the portfolio recipes. + reason: Native ring icon and shared controls remain canonical. - section: components - reason: Shared buttons, logo, theme and support controls remain owned by the portfolio. + reason: Theme and support remain canonical shared portfolio mirrors. --- -# Toolbox landing design +# Orven website design ## English -### Overview -The user selected revised mockup 2 and authorized production release. Scope is the marketing landing only; the native Swift application and existing privacy page remain unchanged. README.md, docs/ARCHITECTURE.md, PRIVACY.md and SECURITY.md provide product evidence. Global macOS users and developers receive EN/VN/JP/CN website copy. Japanese locale does not introduce Japan-specific regulated flows. New Japanese acquisition/install copy has not received independent native review. +### Audience and intent -### Colors -The portfolio css/tokens.css owns the palette. The final official stylesheet is https://thangldw.github.io/css/site-shell.css. Route CSS consumes semantic variables only; canvas is the approved #F6F5F1. Fresh theme defaults to light and explicit saved preferences are restored by the shared owner. +Global macOS users and developers, with EN/VI/JA/ZH localization. Japanese locale does not introduce Japan-specific regulated business flows. This is a marketing/help site for Orven3.0; native app owns the approved ring identity. No mock dashboard or synthetic measurement is shown as live evidence. Critical Japanese privacy/acquisition copy has not received independent native review. -### Typography -Use canonical Inter and Japanese/Chinese font stacks. Hero, headings, lead, body and paths use the existing semantic type recipes. Vietnamese headings inherit the diacritic fix. Japanese and Chinese prose wraps naturally; technical paths and release filenames stay literal. +### Visual and component ownership -### Layout -Preserve the hero/trust split, equal Storage/Projects/Applications columns, review/Trash/recovery workflow, secondary Change Timeline/Install Trace row and install block. Canonical thang. replaces the mock's accidental Space. wordmark. Synthetic sizes and paths are labeled; first-launch guidance is a disclosure. Narrow layouts stack columns and use a 2x2 workflow, with document scrolling. +Native OrvenTheme.swift owns the brand palette. scripts/sync_brand_tokens.py generates site/orven-brand.css using the existing light/dark selector contract; no second hand-maintained brand palette. site-shell.css owns shared typography and controls; product-shell.js/product-support.js remain reviewed shared mirrors recorded in shared-assets.json. orven-page.css owns the hero, nine capability cells, help/privacy composition, global scrollbar and responsive layout. The app icon is copied from the current native1024px asset. -### Elevation and shapes -Flat surfaces and hairline borders follow the portfolio. Shared logo and button shapes are unchanged. Native meter elements show synthetic folder sizes; they are not measurements or benchmarks. Supplied Tabler Icons v3.35.0 assets and MIT license live under site/assets/icons. +### Content and behavior -### Components -The final canonical CSS owns shared states; toolbox-page.css owns composition only. product-shell.js and product-support.js are exact reviewed deployment mirrors, recorded with source commit and SHA-256 in shared-assets.json. Shared theme accessibility and support behavior remain canonical. preview.js owns complete locale messages, metadata, illustrative number formatting and toolbox.site.locale. Copy announces success or a manual fallback; native details/summary owns first-launch disclosure. - -### Product boundaries -Only recognized rebuildable project artifacts within a selected root are represented. App-linked evidence is not deletion permission. Foreground review precedes eligible mutations to Trash; restore requires an unoccupied original path. Preserve v2.0.0, macOS 13+, ARM64/x86_64 slices and the ad-hoc/not-notarized disclosure. Do not claim physical Intel validation, automatic cleanup, installer execution or Homebrew installation. +Hero states what the app does; capability cells name actual implemented workflows. Release/first-launch guidance explicitly discloses ad-hoc signing and no notarization. While preparing, CTA links to release status rather than a nonexistent new download. preview.js is the shared four-locale copy/metadata owner; existing locale/theme preferences remain compatible. Help and privacy are sibling routes. Source identifiers/data namespace remain Toolbox for compatibility, not user-facing branding. ### Verification -Run JavaScript syntax, site and documentation contracts, shared mirror hashes, and DESIGN.md lint. Verify browser desktop/mobile, locale reload, theme, Copy, disclosure, keyboard focus and Support dialog. The premium scanner's actionless-button rule recognizes only inline handlers; corroborate externally bound controls with browser evidence. No fake inline handlers or scanner workarounds. Native Swift is unchanged; no native build is claimed for this landing. + +Verify real browser wide/narrow, all locales, theme and reload, first-launch disclosure, keyboard focus, support dialog and sibling guide/privacy routes. Run locale parity, link/content contracts, brand generation, shared mirror hashes, strict premium audit and docs checks. Local/native or historical benchmarks are not website production evidence. Publication state must follow actual GitHub release/Pages outcomes. ## Tiếng Việt -### Tổng quan -Người dùng chọn mockup mới số 2 và cho phép release production. Phạm vi chỉ là landing giới thiệu; ứng dụng Swift và trang quyền riêng tư hiện có không đổi. README.md, docs/ARCHITECTURE.md, PRIVACY.md và SECURITY.md là nguồn thông tin sản phẩm. Website hỗ trợ EN/VN/JP/CN cho người dùng macOS và lập trình viên toàn cầu. Ngôn ngữ Nhật không bổ sung luồng pháp lý riêng của Nhật. Nội dung Nhật mới về giới thiệu và cài đặt chưa được người bản ngữ độc lập review. +### Audience và mục tiêu -### Màu sắc -css/tokens.css của portfolio sở hữu bảng màu. Stylesheet chính thức cuối cùng là https://thangldw.github.io/css/site-shell.css. CSS riêng chỉ dùng biến semantic; nền là #F6F5F1 đã chốt. Mặc định lần đầu là sáng; nguồn chung khôi phục lựa chọn đã lưu. +Mac users/developers toàn cầu, EN/VI/JA/ZH. Locale Nhật không tạo regulatedflow Nhật riêng. Marketing/help choOrven3.0, nativeapp sởhữuringidentityđãduyệt. Khôngmock/syntheticmeasurement giảlàm liveevidence. CriticalJAprivacy/acquisition chưaindependentreview. -### Kiểu chữ -Dùng Inter và font stack Nhật/Trung chuẩn chung. Hero, tiêu đề, lead, nội dung và đường dẫn dùng thang chữ semantic hiện có. Tiêu đề Việt kế thừa sửa lỗi dấu. Văn bản Nhật và Trung xuống dòng tự nhiên; đường dẫn kỹ thuật và tên file release giữ nguyên. +### Visual và component ownership -### Bố cục -Giữ hero và phần tin cậy, ba cột Dung lượng/Dự án/Ứng dụng, quy trình duyệt/Trash/khôi phục, hàng Lịch sử thay đổi/Theo dõi cài đặt và phần cài đặt. Logo thang. thay chữ Space. do mockup tạo nhầm. Dung lượng và đường dẫn mẫu có nhãn; hướng dẫn mở lần đầu là disclosure. Màn hình hẹp xếp các cột dọc, dùng quy trình 2x2 và cuộn toàn trang. +OrvenTheme.swift sởhữubrandpalette; scripts/sync_brand_tokens.py sinhsite/orven-brand.css/lightdarkselector,khônghandmaintainedpalette thứhai. site-shell.css typography/control; product-shell/supportJSmirror trongshared-assets.json. orven-page.css composition/scrollbar/responsive. Iconcopycurrentnative1024px. -### Bề mặt và hình dạng -Bề mặt phẳng và viền mảnh theo portfolio. Logo và hình dạng nút chung không đổi. Phần tử meter hiển thị dung lượng mẫu, không phải số đo hay benchmark. Icon Tabler v3.35.0 và giấy phép MIT nằm trong site/assets/icons. +### Nội dung và hành vi -### Thành phần -CSS chính thức ở cuối sở hữu trạng thái chung; toolbox-page.css chỉ phụ trách bố cục. product-shell.js và product-support.js là bản sao triển khai đã review, ghi commit nguồn và SHA-256 trong shared-assets.json. Nhãn accessibility của theme và hành vi Support giữ nguồn chung. preview.js sở hữu bản dịch đầy đủ, metadata, định dạng số mẫu và toolbox.site.locale. Copy báo thành công hoặc cách sao chép thủ công; details/summary điều khiển hướng dẫn mở lần đầu. +Heromôtảapp,cellsworkflowthật. Release/firstlaunchdisclosead-hoc/chưa notarized;preparingCTAstatuskhôngdownloadkhôngtồn tại. preview.js4locale/metadata,giữprefscompat. Guide/privacy sibling. Toolboxnamespace đểtươngthíchkhôngpublicbrand. -### Ranh giới sản phẩm -Chỉ minh họa dữ liệu dự án có thể tạo lại đã được nhận diện trong thư mục người dùng chọn. Bằng chứng liên quan đến ứng dụng không phải quyền xóa. Người dùng duyệt trước khi chuyển file đủ điều kiện vào Trash; khôi phục cần vị trí gốc còn trống. Giữ v2.0.0, macOS 13+, binary ARM64/x86_64 và thông tin ký ad-hoc/chưa notarize. Không tuyên bố đã thử trên máy Intel thật, tự dọn dẹp, chạy installer hay cài qua Homebrew. +### Kiểm chứng -### Kiểm tra -Chạy kiểm tra cú pháp JavaScript, contract trang và tài liệu, hash bản sao chung và lint DESIGN.md. Kiểm tra trình duyệt desktop/mobile, lưu ngôn ngữ khi reload, theme, Copy, disclosure, focus bàn phím và Support dialog. Scanner premium chỉ nhận handler inline; xác minh các nút gắn sự kiện bên ngoài bằng trình duyệt. Không thêm handler giả hoặc né scanner. Swift không đổi; không tuyên bố build native cho landing này. +Browserwide/narrow/4locale/theme/reload/disclosure/keyboard/support/siblings. Locale/link/content/brandgenerator/mirrorhash/premium/docschecks. Historical/nativebenchkhôngwebsiteproductionproof. PublicationstatusphảitheoGHrelease/Pagesthật. ## 日本語 -### 概要 -利用者が新しいモックアップの2番を選び、本番公開を承認しました。対象は紹介ページのみです。Swiftアプリと既存のプライバシーページは変更しません。製品情報の根拠はREADME.md、docs/ARCHITECTURE.md、PRIVACY.md、SECURITY.mdです。世界のmacOS利用者と開発者向けにEN/VN/JP/CNを提供します。日本語対応によって日本固有の規制対象の手続きは追加しません。新しい日本語の紹介・インストール文言は、独立した日本語母語話者の確認を受けていません。 +### Audienceと目的 -### 配色 -portfolioのcss/tokens.cssが配色を管理します。最後に適用する公式stylesheetはhttps://thangldw.github.io/css/site-shell.cssです。ページ固有のCSSはsemantic変数のみを使用し、背景は承認済みの#F6F5F1です。初回はライトテーマ、保存済みの明示的な選択は共通の実装が復元します。 +GlobalMacusers/developers、EN/VI/JA/ZH。日本語localeはJapan-specificregulatedflowを導入しません。Orven3.0marketing/help、nativeappの承認ringidentity。Mock/syntheticmeasurementをliveevidenceとして表示しません。CriticalJAprivacy/acquisitionはindependentreview未完。 -### 書体 -共通のInterと日本語・中国語font stackを使用します。Hero、見出し、導入文、本文、パスは既存のsemantic文字サイズに従います。ベトナム語見出しの発音記号修正も継承します。日本語と中国語は自然に改行し、技術的なパスと公開ファイル名はそのまま表示します。 +### Visualとcomponent ownership -### レイアウト -Heroと信頼情報、ストレージ・プロジェクト・アプリの等幅3列、確認・ゴミ箱・復元の手順、変更履歴・インストール追跡の補助行、インストール部分を維持します。モックに誤って含まれたSpace.は正規のthang.ロゴに置き換えます。容量とパスの例にはサンプルであることを表示します。初回起動の説明は開閉式です。狭い画面では列を縦に並べ、手順を2x2にし、ページ全体をスクロールできます。 +OrvenTheme.swiftがbrandpalette、scripts/sync_brand_tokens.pyがsite/orven-brand.css/lightdarkselectorを生成、二重handmaintainedpaletteなし。Site-shell.cssはtype/control、product-shell/supportJSmirrorはshared-assets.json。Orven-page.cssはcomposition/scrollbar/responsive、iconはcurrentnative1024pxコピー。 -### 面と形状 -平坦な面と細い境界線はportfolioの規則に従います。共通ロゴとボタンの形状は変更しません。meter要素の容量は説明用であり、実測値やbenchmarkではありません。Tabler Icons v3.35.0の素材とMITライセンスをsite/assets/iconsに含めます。 +### Contentと動作 -### コンポーネント -最後に読み込む公式CSSが共通の状態を管理し、toolbox-page.cssは配置のみを担当します。product-shell.jsとproduct-support.jsは確認済みの配布用コピーで、shared-assets.jsonに元commitとSHA-256を記録します。テーマのアクセシブル名とSupportの動作は共通実装を維持します。preview.jsは翻訳、metadata、例の数値書式、toolbox.site.localeを管理します。Copyは成功または手動コピーの案内を通知し、details/summaryが初回起動説明を開閉します。 - -### 製品の境界 -選択したルート内で認識された再生成可能な生成物だけを示します。アプリとの関連の根拠は削除の許可ではありません。対象ファイルをゴミ箱へ移動する前に確認が必要で、復元先は空いている必要があります。v2.0.0、macOS 13以上、ARM64/x86_64のbinary slice、アドホック署名と未公証の説明を維持します。実機Intel検証、自動整理、installer実行、Homebrew対応を主張しません。 +Heroはapp目的、cellは実workflow。Release/firstlaunchはad-hoc/unnotarized明示、preparingCTAはstatusで未公開downloadなし。Preview.jsは4locale/metadata、既存prefs互換。Guide/privacy sibling、Toolboxnamespaceはcompatでpublicbrandではありません。 ### 検証 -JavaScript構文、サイトと文書のcontract、共通コピーのhash、DESIGN.md lintを実行します。ブラウザーでdesktop/mobile、再読み込み後の言語、テーマ、Copy、開閉説明、キーボードfocus、Support dialogを確認します。premium scannerはinline handlerのみを認識するため、外部scriptで接続した操作はブラウザーの証拠で確認します。偽のhandlerやscanner回避を追加しません。Swiftは変更せず、この紹介ページのためのnative buildは主張しません。 + +Realbrowserwide/narrow/4locale/theme/reload/disclosure/keyboard/support/siblings、locale/link/content/brandgenerator/mirrorhash/premium/docs。Historical/nativebenchはwebsiteproofなし。Publicationstatusは実GHrelease/Pages結果に従います。 diff --git a/PRIVACY.md b/PRIVACY.md index 61abaa6..a269517 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,111 +1,79 @@ -# Toolbox Privacy - -[English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) +# Orven Privacy ## English -### Local processing - -Toolbox has no account, telemetry, advertising SDK, analytics endpoint, or built-in cloud synchronization. Scans, snapshots, hashes, photo analysis, evidence, activity, migration, and recovery data are processed on the Mac. Toolbox does not upload file contents and does not declare software malicious. - -### Data read - -The exact read scope depends on the workflow, roots selected by the user, and macOS permissions. Toolbox may read filesystem paths and metadata, allocated sizes, partial and full file hashes, image features and metadata, project markers, application bundle metadata, code-signing identifiers, package receipts, Launch Services and background-item registration, shell/PATH configuration, configuration profiles, browser-extension metadata, and FSEvents observed during an active trace. Package and registration records are used as evidence, not as deletion authorization. +### Local processing and stored data -Install Trace accepts `.dmg`, `.pkg`, and `.app` paths for identification and evidence capture. It does not mount or execute the dropped installer; the user performs installation, update, or uninstall through the normal macOS flow. +Orven has no account, telemetry, advertising SDK, analytics or cloud sync. Filesystem metadata/hashes, image features, installer FSEvents, package receipts, application registrations, processes, hardware and network observations are read only within OS permissions and selected workflow coverage. Dropped installers are identified, never mounted or executed by Orven. -### Data stored +State remains under `~/Library/Application Support/Toolbox` for upgrade compatibility: cleanup/recovery history, change sessions/snapshots/evidence, storage checkpoints and migration state. Opt-in `health-history-v1.json` holds system CPU/memory/pressure/thermal and available mapped sensors, with availability/source/unit/timestamps; it excludes process names, paths and network endpoints. Retention is24h/7d, at most10080 records/36fields/24MiB. File budget can omit older observations sooner. Expired observations are removed on a successful later write; an app that does not run does not actively erase them. Corrupt/unknown/oversized files are preserved and block writes. -Local application state is stored under `~/Library/Application Support/Toolbox`. It can include cleanup and recovery history in `history.json`, Change Timeline sessions and snapshots in `sessions.json`, `active-snapshot.json`, `active-trace-metadata.json`, and `trusted-baseline.json`, evidence in `evidence-v1.json`, activity in `activity-v1.json`, storage checkpoints, migration state in `migration-v1.json`, and locally generated reports. `active-trace-metadata.json` may contain the selected installer source URL/path, display name, installer kind, and observation timestamp until finish or cancel clears active state; only an unexpected interruption may retain it for reduced-coverage recovery. Recovery entries can contain original and Trash paths. Preferences such as language and scheduled-scan status use macOS preferences, and an enabled schedule creates `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist`. +Sensor sessions contain at most360 points in memory until Quit. Alerts contain at most100 in-app events. Socket-change and network-path timelines are opt-in bounded session data, not disk identity history. Stop preserves a recording session; starting a new session replaces it. Existing management history stays until explicitly managed. -### Permissions and mutations +### Network activity -Full Disk Access is optional. Without it, protected paths may be unreadable and Toolbox reports reduced coverage. Notification permission is requested only when enabling scheduled scan notifications. Toolbox has no privileged helper and does not ask for broad permission to bypass macOS controls. +Check for Updates explicitly requests `https://api.github.com/repos/thangldw/toolbox/releases/latest`; no scan report is attached. Wi-Fi/LAN tools run only explicit refresh/scan/probe operations. DNS resolution queries the system resolver; ICMP latency uses up to20 probes, and connection checks use optional gateway ICMP3 and TCP443 without application payload. Destinations/resolvers can observe ordinary connection metadata, source IP and queried hostnames. Numeric-IP diagnostics skip DNS. Nearby Wi-Fi uses CoreWLAN, with identities possibly redacted by macOS. No background bandwidth test, packet capture, firewall, network configuration change or automatic upload is provided. -Mutations happen only after foreground review and immediate target revalidation. Eligible file operations use macOS Trash and record recovery paths. Scheduled runs only scan and notify; they never delete. Project scan roots and export destinations are selected by the user. +### Permissions and exports -### Exports +Optional Full Disk Access expands filesystem coverage. Location permission is requested by a dedicated button for Wi-Fi identity coverage; Local Network permission may be requested by macOS during probes. Notifications require explicit permission. None of these grants authorizes file deletion. Orven does not read Wi-Fi passwords or bypass protected locations. -Change Timeline exports are generated locally as Markdown or JSON. Paths under the current home directory are redacted by replacing that prefix with `~`; names, attribution, reasons, timestamps, and paths outside the home directory can remain. The user chooses the destination and controls any later sharing. Review every report before transmitting it. +JSON/CSV/Markdown export shows a frozen preview and saves to the destination you choose. Health reports retain raw measurement units, missing values and timestamps. Diagnostic reports may contain process/application names, paths and numeric endpoints; replacing the home prefix is not full anonymization. Review the exact report before sharing. Orven does not transmit it automatically. -### Network behavior +### Controls and release trust -Toolbox makes no automatic update request. Only after the user chooses **Check for Updates**, and only when no scan is active, it sends a GET request to `https://api.github.com/repos/thangldw/toolbox/releases/latest`. The request uses GitHub's JSON `Accept` header and a `User-Agent` containing the Toolbox version. Toolbox adds no scan results, paths, evidence, file metadata, or device fields to the request. There are no other built-in application network paths. +History, alerts, menu-bar monitoring and connection tracking default off. Turning history on does not enable background sampling; inactivity/sleep/missing observations create gaps. Cleanup requires foreground review and immediate path revalidation; eligible file moves use Trash. Recovery refuses to overwrite an occupied path. Scheduled management scans never delete automatically. -### Legacy migration - -On first launch, Toolbox may inspect supported JSON under `~/Library/Application Support/Diskora`, `~/Library/Application Support/MacCleaner`, and `~/Library/Application Support/Changeora`. Import is opt-in: Toolbox decodes the source, merges supported records, writes them to its own directory, reads them back for verification, and records completion. Original legacy files are not modified or deleted, including after a migration failure. - -Replacing a legacy Diskora schedule is a separate, confirmed Settings action. Toolbox first installs and bootstraps its scan-only LaunchAgent; it removes the legacy plist only after that succeeds. +Orven 3.0.0 uses an explicitly authorized ad-hoc-signed, unnotarized release channel. This is not Apple trust approval. The [Security policy](SECURITY.md) and [user guide](docs/USER-GUIDE.md) explain installation and known limits. Website links and external support destinations have their own privacy policies. ## Tiếng Việt -### Xử lý local - -Toolbox không có account, telemetry, advertising SDK, analytics endpoint hoặc cloud synchronization tích hợp. Scan, snapshot, hash, phân tích ảnh, evidence, activity, migration và recovery data được xử lý trên máy Mac. Toolbox không upload nội dung file và không kết luận software là malware. - -### Dữ liệu được đọc +### Xử lý local và dữ liệu lưu -Read scope chính xác phụ thuộc workflow, root người dùng chọn và permission của macOS. Toolbox có thể đọc filesystem path/metadata, allocated size, partial/full file hash, image feature/metadata, project marker, application bundle metadata, code-signing identifier, package receipt, đăng ký Launch Services/background item, cấu hình shell/PATH, configuration profile, browser-extension metadata và FSEvents quan sát trong active trace. Package/registration record chỉ được dùng làm evidence, không phải authorization để xóa. +Orven không có account/telemetry/advertising/analytics/cloud sync. Đọc metadata/hash filesystem, image features, FSEventsinstaller, receipt/registration, process/hardware/network trong quyền OS và coverage workflow. Installer thả vào chỉ được định danh, không mount/execute. -Install Trace nhận path `.dmg`, `.pkg`, `.app` để nhận diện và capture evidence. Toolbox không mount hoặc execute installer được thả vào; người dùng thực hiện install, update hoặc uninstall qua luồng macOS bình thường. +Dữ liệu giữ trong `~/Library/Application Support/Toolbox` để tương thích nâng cấp: cleanup/recovery, change sessions/snapshots/evidence, storagecheckpoint/migration. `health-history-v1.json` opt-in lưu systemCPU/memory/pressure/thermal/sensor khả dụng, availability/source/unit/timestamp; không tênprocess/path/endpoint. Retention24h/7d, tối đa10080record/36field/24MiB; budget có thể bỏ record sớm. Expired bị loại trong write thành công sau đó; app không chạy không tự xóa. File corrupt/unknown/oversized giữ nguyên và chặn write. -### Dữ liệu được lưu +Sensor session tối đa360point RAM tới Quit, alert tối đa100event. Socket-change/networkpath opt-in bounded/RAM, không identityhistorydisk. Stop giữ session; Start thay session cũ. History quản lý vẫn giữ tới khi được quản lý rõ ràng. -Application state local được lưu dưới `~/Library/Application Support/Toolbox`. Dữ liệu có thể gồm cleanup/recovery history trong `history.json`; session/snapshot Change Timeline trong `sessions.json`, `active-snapshot.json`, `active-trace-metadata.json`, `trusted-baseline.json`; evidence trong `evidence-v1.json`; activity trong `activity-v1.json`; storage checkpoint; migration state trong `migration-v1.json`; và report tạo local. `active-trace-metadata.json` có thể chứa source URL/path của installer đã chọn, display name, installer kind và observation timestamp cho đến khi finish hoặc cancel xóa active state; chỉ unexpected interruption mới có thể giữ file này để recovery với reduced coverage. Recovery entry có thể chứa vị trí gốc và Trash. Preference như language và scheduled-scan status dùng macOS preferences; khi bật lịch, app tạo `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist`. +### Hoạt động mạng -### Permission và mutation +Check for Updates theo nút GET `https://api.github.com/repos/thangldw/toolbox/releases/latest`, không đính kèm scan report. Wi-Fi/LAN chỉ refresh/scan/probe explicit. DNS dùng system resolver; latency tối đa20ICMP; connectioncheck gatewayICMP3 tùy chọn/TCP443 không application payload. Đích/resolver có thể thấy metadata kết nối, source IP/hostname query. NumericIP bỏ DNS. Nearby dùngCoreWLAN, identity có thể bị OSẩn. Không backgroundspeedtest/packet capture/firewall/đổicấuhình/tựupload. -Full Disk Access là tùy chọn. Khi không có quyền này, protected path có thể không đọc được và Toolbox báo reduced coverage. Notification permission chỉ được yêu cầu khi bật thông báo scheduled scan. Toolbox không có privileged helper và không yêu cầu quyền rộng để bypass cơ chế macOS. +### Quyền và export -Mutation chỉ xảy ra sau foreground review và revalidation target ngay trước thao tác. File operation đủ điều kiện dùng macOS Trash và ghi recovery path. Lịch chạy chỉ scan/notify, tuyệt đối không xóa. Project scan root và export destination do người dùng chọn. +FullDiskAccess tùy chọn mở filesystemcoverage. Location qua nút riêng để đọc Wi-Fiidentity; macOS có thể xin LocalNetwork khi probe. Notification cần quyền riêng. Không quyền nào cấp quyền xóa file. Không đọc mật khẩu Wi-Fi/vượt protectedlocations. -### Export +Export JSON/CSV/Markdown preview chốt trước và lưu vào đích chọn. Health giữunit/missing/timestamp. Diagnostics có thể chứaprocess/app name/path/numeric endpoint; thayhome prefix không anonymize đầy đủ. Reviewreport trước chia sẻ; app không tự truyền. -Change Timeline export được tạo local dưới dạng Markdown hoặc JSON. Path trong home directory hiện tại được redaction bằng cách thay prefix đó bằng `~`; tên, attribution, reason, timestamp và path ngoài home có thể vẫn còn. Người dùng chọn destination và kiểm soát mọi chia sẻ sau đó. Phải review từng report trước khi truyền đi. +### Điều khiển và trust release -### Network behavior +History/alerts/menu-bar/connectiontracking mặc định off. Bật history không bật sampler nền; inactive/sleep/missing tạo gap. Cleanup cần foreground review/path revalidation; eligiblefile dùngTrash. Recovery không overwritepathoccupied. Scheduledscan không tự delete. -Toolbox không tự gửi update request. Chỉ sau khi người dùng chọn **Check for Updates**, và chỉ khi không có scan active, app gửi GET tới `https://api.github.com/repos/thangldw/toolbox/releases/latest`. Request dùng JSON `Accept` header của GitHub và `User-Agent` chứa version Toolbox. Toolbox không thêm kết quả scan, path, evidence, file metadata hoặc device field vào request. Ứng dụng không có built-in network path nào khác. - -### Legacy migration - -Ở lần chạy đầu, Toolbox có thể inspect JSON được hỗ trợ dưới `~/Library/Application Support/Diskora`, `~/Library/Application Support/MacCleaner` và `~/Library/Application Support/Changeora`. Import là opt-in: Toolbox decode source, merge record được hỗ trợ, ghi vào directory riêng, đọc lại để verify và ghi completion. File legacy gốc không bị sửa hoặc xóa, kể cả khi migration fail. - -Thay lịch Diskora cũ là action riêng trong Settings, có confirmation. Toolbox cài và bootstrap scan-only LaunchAgent trước; app chỉ xóa legacy plist sau khi bước đó thành công. +Orven 3.0.0 kýad-hoc/chưa notarized theo lựa chọn rõ ràng, không Appletrustapproval. Xem [Security](SECURITY.md)/[userguide](docs/USER-GUIDE.md). Linkwebsite/external support có policy riêng. ## 日本語 -### Local processing - -Toolbox には account、telemetry、advertising SDK、analytics endpoint、内蔵 cloud synchronization がありません。Scan、snapshot、hash、photo analysis、evidence、activity、migration、recovery data は Mac 上で処理されます。File content を upload せず、software を malware と判定しません。 - -### 読み取る data - -正確な read scope は workflow、user が選択した root、macOS permission に依存します。Toolbox は filesystem path/metadata、allocated size、partial/full file hash、image feature/metadata、project marker、application bundle metadata、code-signing identifier、package receipt、Launch Services/background-item registration、shell/PATH configuration、configuration profile、browser-extension metadata、active trace 中に観測した FSEvents を読み取る場合があります。Package/registration record は evidence であり、削除 authorization ではありません。 - -Install Trace は識別と evidence capture のため `.dmg`、`.pkg`、`.app` path を受け取ります。Drop された installer を mount または execute しません。User が通常の macOS flow で install、update、uninstall を実行します。 - -### 保存する data +### Local処理と保存データ -Local application state は `~/Library/Application Support/Toolbox` に保存されます。Cleanup/recovery history の `history.json`、Change Timeline session/snapshot の `sessions.json`、`active-snapshot.json`、`active-trace-metadata.json`、`trusted-baseline.json`、evidence の `evidence-v1.json`、activity の `activity-v1.json`、storage checkpoint、migration state の `migration-v1.json`、local 生成 report を含む場合があります。`active-trace-metadata.json` は、finish または cancel が active state を消去するまで、選択した installer の source URL/path、display name、installer kind、observation timestamp を含む場合があります。Reduced-coverage recovery のために保持されるのは unexpected interruption の場合だけです。Recovery entry は original/Trash path を含む場合があります。Language と scheduled-scan status などの preference は macOS preferences を使い、schedule を有効にすると `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist` を作成します。 +Account/telemetry/広告/analytics/cloud syncなし。OS権限とworkflowcoverage内でfilesystem metadata/hash、image features、installer FSEvents、receipt/registration、process/hardware/networkを読みます。Droppedinstallerをidentifyしますがmount/executeしません。 -### Permission と mutation +Upgrade互換のため`~/Library/Application Support/Toolbox`にcleanup/recovery、changesession/snapshot/evidence、storagecheckpoint/migrationを保存。Opt-in`health-history-v1.json`はsystemCPU/memory/pressure/thermal/availablesensorとavailability/source/unit/timestampのみ、process name/path/endpointなし。Retention24h/7d、最大10080record/36field/24MiB。Budgetで早期省略可能、期限切れは後の成功writeで削除、非実行appは能動削除しません。Corrupt/unknown/oversizedfile保持・write停止。 -Full Disk Access は任意です。許可しない場合、protected path を読めず reduced coverage と表示することがあります。Notification permission は scheduled scan notification を有効にする場合だけ要求します。Toolbox には privileged helper がなく、macOS control を bypass する広範な permission を要求しません。 +Sensor session最大360pointはQuitまでRAM、alert最大100event。Socket-change/networkpathはopt-inbounded/sessionRAMでdiskidentityhistoryなし。Stopは保持、Startは以前sessionを置換。Managementhistoryは明示管理まで保持。 -Mutation は foreground review と action 直前の target revalidation 後だけ実行します。対象 file operation は macOS Trash を使い recovery path を記録します。Scheduled run は scan/notification のみで削除しません。Project scan root と export destination は user が選択します。 +### Network動作 -### Export +明示Check for Updatesは`https://api.github.com/repos/thangldw/toolbox/releases/latest`へGET、scan report添付なし。Wi-Fi/LANは明示refresh/scan/probeのみ。DNSはsystem resolver、latency最大20ICMP、connectioncheckはoptionalgatewayICMP3/TCP443・application payloadなし。Destination/resolverに通常connectionmetadata/source IP/hostname queryが見える場合があります。NumericIPはDNS省略。NearbyはCoreWLAN、identityはOSredaction可能。Backgroundspeedtest/packet capture/firewall/設定変更/自動uploadなし。 -Change Timeline export は local で Markdown または JSON として生成します。現在の home directory 配下の path は prefix を `~` に置換して redaction します。Name、attribution、reason、timestamp、home 外の path は残る場合があります。Destination とその後の共有は user が管理します。送信前に各 report を review してください。 +### 権限とexport -### Network behavior +OptionalFullDiskAccessはfilesystemcoverage拡大。Locationは専用buttonでWi-Fiidentity用、probe中にOSがLocalNetwork権限を要求する場合があります。Notificationは明示権限。権限は削除許可ではなくWi-Fipassword/protectedlocation回避なし。 -Toolbox は自動 update request を行いません。User が **Check for Updates** を選択し、scan が active でない場合だけ、`https://api.github.com/repos/thangldw/toolbox/releases/latest` に GET を送信します。Request は GitHub JSON `Accept` header と Toolbox version を含む `User-Agent` を使います。Scan result、path、evidence、file metadata、device field を request に追加しません。その他の built-in application network path はありません。 +JSON/CSV/Markdownは凍結preview後、選択先へ保存。Healthはunit/missing/timestamp保持。Diagnosticsはprocess/app name/path/numeric endpointを含む場合がありhome prefix置換は完全匿名化ではありません。共有前にreportをreview、自動送信なし。 -### Legacy migration +### Controlとrelease trust -初回起動時、Toolbox は `~/Library/Application Support/Diskora`、`~/Library/Application Support/MacCleaner`、`~/Library/Application Support/Changeora` の supported JSON を inspect できます。Import は opt-in です。Source を decode し、supported record を merge して Toolbox directory に書き、再度読み取って verify し、completion を記録します。Migration failure の場合も含め、元の legacy file を変更・削除しません。 +History/alerts/menu-bar/connectiontracking初期off。Historyを有効にしても背景samplingは開始せずinactive/sleep/missingはgap。Cleanupはforeground review/path revalidation、eligiblefileをTrashへ。Recoveryはoccupiedpathをoverwriteしません。Scheduledscanは自動deleteなし。 -Legacy Diskora schedule の置換は Settings の別 action で、confirmation が必要です。Toolbox は先に scan-only LaunchAgent を install/bootstrap し、成功した場合だけ legacy plist を削除します。 +Orven 3.0.0は明示承認されたad-hoc署名/unnotarizedでAppletrustapprovalではありません。[Security](SECURITY.md)/[userguide](docs/USER-GUIDE.md)参照。外部support/linkには別privacy policyがあります。 diff --git a/README.md b/README.md index 5531f1b..7408dca 100644 --- a/README.md +++ b/README.md @@ -1,246 +1,103 @@ -# Toolbox for macOS - -[English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) +# Orven for macOS ## English -Product introduction: [thangldw.github.io/toolbox/](https://thangldw.github.io/toolbox/). Latest published release: [v2.0.0](https://github.com/thangldw/toolbox/releases/tag/v2.0.0). Main may contain later source or documentation changes; they do not replace the published artifact or its recorded verification. - -Toolbox is a local-first macOS GUI for understanding installer changes and reclaiming storage through reviewed, recoverable actions. It combines the former Diskora and Changeora workflows in one application. It is not a malware scanner, package manager, automatic cleaner, cloud service, or privileged system repair tool. - -### Install `v2.0.0` - -Requirements: macOS 13 or later. The published DMG contains `arm64` and `x86_64` slices; the release record does not claim a physical Intel launch test. - -1. Download [`Toolbox-2.0.0.dmg`](https://github.com/thangldw/toolbox/releases/download/v2.0.0/Toolbox-2.0.0.dmg) and [`Toolbox-2.0.0.dmg.sha256`](https://github.com/thangldw/toolbox/releases/download/v2.0.0/Toolbox-2.0.0.dmg.sha256) into the same folder. -2. Verify the download: - - ```bash - shasum -a 256 -c Toolbox-2.0.0.dmg.sha256 - ``` - -3. Open the DMG, read `Open Toolbox - First Launch.html`, and drag Toolbox to Applications. -4. Try to open Toolbox once. If macOS blocks it, open **System Settings → Privacy & Security**, find the Toolbox message, choose **Open Anyway**, and authenticate. - -`v2.0.0` is the stable product release, but it is ad-hoc signed and **not notarized** by Apple. Stable identifies the release channel; it is not Apple trust approval. Gatekeeper rejection before the manual approval is expected. Do not disable Gatekeeper or remove quarantine attributes. Homebrew installation is unavailable because this release is not Developer ID-signed and notarized. - -### Core workflows - -- **Install Trace:** accept a `.dmg`, `.pkg`, or `.app`, capture a before snapshot, observe bounded FSEvents while the user runs the normal macOS install, update, or uninstall flow, then compare and persist the after evidence. Toolbox does not mount or execute the dropped installer. -- **Storage:** inspect folder allocation, duplicate candidates verified by hashing, similar-photo candidates, developer storage, and safe cleanup categories. -- **Projects:** find only recognized rebuildable artifacts such as `.build`, `node_modules`, `.venv`, `target`, Gradle/Flutter output, and `Pods` inside a root selected by the user. -- **Applications:** inspect installed applications and evidence-linked leftovers; package receipts and registration metadata are evidence, not permission to delete. -- **Change Timeline:** compare saved sessions and baselines, explain attribution and risk, and export redacted Markdown or JSON reports. -- **Recovery:** retain original and Trash paths for eligible reviewed mutations; restore fails rather than overwrite an existing destination. +### Orven 3.0 -### Safety and privacy boundary +Orven brings live Mac monitoring, hardware observations, Wi-Fi/LAN diagnostics and recoverable storage management into one native macOS app. It is the renamed successor to Toolbox, with a new ring icon and interface. English, Vietnamese, Japanese and Simplified Chinese are available. -Scans, snapshots, hashes, evidence, activity, migration state, and recovery manifests stay on the Mac under `~/Library/Application Support/Toolbox`. Toolbox has no account, telemetry, advertising SDK, analytics endpoint, content upload, privileged helper, automatic deletion, or malware verdict. Full Disk Access is optional and only expands readable coverage; inaccessible protected locations remain reduced coverage. +### Install and release status -Every mutation requires foreground review and immediate canonical-path revalidation. Eligible file mutations use macOS Trash. Scheduled runs scan and notify only; they never delete. The only built-in network path is a GET to `https://api.github.com/repos/thangldw/toolbox/releases/latest` after the user chooses **Check for Updates**; it sends no scan results, paths, evidence, file metadata, or device fields. +Orven 3.0.0 is being prepared for production. Use the [release page](https://github.com/thangldw/toolbox/releases) for published assets; a local build is not a published download. The release is ad-hoc signed and **not notarized**. macOS may require **System Settings → Privacy & Security → Open Anyway** after the first blocked launch. Do not disable Gatekeeper or remove quarantine. Requirements: macOS 13+, ARM64 or x86_64; physical Intel/macOS 13 verification remains incomplete. Homebrew installation is unavailable. -See [Privacy](PRIVACY.md) and [Security](SECURITY.md) for the complete policy. +Download the DMG and matching SHA-256 file, verify the checksum, open the disk image, read the first-launch guide and drag Orven.app into Applications. Retain your data when upgrading: bundle ID `com.thang.toolbox` and `~/Library/Application Support/Toolbox` intentionally remain unchanged. -### Build and validation +### What you can do -Swift 6.0 or later is required to build from source. From `apps/toolbox`: +- Live monitoring: process identities and trends, CPU/memory, disk/network rates, battery/thermal and optional menu-bar status. +- Hardware: machine profile, available mapped CPU/GPU temperatures, fan RPM, battery temperature and observed system power, with freshness and missing readings. +- Wi-Fi/LAN: interface/IP/gateway/DNS/media/carrier observations, Wi-Fi signal/channel/link rate and explicit nearby scans. +- Network measurements: destination DNS/TCP443 checks and 20-probe ICMP latency/loss/jitter by button; cancel and retained timestamps. +- Health: default-off bounded local history, median baselines, session sensor charts/JSON/CSV and opt-in in-app alerts. +- Network changes: opt-in session connection/path observations; no packet capture or firewall. +- Storage/projects: folder allocation, duplicate candidates, selected-root rebuildable artifacts, developer workspace and growth checkpoints. +- Applications: dossiers and evidence-linked leftovers; evidence is not deletion permission. CPU priority changes require explicit review. +- Changes/recovery: installer evidence, comparisons, reviewed eligible moves to Trash and recovery to an unoccupied original path. -```bash -swift format lint --recursive --parallel Sources Tests Package.swift -swift test -./scripts/test_core.sh -./scripts/test_storage.sh -./scripts/test_changes.sh -./scripts/test_app.sh -./scripts/lint_localizations.swift -swift build -c release -./scripts/build_app.sh -codesign --verify --deep --strict dist/Toolbox.app -``` +### Privacy and limits -Command Line Tools can run the smoke gates and release build. The XCTest targets require the XCTest module from full Xcode. Local build and `codesign` success prove structural integrity only; they do not prove Developer ID signing, notarization, stapling, Gatekeeper acceptance, or a published release. +No account, telemetry, analytics, automatic deletion or cloud sync. Explicit network probes contact the destinations you enter; update checking contacts GitHub. History is optional and bounded. Permissions expand coverage, never authorize deletion. Physical sensor mappings are model dependent; link rate is not Internet speed, snapshots do not establish exclusive bytes, and connection observations are not a firewall. See [Privacy](PRIVACY.md), [Security](SECURITY.md) and [release readiness](docs/production/RELEASE-READINESS.md) for executed checks and remaining limits. -### Documentation and repository layout +### Build, contribute and documentation -```text -toolbox/ -├── apps/toolbox/ # ToolboxCore, ToolboxStorage, ToolboxChanges, GUI executable -├── docs/ # Architecture, operations, launch, and release evidence -├── site/ # Product landing page -├── .github/workflows/ # CI, Pages, and release workflows -├── PRIVACY.md -├── SECURITY.md -└── CONTRIBUTING.md -``` +Build with Swift 6 and full Xcode. From `apps/toolbox`, run `swift test`, `./scripts/test_monitoring.sh`, `./scripts/lint_localizations.swift`, `./scripts/build_universal.sh` and `./scripts/build_dmg.sh`. The resulting app is `dist/Orven.app`; the internal executable/module names remain Toolbox for compatibility. Verify with `./scripts/verify_release.sh --allow-adhoc`; this checks structural integrity, not Apple notarization. -Start with [Architecture](docs/ARCHITECTURE.md), the [architecture diagram](docs/diagrams/toolbox-architecture.html), [Operations](docs/OPERATIONS.md), and [Release operations](docs/OPERATIONS-RELEASE.md). Historical release details are in [Changelog](CHANGELOG.md) and the [`v2.0.0` release note](docs/launch/toolbox-2.0.0.md). Engineering changes follow [Contributing](CONTRIBUTING.md). - -### Version 1 history - -Tags through `v1.4.0` retain the standalone Diskora and Changeora source and binaries as historical artifacts; they are not active support branches. On first launch, Toolbox 2 can inspect supported legacy Application Support JSON and, only with user approval, copy, merge, and verify it into the Toolbox store. Original legacy files remain unchanged. - -### License - -[MIT](LICENSE) +Read [User guide](docs/USER-GUIDE.md), [Architecture](docs/ARCHITECTURE.md), [Operations](docs/OPERATIONS.md), [Release operations](docs/OPERATIONS-RELEASE.md), [Contributing](CONTRIBUTING.md) and [Changelog](CHANGELOG.md). Website: [Orven](https://thangldw.github.io/toolbox/). Previous Toolbox releases remain in GitHub's immutable tag/release history. [MIT license](LICENSE). ## Tiếng Việt -Trang giới thiệu: [thangldw.github.io/toolbox/](https://thangldw.github.io/toolbox/). Release đã phát hành mới nhất: [v2.0.0](https://github.com/thangldw/toolbox/releases/tag/v2.0.0). Main có thể chứa source hoặc tài liệu mới hơn; chúng không thay thế artifact đã phát hành hay evidence kiểm chứng của artifact. - -Toolbox là GUI macOS ưu tiên xử lý local để hiểu thay đổi do installer tạo ra và giải phóng dung lượng bằng thao tác được duyệt, có thể khôi phục. Ứng dụng hợp nhất workflow Diskora và Changeora trước đây. Toolbox không phải malware scanner, package manager, trình dọn tự động, cloud service hay công cụ sửa hệ thống có quyền đặc biệt. - -### Cài đặt `v2.0.0` - -Yêu cầu: macOS 13 trở lên. DMG đã phát hành chứa slice `arm64` và `x86_64`; release record không claim đã launch trực tiếp trên máy Intel vật lý. - -1. Tải [`Toolbox-2.0.0.dmg`](https://github.com/thangldw/toolbox/releases/download/v2.0.0/Toolbox-2.0.0.dmg) và [`Toolbox-2.0.0.dmg.sha256`](https://github.com/thangldw/toolbox/releases/download/v2.0.0/Toolbox-2.0.0.dmg.sha256) vào cùng folder. -2. Xác minh file tải về: - - ```bash - shasum -a 256 -c Toolbox-2.0.0.dmg.sha256 - ``` - -3. Mở DMG, đọc `Open Toolbox - First Launch.html`, rồi kéo Toolbox vào Applications. -4. Thử mở Toolbox một lần. Nếu macOS chặn, mở **System Settings → Privacy & Security**, tìm thông báo về Toolbox, chọn **Open Anyway** rồi xác thực. +### Orven 3.0 -`v2.0.0` là stable product release nhưng được ký ad-hoc và **chưa notarize** bởi Apple. Stable chỉ kênh phát hành, không phải Apple trust approval. Gatekeeper từ chối trước bước phê duyệt thủ công là kết quả dự kiến. Không tắt Gatekeeper hoặc xóa quarantine attribute. Chưa có Homebrew vì release này chưa được ký Developer ID và notarize. +Orven hợp nhất giám sát Mac, quan sát phần cứng, chẩn đoán Wi-Fi/LAN và quản lý dung lượng có khôi phục trong app macOS native. Đây là tên mới của Toolbox, với ring icon và giao diện mới. Hỗ trợ Anh, Việt, Nhật và Trung giản thể. -### Workflow chính +### Cài đặt và trạng thái release -- **Install Trace:** nhận `.dmg`, `.pkg` hoặc `.app`, chụp snapshot trước, quan sát FSEvents có giới hạn trong khi người dùng chạy luồng cài đặt, cập nhật hoặc uninstall bình thường của macOS, sau đó so sánh và lưu evidence sau thao tác. Toolbox không mount hoặc execute installer được thả vào. -- **Storage:** kiểm tra dung lượng folder, candidate file trùng được xác minh bằng hash, candidate ảnh tương tự, developer storage và các nhóm cleanup an toàn. -- **Projects:** chỉ tìm artifact có thể build lại đã được nhận diện như `.build`, `node_modules`, `.venv`, `target`, output Gradle/Flutter và `Pods` bên trong root do người dùng chọn. -- **Applications:** kiểm tra ứng dụng đã cài và leftover gắn với evidence; package receipt và registration metadata chỉ là evidence, không phải quyền xóa. -- **Change Timeline:** so sánh session và baseline đã lưu, giải thích attribution/risk, đồng thời export report Markdown hoặc JSON đã redaction. -- **Recovery:** giữ vị trí gốc và vị trí Trash cho mutation đủ điều kiện đã được duyệt; restore sẽ fail thay vì ghi đè destination đang tồn tại. +Orven 3.0.0 đang được chuẩn bị cho production. Tải artifact đã công bố từ [release page](https://github.com/thangldw/toolbox/releases); local build không phải download đã phát hành. Release ký ad-hoc và **chưa notarize**. Khi lần mở đầu bị chặn, macOS có thể yêu cầu **System Settings → Privacy & Security → Open Anyway**. Không tắt Gatekeeper/xóa quarantine. Yêu cầu macOS 13+, ARM64/x86_64; kiểm chứng Intel/macOS 13 vật lý còn thiếu. Chưa có Homebrew. -### Ranh giới an toàn và quyền riêng tư +Tải DMG và SHA-256 cùng phiên bản, xác minh checksum, mở disk image, đọc hướng dẫn và kéo Orven.app vào Applications. Khi nâng cấp giữ dữ liệu: bundle ID `com.thang.toolbox` và `~/Library/Application Support/Toolbox` được giữ nguyên có chủ đích. -Scan, snapshot, hash, evidence, activity, trạng thái migration và recovery manifest ở lại trên máy Mac trong `~/Library/Application Support/Toolbox`. Toolbox không có account, telemetry, advertising SDK, analytics endpoint, content upload, privileged helper, tự động xóa hoặc malware verdict. Full Disk Access là tùy chọn và chỉ mở rộng coverage có thể đọc; vị trí được bảo vệ không truy cập được vẫn được hiển thị là reduced coverage. +### Chức năng -Mọi mutation cần foreground review và revalidation canonical path ngay trước thao tác. File mutation đủ điều kiện dùng macOS Trash. Lịch chạy chỉ scan và notify, tuyệt đối không xóa. Network path tích hợp duy nhất là GET tới `https://api.github.com/repos/thangldw/toolbox/releases/latest` sau khi người dùng chọn **Check for Updates**; request không gửi kết quả scan, path, evidence, file metadata hoặc device field. +- Giám sát: identity/trend process, CPU/memory, tốc độ disk/network, battery/thermal, menu-bar tùy chọn. +- Phần cứng: profile máy, nhiệt độ CPU/GPU mapped khả dụng, RPM quạt, nhiệt pin/công suất hệ thống quan sát; freshness/missing rõ ràng. +- Wi-Fi/LAN: interface/IP/gateway/DNS/media/carrier, signal/channel/link rate và nearby scan theo nút. +- Phép đo mạng: DNS/TCP443 tới đích nhập và20probe ICMP latency/loss/jitter; cancel và timestamp kết quả cũ. +- Health: history local bounded mặc định tắt, baseline median, sensor chart phiên/JSON/CSV, in-app alert opt-in. +- Thay đổi mạng: connection/path observations trong RAM phiên opt-in; không packet capture/firewall. +- Storage/projects: allocation thư mục, duplicate candidates, artifact rebuildable trong root đã chọn, developer workspace/growth checkpoints. +- Applications: dossier/leftover có evidence; evidence không phải quyền xóa. Đổi priority CPU cần review rõ ràng. +- Changes/recovery: evidence installer/comparison, duyệt trước khi chuyển file đủ điều kiện vào Trash, restore khi path gốc trống. -Xem [Quyền riêng tư](PRIVACY.md) và [Bảo mật](SECURITY.md) để đọc policy đầy đủ. +### Privacy và giới hạn -### Build và validation +Không account/telemetry/analytics/tự xóa/cloud sync. Probe explicit kết nối đích bạn nhập; checkupdate kết nối GitHub. History tùy chọn/bounded. Quyền mở coverage không cấp quyền xóa. Mapping sensor theo model; linkrate không Internet speed, snapshot không proofexclusivebytes, quan sát connection không firewall. [Privacy](PRIVACY.md), [Security](SECURITY.md), [release readiness](docs/production/RELEASE-READINESS.md) ghi checks đã chạy và giới hạn. -Cần Swift 6.0 trở lên để build source. Chạy từ `apps/toolbox`: +### Build, đóng góp và tài liệu -```bash -swift format lint --recursive --parallel Sources Tests Package.swift -swift test -./scripts/test_core.sh -./scripts/test_storage.sh -./scripts/test_changes.sh -./scripts/test_app.sh -./scripts/lint_localizations.swift -swift build -c release -./scripts/build_app.sh -codesign --verify --deep --strict dist/Toolbox.app -``` +Build bằng Swift 6 và bộ Xcode đầy đủ. Từ `apps/toolbox`, chạy `swift test`, `./scripts/test_monitoring.sh`, `./scripts/lint_localizations.swift`, `./scripts/build_universal.sh`, `./scripts/build_dmg.sh`. App là `dist/Orven.app`; tên executable/module Toolbox giữ để tương thích. `./scripts/verify_release.sh --allow-adhoc` kiểm tra tính toàn vẹn của bộ cài, không chứng minh notarization. -Command Line Tools chạy được smoke gate và release build. XCTest target cần module XCTest từ full Xcode. Local build và `codesign` pass chỉ chứng minh structural integrity; không chứng minh Developer ID signing, notarization, stapling, Gatekeeper acceptance hoặc release đã publish. - -### Tài liệu và layout repository - -```text -toolbox/ -├── apps/toolbox/ # ToolboxCore, ToolboxStorage, ToolboxChanges, GUI executable -├── docs/ # Architecture, operations, launch, and release evidence -├── site/ # Product landing page -├── .github/workflows/ # CI, Pages, and release workflows -├── PRIVACY.md -├── SECURITY.md -└── CONTRIBUTING.md -``` - -Bắt đầu với [Architecture](docs/ARCHITECTURE.md), [sơ đồ architecture](docs/diagrams/toolbox-architecture.html), [Operations](docs/OPERATIONS.md) và [Release operations](docs/OPERATIONS-RELEASE.md). Chi tiết release lịch sử nằm trong [Changelog](CHANGELOG.md) và [release note `v2.0.0`](docs/launch/toolbox-2.0.0.md). Thay đổi kỹ thuật tuân theo [Contributing](CONTRIBUTING.md). - -### Lịch sử Version 1 - -Các tag đến `v1.4.0` giữ source và binary độc lập của Diskora/Changeora dưới dạng artifact lịch sử; đây không phải active support branch. Ở lần chạy đầu, Toolbox 2 có thể inspect Application Support JSON cũ được hỗ trợ và chỉ khi người dùng đồng ý mới copy, merge, verify vào Toolbox store. File legacy gốc không thay đổi. - -### Giấy phép - -[MIT](LICENSE) +Xem [User guide](docs/USER-GUIDE.md), [Architecture](docs/ARCHITECTURE.md), [Operations](docs/OPERATIONS.md), [Release operations](docs/OPERATIONS-RELEASE.md), [Contributing](CONTRIBUTING.md), [Changelog](CHANGELOG.md), [website Orven](https://thangldw.github.io/toolbox/). Release Toolbox trước đó giữ trong tag/release history trên GitHub. [MIT](LICENSE). ## 日本語 -紹介ページ: [thangldw.github.io/toolbox/](https://thangldw.github.io/toolbox/)。最新の公開 release は [v2.0.0](https://github.com/thangldw/toolbox/releases/tag/v2.0.0) です。Main の新しい source/documentation change は公開 artifact やその検証記録を置換しません。 - -Toolbox は、installer による変更を理解し、review 済みで復元可能な操作によって storage を整理する local-first macOS GUI です。旧 Diskora と Changeora の workflow を一つの application に統合しています。Malware scanner、package manager、自動 cleaner、cloud service、privileged system repair tool ではありません。 - -### `v2.0.0` のインストール - -要件は macOS 13 以降です。公開 DMG は `arm64` と `x86_64` slice を含みますが、release record は物理 Intel Mac での launch test を claim していません。 - -1. [`Toolbox-2.0.0.dmg`](https://github.com/thangldw/toolbox/releases/download/v2.0.0/Toolbox-2.0.0.dmg) と [`Toolbox-2.0.0.dmg.sha256`](https://github.com/thangldw/toolbox/releases/download/v2.0.0/Toolbox-2.0.0.dmg.sha256) を同じ folder に download します。 -2. Download を検証します。 - - ```bash - shasum -a 256 -c Toolbox-2.0.0.dmg.sha256 - ``` - -3. DMG を開き、`Open Toolbox - First Launch.html` を読んで Toolbox を Applications に移動します。 -4. Toolbox を一度開きます。macOS に block された場合は **System Settings → Privacy & Security** を開き、Toolbox の message から **Open Anyway** を選び、認証します。 - -`v2.0.0` は stable product release ですが、ad-hoc 署名で Apple により **notarize されていません**。Stable は release channel を示すだけで、Apple trust approval ではありません。手動承認前の Gatekeeper rejection は expected result です。Gatekeeper を無効化したり quarantine attribute を削除したりしないでください。この release は Developer ID 署名・notarize 済みではないため、Homebrew install は提供しません。 - -### Core workflow - -- **Install Trace:** `.dmg`、`.pkg`、`.app` を受け取り、before snapshot を取得し、user が通常の macOS install/update/uninstall flow を実行している間だけ bounded FSEvents を観測して、after evidence を比較・保存します。Toolbox は drop された installer を mount または execute しません。 -- **Storage:** folder allocation、hash で検証した duplicate candidate、similar-photo candidate、developer storage、安全な cleanup category を確認します。 -- **Projects:** user が選択した root 内だけで、`.build`、`node_modules`、`.venv`、`target`、Gradle/Flutter output、`Pods` など既知の再生成可能 artifact を検出します。 -- **Applications:** install 済み application と evidence-linked leftover を確認します。Package receipt と registration metadata は evidence であり、削除許可ではありません。 -- **Change Timeline:** 保存済み session と baseline を比較し、attribution/risk を説明し、redaction 済み Markdown または JSON report を export します。 -- **Recovery:** 対象となる review 済み mutation の original path と Trash path を保持します。既存 destination を上書きせず restore を fail させます。 - -### Safety と privacy の境界 - -Scan、snapshot、hash、evidence、activity、migration state、recovery manifest は Mac 内の `~/Library/Application Support/Toolbox` に保持されます。Account、telemetry、advertising SDK、analytics endpoint、content upload、privileged helper、自動削除、malware verdict はありません。Full Disk Access は任意で、読み取り可能範囲を広げるだけです。Access できない protected location は reduced coverage として表示されます。 - -すべての mutation は foreground review と action 直前の canonical-path revalidation を必要とします。対象 file mutation は macOS Trash を使います。Scheduled run は scan と notification のみで、削除しません。内蔵 network path は、user が **Check for Updates** を選択した後の `https://api.github.com/repos/thangldw/toolbox/releases/latest` への GET だけです。Scan result、path、evidence、file metadata、device field は送信しません。 - -完全な policy は [Privacy](PRIVACY.md) と [Security](SECURITY.md) を参照してください。 +### Orven 3.0 -### Build と validation +OrvenはMacのライブ監視、ハードウェア観測、Wi-Fi/LAN診断、復元可能なストレージ管理を統合したnative macOS appです。Toolboxの後継で、名前・ring icon・画面を刷新。英語、ベトナム語、日本語、簡体字中国語に対応します。 -Source build には Swift 6.0 以降が必要です。`apps/toolbox` から実行します。 +### インストールとrelease状況 -```bash -swift format lint --recursive --parallel Sources Tests Package.swift -swift test -./scripts/test_core.sh -./scripts/test_storage.sh -./scripts/test_changes.sh -./scripts/test_app.sh -./scripts/lint_localizations.swift -swift build -c release -./scripts/build_app.sh -codesign --verify --deep --strict dist/Toolbox.app -``` +Orven 3.0.0はproduction向け準備中です。[Release page](https://github.com/thangldw/toolbox/releases)に公開されたartifactを使用してください。Localbuildは公開downloadではありません。Ad-hoc署名で**notarize されていません**。初回起動がblockedの場合、**System Settings → Privacy & Security → Open Anyway**が必要なことがあります。Gatekeeper無効化/quarantine削除はしません。macOS 13+/ARM64/x86_64、physicalIntel/macOS 13検証は未完。Homebrewなし。 -Command Line Tools では smoke gate と release build を実行できます。XCTest target には full Xcode の XCTest module が必要です。Local build と `codesign` pass は structural integrity の evidence に限られ、Developer ID signing、notarization、stapling、Gatekeeper acceptance、公開 release の evidence ではありません。 +同versionのDMG/SHA-256をdownloadしてchecksum確認、diskimage内guideを読みOrven.appをApplicationsへ。Upgrade時のデータを保持するためbundleID `com.thang.toolbox`と`~/Library/Application Support/Toolbox`を意図的に維持します。 -### Documentation と repository layout +### 機能 -```text -toolbox/ -├── apps/toolbox/ # ToolboxCore, ToolboxStorage, ToolboxChanges, GUI executable -├── docs/ # Architecture, operations, launch, and release evidence -├── site/ # Product landing page -├── .github/workflows/ # CI, Pages, and release workflows -├── PRIVACY.md -├── SECURITY.md -└── CONTRIBUTING.md -``` +- ライブ監視:processidentity/trend、CPU/memory、disk/networkrate、battery/thermal、optionalmenu-bar。 +- Hardware:machineprofile、利用可能なmappedCPU/GPUtemp、fanRPM、batterytemp/観測systempower、freshness/missing表示。 +- Wi-Fi/LAN:interface/IP/gateway/DNS/media/carrier、signal/channel/linkrate、明示nearby scan。 +- Network測定:入力destinationのDNS/TCP443と20ICMPprobe latency/loss/jitter、cancel/以前のtimestamp保持。 +- Health:初期offのboundedlocalhistory、medianbaseline、sessionchart/JSON/CSV、opt-inin-appalert。 +- Networkchanges:opt-insessionconnection/path観測、packet capture/firewallなし。 +- Storage/projects:folderallocation/duplicatecandidate、選択root内rebuildableartifact、developerworkspace/growthcheckpoint。 +- Applications:dossier/evidence-linkedleftover。Evidenceは削除許可ではなく、CPUpriority変更は明示reviewが必要。 +- Changes/recovery:installerevidence/comparison、review後のeligibleTrash移動、元pathが空の場合のrestore。 -[Architecture](docs/ARCHITECTURE.md)、[architecture diagram](docs/diagrams/toolbox-architecture.html)、[Operations](docs/OPERATIONS.md)、[Release operations](docs/OPERATIONS-RELEASE.md) から参照してください。Historical release detail は [Changelog](CHANGELOG.md) と [`v2.0.0` release note](docs/launch/toolbox-2.0.0.md) にあります。Engineering change は [Contributing](CONTRIBUTING.md) に従います。 +### Privacyと制限 -### Version 1 の履歴 +Account/telemetry/analytics/自動削除/cloud syncなし。明示probeは入力先へ、updatecheckはGitHubへ接続。Historyはoptional/bounded。権限はcoverageを広げますが削除許可ではありません。Sensorはmodel依存、linkrateはInternet速度ではなくsnapshotのexclusivebytesやfirewallを証明しません。[Privacy](PRIVACY.md)、[Security](SECURITY.md)、[release readiness](docs/production/RELEASE-READINESS.md)に検証と制限を記載。 -`v1.4.0` までの tag は、standalone Diskora/Changeora の source と binary を historical artifact として保持しています。Active support branch ではありません。初回起動時、Toolbox 2 は supported legacy Application Support JSON を inspect でき、user が承認した場合だけ Toolbox store に copy、merge、verify します。元の legacy file は変更しません。 +### Build・貢献・資料 -### License +Swift 6 và bộ Xcode đầy đủでbuild。`apps/toolbox`から`swift test`、`./scripts/test_monitoring.sh`、`./scripts/lint_localizations.swift`、`./scripts/build_universal.sh`、`./scripts/build_dmg.sh`。Appは`dist/Orven.app`、internalexecutable/moduleは互換性のためToolboxを維持。`./scripts/verify_release.sh --allow-adhoc`はstructuralintegrityでApple notarization証明ではありません。 -[MIT](LICENSE) +[User guide](docs/USER-GUIDE.md)、[Architecture](docs/ARCHITECTURE.md)、[Operations](docs/OPERATIONS.md)、[Release operations](docs/OPERATIONS-RELEASE.md)、[Contributing](CONTRIBUTING.md)、[Changelog](CHANGELOG.md)、[website](https://thangldw.github.io/toolbox/)を参照。以前のToolboxreleaseはGitHubtag/release historyで保持。[MIT](LICENSE)。 diff --git a/SECURITY.md b/SECURITY.md index 67c4b67..98ce37c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,123 +1,61 @@ -# Toolbox Security Policy - -[English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) +# Orven Security ## English -### Supported versions - -Security fixes target the latest Toolbox 2.x release. Historical Diskora and Changeora releases through `v1.4.0` remain available for archive and migration but are not active support branches. Reports against older versions should state whether the issue reproduces on the latest 2.x release. - -### Private vulnerability reporting - -Use [GitHub private vulnerability reporting](https://github.com/thangldw/toolbox/security/advisories/new) before public disclosure. Include the affected version or commit, macOS version and architecture, minimal reproduction steps, expected and observed results, security impact, and a proposed mitigation if available. Redact personal paths, account data, tokens, credentials, and unrelated file contents. Never attach private keys, certificates, notarization credentials, or a full private dataset. - -Ordinary reproducible bugs without a security impact belong in [GitHub Issues](https://github.com/thangldw/toolbox/issues). Do not open a public issue for an uncoordinated vulnerability disclosure. +### Supported scope and reporting -### Safety invariants - -- Mutation targets are canonicalized and validated against exact allowed roots immediately before use; path, symlink, permission, or state uncertainty fails closed. -- Project cleanup matches explicit project markers and known rebuildable artifacts inside the user-selected root. Globs, unresolved variables, name-only deletion, and inferred targets are not accepted authority. -- External process execution uses exact executable and argument allowlists. Toolbox has no privileged helper and does not bypass Gatekeeper. -- Every mutation requires foreground review. Eligible files move to macOS Trash, recovery records original and Trash paths, and restore refuses to overwrite an existing destination. -- Scheduled work scans and notifies only. It never deletes automatically. -- Evidence remains local; exports redact the current home prefix but still require user review before sharing. -- The only built-in network request is the user-initiated public GitHub Releases GET described in [Privacy](PRIVACY.md), with no scan or device payload. +Report vulnerabilities privately through [GitHub private reporting](https://github.com/thangldw/toolbox/security/advisories/new). Include affected version, reproduction and impact; omit credentials and private filesystem/network reports. Orven 3.0 is the current development/release line. Older Toolbox binaries remain historical downloads. ### Release trust -Published releases provide a SHA-256 checksum. Verify `v2.0.0` with: - -```bash -shasum -a 256 -c Toolbox-2.0.0.dmg.sha256 -``` +Orven 3.0.0 is an explicitly authorized ad-hoc-signed, unnotarized release. Verify the SHA-256 from the same published release before installing. Structural codesign verification does not establish Developer ID trust or notarization. macOS may require Open Anyway after the blocked first launch; do not disable Gatekeeper or strip quarantine. No Homebrew distribution is claimed. -The checksum detects download corruption or mismatch with the published checksum; because it is distributed beside the DMG, it is not an independent Apple trust assertion. +### Data and actions -`v2.0.0` is an explicit ad-hoc-signed, unnotarized exception. Stable describes its product channel, not Developer ID signing, Apple notarization, stapling, or Gatekeeper acceptance. Initial Gatekeeper rejection is expected. Follow the DMG's **Open Anyway** instructions; never disable Gatekeeper or remove quarantine attributes. Homebrew remains unavailable for this release. +No privileged helper, automatic cleanup, credential collection or malware verdict. Explicit network probes are bounded direct-argument helpers, without shell evaluation; user-entered hosts reject URLs/options/credentials. Pending operations cancel only owned processes. Process actions revalidate PID/start identity. Priority lowering is manual, not a hard CPU cap or automatic suspension. -The default policy for later releases is Developer ID signing, notarization, stapling, strict verification, and post-publication re-download checks. Local build, ad-hoc `codesign` verification, architecture inspection, and CI success are necessary evidence for their respective claims but do not substitute for Apple notarization evidence. Published assets are immutable; corrections require a new patch release. +Eligible file mutations require foreground review, canonical-path/identity revalidation and recovery evidence. Trash is used where supported. Restore refuses to overwrite existing paths; package receipts/registrations/leftovers are evidence, not authorization. Corrupt health stores are preserved and block writes. Permission denial and missing readings remain explicit; missing sensor values are not zero. -### Incident response +### Known coverage -For a confirmed incident, preserve evidence without exposing user data, determine affected versions and artifacts, make unsafe assets unavailable, publish a coordinated advisory, rotate compromised credentials, and issue a new patch release with fresh build, checksum, signing, notarization, and exact-run evidence as applicable. Do not silently replace an existing release asset. Document any residual unknowns and recovery steps. +Mapped hardware sensors are community mappings and model dependent. No firewall, full packet history, fan control, calibrated per-core temperature, exclusive snapshot-byte calculation or privileged system repair is provided. Current foreground resource profiling, physical Intel/macOS 13/Ethernet, full VoiceOver, Japanese critical-copy review and OS notification delivery are incomplete. See [release readiness](docs/production/RELEASE-READINESS.md) and [Privacy](PRIVACY.md). ## Tiếng Việt -### Version được support - -Security fix tập trung vào release Toolbox 2.x mới nhất. Release Diskora/Changeora lịch sử đến `v1.4.0` vẫn tồn tại cho archive và migration nhưng không phải active support branch. Report cho version cũ cần nêu issue có tái hiện trên release 2.x mới nhất hay không. - -### Báo cáo lỗ hổng riêng tư - -Dùng [GitHub private vulnerability reporting](https://github.com/thangldw/toolbox/security/advisories/new) trước khi public disclosure. Gửi version hoặc commit bị ảnh hưởng, macOS version/architecture, bước tái hiện tối thiểu, kết quả expected/observed, security impact và mitigation đề xuất nếu có. Redact path cá nhân, account data, token, credential và nội dung file không liên quan. Không bao giờ đính kèm private key, certificate, notarization credential hoặc toàn bộ private dataset. +### Scope hỗ trợ và báo cáo -Bug tái hiện được nhưng không có security impact nên gửi qua [GitHub Issues](https://github.com/thangldw/toolbox/issues). Không mở public issue để disclosure lỗ hổng chưa phối hợp. +Báo vulnerability riêng qua [GitHub private reporting](https://github.com/thangldw/toolbox/security/advisories/new). Gửiversion/reproduction/impact, khôngcredentials/reportfilesystem/networkriêng. Orven 3.0 là dòng hiện tại; binaryToolbox cũ là downloadhistorical. -### Safety invariant +### Trustrelease -- Mutation target được canonicalize và validate với allowed root chính xác ngay trước khi dùng; uncertainty về path, symlink, permission hoặc state phải fail closed. -- Project cleanup khớp explicit project marker và artifact có thể build lại đã biết trong root người dùng chọn. Glob, biến chưa resolve, name-only deletion và target suy luận không phải authority hợp lệ. -- External process execution dùng executable/argument allowlist chính xác. Toolbox không có privileged helper và không bypass Gatekeeper. -- Mọi mutation cần foreground review. File đủ điều kiện được đưa vào macOS Trash, recovery ghi vị trí gốc/Trash và restore từ chối ghi đè destination đang tồn tại. -- Scheduled work chỉ scan/notify, tuyệt đối không tự động xóa. -- Evidence ở local; export redaction home prefix hiện tại nhưng vẫn cần người dùng review trước khi chia sẻ. -- Built-in network request duy nhất là public GitHub Releases GET do người dùng khởi tạo như mô tả trong [Privacy](PRIVACY.md), không có scan hoặc device payload. +Orven 3.0.0 kýad-hoc/chưa notarized theo lựa chọn được phép. VerifySHA-256 của cùngrelease trước cài. Codesignstructural không proofDeveloperID/notarization. macOS có thể cầnOpenAnyway saufirst launch blocked; không tắtGatekeeper/xóaquarantine. Không claimHomebrew. -### Release trust - -Release đã publish cung cấp checksum SHA-256. Xác minh `v2.0.0` bằng: - -```bash -shasum -a 256 -c Toolbox-2.0.0.dmg.sha256 -``` +### Dữ liệu và actions -Checksum phát hiện download bị lỗi hoặc không khớp checksum đã publish; vì checksum được phân phối cạnh DMG, nó không phải independent Apple trust assertion. +Khôngprivilegedhelper/autocleanup/credentialcollection/malwareverdict. Probe bounded/directargs khôngshell; hostrejectURL/options/credentials. Cancel chỉownedprocess. ProcessactionrevalidatePID/start. Lowerprioritymanual khônghardCPU cap/autosuspend. -`v2.0.0` là ngoại lệ explicit được ký ad-hoc và chưa notarize. Stable mô tả product channel, không phải Developer ID signing, Apple notarization, stapling hoặc Gatekeeper acceptance. Gatekeeper từ chối ban đầu là expected. Làm theo hướng dẫn **Open Anyway** trong DMG; không bao giờ tắt Gatekeeper hoặc xóa quarantine attribute. Homebrew vẫn không có cho release này. +Eligiblemutation cầnforeground review/canonicalpath/identity revalidation/recoveryevidence; dùngTrash nếu hỗ trợ. Restore khôngoverwritepathoccupied. Receipt/registration/leftover chỉevidence. Healthstorehỏng giữnguyên/chặnwrite. Permissiondenial/missingrõràng, sensor thiếu không thành0. -Policy mặc định cho release sau là Developer ID signing, notarization, stapling, strict verification và re-download check sau publish. Local build, ad-hoc `codesign` verification, architecture inspection và CI success là evidence cần thiết cho claim tương ứng nhưng không thay Apple notarization evidence. Asset đã publish là immutable; correction cần patch release mới. +### Coverage còn thiếu -### Incident response - -Với incident đã xác nhận, giữ evidence mà không lộ user data, xác định version/artifact bị ảnh hưởng, gỡ khả dụng asset không an toàn, publish advisory đã phối hợp, rotate credential bị compromise và phát hành patch mới với fresh build, checksum, signing, notarization, exact-run evidence phù hợp. Không âm thầm thay release asset hiện tại. Ghi lại residual unknown và recovery step. +Sensorcommunity/modeldependent; khôngfirewall/fullpackethistory/fan control/calibratedper-coretemp/exclusive snapshot bytes/privileged repair. Foregroundresource/Intel/macOS 13/Ethernetvật lý/full VoiceOver/JAcriticalcopy/OS notification cònchưa xác minh. [Release readiness](docs/production/RELEASE-READINESS.md)/[Privacy](PRIVACY.md). ## 日本語 -### Supported version - -Security fix は最新 Toolbox 2.x release を対象にします。`v1.4.0` までの historical Diskora/Changeora release は archive と migration 用に残しますが、active support branch ではありません。古い version の report は、最新 2.x release でも再現するかを記載してください。 +### Support範囲と報告 -### Private vulnerability reporting - -Public disclosure 前に [GitHub private vulnerability reporting](https://github.com/thangldw/toolbox/security/advisories/new) を使用します。Affected version/commit、macOS version/architecture、最小の reproduction step、expected/observed result、security impact、可能であれば proposed mitigation を含めます。Personal path、account data、token、credential、無関係な file content は redaction してください。Private key、certificate、notarization credential、private dataset 全体を添付しないでください。 - -Security impact のない通常の reproducible bug は [GitHub Issues](https://github.com/thangldw/toolbox/issues) に報告します。未調整の vulnerability disclosure を public issue に投稿しません。 - -### Safety invariant - -- Mutation target は使用直前に canonicalize し、exact allowed root に対して validate します。Path、symlink、permission、state が不確実な場合は fail closed します。 -- Project cleanup は user-selected root 内の explicit project marker と既知の再生成可能 artifact に一致させます。Glob、未解決変数、name-only deletion、推測 target は有効な authority ではありません。 -- External process execution は exact executable/argument allowlist を使います。Toolbox には privileged helper がなく、Gatekeeper を bypass しません。 -- すべての mutation は foreground review が必要です。対象 file は macOS Trash に移動し、recovery は original/Trash path を記録し、既存 destination を上書きせず restore を拒否します。 -- Scheduled work は scan/notification のみで、自動削除しません。 -- Evidence は local に保持します。Export は現在の home prefix を redaction しますが、共有前に user review が必要です。 -- Built-in network request は [Privacy](PRIVACY.md) に記載した user-initiated public GitHub Releases GET だけで、scan/device payload を含みません。 +Vulnerabilityは[GitHub private reporting](https://github.com/thangldw/toolbox/security/advisories/new)へ非公開報告。Version/reproduction/impactを含めcredentials/privatefilesystem/networkreportは除外。Orven 3.0が現在line、旧Toolboxbinaryはhistoricaldownload。 ### Release trust -公開 release は SHA-256 checksum を提供します。`v2.0.0` は次で検証します。 - -```bash -shasum -a 256 -c Toolbox-2.0.0.dmg.sha256 -``` +Orven 3.0.0は承認されたad-hoc署名/unnotarized。同publishedreleaseのSHA-256を確認。StructuralcodesignはDeveloperID/notarization証明ではありません。Firstlaunchblocked後OpenAnywayが必要な場合、Gatekeeper無効化/quarantine除去なし。Homebrewの主張なし。 -Checksum は download corruption または公開 checksum との mismatch を検出します。DMG と同じ場所で配布されるため、独立した Apple trust assertion ではありません。 +### Dataとaction -`v2.0.0` は明示的な ad-hoc 署名・未 notarize の例外です。Stable は product channel を示し、Developer ID signing、Apple notarization、stapling、Gatekeeper acceptance を意味しません。最初の Gatekeeper rejection は expected result です。DMG の **Open Anyway** 手順に従い、Gatekeeper を無効化したり quarantine attribute を削除したりしないでください。この release に Homebrew はありません。 +Privilegedhelper/autocleanup/credentialcollection/malwareverdictなし。Probeはbounded/directargs、shell評価なし。HostはURL/options/credentialsをreject。Cancelはownedprocessのみ、processactionはPID/startrevalidate。ManualpriorityloweringはhardCPU cap/autosuspendではありません。 -以後の release の default policy は Developer ID signing、notarization、stapling、strict verification、公開後 re-download check です。Local build、ad-hoc `codesign` verification、architecture inspection、CI success は各 claim に必要な evidence ですが、Apple notarization evidence の代わりにはなりません。公開 asset は immutable とし、修正には新しい patch release を使います。 +Eligiblemutationはforeground review/canonicalpath/identity revalidation/recoveryevidence、対応時Trash使用。Restoreはoccupiedpathをoverwriteしません。Receipt/registration/leftoverはevidenceのみ。破損health store保持・write停止。Permissiondenial/missing明示、sensor欠測は0にしません。 -### Incident response +### 未確認coverage -Incident を確認したら、user data を露出せず evidence を保持し、affected version/artifact を特定し、unsafe asset を unavailable にし、coordinated advisory を公開し、compromised credential を rotate し、必要に応じて fresh build、checksum、signing、notarization、exact-run evidence を持つ新しい patch release を発行します。既存 release asset を黙って置換しません。Residual unknown と recovery step を記録します。 +Sensorはcommunity/modeldependent。Firewall/fullpackethistory/fan control/calibratedper-coretemp/exclusive snapshot bytes/privileged repairなし。Foregroundresource/physicalIntel/macOS 13/Ethernet/full VoiceOver/JAcriticalcopy/OS notificationは未確認。[Release readiness](docs/production/RELEASE-READINESS.md)/[Privacy](PRIVACY.md)参照。 diff --git a/apps/toolbox/DESIGN.md b/apps/toolbox/DESIGN.md new file mode 100644 index 0000000..e5c3246 --- /dev/null +++ b/apps/toolbox/DESIGN.md @@ -0,0 +1,211 @@ +--- +version: alpha +name: Orven +description: Native macOS resource and storage workspace +colors: + primary: "#2464DF" + background: "#F5F7FA" + surface: "#FFFFFF" + text: "#17263C" + secondary: "#586D8A" + accent: "#2464DF" +typography: + sans: + fontFamily: system-ui + numeric: + fontFamily: system-ui +rounded: + surface: 16px + compact: 9px +spacing: + page: 28px + section: 20px +omitted: + - section: components + reason: Native component ownership is defined in UX-CONTRACT.md. +--- +# Orven native design context + +## English + +### Wi-Fi and LAN + +Wi-Fi/LAN uses the existing Orven theme, native controls, locale provider, MonitoringChart and rate sampler. A page-owned explicit read/probe service has one operation, cancellation and timestamped results; NWPathMonitor is a visible-page event observer. No configuration changes or persisted identities. See [network contract](../../docs/monitoring/NETWORK-TOOLS.md). + + +### Health workflows + +The user authorized local Health History/Baseline, opt-in Smart Alerts, Sensor Charts/Recording and session-only Network Connection Changes. This scoped change supersedes earlier no-persistence/no-alert wording below. Existing sampler, MonitoringChart, OrvenTheme, L10n, native controls and DiagnosticsExportView remain owners. Persistence is bounded/default-off; notification permission is explicit; endpoints stay in session memory. See [health workflow contract](../../docs/monitoring/HEALTH-WORKFLOWS.md). + + +### Physical sensors + +Physical Sensor Panel reuses the existing hardware cadence, Orven surface, native disclosure/Grid and shared export. Mapped identity, stale current, missing values and limited coverage are explicit; no fan mutation. See [physical sensor scope](../../docs/monitoring/PHYSICAL-SENSORS.md). + +### Hardware Inspector + +Hardware Inspector reuses Orven surfaces, native Grid, explicit refresh and the shared diagnostics preview/save sheet. Range reset preserves engine history; missing/current-stale fields remain explicit. See [hardware scope](../../docs/monitoring/HARDWARE-TOOLS.md). + +### Feature expansion + +New diagnostics reuse OrvenTheme, native controls and existing sampling. Explicit helper refresh owns loading/error/cancellation states. The approved CPU priority action has its own native confirmation and same-user PID/start guard; monitoring does not change process scheduling automatically. Scope and prerequisites are defined in [feature expansion](../../docs/monitoring/FEATURE-EXPANSION.md). + +# + +### Sleep Inspector + +Sleep Inspector extends Battery & Thermal using the existing surface, native refresh button and process route. It is an explicit snapshot, with wall time, partial-data and unverified-owner states; it never polls in the background or changes power settings. Public assertion type/name strings are OS-provided source evidence, not localized app instructions. + +## Overview + +The user approved the Orven observation-ring identity and the light diagnostics workspace, then authorized native implementation. Layout reference: prototypes/toolbox-next/visual-target.png; identity reference: prototypes/toolbox-next/orven-brand-target.png. This is native SwiftUI with real monitoring snapshots, not a browser shell or synthetic data. GUI locales are English, Vietnamese, Japanese and Simplified Chinese, as explicitly requested. L10n owns language selection; dates and numbers follow the selected locale while source timestamps and paths retain their meaning. + +### Colors + +ToolboxCore/OrvenTheme.swift owns native adaptive tokens. Light values mirror this frontmatter. Dark canvas/surface/ink/secondary/border are #14283B/#1C3247/#E5EDF7/#B0C0D6/#375069. Chart/icon accent brightens to #73A8FF; primary actions keep #2464DF. System/light/dark is an app-local preference, default system, toolbox.appearance. Native selection, focus, pickers, tables and scrollbar rendering remain system owned. Status always includes text. + +### Typography + +System sans and monospaced digits. Page title 36 points, section title2, metric values title2 with the Overview CPU at32 points, caption for source freshness. Full names are available in tooltips; process status text wraps rather than silently becoming zero. Native font coverage supports both GUI locales. + +### Layout + +Default1320×900, minimum960×640, sidebar220–280 with ideal240, main inset28 and section gaps18–20. Overview has the CPU chart and attached summary, a top-process list and startup storage strip; document scrolling preserves access on smaller screens. All navigation raw values are stable. Processes uses a native internally scrolling Table; verified details occupy a300-point inspector at main widths900 or more, otherwise a440×520 native sheet. Management modules retain their mutation owners inside a common surface. + +### Elevation & Depth + +Grouped flat adaptive surfaces with1-point borders. No ornamental shadows/gradients or entrance animations. macOS owns sheet/menu depth and focus. Charts retain generation/missing-sample gaps and linear interpolation; a subtle area fill is data context. + +### Shapes + +Surface radius16, compact control radius9; native controls retain OS geometry. The observation-ring icon comes from AppIcon.icns, the supplied OrvenLogo.png is bundled and displayed on a white contrast plate in Help. Existing file/driver identities are not inferred from branding. + +### Components + +OrvenTheme/orvenSurface are shared across app/Core/Storage. OrvenPageHeader, OrvenSamplingStatus, MetricReadout and MonitoringChart are shared display owners. ToolboxCoordinator owns section routing and transient PID/start inspection focus; MonitoringViewModel remains the sole snapshot/render-demand owner. Source/provider values and availability are retained. Only the approved HealthHistoryStore persists bounded opt-in metrics. No additional polling engine, telemetry, privileges, process termination or automatic cleanup. + +### Do's and Don'ts + +Display live timestamps, CPU normalization, occupied-memory cache semantics, capacity versus rates, unsupported and stale states. Only processes with measured CPU values are ranked in Overview; explicit sample coverage leads to the complete process view, where restricted processes remain visible. Never interpret app correlation as deletion permission. Native cleanup/recovery confirmations and exact target checks remain unchanged. Keep com.thang.toolbox, the Toolbox data directory, executable/module names and existing preference keys stable. + +## Tiếng Việt + +### Wi-Fi and LAN + +Wi-Fi/LAN dùng theme/control/locale/chart/sampler cũ; owner riêng cho read/probe theo yêu cầu, một operation/cancel/timestamp. NWPathMonitor quan sát event khi trang mở. Không đổi cấu hình hoặc lưu identity. Xem [network contract](../../docs/monitoring/NETWORK-TOOLS.md). + + +### Health workflows + +Người dùng cho phép Health History/Baseline trên máy, Smart Alerts opt-in, Sensor Charts/Recording và Network Connection Changes chỉ trong phiên. Scope này thay thế các hạn chế persistence/alert cũ bên dưới. Sampler, MonitoringChart, OrvenTheme, L10n, native controls và DiagnosticsExportView hiện có tiếp tục sở hữu chức năng. Persistence có giới hạn và mặc định tắt; quyền notification qua nút riêng; endpoint chỉ giữ trong RAM phiên. Xem [health workflow contract](../../docs/monitoring/HEALTH-WORKFLOWS.md). + +### Physical sensors + +Physical Sensor Panel dùng cadence phần cứng cũ, surface Orven, disclosure/Grid native và export chung. Ghi rõ mapped identity, current stale, missing/limited coverage; không đổi quạt. Xem [scope sensor](../../docs/monitoring/PHYSICAL-SENSORS.md). + +### Hardware Inspector + +Hardware Inspector dùng lại surface Orven, Grid native, refresh theo yêu cầu và sheet preview/save diagnostics chung. Reset range giữ history engine; field thiếu/current-stale được ghi rõ. Xem [scope phần cứng](../../docs/monitoring/HARDWARE-TOOLS.md). + +### Feature expansion + +Diagnostics mới dùng lại OrvenTheme, control native và sampling cũ. Refresh helper theo yêu cầu có loading/error/cancellation. CPU priority đã được cho phép có xác nhận native riêng và guard cùng user/PID/start; monitoring không tự đổi scheduling. Scope/prerequisite ở [feature expansion](../../docs/monitoring/FEATURE-EXPANSION.md). + +# + +### Sleep Inspector + +Sleep Inspector mở rộng Battery & Thermal bằng surface, nút refresh native và route process hiện có. Đây là snapshot theo yêu cầu, có wall time, partial data và owner chưa verify; không polling nền hay đổi power settings. Type/name assertion từ OS là evidence gốc, không phải chỉ thị của app. + +## Overview + +Người dùng chốt identity Orven vòng quan sát và workspace diagnostics sáng, rồi cho phép triển khai native. Layout reference: prototypes/toolbox-next/visual-target.png; identity reference: prototypes/toolbox-next/orven-brand-target.png. SwiftUI dùng snapshot thật; không browser shell hoặc dữ liệu mẫu. GUI English/Tiếng Việt/日本語/简体中文 theo yêu cầu rõ ràng. L10n sở hữu language; date/number theo locale, timestamp nguồn/path giữ nguyên ý nghĩa. + +### Colors + +ToolboxCore/OrvenTheme.swift sở hữu token adaptive. Giá trị sáng theo frontmatter. Dark canvas/surface/ink/secondary/border: #14283B/#1C3247/#E5EDF7/#B0C0D6/#375069. Chart/icon accent #73A8FF; primary action #2464DF. Theme riêng app với key toolbox.appearance, mặc định system. macOS sở hữu selection/focus/picker/table/scrollbar. Trạng thái luôn có chữ. + +### Typography + +System sans và monospaced digit. Page36 point, section title2, metric title2, CPU Overview32 point, freshness caption. Tooltip giữ tên đầy đủ; status wrap và không biến missing thành zero. Font native hỗ trợ hai locale. + +### Layout + +Mặc định1320×900, minimum960×640, sidebar220–280/ideal240, main inset28/gap18–20. Overview gồm CPU chart-summary, danh sách tiến trình và strip dung lượng; cuộn tài liệu giữ truy cập khi cửa sổ nhỏ. Raw route giữ nguyên. Processes dùng Table tự cuộn; inspector300 point khi main width>=900, còn lại sheet440×520. Module quản lý giữ owner mutation trong surface chung. + +### Elevation & Depth + +Surface adaptive phẳng, viền1 point; không shadow/gradient trang trí hoặc entrance animation. macOS sở hữu depth/focus sheet/menu. Chart giữ gap theo missing/generation, interpolation linear và area fill nhẹ có ý nghĩa dữ liệu. + +### Shapes + +Surface16, control compact9; geometry control native. Icon từ AppIcon.icns; OrvenLogo.png được bundle và đặt trên nền trắng tương phản ở Help. Không suy diễn file/driver identity từ branding. + +### Components + +OrvenTheme/orvenSurface dùng chung app/Core/Storage. Header/status/MetricReadout/MonitoringChart là display owner. Coordinator sở hữu route/focus PID-start tạm; MonitoringViewModel vẫn sở hữu snapshot/render demand. Giữ source/availability; chỉ HealthHistoryStore được lưu metric local opt-in có giới hạn; không thêm sampler/telemetry/privilege/kill/auto-cleanup. + +### Do's and Don'ts + +Ghi timestamp, CPU normalization, memory gồm cache, capacity khác rate, unsupported/stale. Overview chỉ xếp hạng CPU đã đo và hiển thị coverage; màn hình đầy đủ vẫn giữ process bị hạn chế quyền. App correlation không phải quyền xóa. Giữ confirmation/target checks cleanup-recovery, com.thang.toolbox, thư mục dữ liệu Toolbox, executable/module và preference keys cũ. + +## 日本語 + +### Wi-Fi and LAN + +Wi-Fi/LANは既存theme/control/locale/chart/samplerを再利用。Page-owned明示read/probeはsingleoperation/cancel/timestamp、NWPathMonitorは表示pageのeventobserver。設定変更/identity保存なし。[Network contract](../../docs/monitoring/NETWORK-TOOLS.md)参照。 + + +### Health workflows + +ユーザーはlocal Health History/Baseline、opt-in Smart Alerts、Sensor Charts/Recording、session-only Network Connection Changesを承認しました。このscopeでは下記の従来persistence/alert制限を置き換えます。既存sampler、MonitoringChart、OrvenTheme、L10n、native controls、DiagnosticsExportViewを引き続き使用します。Persistenceは上限付きで初期off、notification権限は明示button、endpointはsession memoryのみ。[Health workflow contract](../../docs/monitoring/HEALTH-WORKFLOWS.md)を参照。 + +### Physical sensors + +Physical Sensor Panelは既存hardware cadence、Orven surface、native disclosure/Grid、共通exportを再利用。Mapped identity/stale current/missing/limited coverageを明示し、fanを変更しません。[Scope](../../docs/monitoring/PHYSICAL-SENSORS.md)参照。 + +### Hardware Inspector + +Hardware InspectorはOrven surface、native Grid、明示refresh、共通diagnostics preview/save sheetを再利用。Range resetはengine historyを保持し、欠測/current-staleを明示します。[Hardware scope](../../docs/monitoring/HARDWARE-TOOLS.md)参照。 + +### Feature expansion + +新diagnosticsはOrvenTheme/native control/既存samplingを再利用。明示helper refreshはloading/error/cancellationを所有。承認済みCPU priority actionはnative確認とsame-user PID/start guardを持ちmonitoringは自動変更しません。Scope/prerequisiteは[feature expansion](../../docs/monitoring/FEATURE-EXPANSION.md)。 + +# + +### Sleep Inspector + +Sleep Inspectorは既存surface/native refresh/process routeでBattery & Thermalを拡張。明示的snapshot、wall time、partial data、unverified ownerを示し、background polling/power設定変更なし。OS assertion type/nameはsource evidenceでapp instructionではない。 + +## Overview + +利用者がOrven observation-ring identityとlight diagnostics workspaceを承認しnative実装を依頼。Layout sourceはprototypes/toolbox-next/visual-target.png、identity sourceはorven-brand-target.png。Native SwiftUIと実snapshotを使い、browser shell/demo dataではありません。明示依頼によりGUIはEnglish/Vietnamese/日本語/简体中文。LanguageはL10nが所有しdate/numberは選択localeを使用。Source timestamp/pathの意味は保持。 + +### Colors + +ToolboxCore/OrvenTheme.swiftがadaptive tokenを所有。Lightはfrontmatter、darkは#14283B/#1C3247/#E5EDF7/#B0C0D6/#375069。Chart/icon accentは#73A8FF、actionは#2464DF。App-local toolbox.appearanceはsystem default。Native selection/focus/picker/table/scrollbarはOS管理、statusは文字付き。 + +### Typography + +System sans/monospaced digits。Page36pt、section/metric title2、Overview CPU32pt、freshness caption。名前tooltip/status wrap、missingをzeroに変換しません。二GUI localeのnative font。 + +### Layout + +Default1320×900/min960×640、sidebar220–280/ideal240、main inset28/gap18–20。OverviewはCPU instrument/summary、process list、storage strip。小windowでdocument scroll。Raw routeは安定。Process Tableは内部scroll、main width>=900で300pt inspector、他は440×520 sheet。Management mutation ownerは保持。 + +### Elevation & Depth + +Flat adaptive surface/1pt border、装飾shadow/gradient/entrance animationなし。Sheet/menu depth/focusはOS。Chartはgeneration/missing gapとlinear interpolation、控えめなdata area fill。 + +### Shapes + +Surface16/control9、native control geometry。AppIcon.icnsとbundled OrvenLogo.pngを使用し、Helpはwhite contrast plate。Brandからfile/driver identityを推測しません。 + +### Components + +OrvenTheme/orvenSurfaceはapp/Core/Storage共有。Header/status/readout/chartがdisplay owner。Coordinatorがrouteとtransient PID/start focus、MonitoringViewModelが唯一のsnapshot/render demand owner。Source/availability保持、承認済みHealthHistoryStoreのみopt-in metricを制限付きで保存。追加sampler/telemetry/privilege/kill/auto-cleanupなし。 + +### Do's and Don'ts + +Timestamp/CPU normalization/cache/capacity-rate/unsupported-staleを明記。Overviewは実CPU sampleのみrankしcoverageを表示、完全viewはrestricted processを保持。Correlationは削除許可ではありません。Confirmation/target checksとcom.thang.toolbox/Toolbox data directory/executable/module/preference keyを保持。 diff --git a/apps/toolbox/Package.swift b/apps/toolbox/Package.swift index 655e81a..8353ca8 100644 --- a/apps/toolbox/Package.swift +++ b/apps/toolbox/Package.swift @@ -8,8 +8,10 @@ let package = Package( .library(name: "ToolboxCore", targets: ["ToolboxCore"]), .library(name: "ToolboxStorage", targets: ["ToolboxStorage"]), .library(name: "ToolboxChanges", targets: ["ToolboxChanges"]), + .library(name: "ToolboxMonitoring", targets: ["ToolboxMonitoring"]), .executable(name: "Toolbox", targets: ["Toolbox"]), .executable(name: "SmokeCore", targets: ["SmokeCore"]), + .executable(name: "SmokeMonitoring", targets: ["SmokeMonitoring"]), ], targets: [ .target(name: "ToolboxCore", path: "Sources/ToolboxCore"), @@ -17,11 +19,15 @@ let package = Package( name: "ToolboxStorage", dependencies: ["ToolboxCore"], path: "Sources/ToolboxStorage"), .target( name: "ToolboxChanges", dependencies: ["ToolboxCore"], path: "Sources/ToolboxChanges"), + .target(name: "ToolboxMonitoring", dependencies: ["ToolboxCore"]), .executableTarget( - name: "Toolbox", dependencies: ["ToolboxCore", "ToolboxStorage", "ToolboxChanges"], + name: "Toolbox", + dependencies: ["ToolboxCore", "ToolboxStorage", "ToolboxChanges", "ToolboxMonitoring"], path: "Sources/Toolbox"), .executableTarget( name: "SmokeCore", dependencies: ["ToolboxCore"], path: "Tests/SmokeCore"), + .executableTarget( + name: "SmokeMonitoring", dependencies: ["ToolboxMonitoring"], path: "Tests/SmokeMonitoring"), .testTarget( name: "ToolboxCoreTests", dependencies: ["ToolboxCore"], path: "Tests/ToolboxCoreTests", resources: [.copy("Fixtures")]), @@ -31,6 +37,7 @@ let package = Package( .testTarget( name: "ToolboxChangesTests", dependencies: ["ToolboxChanges"], path: "Tests/ToolboxChangesTests"), + .testTarget(name: "ToolboxMonitoringTests", dependencies: ["ToolboxMonitoring"]), .testTarget( name: "ToolboxAppTests", dependencies: ["Toolbox"], path: "Tests/ToolboxAppTests"), ] diff --git a/apps/toolbox/Resources/AppIcon-1024.png b/apps/toolbox/Resources/AppIcon-1024.png index 3653c94..26e3a0e 100644 Binary files a/apps/toolbox/Resources/AppIcon-1024.png and b/apps/toolbox/Resources/AppIcon-1024.png differ diff --git a/apps/toolbox/Resources/AppIcon.icns b/apps/toolbox/Resources/AppIcon.icns index 6cba9fa..84e9fef 100644 Binary files a/apps/toolbox/Resources/AppIcon.icns and b/apps/toolbox/Resources/AppIcon.icns differ diff --git a/apps/toolbox/Resources/Info.plist b/apps/toolbox/Resources/Info.plist index f08dd25..2944e1d 100644 --- a/apps/toolbox/Resources/Info.plist +++ b/apps/toolbox/Resources/Info.plist @@ -2,38 +2,46 @@ - CFBundleDevelopmentRegion - en - CFBundleDisplayName - Toolbox - CFBundleExecutable - Toolbox - CFBundleIconFile - AppIcon - CFBundleIdentifier - com.thang.toolbox - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - vi - - CFBundleName - Toolbox - CFBundlePackageType - APPL - CFBundleShortVersionString - 2.0.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 13.0 - NSHighResolutionCapable - - NSPrincipalClass - NSApplication - NSHumanReadableCopyright - Copyright © 2026 Thang. MIT License. + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Orven + CFBundleExecutable + Toolbox + CFBundleIconFile + AppIcon + CFBundleIdentifier + com.thang.toolbox + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + vi + ja + zh-Hans + + CFBundleName + Orven + CFBundlePackageType + APPL + CFBundleShortVersionString + 3.0.0 + CFBundleVersion + 300 + LSMinimumSystemVersion + 13.0 + NSLocationWhenInUseUsageDescription + Orven uses location permission to display Wi-Fi network names and access point identities when you inspect or scan Wi-Fi. + NSLocalNetworkUsageDescription + Orven connects to the gateway or local destination you choose when you start a network diagnostic. + NSLocationUsageDescription + Orven uses location permission to display Wi-Fi network names and access point identities when you inspect or scan Wi-Fi. + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + NSHumanReadableCopyright + Copyright © 2026 Thang. MIT License. diff --git a/apps/toolbox/Resources/Open Toolbox - First Launch.html b/apps/toolbox/Resources/Open Orven - First Launch.html similarity index 54% rename from apps/toolbox/Resources/Open Toolbox - First Launch.html rename to apps/toolbox/Resources/Open Orven - First Launch.html index 077f84b..7b4fea9 100644 --- a/apps/toolbox/Resources/Open Toolbox - First Launch.html +++ b/apps/toolbox/Resources/Open Orven - First Launch.html @@ -3,7 +3,7 @@ - Open Toolbox — First Launch + Open Orven — First Launch -

Open Toolbox for the first time

-

Toolbox 2.0.0 is not Apple-notarized. macOS therefore requires one manual approval. Do not disable Gatekeeper or remove quarantine attributes.

+

Open Orven for the first time

+

Orven 3.0.0 is not Apple-notarized. macOS therefore requires one manual approval. Do not disable Gatekeeper or remove quarantine attributes.

    -
  1. Drag Toolbox to the Applications shortcut in this window.
  2. -
  3. Open Toolbox from Applications once. macOS will block this first attempt.
  4. +
  5. Drag Orven to the Applications shortcut in this window.
  6. +
  7. Open Orven from Applications once. macOS will block this first attempt.
  8. Open System Settings → Privacy & Security.
  9. -
  10. Find the Toolbox message, click Open Anyway, then authenticate.
  11. +
  12. Find the Orven message, click Open Anyway, then authenticate.
  13. Confirm Open. Later launches should open normally.

Verify the downloaded DMG before installation:

-
shasum -a 256 -c Toolbox-2.0.0.dmg.sha256
+
shasum -a 256 -c Orven-3.0.0.dmg.sha256

Installation page · Support · Source code

-

Mở Toolbox lần đầu

-

Toolbox 2.0.0 chưa được Apple notarize. Kéo Toolbox vào Applications, thử mở một lần, sau đó vào System Settings → Privacy & Security → Open Anyway và xác thực. Không tắt Gatekeeper hoặc xóa quarantine attribute.

+

Mở Orven lần đầu

+

Orven 3.0.0 chưa được Apple notarize. Kéo Orven vào Applications, thử mở một lần, sau đó vào System Settings → Privacy & Security → Open Anyway và xác thực. Không tắt Gatekeeper hoặc xóa quarantine attribute.

diff --git a/apps/toolbox/Resources/OrvenLogo.png b/apps/toolbox/Resources/OrvenLogo.png new file mode 100644 index 0000000..3ae578b Binary files /dev/null and b/apps/toolbox/Resources/OrvenLogo.png differ diff --git a/apps/toolbox/Resources/en.lproj/Changes.strings b/apps/toolbox/Resources/en.lproj/Changes.strings index c82d10d..f34439d 100644 --- a/apps/toolbox/Resources/en.lproj/Changes.strings +++ b/apps/toolbox/Resources/en.lproj/Changes.strings @@ -96,19 +96,19 @@ /* Install Trace */ "Thả installer, chụp trước/sau và xem bằng chứng thay đổi" = "Drop an installer, capture before and after, and review change evidence"; "Thả .dmg, .pkg hoặc .app vào đây" = "Drop a .dmg, .pkg, or .app here"; -"Toolbox chỉ đọc loại file và đường dẫn; installer chỉ mở sau khi baseline đã lưu." = "Toolbox reads only the file type and path; the installer opens only after the baseline is saved."; +"Orven chỉ đọc loại file và đường dẫn; installer chỉ mở sau khi baseline đã lưu." = "Orven reads only the file type and path; the installer opens only after the baseline is saved."; "Chọn installer…" = "Choose Installer…"; "Bỏ chọn" = "Clear"; "Lưu baseline và mở installer" = "Save Baseline and Open Installer"; "Đang theo dõi installer" = "Watching Installer"; -"Coverage giảm vì Toolbox không chạy trong một phần của phiên." = "Coverage is reduced because Toolbox was not running for part of the session."; +"Coverage giảm vì Orven không chạy trong một phần của phiên." = "Coverage is reduced because Orven was not running for part of the session."; "Install Trace bị gián đoạn" = "Install Trace Was Interrupted"; "Baseline vẫn còn nguyên. Có thể tiếp tục với coverage giảm hoặc bỏ phiên này." = "The baseline is intact. Continue with reduced coverage or discard this trace."; "Tiếp tục với coverage giảm" = "Continue with Reduced Coverage"; "Bỏ phiên" = "Discard Trace"; -"Snapshot và FSEvents chỉ lưu metadata cục bộ. Toolbox không mount, execute hoặc bypass Gatekeeper." = "Snapshots and FSEvents store local metadata only. Toolbox does not mount, execute, or bypass Gatekeeper."; +"Snapshot và FSEvents chỉ lưu metadata cục bộ. Orven không mount, execute hoặc bypass Gatekeeper." = "Snapshots and FSEvents store local metadata only. Orven does not mount, execute, or bypass Gatekeeper."; "Chọn DMG, PKG hoặc ứng dụng" = "Choose a DMG, PKG, or Application"; -"Toolbox chỉ nhận .dmg, .pkg hoặc .app" = "Toolbox accepts only .dmg, .pkg, or .app"; +"Orven chỉ nhận .dmg, .pkg hoặc .app" = "Orven accepts only .dmg, .pkg, or .app"; "Install Trace đã lưu" = "Install Trace saved"; "Một Install Trace đang hoạt động." = "An Install Trace is already active."; "Không có Install Trace đang hoạt động." = "No Install Trace is active."; @@ -117,8 +117,8 @@ "Đã tiếp tục theo dõi; thời gian app không chạy có coverage giảm." = "Tracing resumed; coverage is reduced while the app was not running."; "Đang chụp trạng thái sau thay đổi…" = "Capturing the state after changes…"; "Đã hủy Install Trace." = "Install Trace cancelled."; -"Minh bạch về dữ liệu Toolbox đọc, lưu và không thể quan sát." = "A transparent view of what Toolbox reads, stores, and cannot observe."; -"Toolbox đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại." = "Toolbox presents technical evidence; it does not declare an application malicious."; +"Minh bạch về dữ liệu Orven đọc, lưu và không thể quan sát." = "A transparent view of what Orven reads, stores, and cannot observe."; +"Orven đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại." = "Orven presents technical evidence; it does not declare an application malicious."; /* Dynamic-message fragments; longest matches are applied first. */ "Không thể" = "Unable to"; diff --git a/apps/toolbox/Resources/en.lproj/InfoPlist.strings b/apps/toolbox/Resources/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..6abece1 --- /dev/null +++ b/apps/toolbox/Resources/en.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSLocationUsageDescription" = "Orven uses location permission to display Wi-Fi network names and access point identities when you inspect or scan Wi-Fi."; +"NSLocationWhenInUseUsageDescription" = "Orven uses location permission to display Wi-Fi network names and access point identities when you inspect or scan Wi-Fi."; +"NSLocalNetworkUsageDescription" = "Orven connects to the gateway or local destination you choose when you start a network diagnostic."; diff --git a/apps/toolbox/Resources/en.lproj/Localizable.strings b/apps/toolbox/Resources/en.lproj/Localizable.strings index bc2812c..a3dab02 100644 --- a/apps/toolbox/Resources/en.lproj/Localizable.strings +++ b/apps/toolbox/Resources/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"About Toolbox" = "About Toolbox"; +"About Orven" = "About Orven"; "Home" = "Home"; "Storage" = "Storage"; "Projects" = "Projects"; @@ -7,7 +7,7 @@ "Recovery" = "Recovery"; "Language" = "Language"; "Your Mac, explained." = "Your Mac, explained."; -"See what changed. Reclaim space safely." = "See what changed. Reclaim space safely."; +"Understand your Mac. Act with context." = "Understand your Mac. Act with context."; "Recover space safely" = "Recover space safely"; "See reclaimable files before anything moves to Trash." = "See reclaimable files before anything moves to Trash."; "Scan storage" = "Scan storage"; @@ -18,12 +18,12 @@ "Review recoverable actions and restore without overwriting files." = "Review recoverable actions and restore without overwriting files."; "Open Recovery" = "Open Recovery"; "Runs locally. No telemetry. Nothing is deleted automatically." = "Runs locally. No telemetry. Nothing is deleted automatically."; -"Welcome to Toolbox" = "Welcome to Toolbox"; +"Welcome to Orven" = "Welcome to Orven"; "Local evidence for safer cleanup and app changes." = "Local evidence for safer cleanup and app changes."; "Private by default" = "Private by default"; -"Scans, snapshots, and activity stay on this Mac. Toolbox has no telemetry and never cleans automatically." = "Scans, snapshots, and activity stay on this Mac. Toolbox has no telemetry and never cleans automatically."; +"Scans, snapshots, and activity stay on this Mac. Orven has no telemetry and never cleans automatically." = "Scans, snapshots, and activity stay on this Mac. Orven has no telemetry and never cleans automatically."; "First-launch approval" = "First-launch approval"; -"If macOS blocks Toolbox, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper." = "If macOS blocks Toolbox, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper."; +"If macOS blocks Orven, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper." = "If macOS blocks Orven, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper."; "Open installation guide" = "Open installation guide"; "Scan coverage" = "Scan coverage"; "Sentinel folders are readable." = "Sentinel folders are readable."; @@ -38,7 +38,7 @@ "Retry import" = "Retry import"; "No legacy data found." = "No legacy data found."; "You can change these choices later in Settings." = "You can change these choices later in Settings."; -"Continue to Toolbox" = "Continue to Toolbox"; +"Continue to Orven" = "Continue to Orven"; "Migration error" = "Migration error"; "Close" = "Close"; "Recoverable" = "Recoverable"; @@ -58,19 +58,19 @@ "12 hours" = "12 hours"; "24 hours" = "24 hours"; "7 days" = "7 days"; -"Replace with Toolbox schedule…" = "Replace with Toolbox schedule…"; +"Replace with Orven schedule…" = "Replace with Orven schedule…"; "No Diskora schedule needs migration." = "No Diskora schedule needs migration."; "The old agent remains untouched unless the new scan-only agent bootstraps successfully." = "The old agent remains untouched unless the new scan-only agent bootstraps successfully."; "Updates" = "Updates"; -"Only when you click the button, Toolbox requests public release metadata from GitHub. No scan or device data is sent." = "Only when you click the button, Toolbox requests public release metadata from GitHub. No scan or device data is sent."; +"Only when you click the button, Orven requests public release metadata from GitHub. No scan or device data is sent." = "Only when you click the button, Orven requests public release metadata from GitHub. No scan or device data is sent."; "Check for Updates" = "Check for Updates"; "Replace the Diskora scheduled scan?" = "Replace the Diskora scheduled scan?"; "Replace after verification" = "Replace after verification"; "Cancel" = "Cancel"; -"Toolbox first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds." = "Toolbox first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds."; +"Orven first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds." = "Orven first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds."; "Settings error" = "Settings error"; -"Legacy schedule replaced after Toolbox bootstrap succeeded." = "Legacy schedule replaced after Toolbox bootstrap succeeded."; -"Toolbox %@ is available." = "Toolbox %@ is available."; +"Legacy schedule replaced after Orven bootstrap succeeded." = "Legacy schedule replaced after Orven bootstrap succeeded."; +"Orven %@ is available." = "Orven %@ is available."; "You are using the latest public release." = "You are using the latest public release."; "Finish the active scan before checking for updates." = "Finish the active scan before checking for updates."; "Cleanup" = "Cleanup"; @@ -83,3 +83,620 @@ "Succeeded" = "Succeeded"; "Failed" = "Failed"; "Cancelled" = "Cancelled"; + +"Live monitoring" = "Live monitoring"; + +"CPU • all cores" = "CPU • all cores"; + +"Memory • includes caches" = "Memory • includes caches"; + +"Read-only. Samples stay in memory for up to 60 seconds." = "Read-only. Samples stay in memory for up to 60 seconds."; + +"Warming up" = "Warming up"; + +"Available" = "Available"; + +"Stale" = "Stale"; + +"Unsupported" = "Unsupported"; + +"Permission required" = "Permission required"; + +"Temporarily unavailable" = "Temporarily unavailable"; + +"Read failed" = "Read failed"; + +"Processes" = "Processes"; + +"Search processes or PID" = "Search processes or PID"; + +"Clear search" = "Clear search"; + +"Sort by" = "Sort by"; + +"CPU" = "CPU"; + +"Memory" = "Memory"; + +"Name" = "Name"; + +"%d processes shown" = "%d processes shown"; + +"CPU • one core" = "CPU • one core"; + +"Resident memory" = "Resident memory"; + +"No process data available." = "No process data available."; + +"No matching processes." = "No matching processes."; + +"Select a process to inspect its verified identity and recent activity." = "Select a process to inspect its verified identity and recent activity."; + +"100% process CPU is one logical core. Restricted processes remain visible. No termination actions." = "100% process CPU is one logical core. Restricted processes remain visible. No termination actions."; + +"Review in Applications" = "Review in Applications"; + +"Observed parent PID: %d" = "Observed parent PID: %d"; + +"Process exited or identity changed." = "Process exited or identity changed."; + +"Threads: %d" = "Threads: %d"; + +"Revalidating process identity…" = "Revalidating process identity…"; + +"Disk reads • observed drivers" = "Disk reads • observed drivers"; + +"Disk writes • observed drivers" = "Disk writes • observed drivers"; + +"Network received • physical" = "Network received • physical"; + +"Network sent • physical" = "Network sent • physical"; + +"Startup volume available" = "Startup volume available"; + +"Battery" = "Battery"; + +"Charging" = "Charging"; + +"On battery" = "On battery"; + +"External power" = "External power"; + +"Nominal" = "Nominal"; + +"Fair" = "Fair"; + +"Serious" = "Serious"; + +"Critical" = "Critical"; + +"Unknown" = "Unknown"; + +"Network" = "Network"; + +"Monitoring" = "Monitoring"; + +"Menu bar quick diagnostics" = "Menu bar quick diagnostics"; + +"Menu bar indicator" = "Menu bar indicator"; + +"Disabled by default. When enabled, menu bar and windows share one sampler. Background sampling runs every 5 seconds; Quit stops it." = "Disabled by default. When enabled, menu bar and windows share one sampler. Background sampling runs every 5 seconds; Quit stops it."; + +"Last sample" = "Last sample"; + +"Top CPU consumers • one core" = "Top CPU consumers • one core"; + +"Thermal state" = "Thermal state"; + +"Automatic alerts are not available." = "Automatic alerts are not available."; + +"Overview" = "Overview"; + +"Disable menu bar" = "Disable menu bar"; + +"Quit Orven" = "Quit Orven"; + +"Seconds before latest sample" = "Seconds before latest sample"; + +"Previous sample" = "Previous sample"; + +"Next sample" = "Next sample"; + +"Unavailable" = "Unavailable"; + +"No samples yet." = "No samples yet."; + +"%d valid samples. Minimum %.1f, maximum %.1f %@. Missing samples are gaps." = "%d valid samples. Minimum %.1f, maximum %.1f %@. Missing samples are gaps."; + +"System CPU • all cores" = "System CPU • all cores"; + +"Occupied memory • includes caches" = "Occupied memory • includes caches"; + +"Physical memory" = "Physical memory"; + +"Wired memory" = "Wired memory"; + +"Compressor storage" = "Compressor storage"; + +"Swap used" = "Swap used"; + +"Memory pressure • last OS event" = "Memory pressure • last OS event"; + +"Occupied memory is physical memory minus free pages and includes caches. It does not measure reclaimable RAM. Pressure is an OS event, not an inferred score." = "Occupied memory is physical memory minus free pages and includes caches. It does not measure reclaimable RAM. Pressure is an OS event, not an inferred score."; + +"GPU utilization and memory: Unsupported" = "GPU utilization and memory: Unsupported"; + +"Normal" = "Normal"; + +"Warning" = "Warning"; + +"Review Storage" = "Review Storage"; + +"Review Projects" = "Review Projects"; + +"APFS volumes share container capacity. Capacity is shown for the startup volume once; I/O is per observed storage driver, including virtual devices when exposed." = "APFS volumes share container capacity. Capacity is shown for the startup volume once; I/O is per observed storage driver, including virtual devices when exposed."; + +"Disk I/O by device" = "Disk I/O by device"; + +"Read" = "Read"; + +"Write" = "Write"; + +"Show loopback, tunnels and other interfaces" = "Show loopback, tunnels and other interfaces"; + +"Physical summary includes active en* interfaces. Tunnel and loopback counters are shown separately and are never added to that summary." = "Physical summary includes active en* interfaces. Tunnel and loopback counters are shown separately and are never added to that summary."; + +"Received" = "Received"; + +"Sent" = "Sent"; + +"Thermal state is macOS's coarse system condition, not a temperature in °C. Macs without an internal battery report Unsupported." = "Thermal state is macOS's coarse system condition, not a temperature in °C. Macs without an internal battery report Unsupported."; + +"Temperature, fan sensors and per-process energy: Unsupported" = "Temperature, fan sensors and per-process energy: Unsupported"; + +"CPU & Memory" = "CPU & Memory"; + +"Disk & Network" = "Disk & Network"; + +"Battery & Thermal" = "Battery & Thermal"; + +"Management" = "Management"; + +"History" = "History"; + +"Recent CPU activity" = "Recent CPU activity"; + +"Process CPU • one core" = "Process CPU • one core"; + +"Read-only. Recent trends stay on this Mac and are not saved." = "Read-only. Recent trends stay on this Mac and are not saved."; + +"Sampling paused" = "Sampling paused"; + +"Sampling paused while the main window is inactive." = "Sampling paused while the main window is inactive."; +"Your Mac, at a glance" = "Your Mac, at a glance"; +"Read-only" = "Read-only"; +"Using the most CPU" = "Using the most CPU"; +"Show all processes" = "Show all processes"; +"Process" = "Process"; +"Inspect process" = "Inspect process"; +"Thermal state is not a temperature in °C." = "Thermal state is not a temperature in °C."; +"Used storage" = "Used storage"; +"Open storage" = "Open storage"; +"Inspect what's running on your Mac. Monitoring is read-only." = "Inspect what's running on your Mac. Monitoring is read-only."; +"Close details" = "Close details"; +"Settings" = "Settings"; +"About Orven" = "About Orven"; +"Appearance" = "Appearance"; +"System" = "System"; +"Light" = "Light"; +"Dark" = "Dark"; +"%d of %d processes have CPU samples." = "%d of %d processes have CPU samples."; + +"Sleep Inspector" = "Sleep Inspector"; +"Refresh sleep requests" = "Refresh sleep requests"; +"A snapshot of active macOS power requests. Requests can be legitimate; they do not explain every sleep or wake event." = "A snapshot of active macOS power requests. Requests can be legitimate; they do not explain every sleep or wake event."; +"Reading sleep requests" = "Reading sleep requests"; +"Observed at" = "Observed at"; +"No active requests in this snapshot" = "No active requests in this snapshot"; +"Process identity unavailable; ownership is unverified" = "Process identity unavailable; ownership is unverified"; +"Some requests could not be displayed" = "Some requests could not be displayed"; +"Refresh to inspect current requests. No background polling." = "Refresh to inspect current requests. No background polling."; +"No readable requests in this snapshot" = "No readable requests in this snapshot"; + +/* Application inspection and management improvements. */ +"Search apps or Bundle ID" = "Search apps or Bundle ID"; +"Sort by name" = "Sort by name"; +"No matching applications" = "No matching applications"; +"App Dossier" = "App Dossier"; +"Bundle ID" = "Bundle ID"; +"Application path" = "Application path"; +"Version" = "Version"; +"Minimum macOS" = "Minimum macOS"; +"Application bundle size" = "Application bundle size"; +"Related data observed" = "Related data observed"; +"Last application scan" = "Last application scan"; +"Sizes are from the last scan. Related data is attributed by scan evidence, not proof of exclusive ownership. Unreadable locations may be missing." = "Sizes are from the last scan. Related data is attributed by scan evidence, not proof of exclusive ownership. Unreadable locations may be missing."; +"No related data found within scan coverage." = "No related data found within scan coverage."; +"Largest first" = "Largest first"; +"Application Support" = "Application Support"; +"Cache" = "Cache"; +"Preference" = "Preference"; +"Log" = "Log"; +"Container" = "Container"; +"LaunchAgent" = "LaunchAgent"; +"Login Item / Background Task" = "Login Item / Background Task"; +"Package receipt" = "Package receipt"; +"Clear filters or change the search query." = "Clear filters or change the search query."; +"Background-item evidence unavailable. The macOS helper did not complete; scan coverage is partial." = "Background-item evidence unavailable. The macOS helper did not complete; scan coverage is partial."; +"Remove project %@" = "Remove project %@"; +"%@ was identified from %@ markers and can be regenerated." = "%@ was identified from %@ markers and can be regenerated."; +"Found %d artifacts in %d projects." = "Found %d artifacts in %d projects."; + +"Explain My Mac" = "Explain My Mac"; +"Export diagnostics…" = "Export diagnostics…"; +"Evidence from recent samples. These observations do not establish causality and do not change your Mac." = "Evidence from recent samples. These observations do not establish causality and do not change your Mac."; +"No matching findings in fresh, available samples. Missing data is not a health check." = "No matching findings in fresh, available samples. Missing data is not a health check."; +"Sampling is paused. Resume active monitoring to evaluate current evidence." = "Sampling is paused. Resume active monitoring to evaluate current evidence."; +"Review evidence" = "Review evidence"; +"Memory pressure needs attention" = "Memory pressure needs attention"; +"macOS reports memory pressure. Occupied memory alone does not establish a problem." = "macOS reports memory pressure. Occupied memory alone does not establish a problem."; +"Sustained CPU activity" = "Sustained CPU activity"; +"System CPU stayed at or above 85% for at least 30 seconds. Review the measured processes before attributing a cause." = "System CPU stayed at or above 85% for at least 30 seconds. Review the measured processes before attributing a cause."; +"Startup volume is nearly full" = "Startup volume is nearly full"; +"Available space is below 10% of reported capacity. APFS volumes share container space; inspect coverage and snapshots before cleanup." = "Available space is below 10% of reported capacity. APFS volumes share container space; inspect coverage and snapshots before cleanup."; +"Elevated thermal state" = "Elevated thermal state"; +"macOS reports thermal pressure. This is an OS state, not a temperature measurement or proof of a particular app's impact." = "macOS reports thermal pressure. This is an OS state, not a temperature measurement or proof of a particular app's impact."; +"Local Diagnostics Export" = "Local Diagnostics Export"; +"Preview the exact JSON before saving. Paths, username, hostname and network endpoints are excluded. Nothing is uploaded." = "Preview the exact JSON before saving. Paths, username, hostname and network endpoints are excluded. Nothing is uploaded."; +"Include process names" = "Include process names"; +"Refresh preview" = "Refresh preview"; +"Save JSON…" = "Save JSON…"; +"Diagnostics saved." = "Diagnostics saved."; +"Could not save diagnostics. Choose a writable location and try again." = "Could not save diagnostics. Choose a writable location and try again."; +"Could not generate diagnostics from the current sample. Refresh and try again." = "Could not generate diagnostics from the current sample. Refresh and try again."; +"Network Inspector" = "Network Inspector"; +"Refresh connections" = "Refresh connections"; +"Explicit TCP/UDP socket snapshot from lsof. Restricted processes and short-lived connections may be missing. No traffic history, domain attribution, byte totals or firewall control." = "Explicit TCP/UDP socket snapshot from lsof. Restricted processes and short-lived connections may be missing. No traffic history, domain attribution, byte totals or firewall control."; +"Search process, PID or endpoint" = "Search process, PID or endpoint"; +"Connection inspection failed. Refresh to try again; coverage remains unknown." = "Connection inspection failed. Refresh to try again; coverage remains unknown."; +"%d observed sockets; %d omitted by the display cap." = "%d observed sockets; %d omitted by the display cap."; +"Protocol" = "Protocol"; +"Endpoint" = "Endpoint"; +"State" = "State"; +"Details" = "Details"; +"Only a revalidated PID/start identity can be inspected." = "Only a revalidated PID/start identity can be inspected."; +"No matching sockets within observable coverage. This does not prove the Mac has no connections." = "No matching sockets within observable coverage. This does not prove the Mac has no connections."; +"Refresh to inspect current connections." = "Refresh to inspect current connections."; +"Hidden Space & Snapshots" = "Hidden Space & Snapshots"; +"Inspect APFS space" = "Inspect APFS space"; +"Startup Data volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." = "Startup Data volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements."; +"APFS inspection failed or is unsupported. Refresh to try again." = "APFS inspection failed or is unsupported. Refresh to try again."; +"APFS container capacity" = "APFS container capacity"; +"APFS container free" = "APFS container free"; +"Data volume used" = "Data volume used"; +"Unaccounted space is not measured here. It may include unreadable files, other volumes, filesystem metadata, clones or snapshots; Orven does not attribute the remainder to one cause." = "Unaccounted space is not measured here. It may include unreadable files, other volumes, filesystem metadata, clones or snapshots; Orven does not attribute the remainder to one cause."; +"APFS snapshots" = "APFS snapshots"; +"No APFS snapshots reported for the Data volume. Other volumes may have snapshots." = "No APFS snapshots reported for the Data volume. Other volumes may have snapshots."; +"OS marks this snapshot purgeable." = "OS marks this snapshot purgeable."; +"OS does not mark this snapshot purgeable." = "OS does not mark this snapshot purgeable."; +"Snapshot purgeability unavailable." = "Snapshot purgeability unavailable."; +"%d snapshot entries omitted." = "%d snapshot entries omitted."; +"Exclusive snapshot bytes are unavailable from this inspection. Snapshot count does not measure reclaimable space. No snapshots are created, mounted, thinned or deleted." = "Exclusive snapshot bytes are unavailable from this inspection. Snapshot count does not measure reclaimable space. No snapshots are created, mounted, thinned or deleted."; +"Inspect to read container space and Data volume snapshots." = "Inspect to read container space and Data volume snapshots."; +"CPU scheduling" = "CPU scheduling"; +"Lower priority raises nice to at least 10 for this same-user process. It is not a CPU percentage cap. Raising priority back may require privileges; relaunch the app to reset. Child processes are not individually controlled." = "Lower priority raises nice to at least 10 for this same-user process. It is not a CPU percentage cap. Raising priority back may require privileges; relaunch the app to reset. Child processes are not individually controlled."; +"Lower process priority…" = "Lower process priority…"; +"Lower process priority?" = "Lower process priority?"; +"Lower priority" = "Lower priority"; +"Process priority lowered and rechecked. This does not guarantee lower CPU usage." = "Process priority lowered and rechecked. This does not guarantee lower CPU usage."; +"Priority change could not be verified. The process may have exited, changed identity or denied access. No retry is automatic." = "Priority change could not be verified. The process may have exited, changed identity or denied access. No retry is automatic."; +"This changes scheduling for the selected process. Restoring the previous priority is not guaranteed without relaunching it." = "This changes scheduling for the selected process. Restoring the previous priority is not guaranteed without relaunching it."; +"Storage Growth Detective" = "Storage Growth Detective"; +"Baseline saved. Scan the same location again to compare growth." = "Baseline saved. Scan the same location again to compare growth."; +"Scans are not comparable: scope, identity, coverage or checkpoint format differs. A new baseline was saved." = "Scans are not comparable: scope, identity, coverage or checkpoint format differs. A new baseline was saved."; +"Comparable observed folder changes. Decreases do not prove APFS space was freed; changes do not identify their cause." = "Comparable observed folder changes. Decreases do not prove APFS space was freed; changes do not identify their cause."; +"No comparable folder deltas to display." = "No comparable folder deltas to display."; +"Developer Workspace" = "Developer Workspace"; +"Recognized projects within selected roots. Manifest names are evidence of an ecosystem, not proof that its runtime is installed. Source size and runtime versions are not measured." = "Recognized projects within selected roots. Manifest names are evidence of an ecosystem, not proof that its runtime is installed. Source size and runtime versions are not measured."; +"%d scan issues; results may be partial." = "%d scan issues; results may be partial."; +"%d recognized artifacts; size is observed artifact data only." = "%d recognized artifacts; size is observed artifact data only."; +"No recognized project manifests within scan coverage." = "No recognized project manifests within scan coverage."; +"Only the first 100 projects are displayed. Artifact filters remain available below." = "Only the first 100 projects are displayed. Artifact filters remain available below."; +"App Activity Timeline" = "App Activity Timeline"; +"Recent in-memory CPU trends for running processes mapped by bundle path and launch identity. Helper processes, older activity and unverified ownership may be missing. Related-data sizes remain scan-time observations." = "Recent in-memory CPU trends for running processes mapped by bundle path and launch identity. Helper processes, older activity and unverified ownership may be missing. Related-data sizes remain scan-time observations."; +"No running process could be mapped within current coverage." = "No running process could be mapped within current coverage."; +"Inspect app sleep requests" = "Inspect app sleep requests"; +"No sleep requests attributed to mapped processes within this snapshot's coverage." = "No sleep requests attributed to mapped processes within this snapshot's coverage."; +"Some assertion entries were omitted; coverage is partial." = "Some assertion entries were omitted; coverage is partial."; +"warning" = "warning"; +"critical" = "critical"; +"serious" = "serious"; + +"%@, PID %d, %@, %@, %@" = "%@, PID %d, %@, %@, %@"; +"Measure workspace" = "Measure workspace"; +"Observed workspace size" = "Observed workspace size"; +"Other observed project data" = "Other observed project data"; + +"Startup volume" = "Startup volume"; +"Data volume" = "Data volume"; +"System volume" = "System volume"; +"Selected volume used" = "Selected volume used"; +"Selected startup volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." = "Selected startup volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements."; +"No APFS snapshots reported for the selected volume. Other volumes may have snapshots." = "No APFS snapshots reported for the selected volume. Other volumes may have snapshots."; +"Inspect to read container space and selected-volume snapshots." = "Inspect to read container space and selected-volume snapshots."; + +"Stop" = "Stop"; + +"Inspect selected process" = "Inspect selected process"; + +"Volumes sharing this container" = "Volumes sharing this container"; +"Unattributed container allocation" = "Unattributed container allocation"; +"Container remainder uses OS-reported capacity minus free space and listed volume usage. It is not hidden-file size or exclusive snapshot space. Incomplete or inconsistent accounting stays unknown." = "Container remainder uses OS-reported capacity minus free space and listed volume usage. It is not hidden-file size or exclusive snapshot space. Incomplete or inconsistent accounting stays unknown."; +"%d volume entries omitted." = "%d volume entries omitted."; + +"Preview the exact JSON before saving. No dedicated path, username, hostname or network endpoint fields are included. Opt-in process names may contain identifying text. Nothing is uploaded." = "Preview the exact JSON before saving. No dedicated path, username, hostname or network endpoint fields are included. Opt-in process names may contain identifying text. Nothing is uploaded."; + +"Hardware Inspector" = "Hardware Inspector"; +"Hardware profile" = "Hardware profile"; +"Hardware report" = "Hardware report"; +"Refresh hardware" = "Refresh hardware"; +"Export hardware report…" = "Export hardware report…"; +"Reading hardware profile…" = "Reading hardware profile…"; +"Model identifier" = "Model identifier"; +"Operating system" = "Operating system"; +"Processor" = "Processor"; +"Physical cores" = "Physical cores"; +"Logical processors" = "Logical processors"; +"Reported nominal frequency" = "Reported nominal frequency"; +"Page size" = "Page size"; +"Cache line size" = "Cache line size"; +"L1 instruction cache" = "L1 instruction cache"; +"L1 data cache" = "L1 data cache"; +"L2 cache" = "L2 cache"; +"L3 cache" = "L3 cache"; +"Performance level" = "Performance level"; +"CPU level %d" = "CPU level %d"; +"Cores sharing L2" = "Cores sharing L2"; +"Metal device" = "Metal device"; +"Unified memory" = "Unified memory"; +"Recommended GPU working set" = "Recommended GPU working set"; +"Performance" = "Performance"; +"Efficiency" = "Efficiency"; +"Yes" = "Yes"; +"No" = "No"; +"Recent sensor range" = "Recent sensor range"; +"Reset range" = "Reset range"; +"Sensor" = "Sensor"; +"Current" = "Current"; +"Minimum" = "Minimum"; +"Maximum" = "Maximum"; +"Samples" = "Samples"; +"Disk read" = "Disk read"; +"Disk write" = "Disk write"; +"Network receive" = "Network receive"; +"Network send" = "Network send"; +"Cache sizes are OS-reported values, not summed totals. Nominal frequency is not a live core clock. The GPU working set is a recommendation, not VRAM capacity or current usage." = "Cache sizes are OS-reported values, not summed totals. Nominal frequency is not a live core clock. The GPU working set is a recommendation, not VRAM capacity or current usage."; +"Min/max covers available observations in the latest sampling generation, up to 60 seconds. Missing readings are gaps. Reset affects this range only; it does not clear monitoring history." = "Min/max covers available observations in the latest sampling generation, up to 60 seconds. Missing readings are gaps. Reset affects this range only; it does not clear monitoring history."; +"Sensor coverage: temperatures in °C, fan RPM, voltage, package power, live core clocks, SPD timings and SMART health are not collected. Thermal state is the macOS condition, not a temperature. No fan control, overclocking or stress test is performed." = "Sensor coverage: temperatures in °C, fan RPM, voltage, package power, live core clocks, SPD timings and SMART health are not collected. Thermal state is the macOS condition, not a temperature. No fan control, overclocking or stress test is performed."; + +"Preview the hardware profile and recent range before saving. No serial, hostname, paths, process names or network endpoints are included. Nothing is uploaded." = "Preview the hardware profile and recent range before saving. No serial, hostname, paths, process names or network endpoints are included. Nothing is uploaded."; + +"Hardware architecture" = "Hardware architecture"; + +"System information" = "System information"; + +"Temperature & Fans" = "Temperature & Fans"; +"CPU sensors" = "CPU sensors"; +"GPU sensors" = "GPU sensors"; +"Fans" = "Fans"; +"Battery sensors" = "Battery sensors"; +"System power" = "System power"; +"CPU • hottest mapped sensor" = "CPU • hottest mapped sensor"; +"GPU • hottest mapped sensor" = "GPU • hottest mapped sensor"; +"SMC reports no fans. This is not a fan-speed measurement." = "SMC reports no fans. This is not a fan-speed measurement."; +"Fan count unavailable; no fan speeds inferred." = "Fan count unavailable; no fan speeds inferred."; +"Fan coverage is capped at 8 reported fans." = "Fan coverage is capped at 8 reported fans."; +"Read-only SMC sensors refresh every 10 seconds while monitoring runs. Coverage is limited and model-dependent. CPU/GPU labels use community mappings, not verified physical core IDs. Missing readings are never zero." = "Read-only SMC sensors refresh every 10 seconds while monitoring runs. Coverage is limited and model-dependent. CPU/GPU labels use community mappings, not verified physical core IDs. Missing readings are never zero."; +"Temperatures are individual mapped readings, not CPU/GPU package averages or a safety diagnosis. Fan speeds are observed RPM only; Orven does not change fan modes or targets. Voltage, live clocks, SPD and SMART are not collected." = "Temperatures are individual mapped readings, not CPU/GPU package averages or a safety diagnosis. Fan speeds are observed RPM only; Orven does not change fan modes or targets. Voltage, live clocks, SPD and SMART are not collected."; +"SMC temperature, fan and power coverage is shown above. Thermal state remains a separate macOS condition. Voltage, live core clocks, SPD timings, SMART health, fan control, overclocking and stress tests are not provided." = "SMC temperature, fan and power coverage is shown above. Thermal state remains a separate macOS condition. Voltage, live core clocks, SPD timings, SMART health, fan control, overclocking and stress tests are not provided."; + +"Review temperatures and fans" = "Review temperatures and fans"; +"Per-process energy: Unsupported" = "Per-process energy: Unsupported"; + +/* Health history, alerts, recording and connection changes. */ +"Health History & Baseline" = "Health History & Baseline"; +"Record health history locally" = "Record health history locally"; +"One observed sample per minute, up to seven days. No process names, paths or network endpoints. Paused, sleep and missing samples are gaps; enabling history does not enable background monitoring." = "One observed sample per minute, up to seven days. No process names, paths or network endpoints. Paused, sleep and missing samples are gaps; enabling history does not enable background monitoring."; +"History retention" = "History retention"; +"%d recorded observations" = "%d recorded observations"; +"Time range" = "Time range"; +"1 hour" = "1 hour"; +"Export history JSON…" = "Export history JSON…"; +"Baseline comparison" = "Baseline comparison"; +"Capture baseline" = "Capture baseline"; +"Capture the latest 30–60 recorded samples as a reference. Compare medians with at least ten newer samples per metric. Workload differences can explain changes; this is not causal diagnosis." = "Capture the latest 30–60 recorded samples as a reference. Compare medians with at least ten newer samples per metric. Workload differences can explain changes; this is not causal diagnosis."; +"Not enough baseline or newer observations yet. Keep recording during comparable workloads." = "Not enough baseline or newer observations yet. Keep recording during comparable workloads."; +"Baseline median" = "Baseline median"; +"Recent median" = "Recent median"; +"Chart metric" = "Chart metric"; +"Occupied memory" = "Occupied memory"; +"Temperature labels use community mappings; missing readings are gaps. Fan RPM and system power are observations, not fan control or CPU package power." = "Temperature labels use community mappings; missing readings are gaps. Fan RPM and system power are observations, not fan control or CPU package power."; +"Sensor Charts & Recording" = "Sensor Charts & Recording"; +"Record existing samples every ten seconds for 15, 30 or 60 minutes. Sleep extends the wall-clock duration. The session stays in memory until Quit. Sampling pauses when the app is inactive unless menu bar monitoring is enabled; no readings are backfilled." = "Record existing samples every ten seconds for 15, 30 or 60 minutes. Sleep extends the wall-clock duration. The session stays in memory until Quit. Sampling pauses when the app is inactive unless menu bar monitoring is enabled; no readings are backfilled."; +"Recording duration" = "Recording duration"; +"15 minutes" = "15 minutes"; +"30 minutes" = "30 minutes"; +"60 minutes" = "60 minutes"; +"Start recording" = "Start recording"; +"Stop recording" = "Stop recording"; +"Recording observed samples" = "Recording observed samples"; +"Recording stopped" = "Recording stopped"; +"Recording paused until monitoring resumes" = "Recording paused until monitoring resumes"; +"Export session JSON…" = "Export session JSON…"; +"Export session CSV…" = "Export session CSV…"; +"Smart Alerts" = "Smart Alerts"; +"Enable health alerts" = "Enable health alerts"; +"Off by default. Fresh active evidence only: sustained CPU, memory pressure, low startup space and elevated thermal state. A recovered condition must stay clear for 30 seconds; repeat alerts wait at least 15 minutes. Nothing is changed automatically." = "Off by default. Fresh active evidence only: sustained CPU, memory pressure, low startup space and elevated thermal state. A recovered condition must stay clear for 30 seconds; repeat alerts wait at least 15 minutes. Nothing is changed automatically."; +"Allow macOS notifications…" = "Allow macOS notifications…"; +"Snooze for one hour" = "Snooze for one hour"; +"No alert events in this app session." = "No alert events in this app session."; +"Recent pressure and thermal observations" = "Recent pressure and thermal observations"; +"History could not be read. The existing file is preserved; recording to disk is blocked." = "History could not be read. The existing file is preserved; recording to disk is blocked."; +"History could not be saved. Existing data is preserved; check the local storage location." = "History could not be saved. Existing data is preserved; check the local storage location."; +"Baseline needs at least 30 recorded samples and writable local storage." = "Baseline needs at least 30 recorded samples and writable local storage."; +"History retention could not be saved. Existing data is preserved." = "History retention could not be saved. Existing data is preserved."; +"Notifications require the Orven app bundle." = "Notifications require the Orven app bundle."; +"Notification permission granted." = "Notification permission granted."; +"Notifications are not allowed. In-app alerts remain available." = "Notifications are not allowed. In-app alerts remain available."; +"Notification permission could not be requested. In-app alerts remain available." = "Notification permission could not be requested. In-app alerts remain available."; +"Alerts snoozed for one hour." = "Alerts snoozed for one hour."; +"Notification delivery failed. Review the in-app alert evidence." = "Notification delivery failed. Review the in-app alert evidence."; +"Network Connection Changes" = "Network Connection Changes"; +"Track observed connection changes this session" = "Track observed connection changes this session"; +"Explicit Refresh compares observable sockets with verified PID/start identities. New or no-longer-observed does not mean opened or closed at this time. Short connections and restricted processes may be missed. Process names and numeric endpoints stay in memory, capped at 200 events; nothing is uploaded or saved to disk." = "Explicit Refresh compares observable sockets with verified PID/start identities. New or no-longer-observed does not mean opened or closed at this time. Short connections and restricted processes may be missed. Process names and numeric endpoints stay in memory, capped at 200 events; nothing is uploaded or saved to disk."; +"Connection baseline established" = "Connection baseline established"; +"Refresh to establish a connection baseline" = "Refresh to establish a connection baseline"; +"%d unverified sockets excluded; %d older events omitted." = "%d unverified sockets excluded; %d older events omitted."; +"Clear connection history" = "Clear connection history"; +"No connection changes observed yet." = "No connection changes observed yet."; +"Showing the latest 50 change events." = "Showing the latest 50 change events."; +"Newly observed" = "Newly observed"; +"No longer observed" = "No longer observed"; +"State changed" = "State changed"; +"Local report export" = "Local report export"; +"Preview the exact local report before saving. Nothing is uploaded." = "Preview the exact local report before saving. Nothing is uploaded."; +"Save report…" = "Save report…"; +"normal" = "normal"; +"nominal" = "nominal"; +"fair" = "fair"; +"unknown" = "unknown"; + +"Change" = "Change"; +"Memory pressure" = "Memory pressure"; +"System CPU" = "System CPU"; + +"%d older observations omitted by the file-size budget." = "%d older observations omitted by the file-size budget."; + +"Health History" = "Health History"; + +"Could not prepare the local report. Stop recording and try again." = "Could not prepare the local report. Stop recording and try again."; +"Waiting for macOS notification permission. Respond to the system prompt if it appears." = "Waiting for macOS notification permission. Respond to the system prompt if it appears."; + +"This report is frozen when opened. Close and reopen for newer observations. Preview the exact content before saving; nothing is uploaded." = "This report is frozen when opened. Close and reopen for newer observations. Preview the exact content before saving; nothing is uploaded."; +"Report saved." = "Report saved."; +"Could not save the report. Choose a writable location and try again." = "Could not save the report. Choose a writable location and try again."; + +/* Wi-Fi and LAN tools */ +"Wi-Fi & LAN" = "Wi-Fi & LAN"; +"Network Overview" = "Network Overview"; +"Wi-Fi Inspector" = "Wi-Fi Inspector"; +"Ethernet Inspector" = "Ethernet Inspector"; +"Connection Diagnostics" = "Connection Diagnostics"; +"Latency & Stability" = "Latency & Stability"; +"Nearby Wi-Fi" = "Nearby Wi-Fi"; +"Network Change Timeline" = "Network Change Timeline"; +"Network tool" = "Network tool"; +"Refresh network status" = "Refresh network status"; +"Last network observation" = "Last network observation"; +"Path interfaces" = "Path interfaces"; +"Low Data Mode" = "Low Data Mode"; +"Expensive path" = "Expensive path"; +"An available path does not prove that an Internet destination is reachable." = "An available path does not prove that an Internet destination is reachable."; +"Primary interface" = "Primary interface"; +"IPv4 gateway" = "IPv4 gateway"; +"IPv6 gateway" = "IPv6 gateway"; +"DNS servers" = "DNS servers"; +"No matching network adapter was observed." = "No matching network adapter was observed."; +"IP addresses" = "IP addresses"; +"MTU" = "MTU"; +"Negotiated media" = "Negotiated media"; +"Received / sent bytes" = "Received / sent bytes"; +"Input / output packets" = "Input / output packets"; +"Input / output errors" = "Input / output errors"; +"Input queue drops" = "Input queue drops"; +"Adapter counters are cumulative observations, not session traffic or Internet speed. Packet/error counters may wrap. Live rates reuse the existing sampler." = "Adapter counters are cumulative observations, not session traffic or Internet speed. Packet/error counters may wrap. Live rates reuse the existing sampler."; +"%d adapters omitted by the display limit." = "%d adapters omitted by the display limit."; +"Refresh to inspect Wi-Fi and LAN." = "Refresh to inspect Wi-Fi and LAN."; +"SSID and BSSID can be hidden by macOS location permissions. Missing identity does not mean Wi-Fi is disconnected." = "SSID and BSSID can be hidden by macOS location permissions. Missing identity does not mean Wi-Fi is disconnected."; +"Request Wi-Fi identity permission" = "Request Wi-Fi identity permission"; +"No Wi-Fi interface was observed." = "No Wi-Fi interface was observed."; +"Unavailable or permission restricted" = "Unavailable or permission restricted"; +"SSID" = "SSID"; +"BSSID" = "BSSID"; +"RSSI" = "RSSI"; +"Noise" = "Noise"; +"SNR" = "SNR"; +"Channel" = "Channel"; +"Band" = "Band"; +"Channel width" = "Channel width"; +"Transmit link rate" = "Transmit link rate"; +"CoreWLAN reports local radio observations. Link rate is not measured Internet throughput; SNR is RSSI minus noise from this observation." = "CoreWLAN reports local radio observations. Link rate is not measured Internet throughput; SNR is RSSI minus noise from this observation."; +"Destination hostname or IP address" = "Destination hostname or IP address"; +"Example: example.com or 192.168.1.1" = "Example: example.com or 192.168.1.1"; +"Measurements send traffic to the displayed destination. Connection diagnostics also tests the recently observed gateway, resolves the destination and opens TCP port 443. No payload or speed-test download is sent." = "Measurements send traffic to the displayed destination. Connection diagnostics also tests the recently observed gateway, resolves the destination and opens TCP port 443. No payload or speed-test download is sent."; +"Check connection" = "Check connection"; +"Measure 20 ICMP probes" = "Measure 20 ICMP probes"; +"Refresh network status first to include a gateway observed within 60 seconds. Each stage is bounded; total connection check is at most 30 seconds." = "Refresh network status first to include a gateway observed within 60 seconds. Each stage is bounded; total connection check is at most 30 seconds."; +"Destination" = "Destination"; +"Elapsed" = "Elapsed"; +"ICMP may be blocked while Internet access works. DNS results may be cached. A successful TCP connection does not verify HTTP, TLS, captive portals or every application." = "ICMP may be blocked while Internet access works. DNS results may be cached. A successful TCP connection does not verify HTTP, TLS, captive portals or every application."; +"20 probes, one second apart, at most 28 seconds. Loss uses the ICMP summary; jitter is the mean absolute difference between adjacent successful replies. Timeouts break the chart." = "20 probes, one second apart, at most 28 seconds. Loss uses the ICMP summary; jitter is the mean absolute difference between adjacent successful replies. Timeouts break the chart."; +"Mean latency" = "Mean latency"; +"Jitter" = "Jitter"; +"Packet loss" = "Packet loss"; +"ICMP did not complete successfully; missing results are unknown or blocked." = "ICMP did not complete successfully; missing results are unknown or blocked."; +"ICMP round-trip latency" = "ICMP round-trip latency"; +"Scan nearby Wi-Fi" = "Scan nearby Wi-Fi"; +"Scan is explicit and controlled by macOS. Stop suppresses results after the OS scan returns; it cannot interrupt CoreWLAN. Identity may require location permission. Results stay in this window only." = "Scan is explicit and controlled by macOS. Stop suppresses results after the OS scan returns; it cannot interrupt CoreWLAN. Identity may require location permission. Results stay in this window only."; +"Filter SSID, BSSID or channel" = "Filter SSID, BSSID or channel"; +"%d visible networks; %d omitted." = "%d visible networks; %d omitted."; +"No matching Wi-Fi network was observed." = "No matching Wi-Fi network was observed."; +"Hidden or permission restricted" = "Hidden or permission restricted"; +"Channel / band" = "Channel / band"; +"RSSI / noise" = "RSSI / noise"; +"Observed networks on this channel" = "Observed networks on this channel"; +"Channel counts describe this scan, not airtime utilization or interference. Different bands can reuse channel numbers; wider channels can overlap. Signal bars are a relative RSSI display." = "Channel counts describe this scan, not airtime utilization or interference. Different bands can reuse channel numbers; wider channels can overlap. Signal bars are a relative RSSI display."; +"Track network changes in this window" = "Track network changes in this window"; +"Clear network changes" = "Clear network changes"; +"Path events are observed while this page is open. IP, gateway, DNS and Wi-Fi identity changes compare explicit successful refreshes within ten minutes. Missing identities and adapter disappearance are not inferred as changes. First refresh establishes a baseline. No history is written to disk." = "Path events are observed while this page is open. IP, gateway, DNS and Wi-Fi identity changes compare explicit successful refreshes within ten minutes. Missing identities and adapter disappearance are not inferred as changes. First refresh establishes a baseline. No history is written to disk."; +"%d status changes and %d path events; %d older events omitted." = "%d status changes and %d path events; %d older events omitted."; +"No network changes observed in this session." = "No network changes observed in this session."; +"Before" = "Before"; +"After" = "After"; +"Reading network status" = "Reading network status"; +"Scanning Wi-Fi" = "Scanning Wi-Fi"; +"Measuring latency" = "Measuring latency"; +"Checking connection" = "Checking connection"; +"Stopping after the macOS Wi-Fi scan returns." = "Stopping after the macOS Wi-Fi scan returns."; +"Stopping measurement." = "Stopping measurement."; +"Enter a hostname or IP address without a URL, port or spaces." = "Enter a hostname or IP address without a URL, port or spaces."; +"Observation completed." = "Observation completed."; +"Cancelled. Previous results remain timestamped." = "Cancelled. Previous results remain timestamped."; +"Network observation failed or timed out. Check permissions and retry." = "Network observation failed or timed out. Check permissions and retry."; +"Not requested" = "Not requested"; +"Authorized" = "Authorized"; +"Denied" = "Denied"; +"Restricted" = "Restricted"; +"Available path" = "Available path"; +"Connection required" = "Connection required"; +"No available path" = "No available path"; +"Gateway ICMP" = "Gateway ICMP"; +"DNS resolution" = "DNS resolution"; +"TCP connection" = "TCP connection"; +"Failed or blocked" = "Failed or blocked"; +"Timed out or unavailable" = "Timed out or unavailable"; +"Link state" = "Link state"; +"Ethernet" = "Ethernet"; +"Other" = "Other"; +"Global DNS configuration is shown; scoped VPN and per-domain resolvers may differ." = "Global DNS configuration is shown; scoped VPN and per-domain resolvers may differ."; +"Observed networks by channel" = "Observed networks by channel"; +"Wi-Fi" = "Wi-Fi"; +"Not applicable to a numeric IP" = "Not applicable to a numeric IP"; +"Active" = "Active"; +"Inactive" = "Inactive"; +"Carrier state" = "Carrier state"; +"Link detected" = "Link detected"; +"No link detected" = "No link detected"; +"Up / running" = "Up / running"; +"Down / not running" = "Down / not running"; +"Interface flags" = "Interface flags"; diff --git a/apps/toolbox/Resources/en.lproj/Storage.strings b/apps/toolbox/Resources/en.lproj/Storage.strings index d198dd3..5070fd4 100644 --- a/apps/toolbox/Resources/en.lproj/Storage.strings +++ b/apps/toolbox/Resources/en.lproj/Storage.strings @@ -7,7 +7,7 @@ "Gỡ ứng dụng" = "Applications"; "Lịch quét" = "Scheduled Scan"; "Lịch sử" = "History"; -"Giới thiệu Diskora" = "About Diskora"; +"Giới thiệu Diskora" = "About Orven"; "Tác giả" = "Author"; "Công cụ phân tích và quản lý dung lượng dành cho macOS." = "Storage analysis and management for macOS."; "Phân tích dung lượng • Tệp trùng lặp • Dọn dẹp Developer" = "Storage analysis • Duplicate files • Developer cleanup"; @@ -152,7 +152,7 @@ "Ứng dụng và dữ liệu còn sót" = "Applications and Leftover Data"; "ứng dụng" = "applications"; "Chưa quét ứng dụng" = "Applications Not Scanned"; -"App sẽ tìm bundle trong Applications và đối chiếu Bundle ID với dữ liệu trong Library." = "Diskora finds bundles in Applications and matches their Bundle IDs against Library data."; +"App sẽ tìm bundle trong Applications và đối chiếu Bundle ID với dữ liệu trong Library." = "Orven finds bundles in Applications and matches their Bundle IDs against Library data."; "Không có Bundle ID" = "No Bundle ID"; "Gỡ…" = "Remove…"; "Gỡ %@?" = "Remove %@?"; @@ -164,7 +164,7 @@ "Tên thành phần khớp; cần xem lại" = "Component name match; review required"; "Launch item tham chiếu Bundle ID" = "Launch item references the Bundle ID"; "Launch item tham chiếu tên ứng dụng" = "Launch item references the application name"; -"Receipt hệ thống chỉ dùng làm bằng chứng; Diskora không tự xóa" = "The system receipt is evidence only; Diskora never deletes it automatically"; +"Receipt hệ thống chỉ dùng làm bằng chứng; Diskora không tự xóa" = "The system receipt is evidence only; Orven never deletes it automatically"; "sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Diskora không xóa database hệ thống" = "sfltool detected a login/background item; evidence is shown without deleting system databases"; /* Developer */ @@ -175,12 +175,12 @@ "Dọn bằng công cụ chính thức" = "Cleanup with Official Tools"; "Chưa nhận diện dữ liệu Developer" = "No Developer Data Identified"; "Quét thư mục người dùng ở mục Phân tích dung lượng để thống kê môi trường dev." = "Scan the home folder in Storage Analyzer to inventory development environments."; -"Diskora chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Diskora cleans only after confirmation and prefers each tool's official command."; +"Diskora chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Orven cleans only after confirmation and prefers each tool's official command."; "Gỡ phiên bản runtime?" = "Remove Runtime Version?"; "Chạy công cụ dọn dẹp?" = "Run Cleanup Tool?"; "Developer Cleanup thất bại" = "Developer Cleanup Failed"; "App chưa tìm thấy file cấu hình dự án tham chiếu phiên bản này. Việc quét không thể đảm bảo bao phủ mọi dự án, hãy kiểm tra trước khi tiếp tục." = "No project configuration referencing this version was found. Scanning cannot cover every project, so review before continuing."; -"Diskora sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Diskora will run exactly the displayed allowlisted command. It does not use Trash and cannot be undone."; +"Diskora sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Orven will run exactly the displayed allowlisted command. It does not use Trash and cannot be undone."; "Xóa simulator runtime không còn được Xcode hỗ trợ bằng simctl." = "Delete unsupported simulator runtimes with simctl."; "Dọn build cache, network và container đã dừng; không xóa volume." = "Clean build cache, networks, and stopped containers without deleting volumes."; "Dùng Homebrew để xóa download cũ và phiên bản package không còn cần thiết." = "Use Homebrew to remove old downloads and unneeded package versions."; @@ -203,12 +203,12 @@ "Tắt lịch" = "Disable Schedule"; "Lần chạy gần nhất" = "Last Run"; "Không thể cập nhật lịch quét" = "Unable to Update Schedule"; -"Lịch quét chỉ thông báo; Diskora không tự xóa dữ liệu." = "Scheduled scans only notify; Diskora never deletes data automatically."; -"Lịch quét chỉ thông báo; Toolbox không tự xóa dữ liệu." = "Scheduled scans only notify; Toolbox never deletes data automatically."; -"Receipt hệ thống chỉ dùng làm bằng chứng; Toolbox không tự xóa" = "The system receipt is evidence only; Toolbox never deletes it automatically"; -"sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Toolbox không xóa database hệ thống" = "sfltool detected a login/background item; evidence is shown without deleting system databases"; -"Toolbox chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Toolbox cleans only after confirmation and prefers each tool's official command."; -"Toolbox sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Toolbox will run exactly the displayed allowlisted command. It does not use Trash and cannot be undone."; +"Lịch quét chỉ thông báo; Diskora không tự xóa dữ liệu." = "Scheduled scans only notify; Orven never deletes data automatically."; +"Lịch quét chỉ thông báo; Orven không tự xóa dữ liệu." = "Scheduled scans only notify; Orven never deletes data automatically."; +"Receipt hệ thống chỉ dùng làm bằng chứng; Orven không tự xóa" = "The system receipt is evidence only; Orven never deletes it automatically"; +"sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Orven không xóa database hệ thống" = "sfltool detected a login/background item; evidence is shown without deleting system databases"; +"Orven chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Orven cleans only after confirmation and prefers each tool's official command."; +"Orven sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Orven will run exactly the displayed allowlisted command. It does not use Trash and cannot be undone."; "Lịch sử, vị trí Trash và khôi phục có kiểm tra xung đột" = "History, Trash locations, and conflict-safe restoration"; "thao tác" = "operations"; "Chưa có lịch sử" = "No History Yet"; @@ -242,7 +242,7 @@ "Cần xem lại" = "Review"; "Được bảo vệ" = "Protected"; "Chưa chọn project root" = "No Project Root Selected"; -"Toolbox chỉ quét thư mục bạn chọn và chỉ đề xuất artifact có marker hệ sinh thái rõ ràng." = "Toolbox scans only folders you select and offers artifacts only when ecosystem markers are present."; +"Orven chỉ quét thư mục bạn chọn và chỉ đề xuất artifact có marker hệ sinh thái rõ ràng." = "Orven scans only folders you select and offers artifacts only when ecosystem markers are present."; "Chọn project…" = "Choose Projects…"; "Tất cả hệ sinh thái" = "All Ecosystems"; "Hệ sinh thái" = "Ecosystem"; @@ -250,7 +250,7 @@ "Tìm đường dẫn" = "Search Paths"; "Sẵn sàng quét" = "Ready to Scan"; "Không có artifact phù hợp" = "No Matching Artifacts"; -"Toolbox sẽ nhận diện output có thể tạo lại từ marker dự án." = "Toolbox identifies regenerable output from project markers."; +"Orven sẽ nhận diện output có thể tạo lại từ marker dự án." = "Orven identifies regenerable output from project markers."; "Không có source, manifest, lockfile hoặc thư mục chưa biết nào được đề xuất." = "No source, manifest, lockfile, or unknown folder is offered."; "Thêm project…" = "Add Projects…"; "Chuyển vào Trash…" = "Move to Trash…"; diff --git a/apps/toolbox/Resources/ja.lproj/Changes.strings b/apps/toolbox/Resources/ja.lproj/Changes.strings new file mode 100644 index 0000000..5ad2d3b --- /dev/null +++ b/apps/toolbox/Resources/ja.lproj/Changes.strings @@ -0,0 +1,123 @@ +/* Orven ja localization. */ +"%lld thay đổi" = "%lld件の変更"; +"Application Support, Cache và Container được quan sát sâu hai cấp để nhận diện thay đổi bên trong thư mục ứng dụng." = "Application Support、Cache、Containerを2階層まで確認し、内部の変更を検出します。"; +"Application bundle thay đổi; kiểm tra version, Team ID và chữ ký." = "アプリのバンドルが変更されました。バージョン、Team ID、署名を確認してください。"; +"Baseline có" = "ベースラインの項目数"; +"Baseline tin cậy" = "信頼済みベースライン"; +"Baseline vẫn còn nguyên. Có thể tiếp tục với coverage giảm hoặc bỏ phiên này." = "ベースラインは保持されています。範囲を縮小して続行するか、この追跡を破棄してください。"; +"Baseline đã lưu. macOS có thể mở installer an toàn." = "ベースラインを保存しました。macOSでインストーラを開けます。"; +"Browser extension có thể đọc hoặc thay đổi dữ liệu trong trình duyệt." = "ブラウザ拡張機能はブラウザ内のデータを読み取ったり変更したりする可能性があります。"; +"Bắt đầu một phiên thay đổi" = "変更セッションを開始"; +"Bỏ chọn" = "クリア"; +"Bỏ phiên" = "追跡を破棄"; +"Changeora chỉ đọc metadata cục bộ, không đọc nội dung tài liệu, không telemetry và không tự động xóa hoặc hoàn tác thay đổi." = "Changeoraはローカルのメタデータのみを読み取ります。書類の内容を読まず、テレメトリ、削除、自動的な変更の取り消しも行いません。"; +"Changeora sẽ lưu snapshot và bật FSEvents. Sau đó bạn cài, cập nhật hoặc gỡ ứng dụng như bình thường rồi quay lại để so sánh." = "Changeoraはスナップショットを保存しFSEventsを開始します。アプリをインストール、更新、削除した後、戻って比較してください。"; +"Changeora đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại." = "Changeoraは技術的な証拠を示しますが、アプリが悪意を持つとは断定しません。"; +"Chưa có lịch sử" = "履歴はまだありません"; +"Chưa có phiên so sánh." = "比較セッションが選択されていません。"; +"Chưa có thay đổi" = "変更はまだありません"; +"Chỉ đọc metadata như đường dẫn, thời gian, kích thước, Bundle ID và chữ ký." = "パス、時刻、サイズ、Bundle ID、署名などのメタデータを読み取ります。"; +"Chọn DMG, PKG hoặc ứng dụng" = "DMG、PKG、またはアプリを選択"; +"Chọn installer…" = "インストーラを選択…"; +"Chọn phiên" = "セッションを選択"; +"Chụp trạng thái ban đầu" = "初期状態を記録"; +"Configuration profile có thể thay đổi policy và thiết lập quản trị của macOS." = "構成プロファイルはmacOSのポリシーや管理設定を変更する可能性があります。"; +"Coverage giảm vì Orven không chạy trong một phần của phiên." = "セッションの一部でOrvenが動作していなかったため、対象範囲が縮小されています。"; +"Cài đặt / cập nhật / gỡ ứng dụng" = "アプリをインストール/更新/削除"; +"Các phiên so sánh hoàn tất sẽ xuất hiện tại đây." = "完了した比較セッションがここに表示されます。"; +"Dữ liệu người dùng hoặc metadata ứng dụng thay đổi trong Library." = "Library内のユーザーデータやアプリのメタデータが変更されました。"; +"FSEvent" = "FSEvents"; +"FSEvents và snapshot ghi nhận quá trình cài đặt, cập nhật hoặc uninstall." = "FSEventsとスナップショットでインストール、更新、アンインストールを記録します。"; +"Giới thiệu Changeora" = "Changeoraについて"; +"Hiển thị trong Finder" = "Finderで表示"; +"Hoàn tất" = "完了"; +"Hoàn tất và so sánh" = "完了して比較"; +"Hãy hoàn thành một phiên theo dõi cài đặt để xem kết quả trước và sau." = "インストール追跡を完了すると、前後の状態を比較できます。"; +"Hủy" = "キャンセル"; +"Hủy phiên" = "セッションをキャンセル"; +"Install Trace bị gián đoạn" = "インストール追跡が中断されました"; +"Install Trace đã lưu" = "インストール追跡を保存しました"; +"Kernel extension tác động ở mức hệ thống và cần được xem xét cẩn thận." = "カーネル拡張機能はシステムレベルで動作するため、慎重な確認が必要です。"; +"Không có Install Trace đang hoạt động." = "実行中のインストール追跡はありません。"; +"Không có kết quả phù hợp" = "一致する結果はありません"; +"Không phát hiện" = "なし"; +"Không phát hiện thay đổi trong phạm vi theo dõi." = "追跡範囲内で変更は検出されませんでした。"; +"Không thể" = "実行できません"; +"Không thể đọc" = "読み取り不可"; +"Không tự xóa, tắt service hoặc sửa System Settings." = "データの削除、サービスの無効化、システム設定の変更を行いません。"; +"Không đọc nội dung tài liệu cá nhân và không gửi dữ liệu ra mạng." = "個人の書類の内容を読み取ったり、ネットワークへデータを送信したりしません。"; +"Kết quả gần nhất" = "最新の結果"; +"Language / Ngôn ngữ" = "言語"; +"LaunchDaemon mới có thể chạy nền với quyền hệ thống/root." = "新しいLaunchDaemonはシステム/root権限でバックグラウンド実行される場合があります。"; +"Làm mới" = "更新"; +"Lưu baseline và mở installer" = "ベースラインを保存してインストーラを開く"; +"Lịch sử" = "履歴"; +"Minh bạch về dữ liệu Changeora đọc, lưu và không thể quan sát." = "Changeoraが読み取り、保存できる情報と、確認できない情報を説明します。"; +"Minh bạch về dữ liệu Orven đọc, lưu và không thể quan sát." = "Orvenが読み取り、保存できる情報と、確認できない情報を説明します。"; +"Mọi báo cáo được lưu cục bộ trên máy Mac này." = "すべてのレポートはこのMacに保存されます。"; +"Một Install Trace đang hoạt động." = "インストール追跡はすでに実行中です。"; +"Một số vùng được macOS bảo vệ có thể cần Full Disk Access để quan sát đầy đủ." = "macOSで保護された場所を十分に確認するには、フルディスクアクセスが必要な場合があります。"; +"Mở báo cáo" = "レポートを開く"; +"Mục thay đổi trong phiên gần nhất" = "最新セッションの変更"; +"Mức" = "リスク"; +"Nguyên tắc an toàn" = "安全方針"; +"Nên xem" = "要確認"; +"Package receipt là bằng chứng cài đặt do Installer ghi lại." = "パッケージ受領記録はInstallerが記録したインストールの証拠です。"; +"Phiên sau" = "後のセッション"; +"Phiên trước" = "前のセッション"; +"Phạm vi & quyền riêng tư" = "対象範囲とプライバシー"; +"Quan trọng" = "重要"; +"Shell/PATH thay đổi có thể tác động mọi terminal session và command được thực thi." = "Shell/PATHの変更はすべてのターミナルセッションと実行コマンドに影響する可能性があります。"; +"Snapshot ban đầu có %lld mục. FSEvents đang ghi thay đổi sâu; hãy hoàn tất thao tác trước khi tạo snapshot thứ hai." = "初期スナップショットには%lld項目があります。FSEventsで内部の変更を記録中です。操作を終えてから2回目のスナップショットを取得してください。"; +"Snapshot và FSEvents chỉ lưu metadata cục bộ. Orven không mount, execute hoặc bypass Gatekeeper." = "スナップショットとFSEventsはローカルのメタデータのみを保存します。Orvenはマウント、実行、Gatekeeperの回避を行いません。"; +"Snapshot đang theo dõi" = "追跡中のスナップショット"; +"So sánh hai phiên" = "2つのセッションを比較"; +"So sánh hai phiên có" = "セッション比較の項目数"; +"So sánh với baseline" = "ベースラインと比較"; +"Thay đổi" = "変更"; +"Theo dõi thay đổi" = "変更を追跡"; +"Theo dõi và giải thích thay đổi hệ thống sau khi cài hoặc cập nhật ứng dụng." = "アプリのインストールや更新後のシステム変更を記録し、説明します。"; +"Thành phần persistence có thể tự chạy khi đăng nhập hoặc trong nền." = "常駐コンポーネントはログイン時やバックグラウンドで起動する場合があります。"; +"Thành phần đặc quyền hoặc system extension có thể chạy ngoài tiến trình ứng dụng." = "特権コンポーネントやシステム拡張機能はアプリのプロセス外で動作する場合があります。"; +"Thông tin" = "参考情報"; +"Thả .dmg, .pkg hoặc .app vào đây" = ".dmg、.pkg、.appをここにドロップ"; +"Thả installer, chụp trước/sau và xem bằng chứng thay đổi" = "インストーラをドロップして前後を記録し、変更の証拠を確認"; +"Thử bỏ bộ lọc hoặc thay đổi từ khóa tìm kiếm." = "フィルタを解除するか検索語を変更してください。"; +"Tiếp tục với coverage giảm" = "範囲を縮小して続行"; +"Orven chỉ nhận .dmg, .pkg hoặc .app" = "Orvenで扱えるのは.dmg、.pkg、.appのみです"; +"Orven chỉ đọc loại file và đường dẫn; installer chỉ mở sau khi baseline đã lưu." = "Orvenはファイルの種類とパスのみを読み取ります。ベースラインの保存後にインストーラを開きます。"; +"Orven đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại." = "Orvenは技術的な証拠を示しますが、アプリが悪意を持つとは断定しません。"; +"Trạng thái hiện tại" = "現在の状態"; +"Tác giả" = "作成者"; +"Tên phiên" = "セッション名"; +"Tìm tên, đường dẫn hoặc chủ sở hữu" = "名前、パス、所有者を検索"; +"Tạo baseline hiện tại" = "現在のベースラインを作成"; +"Tất cả mức" = "すべてのリスク"; +"Tổng thay đổi" = "変更の合計"; +"không telemetry" = "テレメトリなし"; +"kết quả đang hiển thị" = "表示中の結果"; +"mục" = "項目"; +"phiên" = "セッション"; +"phiên đã lưu" = "保存済みセッション"; +"thay đổi" = "変更"; +"trong phạm vi theo dõi" = "追跡範囲内"; +"từ snapshot và" = "スナップショットと"; +"Đang chụp" = "記録中"; +"Đang chụp trạng thái sau thay đổi…" = "変更後の状態を記録中…"; +"Đang chụp trạng thái trước khi mở installer…" = "インストーラを開く前の状態を記録中…"; +"Đang quét…" = "スキャン中…"; +"Đang theo dõi" = "追跡中"; +"Đang theo dõi installer" = "インストーラを追跡中"; +"Đã gỡ" = "削除済み"; +"Đã hủy" = "キャンセル済み"; +"Đã hủy Install Trace." = "インストール追跡をキャンセルしました。"; +"Đã lưu" = "保存済み"; +"Đã phát hiện" = "検出済み"; +"Đã thay đổi" = "変更済み"; +"Đã thêm" = "追加済み"; +"Đã tiếp tục theo dõi; thời gian app không chạy có coverage giảm." = "追跡を再開しました。アプリが動作していなかった時間は対象範囲が縮小されています。"; +"Đã đổi" = "変更済み"; +"Đóng" = "閉じる"; +"đường dẫn" = "パス"; +"Ứng dụng" = "アプリ"; +"…và %lld đường dẫn khác" = "…ほか%lld件のパス"; diff --git a/apps/toolbox/Resources/ja.lproj/InfoPlist.strings b/apps/toolbox/Resources/ja.lproj/InfoPlist.strings new file mode 100644 index 0000000..e6df5a8 --- /dev/null +++ b/apps/toolbox/Resources/ja.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSLocationUsageDescription" = "Wi-Fi の確認やスキャン時にネットワーク名とアクセスポイントの識別情報を表示するため、位置情報へのアクセスを使用します。"; +"NSLocationWhenInUseUsageDescription" = "Wi-Fi の確認やスキャン時にネットワーク名とアクセスポイントの識別情報を表示するため、位置情報へのアクセスを使用します。"; +"NSLocalNetworkUsageDescription" = "ネットワーク診断を開始すると、選択したゲートウェイまたはローカルの接続先に接続します。"; diff --git a/apps/toolbox/Resources/ja.lproj/Localizable.strings b/apps/toolbox/Resources/ja.lproj/Localizable.strings new file mode 100644 index 0000000..e2bafce --- /dev/null +++ b/apps/toolbox/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,607 @@ +/* Orven ja localization. */ +"%d of %d processes have CPU samples." = "CPUサンプルあり%d件/全%d件。"; +"%d processes shown" = "%d件のプロセスを表示中"; +"%d valid samples. Minimum %.1f, maximum %.1f %@. Missing samples are gaps." = "有効なサンプル%d件。最小%.1f、最大%.1f %@。欠測部分は空白です。"; +"%lld cleanup records and %lld trace sessions are ready to import." = "%lld件のクリーンアップ記録と%lld件の追跡セッションを読み込めます。"; +"100% process CPU is one logical core. Restricted processes remain visible. No termination actions." = "プロセスのCPU使用率100%は論理コア1つ分です。制限されたプロセスも表示します。終了操作はありません。"; +"12 hours" = "12時間"; +"24 hours" = "24時間"; +"6 hours" = "6時間"; +"7 days" = "7日"; +"A Diskora scheduled scan is installed." = "Diskoraの定期スキャンがインストールされています。"; +"A snapshot of active macOS power requests. Requests can be legitimate; they do not explain every sleep or wake event." = "macOSの有効な電源要求のスナップショットです。要求は正常な動作による場合もあり、すべてのスリープやスリープ解除の原因を説明するものではありません。"; +"APFS volumes share container capacity. Capacity is shown for the startup volume once; I/O is per observed storage driver, including virtual devices when exposed." = "APFSボリュームはコンテナの容量を共有します。容量は起動ボリュームについて1回だけ表示します。I/Oは確認できたストレージドライバごとに表示し、取得可能な仮想デバイスも含みます。"; +"About Orven" = "Orvenについて"; +"About Orven" = "Orvenについて"; +"Appearance" = "外観"; +"Applications" = "アプリ"; +"Automatic alerts are not available." = "自動通知には対応していません。"; +"Available" = "利用可能"; +"Battery" = "バッテリー"; +"Battery & Thermal" = "バッテリーと熱状態"; +"CPU" = "CPU"; +"CPU & Memory" = "CPUとメモリ"; +"CPU • all cores" = "CPU・全コア"; +"CPU • one core" = "CPU・1コア基準"; +"Cancel" = "キャンセル"; +"Cancelled" = "キャンセル済み"; +"Change Timeline" = "変更タイムライン"; +"Charging" = "充電中"; +"Check again" = "再確認"; +"Check for Updates" = "アップデートを確認"; +"Cleanup" = "クリーンアップ"; +"Clear search" = "検索をクリア"; +"Close" = "閉じる"; +"Close details" = "詳細を閉じる"; +"Compare local snapshots around an install, update, or removal." = "インストール、更新、削除の前後でローカルのスナップショットを比較します。"; +"Compressor storage" = "圧縮データ用メモリ"; +"Continue to Orven" = "Orvenを使い始める"; +"Coverage" = "対象範囲"; +"Coverage is reduced until protected folders are readable." = "保護されたフォルダを読み取れるようになるまで、対象範囲は縮小されます。"; +"Critical" = "危機的"; +"Dark" = "ダーク"; +"Developer command" = "開発者向けコマンド"; +"Disable menu bar" = "メニューバーを無効にする"; +"Disabled by default. When enabled, menu bar and windows share one sampler. Background sampling runs every 5 seconds; Quit stops it." = "初期状態では無効です。有効にすると、メニューバーとウィンドウは1つのサンプラーを共有します。バックグラウンドでは5秒ごとにサンプリングし、終了すると停止します。"; +"Disk & Network" = "ディスクとネットワーク"; +"Disk I/O by device" = "デバイス別ディスクI/O"; +"Disk reads • observed drivers" = "ディスク読み取り・確認済みドライバ"; +"Disk writes • observed drivers" = "ディスク書き込み・確認済みドライバ"; +"Diskora and Changeora files remain untouched." = "DiskoraとChangeoraのファイルは変更しません。"; +"Evidence path" = "証拠のパス"; +"Export" = "書き出し"; +"External power" = "外部電源"; +"Failed" = "失敗"; +"Fair" = "やや高い"; +"Finish the active scan before checking for updates." = "実行中のスキャンを完了してから、アップデートを確認してください。"; +"First-launch approval" = "初回起動の許可"; +"Full" = "完全"; +"GPU utilization and memory: Unsupported" = "GPU使用率とメモリ:非対応"; +"History" = "履歴"; +"Home" = "ホーム"; +"If macOS blocks Orven, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper." = "macOSがOrvenをブロックした場合、一度開いてから「システム設定 → プライバシーとセキュリティ → このまま開く」を使用してください。Gatekeeperを無効にしないでください。"; +"Import and verify" = "読み込んで検証"; +"Inspect process" = "プロセスを確認"; +"Inspect what's running on your Mac. Monitoring is read-only." = "このMacで動作中のプロセスを確認します。モニタリングは読み取り専用です。"; +"Install Trace" = "インストール追跡"; +"Keep your existing history" = "既存の履歴を保持"; +"Language" = "言語"; +"Last sample" = "最新サンプル"; +"Legacy data was copied and verified." = "旧バージョンのデータをコピーして検証しました。"; +"Legacy schedule replaced after Orven bootstrap succeeded." = "Orvenのエージェント起動を確認し、旧スケジュールを置き換えました。"; +"Legacy scheduled scan" = "旧バージョンの定期スキャン"; +"Light" = "ライト"; +"Live monitoring" = "ライブモニタリング"; +"Local evidence for safer cleanup and app changes." = "安全なクリーンアップとアプリの変更確認に役立つローカルの証拠。"; +"Management" = "管理"; +"Memory" = "メモリ"; +"Memory pressure • last OS event" = "メモリプレッシャー・最後のOSイベント"; +"Memory • includes caches" = "メモリ・キャッシュを含む"; +"Menu bar indicator" = "メニューバーの表示"; +"Menu bar quick diagnostics" = "メニューバーの簡易診断"; +"Migration" = "移行"; +"Migration error" = "移行エラー"; +"Monitoring" = "モニタリング"; +"Name" = "名前"; +"Needs attention" = "要対応"; +"Network" = "ネットワーク"; +"Network received • physical" = "ネットワーク受信・物理"; +"Network sent • physical" = "ネットワーク送信・物理"; +"Next sample" = "次のサンプル"; +"No Diskora schedule needs migration." = "移行が必要なDiskoraのスケジュールはありません。"; +"No active requests in this snapshot" = "このスナップショットに有効な要求はありません"; +"No legacy data found." = "旧バージョンのデータはありません。"; +"No matching processes." = "一致するプロセスはありません。"; +"No process data available." = "プロセスデータを取得できません。"; +"No readable requests in this snapshot" = "このスナップショットに読み取れる要求はありません"; +"No samples yet." = "サンプルはまだありません。"; +"Nominal" = "正常"; +"Normal" = "通常"; +"Not selected or cleaned automatically." = "自動選択や自動削除は行いません。"; +"Observed at" = "取得時刻"; +"Observed parent PID: %d" = "確認した親プロセスのPID:%d"; +"Occupied memory is physical memory minus free pages and includes caches. It does not measure reclaimable RAM. Pressure is an OS event, not an inferred score." = "使用中のメモリは物理メモリから空きページを差し引いた値で、キャッシュを含みます。解放可能なRAMを示すものではありません。プレッシャーはOSイベントであり、推定スコアではありません。"; +"Occupied memory • includes caches" = "使用中のメモリ・キャッシュを含む"; +"On battery" = "バッテリー使用中"; +"Only when you click the button, Orven requests public release metadata from GitHub. No scan or device data is sent." = "ボタンを押したときのみ、OrvenはGitHubの公開リリース情報を取得します。スキャンやデバイスのデータは送信しません。"; +"Open Full Disk Access" = "フルディスクアクセスを開く"; +"Open Recovery" = "復元を開く"; +"Open installation guide" = "インストールガイドを開く"; +"Open storage" = "ストレージを開く"; +"Overview" = "概要"; +"Permission required" = "権限が必要"; +"Physical memory" = "物理メモリ"; +"Physical summary includes active en* interfaces. Tunnel and loopback counters are shown separately and are never added to that summary." = "物理インターフェイスの集計には有効なen*を含みます。トンネルとループバックは別に表示し、集計には加えません。"; +"Previous sample" = "前のサンプル"; +"Privacy and coverage" = "プライバシーと対象範囲"; +"Private by default" = "プライバシーを標準で保護"; +"Process" = "プロセス"; +"Process CPU • one core" = "プロセスCPU・1コア基準"; +"Process exited or identity changed." = "プロセスが終了したか、識別情報が変わりました。"; +"Process identity unavailable; ownership is unverified" = "プロセスの識別情報を取得できず、所有者は未確認です"; +"Processes" = "プロセス"; +"Projects" = "プロジェクト"; +"Quit Orven" = "Orvenを終了"; +"Read" = "読み取り"; +"Read failed" = "読み取り失敗"; +"Read-only" = "読み取り専用"; +"Read-only. Recent trends stay on this Mac and are not saved." = "読み取り専用。最近の推移はこのMacのメモリ内に保持し、保存しません。"; +"Read-only. Samples stay in memory for up to 60 seconds." = "読み取り専用。サンプルは最大60秒間メモリに保持されます。"; +"Reading sleep requests" = "スリープ要求を読み取り中"; +"Received" = "受信"; +"Recent CPU activity" = "最近のCPU使用状況"; +"Recover space safely" = "安全に空き容量を増やす"; +"Recoverable" = "復元可能"; +"Recovery" = "復元"; +"Reduced" = "縮小"; +"Refresh sleep requests" = "スリープ要求を更新"; +"Refresh to inspect current requests. No background polling." = "更新して現在の要求を確認してください。バックグラウンドでは読み取りません。"; +"Replace after verification" = "検証後に置き換える"; +"Replace the Diskora scheduled scan?" = "Diskoraの定期スキャンを置き換えますか?"; +"Replace with Orven schedule…" = "Orvenのスケジュールに置き換える…"; +"Resident memory" = "常駐メモリ"; +"Restore" = "復元"; +"Retry import" = "読み込みを再試行"; +"Revalidating process identity…" = "プロセスの識別情報を再確認中…"; +"Review Projects" = "プロジェクトを確認"; +"Review Storage" = "ストレージを確認"; +"Review in Applications" = "アプリで確認"; +"Review in Storage" = "ストレージで確認"; +"Review recoverable actions and restore without overwriting files." = "復元可能な操作を確認し、既存ファイルを上書きせずに復元します。"; +"Run every" = "実行間隔"; +"Runs locally. No telemetry. Nothing is deleted automatically." = "ローカルで動作。テレメトリなし。自動削除なし。"; +"Sampling paused" = "サンプリング一時停止中"; +"Sampling paused while the main window is inactive." = "メインウィンドウが非アクティブの間はサンプリングを一時停止します。"; +"Scan coverage" = "スキャンの対象範囲"; +"Scan storage" = "ストレージをスキャン"; +"Scans, snapshots, and activity stay on this Mac. Orven has no telemetry and never cleans automatically." = "スキャン、スナップショット、操作記録はこのMacに保持します。Orvenはテレメトリや自動クリーンアップを行いません。"; +"Search processes or PID" = "プロセス名またはPIDで検索"; +"Seconds before latest sample" = "最新サンプルからの経過秒数"; +"See reclaimable files before anything moves to Trash." = "ゴミ箱に移動する前に、空き容量を増やせるファイルを確認できます。"; +"Understand your Mac. Act with context." = "Macの今を知る。確かめて操作する。"; +"Select a process to inspect its verified identity and recent activity." = "プロセスを選択すると、検証済みの識別情報と最近の動作を確認できます。"; +"Sent" = "送信"; +"Sentinel folders are readable." = "確認用フォルダを読み取れます。"; +"Serious" = "深刻"; +"Settings" = "設定"; +"Settings error" = "設定エラー"; +"Show all processes" = "すべてのプロセスを表示"; +"Show in Finder" = "Finderで表示"; +"Show loopback, tunnels and other interfaces" = "ループバック、トンネル、その他のインターフェイスを表示"; +"Sleep Inspector" = "スリープ要求の確認"; +"Some requests could not be displayed" = "表示できなかった要求があります"; +"Sort by" = "並べ替え"; +"Stale" = "古いデータ"; +"Start a trace" = "追跡を開始"; +"Started" = "開始済み"; +"Startup volume available" = "起動ボリュームの空き容量"; +"Storage" = "ストレージ"; +"Succeeded" = "成功"; +"Swap used" = "スワップ使用量"; +"System" = "システムに合わせる"; +"System CPU • all cores" = "システムCPU・全コア"; +"Temperature, fan sensors and per-process energy: Unsupported" = "温度、ファンセンサー、プロセス別エネルギー:非対応"; +"Temporarily unavailable" = "一時的に利用不可"; +"The old agent remains untouched unless the new scan-only agent bootstraps successfully." = "新しいスキャン専用エージェントの起動に成功するまで、旧エージェントは変更しません。"; +"Thermal state" = "熱状態"; +"Thermal state is macOS's coarse system condition, not a temperature in °C. Macs without an internal battery report Unsupported." = "熱状態はmacOSが示す大まかなシステム状態であり、温度(°C)ではありません。内蔵バッテリーのないMacでは「非対応」と表示します。"; +"Thermal state is not a temperature in °C." = "熱状態は温度(°C)ではありません。"; +"Threads: %d" = "スレッド数:%d"; +"Orven %@ is available." = "Orven %@を利用できます。"; +"Orven first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds." = "Orvenは先にスキャン専用エージェントをインストールして起動します。成功した場合のみDiskoraのplistを削除します。"; +"Top CPU consumers • one core" = "CPU使用率の高いプロセス・1コア基準"; +"Unavailable" = "利用不可"; +"Understand app changes" = "アプリの変更を理解する"; +"Undo before regret" = "後悔する前に取り消す"; +"Unknown" = "不明"; +"Unsupported" = "非対応"; +"Updates" = "アップデート"; +"Used storage" = "使用済み容量"; +"Using the most CPU" = "CPU使用率の高いプロセス"; +"Warming up" = "初期サンプルを取得中"; +"Warning" = "警告"; +"Welcome to Orven" = "Orvenへようこそ"; +"Wired memory" = "固定メモリ"; +"Write" = "書き込み"; +"You are using the latest public release." = "最新の公開バージョンを使用しています。"; +"You can change these choices later in Settings." = "これらの設定は後から設定画面で変更できます。"; +"Your Mac, at a glance" = "Macの状態をひと目で"; +"Your Mac, explained." = "Macの状態をわかりやすく。"; + +/* Application inspection and management improvements. */ +"Search apps or Bundle ID" = "アプリ名またはBundle IDで検索"; +"Sort by name" = "名前順"; +"No matching applications" = "一致するアプリはありません"; +"App Dossier" = "アプリの詳細"; +"Bundle ID" = "Bundle ID"; +"Application path" = "アプリのパス"; +"Version" = "バージョン"; +"Minimum macOS" = "必要なmacOS"; +"Application bundle size" = "アプリバンドルのサイズ"; +"Related data observed" = "確認した関連データ"; +"Last application scan" = "最終アプリスキャン"; +"Sizes are from the last scan. Related data is attributed by scan evidence, not proof of exclusive ownership. Unreadable locations may be missing." = "サイズは最終スキャン時の値です。関連データはスキャンの証拠に基づくもので、専有の証明ではありません。読み取れない場所は含まれない場合があります。"; +"No related data found within scan coverage." = "スキャン範囲内で関連データは見つかりませんでした。"; +"Largest first" = "サイズの大きい順"; +"Application Support" = "アプリサポート"; +"Cache" = "キャッシュ"; +"Preference" = "設定"; +"Log" = "ログ"; +"Container" = "コンテナ"; +"LaunchAgent" = "LaunchAgent"; +"Login Item / Background Task" = "ログイン項目/バックグラウンドタスク"; +"Package receipt" = "パッケージ受領記録"; +"Clear filters or change the search query." = "フィルタを解除するか検索語を変更してください。"; +"Background-item evidence unavailable. The macOS helper did not complete; scan coverage is partial." = "バックグラウンド項目の証拠を取得できません。macOSのヘルパーが完了せず、スキャン範囲は一部のみです。"; +"Remove project %@" = "プロジェクト%@を外す"; +"%@ was identified from %@ markers and can be regenerated." = "%@は%@のマーカーから識別され、再生成できます。"; +"Found %d artifacts in %d projects." = "%d件の成果物を%d件のプロジェクトで検出しました。"; + +"Explain My Mac" = "Macの状態を確認"; +"Export diagnostics…" = "診断をエクスポート…"; +"Evidence from recent samples. These observations do not establish causality and do not change your Mac." = "最近のサンプルに基づく情報です。原因を断定するものではなく、Macの設定は変更しません。"; +"No matching findings in fresh, available samples. Missing data is not a health check." = "最新の利用可能なサンプルでは該当する所見がありません。データ不足は正常動作の確認にはなりません。"; +"Sampling is paused. Resume active monitoring to evaluate current evidence." = "サンプリングは一時停止中です。現在の状態を確認するには監視を再開してください。"; +"Review evidence" = "根拠を確認"; +"Memory pressure needs attention" = "メモリプレッシャーに注意"; +"macOS reports memory pressure. Occupied memory alone does not establish a problem." = "macOSがメモリプレッシャーを報告しています。使用メモリ量だけでは問題があるとは判断できません。"; +"Sustained CPU activity" = "CPU負荷が継続"; +"System CPU stayed at or above 85% for at least 30 seconds. Review the measured processes before attributing a cause." = "システムCPU使用率が30秒以上、85%以上でした。原因を判断する前に計測済みプロセスを確認してください。"; +"Startup volume is nearly full" = "起動ボリュームの空き容量が少ない"; +"Available space is below 10% of reported capacity. APFS volumes share container space; inspect coverage and snapshots before cleanup." = "利用可能容量が報告容量の10%未満です。APFSボリュームはコンテナを共有するため、整理前に確認範囲とスナップショットを確認してください。"; +"Elevated thermal state" = "温度に関するシステム状態が上昇"; +"macOS reports thermal pressure. This is an OS state, not a temperature measurement or proof of a particular app's impact." = "macOSが温度に関する負荷を報告しています。これはOSの状態であり、温度の実測値や特定アプリの影響を示す証拠ではありません。"; +"Local Diagnostics Export" = "ローカル診断のエクスポート"; +"Preview the exact JSON before saving. Paths, username, hostname and network endpoints are excluded. Nothing is uploaded." = "保存するJSONを事前に確認できます。パス、ユーザー名、ホスト名、ネットワーク接続先は含まれません。アップロードは行いません。"; +"Include process names" = "プロセス名を含める"; +"Refresh preview" = "プレビューを更新"; +"Save JSON…" = "JSONを保存…"; +"Diagnostics saved." = "診断を保存しました。"; +"Could not save diagnostics. Choose a writable location and try again." = "診断を保存できませんでした。書き込み可能な場所を選んで再試行してください。"; +"Could not generate diagnostics from the current sample. Refresh and try again." = "現在のサンプルから診断を作成できませんでした。更新して再試行してください。"; +"Network Inspector" = "ネットワーク接続の確認"; +"Refresh connections" = "接続を更新"; +"Explicit TCP/UDP socket snapshot from lsof. Restricted processes and short-lived connections may be missing. No traffic history, domain attribution, byte totals or firewall control." = "lsofから取得するTCP/UDPソケットのスナップショットです。アクセス制限のあるプロセスや短時間の接続は含まれない場合があります。通信履歴、ドメインの特定、通信量合計、ファイアウォール制御はありません。"; +"Search process, PID or endpoint" = "プロセス・PID・接続先を検索"; +"Connection inspection failed. Refresh to try again; coverage remains unknown." = "接続を確認できませんでした。更新して再試行してください。確認範囲は不明です。"; +"%d observed sockets; %d omitted by the display cap." = "確認済みソケット:%d件。表示上限により省略:%d件。"; +"Protocol" = "プロトコル"; +"Endpoint" = "接続先"; +"State" = "状態"; +"Details" = "詳細"; +"Only a revalidated PID/start identity can be inspected." = "PIDと開始時刻の同一性を再確認したプロセスのみ詳細を開けます。"; +"No matching sockets within observable coverage. This does not prove the Mac has no connections." = "確認可能な範囲に一致するソケットはありません。Macに接続がないことを示すものではありません。"; +"Refresh to inspect current connections." = "現在の接続を確認するには更新してください。"; +"Hidden Space & Snapshots" = "未確認容量とスナップショット"; +"Inspect APFS space" = "APFS容量を確認"; +"Startup Data volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." = "対象は起動ディスクのDataボリュームです。APFSコンテナ容量はボリューム間で共有され、フォルダの計測値、空き容量、スナップショット容量は異なる指標です。"; +"APFS inspection failed or is unsupported. Refresh to try again." = "APFSを確認できないか、対応していません。更新して再試行してください。"; +"APFS container capacity" = "APFSコンテナ容量"; +"APFS container free" = "APFSコンテナの空き容量"; +"Data volume used" = "Dataボリュームの使用容量"; +"Unaccounted space is not measured here. It may include unreadable files, other volumes, filesystem metadata, clones or snapshots; Orven does not attribute the remainder to one cause." = "未確認の容量はここでは計測していません。読み取り不可のファイル、他のボリューム、ファイルシステム情報、クローン、スナップショットなどが含まれ得ます。残りの容量を単一の原因に帰属させません。"; +"APFS snapshots" = "APFSスナップショット"; +"No APFS snapshots reported for the Data volume. Other volumes may have snapshots." = "DataボリュームにAPFSスナップショットは報告されていません。他のボリュームには存在する可能性があります。"; +"OS marks this snapshot purgeable." = "OSはこのスナップショットを削除可能と報告しています。"; +"OS does not mark this snapshot purgeable." = "OSはこのスナップショットを削除可能と報告していません。"; +"Snapshot purgeability unavailable." = "スナップショットの削除可否は不明です。"; +"%d snapshot entries omitted." = "スナップショットを%d件省略しました。"; +"Exclusive snapshot bytes are unavailable from this inspection. Snapshot count does not measure reclaimable space. No snapshots are created, mounted, thinned or deleted." = "この確認ではスナップショット固有の容量は取得できません。件数から回収可能容量は判断できません。作成、マウント、間引き、削除は行いません。"; +"Inspect to read container space and Data volume snapshots." = "コンテナ容量とDataボリュームのスナップショットを読み取るには確認を実行してください。"; +"CPU scheduling" = "CPUスケジューリング"; +"Lower priority raises nice to at least 10 for this same-user process. It is not a CPU percentage cap. Raising priority back may require privileges; relaunch the app to reset. Child processes are not individually controlled." = "同じユーザーのプロセスのnice値を10以上にして優先度を下げます。CPU使用率の上限ではありません。優先度を戻すには権限が必要な場合があります。アプリを再起動するとリセットできます。子プロセスは個別に制御しません。"; +"Lower process priority…" = "プロセスの優先度を下げる…"; +"Lower process priority?" = "プロセスの優先度を下げますか?"; +"Lower priority" = "優先度を下げる"; +"Process priority lowered and rechecked. This does not guarantee lower CPU usage." = "プロセスの優先度を下げ、再確認しました。CPU使用率が下がることを保証するものではありません。"; +"Priority change could not be verified. The process may have exited, changed identity or denied access. No retry is automatic." = "優先度の変更を確認できませんでした。プロセスが終了したか、同一性が変わったか、アクセスが拒否された可能性があります。自動再試行は行いません。"; +"This changes scheduling for the selected process. Restoring the previous priority is not guaranteed without relaunching it." = "選択したプロセスのスケジューリングを変更します。再起動せずに元の優先度へ戻せるとは限りません。"; +"Storage Growth Detective" = "容量の増減を確認"; +"Baseline saved. Scan the same location again to compare growth." = "基準を保存しました。同じ場所を再スキャンすると増減を比較できます。"; +"Scans are not comparable: scope, identity, coverage or checkpoint format differs. A new baseline was saved." = "範囲、同一性、確認範囲、保存形式のいずれかが異なるため比較できません。新しい基準を保存しました。"; +"Comparable observed folder changes. Decreases do not prove APFS space was freed; changes do not identify their cause." = "比較可能なスキャンで確認したフォルダの増減です。減少はAPFS容量の解放を証明せず、変化の原因も特定しません。"; +"No comparable folder deltas to display." = "表示できる比較可能なフォルダ差分はありません。"; +"Developer Workspace" = "開発ワークスペース"; +"Recognized projects within selected roots. Manifest names are evidence of an ecosystem, not proof that its runtime is installed. Source size and runtime versions are not measured." = "選択したルート内で識別したプロジェクトです。マニフェスト名は環境の手掛かりであり、ランタイムのインストールを証明しません。ソース容量とランタイムのバージョンは計測していません。"; +"%d scan issues; results may be partial." = "スキャン中の問題:%d件。結果は一部のみの場合があります。"; +"%d recognized artifacts; size is observed artifact data only." = "識別済み成果物:%d件。容量は確認済み成果物のみです。"; +"No recognized project manifests within scan coverage." = "スキャン範囲内に識別可能なプロジェクトのマニフェストはありません。"; +"Only the first 100 projects are displayed. Artifact filters remain available below." = "最初の100プロジェクトのみ表示しています。成果物の絞り込みは下で利用できます。"; +"App Activity Timeline" = "アプリの最近の動作"; +"Recent in-memory CPU trends for running processes mapped by bundle path and launch identity. Helper processes, older activity and unverified ownership may be missing. Related-data sizes remain scan-time observations." = "バンドルパスと起動情報で対応付けた実行中プロセスの最近のCPU推移です。メモリ内のみ保持し、補助プロセス、過去の動作、未確認の所属は含まれない場合があります。関連データの容量はスキャン時の値です。"; +"No running process could be mapped within current coverage." = "現在の確認範囲では実行中プロセスを対応付けられませんでした。"; +"Inspect app sleep requests" = "アプリのスリープ阻止要求を確認"; +"No sleep requests attributed to mapped processes within this snapshot's coverage." = "このスナップショットの範囲内では、対応付けたプロセスにスリープ阻止要求がありません。"; +"Some assertion entries were omitted; coverage is partial." = "一部の要求を省略したため、確認範囲は不完全です。"; +"warning" = "警告"; +"critical" = "重大"; +"serious" = "高い"; + +"%@, PID %d, %@, %@, %@" = "%@、PID %d、%@、%@、%@"; +"Measure workspace" = "ワークスペースを計測"; +"Observed workspace size" = "確認済みワークスペース容量"; +"Other observed project data" = "その他の確認済みプロジェクトデータ"; + +"Startup volume" = "起動ボリューム"; +"Data volume" = "Dataボリューム"; +"System volume" = "Systemボリューム"; +"Selected volume used" = "選択ボリュームの使用容量"; +"Selected startup volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." = "選択した起動ボリュームが対象です。APFSコンテナ容量はボリューム間で共有され、フォルダの計測値、空き容量、スナップショット容量は異なる指標です。"; +"No APFS snapshots reported for the selected volume. Other volumes may have snapshots." = "選択ボリュームにAPFSスナップショットは報告されていません。他のボリュームには存在する可能性があります。"; +"Inspect to read container space and selected-volume snapshots." = "コンテナ容量と選択ボリュームのスナップショットを読み取るには確認を実行してください。"; + +"Stop" = "停止"; + +"Inspect selected process" = "選択したプロセスを確認"; + +"Volumes sharing this container" = "同じコンテナを共有するボリューム"; +"Unattributed container allocation" = "ボリュームに帰属しないコンテナ割当量"; +"Container remainder uses OS-reported capacity minus free space and listed volume usage. It is not hidden-file size or exclusive snapshot space. Incomplete or inconsistent accounting stays unknown." = "OSが報告するコンテナ容量から空き容量と一覧のボリューム使用量を引いた値です。隠しファイル容量やスナップショット固有容量ではありません。情報不足や不整合がある場合は不明と表示します。"; +"%d volume entries omitted." = "ボリュームを%d件省略しました。"; + +"Preview the exact JSON before saving. No dedicated path, username, hostname or network endpoint fields are included. Opt-in process names may contain identifying text. Nothing is uploaded." = "保存するJSONを事前に確認できます。パス、ユーザー名、ホスト名、ネットワーク接続先の専用項目は含まれません。プロセス名を含める場合、名前自体に識別情報が含まれる可能性があります。アップロードは行いません。"; + +"Hardware Inspector" = "ハードウェア情報"; +"Hardware profile" = "ハードウェア構成"; +"Hardware report" = "ハードウェアレポート"; +"Refresh hardware" = "構成を再取得"; +"Export hardware report…" = "ハードウェアレポートを書き出す…"; +"Reading hardware profile…" = "ハードウェア構成を取得中…"; +"Model identifier" = "モデル識別子"; +"Operating system" = "オペレーティングシステム"; +"Processor" = "プロセッサ"; +"Physical cores" = "物理コア数"; +"Logical processors" = "論理プロセッサ数"; +"Reported nominal frequency" = "OSが報告する公称周波数"; +"Page size" = "ページサイズ"; +"Cache line size" = "キャッシュラインサイズ"; +"L1 instruction cache" = "L1命令キャッシュ"; +"L1 data cache" = "L1データキャッシュ"; +"L2 cache" = "L2キャッシュ"; +"L3 cache" = "L3キャッシュ"; +"Performance level" = "性能グループ"; +"CPU level %d" = "CPUグループ %d"; +"Cores sharing L2" = "L2を共有するコア数"; +"Metal device" = "Metalデバイス"; +"Unified memory" = "ユニファイドメモリ"; +"Recommended GPU working set" = "GPU推奨ワーキングセット"; +"Performance" = "高性能"; +"Efficiency" = "高効率"; +"Yes" = "はい"; +"No" = "いいえ"; +"Recent sensor range" = "直近の測定範囲"; +"Reset range" = "測定範囲をリセット"; +"Sensor" = "測定項目"; +"Current" = "現在値"; +"Minimum" = "最小値"; +"Maximum" = "最大値"; +"Samples" = "測定数"; +"Disk read" = "ディスク読み取り"; +"Disk write" = "ディスク書き込み"; +"Network receive" = "ネットワーク受信"; +"Network send" = "ネットワーク送信"; +"Cache sizes are OS-reported values, not summed totals. Nominal frequency is not a live core clock. The GPU working set is a recommendation, not VRAM capacity or current usage." = "キャッシュサイズはOSの報告値で、合計値ではありません。公称周波数は各コアの現在のクロックではありません。GPUワーキングセットは推奨値で、VRAM容量や現在の使用量ではありません。"; +"Min/max covers available observations in the latest sampling generation, up to 60 seconds. Missing readings are gaps. Reset affects this range only; it does not clear monitoring history." = "最小値・最大値は、最新の収集世代内で取得できた最大60秒の測定が対象です。欠測値は除外します。リセットはこの測定範囲のみに適用され、監視履歴は消去しません。"; +"Sensor coverage: temperatures in °C, fan RPM, voltage, package power, live core clocks, SPD timings and SMART health are not collected. Thermal state is the macOS condition, not a temperature. No fan control, overclocking or stress test is performed." = "測定範囲:温度(°C)、ファン回転数、電圧、パッケージ電力、リアルタイムのコアクロック、SPDタイミング、SMART健全性は収集しません。熱状態はmacOSの状態であり、温度ではありません。ファン制御、オーバークロック、負荷テストは実行しません。"; + +"Preview the hardware profile and recent range before saving. No serial, hostname, paths, process names or network endpoints are included. Nothing is uploaded." = "保存前にハードウェア構成と直近の測定範囲を確認してください。Serial、hostname、path、process名、network endpointは含めず、uploadもしません。"; + +"Hardware architecture" = "ハードウェアアーキテクチャ"; + +"System information" = "システム情報"; + +"Temperature & Fans" = "温度とファン"; +"CPU sensors" = "CPUセンサー"; +"GPU sensors" = "GPUセンサー"; +"Fans" = "ファン"; +"Battery sensors" = "バッテリーセンサー"; +"System power" = "システム電力"; +"CPU • hottest mapped sensor" = "CPU・対応表上のセンサー最高温度"; +"GPU • hottest mapped sensor" = "GPU・対応表上のセンサー最高温度"; +"SMC reports no fans. This is not a fan-speed measurement." = "SMCはファン数0を報告しています。回転数を測定した結果ではありません。"; +"Fan count unavailable; no fan speeds inferred." = "ファン数を取得できません。回転数は推定しません。"; +"Fan coverage is capped at 8 reported fans." = "表示対象はSMCが報告する最大8台のファンです。"; +"Read-only SMC sensors refresh every 10 seconds while monitoring runs. Coverage is limited and model-dependent. CPU/GPU labels use community mappings, not verified physical core IDs. Missing readings are never zero." = "監視中はSMCセンサーを10秒ごとに読み取ります。取得範囲は限定的で、モデルによって異なります。CPU/GPUの分類はコミュニティの対応表に基づき、検証済みの物理コアIDではありません。欠測値は0として扱いません。"; +"Temperatures are individual mapped readings, not CPU/GPU package averages or a safety diagnosis. Fan speeds are observed RPM only; Orven does not change fan modes or targets. Voltage, live clocks, SPD and SMART are not collected." = "温度は対応表に基づく個別センサー値で、CPU/GPUパッケージ平均や安全診断ではありません。ファンは取得した回転数のみ表示し、モードや目標値は変更しません。電圧、リアルタイムクロック、SPD、SMARTは収集しません。"; +"SMC temperature, fan and power coverage is shown above. Thermal state remains a separate macOS condition. Voltage, live core clocks, SPD timings, SMART health, fan control, overclocking and stress tests are not provided." = "SMC温度・ファン・電力の取得範囲は上記のとおりです。熱状態は別のmacOS状態です。電圧、リアルタイムコアクロック、SPDタイミング、SMART健全性、ファン制御、オーバークロック、負荷テストは提供しません。"; + +"Review temperatures and fans" = "温度とファンを確認"; +"Per-process energy: Unsupported" = "プロセス別エネルギー:未対応"; + +/* Health history, alerts, recording and connection changes. */ +"Health History & Baseline" = "ヘルス履歴と基準値"; +"Record health history locally" = "ヘルス履歴をこのMacに保存"; +"One observed sample per minute, up to seven days. No process names, paths or network endpoints. Paused, sleep and missing samples are gaps; enabling history does not enable background monitoring." = "毎分1件の観測を最大7日間保存します。プロセス名、パス、接続先は保存しません。一時停止、スリープ、欠測は空白として扱います。履歴を有効にしてもバックグラウンド監視は有効になりません。"; +"History retention" = "履歴の保存期間"; +"%d recorded observations" = "保存済みの観測:%d件"; +"Time range" = "表示期間"; +"1 hour" = "1時間"; +"Export history JSON…" = "履歴をJSONで書き出す…"; +"Baseline comparison" = "基準値との比較"; +"Capture baseline" = "基準値を保存"; +"Capture the latest 30–60 recorded samples as a reference. Compare medians with at least ten newer samples per metric. Workload differences can explain changes; this is not causal diagnosis." = "直近30~60件を基準として保存します。各指標に新しい観測が10件以上あれば中央値を比較します。作業内容の違いでも値は変わるため、原因を診断するものではありません。"; +"Not enough baseline or newer observations yet. Keep recording during comparable workloads." = "基準または新しい観測が不足しています。同程度の作業中に記録を続けてください。"; +"Baseline median" = "基準の中央値"; +"Recent median" = "最近の中央値"; +"Chart metric" = "グラフの指標"; +"Occupied memory" = "使用中のメモリ"; +"Temperature labels use community mappings; missing readings are gaps. Fan RPM and system power are observations, not fan control or CPU package power." = "温度ラベルはコミュニティの対応表に基づき、欠測は空白になります。ファン回転数とシステム電力は観測値で、ファン制御やCPUパッケージ電力ではありません。"; +"Sensor Charts & Recording" = "センサーグラフと記録"; +"Record existing samples every ten seconds for 15, 30 or 60 minutes. Sleep extends the wall-clock duration. The session stays in memory until Quit. Sampling pauses when the app is inactive unless menu bar monitoring is enabled; no readings are backfilled." = "既存の観測を10秒ごとに15・30・60分間記録します。スリープ中は実際の終了時刻が遅くなります。記録は終了するまでメモリ内に保持します。メニューバー監視が無効な場合、アプリ非アクティブ時は採取を停止し、欠測は補完しません。"; +"Recording duration" = "記録時間"; +"15 minutes" = "15分"; +"30 minutes" = "30分"; +"60 minutes" = "60分"; +"Start recording" = "記録を開始"; +"Stop recording" = "記録を停止"; +"Recording observed samples" = "観測を記録中"; +"Recording stopped" = "記録停止中"; +"Recording paused until monitoring resumes" = "監視が再開するまで記録を一時停止"; +"Export session JSON…" = "記録をJSONで書き出す…"; +"Export session CSV…" = "記録をCSVで書き出す…"; +"Smart Alerts" = "ヘルス通知"; +"Enable health alerts" = "ヘルス通知を有効にする"; +"Off by default. Fresh active evidence only: sustained CPU, memory pressure, low startup space and elevated thermal state. A recovered condition must stay clear for 30 seconds; repeat alerts wait at least 15 minutes. Nothing is changed automatically." = "初期設定では無効です。アクティブ監視中の新しい証拠だけを使います:CPU高負荷の継続、メモリプレッシャー、起動ディスクの空き不足、温度状態の上昇。回復状態が30秒続き、前回から15分以上経過した場合に再通知します。設定は自動変更しません。"; +"Allow macOS notifications…" = "macOSの通知を許可…"; +"Snooze for one hour" = "1時間通知を止める"; +"No alert events in this app session." = "この起動セッションには通知履歴がありません。"; +"Recent pressure and thermal observations" = "最近のメモリプレッシャーと温度状態"; +"History could not be read. The existing file is preserved; recording to disk is blocked." = "履歴を読み込めません。既存ファイルを保持し、ディスクへの記録を停止しています。"; +"History could not be saved. Existing data is preserved; check the local storage location." = "履歴を保存できません。既存データを保持しています。保存先を確認してください。"; +"Baseline needs at least 30 recorded samples and writable local storage." = "基準値には30件以上の記録と書き込み可能な保存先が必要です。"; +"History retention could not be saved. Existing data is preserved." = "保存期間を変更できません。既存データを保持しています。"; +"Notifications require the Orven app bundle." = "通知にはOrvenアプリからの実行が必要です。"; +"Notification permission granted." = "通知が許可されています。"; +"Notifications are not allowed. In-app alerts remain available." = "通知は許可されていません。アプリ内の通知履歴は利用できます。"; +"Notification permission could not be requested. In-app alerts remain available." = "通知の許可を要求できません。アプリ内の通知履歴は利用できます。"; +"Alerts snoozed for one hour." = "通知を1時間停止しました。"; +"Notification delivery failed. Review the in-app alert evidence." = "通知を配信できません。アプリ内で証拠を確認してください。"; +"Network Connection Changes" = "ネットワーク接続の変化"; +"Track observed connection changes this session" = "この起動セッションで接続の変化を記録"; +"Explicit Refresh compares observable sockets with verified PID/start identities. New or no-longer-observed does not mean opened or closed at this time. Short connections and restricted processes may be missed. Process names and numeric endpoints stay in memory, capped at 200 events; nothing is uploaded or saved to disk." = "更新時に、確認済みPID・開始時刻を持つ観測可能なソケットを比較します。新規観測や未観測はその時点の開閉を意味しません。短時間の接続や権限制限のあるプロセスは見落とす場合があります。プロセス名と数値接続先は最大200件をメモリ内だけに保持し、送信・ディスク保存しません。"; +"Connection baseline established" = "接続の基準を取得しました"; +"Refresh to establish a connection baseline" = "更新して接続の基準を取得してください"; +"%d unverified sockets excluded; %d older events omitted." = "未確認ソケット%d件を除外、古いイベント%d件を省略。"; +"Clear connection history" = "接続履歴を消去"; +"No connection changes observed yet." = "接続の変化はまだ観測されていません。"; +"Showing the latest 50 change events." = "最新50件の変化を表示しています。"; +"Newly observed" = "新たに観測"; +"No longer observed" = "今回は未観測"; +"State changed" = "状態の変化"; +"Local report export" = "ローカルレポートの書き出し"; +"Preview the exact local report before saving. Nothing is uploaded." = "保存する内容を確認してください。データは送信しません。"; +"Save report…" = "レポートを保存…"; +"normal" = "正常"; +"nominal" = "正常"; +"fair" = "やや高い"; +"unknown" = "不明"; + +"Change" = "変化"; +"Memory pressure" = "メモリプレッシャー"; +"System CPU" = "システムCPU"; + +"%d older observations omitted by the file-size budget." = "ファイル容量上限により古い観測%d件を省略。"; + +"Health History" = "ヘルス履歴"; + +"Could not prepare the local report. Stop recording and try again." = "レポートを準備できません。記録を停止して再試行してください。"; +"Waiting for macOS notification permission. Respond to the system prompt if it appears." = "macOSの通知許可を待っています。システムの確認が表示されたら応答してください。"; + +"This report is frozen when opened. Close and reopen for newer observations. Preview the exact content before saving; nothing is uploaded." = "開いた時点の内容を保持しています。新しい観測には閉じて開き直してください。保存内容を確認でき、データは送信しません。"; +"Report saved." = "レポートを保存しました。"; +"Could not save the report. Choose a writable location and try again." = "レポートを保存できません。書き込み可能な保存先で再試行してください。"; + +/* Wi-Fi and LAN tools */ +"Wi-Fi & LAN" = "Wi-Fi・LAN"; +"Network Overview" = "ネットワーク概要"; +"Wi-Fi Inspector" = "Wi-Fi の詳細"; +"Ethernet Inspector" = "Ethernet の詳細"; +"Connection Diagnostics" = "接続診断"; +"Latency & Stability" = "遅延と安定性"; +"Nearby Wi-Fi" = "周辺の Wi-Fi"; +"Network Change Timeline" = "ネットワークの変更履歴"; +"Network tool" = "ネットワークツール"; +"Refresh network status" = "ネットワーク状態を更新"; +"Last network observation" = "最終観測時刻"; +"Path interfaces" = "経路のインターフェース"; +"Low Data Mode" = "省データモード"; +"Expensive path" = "従量課金の可能性がある経路"; +"An available path does not prove that an Internet destination is reachable." = "利用可能な経路があっても、インターネット上の接続先に到達できるとは限りません。"; +"Primary interface" = "プライマリインターフェース"; +"IPv4 gateway" = "IPv4 ゲートウェイ"; +"IPv6 gateway" = "IPv6 ゲートウェイ"; +"DNS servers" = "DNS サーバー"; +"No matching network adapter was observed." = "該当するネットワークアダプターは観測されませんでした。"; +"IP addresses" = "IP アドレス"; +"MTU" = "MTU"; +"Negotiated media" = "ネゴシエーションされた接続方式"; +"Received / sent bytes" = "受信/送信バイト"; +"Input / output packets" = "受信/送信パケット"; +"Input / output errors" = "受信/送信エラー"; +"Input queue drops" = "受信キューの破棄パケット"; +"Adapter counters are cumulative observations, not session traffic or Internet speed. Packet/error counters may wrap. Live rates reuse the existing sampler." = "アダプターのカウンターは累積値です。セッション通信量やインターネット速度ではありません。パケットとエラーのカウンターは周回する場合があります。現在の転送速度は既存のサンプラーを使用します。"; +"%d adapters omitted by the display limit." = "表示上限により %d 個のアダプターを省略しました。"; +"Refresh to inspect Wi-Fi and LAN." = "更新して Wi-Fi と LAN を確認してください。"; +"SSID and BSSID can be hidden by macOS location permissions. Missing identity does not mean Wi-Fi is disconnected." = "macOS の位置情報権限により SSID と BSSID が非表示になる場合があります。識別情報がないことは、Wi-Fi の切断を意味しません。"; +"Request Wi-Fi identity permission" = "Wi-Fi 識別情報のアクセスを要求"; +"No Wi-Fi interface was observed." = "Wi-Fi インターフェースは観測されませんでした。"; +"Unavailable or permission restricted" = "取得不可、または権限による制限"; +"SSID" = "SSID"; +"BSSID" = "BSSID"; +"RSSI" = "RSSI"; +"Noise" = "ノイズ"; +"SNR" = "SNR"; +"Channel" = "チャンネル"; +"Band" = "周波数帯"; +"Channel width" = "チャンネル幅"; +"Transmit link rate" = "送信リンク速度"; +"CoreWLAN reports local radio observations. Link rate is not measured Internet throughput; SNR is RSSI minus noise from this observation." = "CoreWLAN はローカルの無線観測値を提供します。リンク速度は実測のインターネット通信速度ではありません。SNR は今回の RSSI とノイズの差です。"; +"Destination hostname or IP address" = "接続先のホスト名または IP アドレス"; +"Example: example.com or 192.168.1.1" = "例:example.com または 192.168.1.1"; +"Measurements send traffic to the displayed destination. Connection diagnostics also tests the recently observed gateway, resolves the destination and opens TCP port 443. No payload or speed-test download is sent." = "測定では表示中の接続先に通信します。接続診断は直近のゲートウェイ、名前解決、TCP ポート 443 への接続も確認します。ペイロードの送信や速度測定用のダウンロードは行いません。"; +"Check connection" = "接続を確認"; +"Measure 20 ICMP probes" = "ICMP を 20 回測定"; +"Refresh network status first to include a gateway observed within 60 seconds. Each stage is bounded; total connection check is at most 30 seconds." = "ゲートウェイを含めるには、先にネットワーク状態を更新してください。60 秒以内の観測を使用します。各段階に制限があり、全体は最大 30 秒です。"; +"Destination" = "接続先"; +"Elapsed" = "経過時間"; +"ICMP may be blocked while Internet access works. DNS results may be cached. A successful TCP connection does not verify HTTP, TLS, captive portals or every application." = "インターネットが利用できても ICMP が遮断される場合があります。DNS 結果はキャッシュされている場合があります。TCP 接続の成功は HTTP、TLS、認証ポータルやすべてのアプリの動作を保証しません。"; +"20 probes, one second apart, at most 28 seconds. Loss uses the ICMP summary; jitter is the mean absolute difference between adjacent successful replies. Timeouts break the chart." = "1 秒間隔で 20 回、最大 28 秒測定します。損失率は ICMP の集計値を使用します。ジッターは連続して成功した応答の差の絶対値の平均です。タイムアウトはグラフの欠測になります。"; +"Mean latency" = "平均遅延"; +"Jitter" = "ジッター"; +"Packet loss" = "パケット損失率"; +"ICMP did not complete successfully; missing results are unknown or blocked." = "ICMP は正常に完了しませんでした。欠測の原因は不明、または遮断の可能性があります。"; +"ICMP round-trip latency" = "ICMP 往復遅延"; +"Scan nearby Wi-Fi" = "周辺の Wi-Fi をスキャン"; +"Scan is explicit and controlled by macOS. Stop suppresses results after the OS scan returns; it cannot interrupt CoreWLAN. Identity may require location permission. Results stay in this window only." = "スキャンは操作時のみ実行し、macOS が制御します。停止すると OS の応答後に結果を破棄しますが、CoreWLAN 自体は中断できません。識別情報には位置情報権限が必要な場合があります。結果はこのウインドウ内のみで保持します。"; +"Filter SSID, BSSID or channel" = "SSID、BSSID、チャンネルで絞り込む"; +"%d visible networks; %d omitted." = "%d 件を表示中、%d 件を省略しました。"; +"No matching Wi-Fi network was observed." = "該当する Wi-Fi ネットワークは観測されませんでした。"; +"Hidden or permission restricted" = "非表示、または権限による制限"; +"Channel / band" = "チャンネル/周波数帯"; +"RSSI / noise" = "RSSI/ノイズ"; +"Observed networks on this channel" = "同じチャンネルで観測されたネットワーク"; +"Channel counts describe this scan, not airtime utilization or interference. Different bands can reuse channel numbers; wider channels can overlap. Signal bars are a relative RSSI display." = "チャンネル数は今回のスキャン結果であり、通信時間の使用率や干渉を示しません。異なる周波数帯で同じ番号が使われ、広いチャンネルは重なる場合があります。バーは RSSI の相対表示です。"; +"Track network changes in this window" = "このウインドウでネットワーク変更を記録"; +"Clear network changes" = "ネットワーク変更履歴を消去"; +"Path events are observed while this page is open. IP, gateway, DNS and Wi-Fi identity changes compare explicit successful refreshes within ten minutes. Missing identities and adapter disappearance are not inferred as changes. First refresh establishes a baseline. No history is written to disk." = "このページを開いている間に経路イベントを観測します。IP、ゲートウェイ、DNS、Wi-Fi 識別情報は 10 分以内の明示的な更新を比較します。識別情報の欠測やアダプターの消失から変更を推測しません。初回更新は基準値となります。履歴はディスクに保存しません。"; +"%d status changes and %d path events; %d older events omitted." = "状態変更 %d 件、経路イベント %d 件。古いイベント %d 件を省略しました。"; +"No network changes observed in this session." = "このセッションではネットワーク変更を観測していません。"; +"Before" = "変更前"; +"After" = "変更後"; +"Reading network status" = "ネットワーク状態を取得中"; +"Scanning Wi-Fi" = "Wi-Fi をスキャン中"; +"Measuring latency" = "遅延を測定中"; +"Checking connection" = "接続を確認中"; +"Stopping after the macOS Wi-Fi scan returns." = "macOS のスキャン応答後に停止します。"; +"Stopping measurement." = "測定を停止中。"; +"Enter a hostname or IP address without a URL, port or spaces." = "URL、ポート、空白を含めず、ホスト名または IP アドレスを入力してください。"; +"Observation completed." = "観測が完了しました。"; +"Cancelled. Previous results remain timestamped." = "キャンセルしました。以前の結果は観測時刻付きで保持します。"; +"Network observation failed or timed out. Check permissions and retry." = "観測に失敗したか、タイムアウトしました。権限を確認して再試行してください。"; +"Not requested" = "未要求"; +"Authorized" = "許可済み"; +"Denied" = "拒否"; +"Restricted" = "制限あり"; +"Available path" = "利用可能な経路あり"; +"Connection required" = "接続が必要"; +"No available path" = "利用可能な経路なし"; +"Gateway ICMP" = "ゲートウェイへの ICMP"; +"DNS resolution" = "DNS 名前解決"; +"TCP connection" = "TCP 接続"; +"Failed or blocked" = "失敗、または遮断"; +"Timed out or unavailable" = "タイムアウト、または取得不可"; +"Link state" = "リンク状態"; +"Ethernet" = "Ethernet"; +"Other" = "その他"; +"Global DNS configuration is shown; scoped VPN and per-domain resolvers may differ." = "グローバルの DNS 設定を表示します。VPN やドメイン別のリゾルバーは異なる場合があります。"; +"Observed networks by channel" = "チャンネル別の観測ネットワーク数"; +"Wi-Fi" = "Wi-Fi"; +"Not applicable to a numeric IP" = "IP アドレスには適用されません"; +"Active" = "有効"; +"Inactive" = "無効"; +"Carrier state" = "キャリア状態"; +"Link detected" = "リンクを検出"; +"No link detected" = "リンク未検出"; +"Up / running" = "Up/running"; +"Down / not running" = "Down/not running"; +"Interface flags" = "インターフェースのフラグ"; diff --git a/apps/toolbox/Resources/ja.lproj/Storage.strings b/apps/toolbox/Resources/ja.lproj/Storage.strings new file mode 100644 index 0000000..63b3596 --- /dev/null +++ b/apps/toolbox/Resources/ja.lproj/Storage.strings @@ -0,0 +1,270 @@ +/* Orven ja localization. */ +"%lld bản giống nhau • %@/tệp" = "同一コピー%lld件・1ファイル%@"; +"%lld tệp (%@) sẽ được chuyển vào Trash. Một bản mới nhất trong mỗi nhóm luôn được giữ lại." = "%lldファイル(%@)をゴミ箱に移動します。各グループの最新コピーを必ず保持します。"; +"%lld ảnh • tương đồng %lld%%" = "%lld枚・類似度%lld%%"; +"%lld×%lld • nét %@" = "%lld×%lld・鮮明度%@"; +"12 giờ" = "12時間"; +"24 giờ" = "24時間"; +"6 giờ" = "6時間"; +"7 ngày" = "7日"; +"An toàn" = "安全"; +"App chưa tìm thấy file cấu hình dự án tham chiếu phiên bản này. Việc quét không thể đảm bảo bao phủ mọi dự án, hãy kiểm tra trước khi tiếp tục." = "このバージョンを参照するプロジェクト設定は見つかりませんでした。すべてのプロジェクトを確認できるわけではないため、続行前に確認してください。"; +"App chỉ hash các tệp từ 1 MB và bỏ qua package ứng dụng, Trash cùng cache hệ thống." = "1 MB以上のファイルのみハッシュを取得します。アプリバンドル、ゴミ箱、システムキャッシュは除外します。"; +"App sẽ tìm bundle trong Applications và đối chiếu Bundle ID với dữ liệu trong Library." = "OrvenはApplications内のバンドルを探し、Bundle IDとLibraryのデータを照合します。"; +"Archive có thể cần để symbolicate crash hoặc phát hành lại ứng dụng." = "アーカイブはクラッシュのシンボル解決やアプリの再公開に必要な場合があります。"; +"Artifact có thể tạo lại, chỉ trong project root bạn chọn" = "選択したプロジェクトルート内の再生成可能な成果物"; +"Artifact vượt khỏi project root đã chọn nên bị chặn." = "成果物が選択したルートフォルダの外にあるため、操作をブロックしました。"; +"Bundle ID khớp chính xác" = "Bundle IDの完全一致"; +"Bản lưu trữ ứng dụng; nên kiểm tra trước khi xóa" = "アプリのアーカイブ。削除前に確認してください"; +"Bản đồ dung lượng" = "ストレージマップ"; +"Bật" = "オン"; +"Bật lịch quét" = "スケジュールを有効にする"; +"Bỏ chọn" = "選択を解除"; +"Bộ nhớ đệm" = "キャッシュ"; +"Bộ nhớ đệm gói Python" = "Pythonパッケージキャッシュ"; +"Bộ nhớ đệm gói Yarn" = "Yarnパッケージキャッシュ"; +"Bộ nhớ đệm gói npm" = "npmパッケージキャッシュ"; +"Cache Swift Package Manager" = "Swift Package Managerのキャッシュ"; +"Cache của Xcode Simulator" = "Xcodeシミュレータのキャッシュ"; +"Cache, log và dữ liệu build có thể tạo lại" = "再生成可能なキャッシュ、ログ、ビルドデータ"; +"Chuyển artifact đã chọn vào Trash?" = "選択した成果物をゴミ箱に移動しますか?"; +"Chuyển khoảng %@ từ %lld hạng mục vào Trash. Dọn chính Trash sẽ xóa vĩnh viễn nội dung và thực sự giải phóng dung lượng." = "約%@を%lldカテゴリからゴミ箱に移動します。ゴミ箱を空にすると内容は永久に削除され、容量が解放されます。"; +"Chuyển tệp trùng lặp vào Trash?" = "重複ファイルをゴミ箱に移動しますか?"; +"Chuyển vào Trash" = "ゴミ箱に移動"; +"Chuyển vào Trash…" = "ゴミ箱に移動…"; +"Chuyển ảnh đã chọn vào Trash?" = "選択した写真をゴミ箱に移動しますか?"; +"Chưa chọn project root" = "プロジェクトのルート未選択"; +"Chưa có dữ liệu phân tích" = "分析はまだありません"; +"Chưa có lịch sử" = "履歴はまだありません"; +"Chưa có nhóm ảnh tương tự" = "類似写真グループはありません"; +"Chưa nhận diện dữ liệu Developer" = "開発データは見つかりませんでした"; +"Chưa quét tệp trùng lặp" = "重複スキャンはまだありません"; +"Chưa quét ứng dụng" = "アプリはまだスキャンされていません"; +"Chưa tìm thấy file cấu hình sử dụng" = "参照する設定ファイルは見つかりませんでした"; +"Chạy" = "実行"; +"Chạy công cụ dọn dẹp?" = "クリーンアップツールを実行しますか?"; +"Chạy…" = "実行…"; +"Chỉ quét các mục được phân loại An toàn" = "「安全」に分類された項目のみスキャン"; +"Chọn Quét tệp trùng lặp để bắt đầu" = "「重複ファイルをスキャン」を選択して開始"; +"Chọn bản sao" = "コピーを選択"; +"Chọn nơi tìm tệp trùng lặp" = "重複ファイルを探す場所を選択"; +"Chọn project root" = "プロジェクトのルートを選択"; +"Chọn project root để bắt đầu." = "プロジェクトのルートフォルダを選択してください。"; +"Chọn project…" = "プロジェクトを選択…"; +"Chọn thư mục ảnh để bắt đầu" = "写真フォルダを選択して開始"; +"Chọn thư mục…" = "フォルダを選択…"; +"Chọn vị trí…" = "場所を選択…"; +"Chọn ảnh đề xuất loại" = "推奨写真を選択"; +"Các gói đã tải về" = "ダウンロード済みパッケージ"; +"Các thao tác dọn dẹp và gỡ ứng dụng sẽ xuất hiện tại đây." = "クリーンアップやアプリ削除の操作がここに表示されます。"; +"Các tệp cùng kích thước sẽ được xác minh bằng SHA-256. App không so sánh chỉ dựa trên tên tệp." = "同じサイズのファイルはSHA-256で検証します。名前だけを証拠とは扱いません。"; +"Các tệp log trong tài khoản người dùng" = "ユーザーアカウント内のログファイル"; +"Có thể chứa dữ liệu ứng dụng thử nghiệm" = "テストアプリのデータを含む可能性があります"; +"Có thể cần tải lại khi debug thiết bị cũ" = "古いデバイスのデバッグ時に再ダウンロードが必要になる場合があります"; +"Có thể giải phóng" = "解放可能"; +"Có thể là bản sao dữ liệu duy nhất" = "このデータの唯一のコピーである可能性があります"; +"Có thể xem xét" = "確認可能"; +"Công cụ lưu trữ" = "ストレージツール"; +"Công cụ phân tích và quản lý dung lượng dành cho macOS." = "macOS向けのストレージ分析と管理。"; +"Cảnh báo tên dễ nhầm — không được chọn để xóa" = "名前が類似・削除対象には選択していません"; +"Cần xem lại" = "要確認"; +"Cập nhật lịch" = "スケジュールを更新"; +"Dependency và distribution có thể tải lại" = "依存関係と配布物は再ダウンロードできます"; +"Developer Cleanup thất bại" = "開発データのクリーンアップに失敗"; +"Diskora chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Orvenは確認後にのみクリーンアップを行い、各ツールの公式コマンドを優先します。"; +"Diskora sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Orvenは表示された許可リスト内のコマンドをそのまま実行します。ゴミ箱は使用せず、取り消せません。"; +"Dung lượng" = "サイズ"; +"Dùng Homebrew để xóa download cũ và phiên bản package không còn cần thiết." = "Homebrewで古いダウンロードや不要なパッケージバージョンを削除します。"; +"Dùng npm để dọn cache có thể tải lại." = "npmで再取得可能なキャッシュを削除します。"; +"Dùng pip để dọn wheel và HTTP cache." = "pipでwheelとHTTPのキャッシュを削除します。"; +"Dọn build cache, network và container đã dừng; không xóa volume." = "ビルドキャッシュ、ネットワーク、停止済みコンテナを削除します。ボリュームは削除しません。"; +"Dọn bằng công cụ chính thức" = "公式ツールでクリーンアップ"; +"Dọn chuyên sâu" = "詳細クリーンアップ"; +"Dọn chính Trash là thao tác vĩnh viễn và không thể hoàn tác." = "ゴミ箱を空にすると永久に削除され、取り消せません。"; +"Dọn dẹp" = "クリーンアップ"; +"Dọn dẹp…" = "クリーンアップ…"; +"Dọn mục đã chọn…" = "選択項目をクリーンアップ…"; +"Dọn nhanh" = "クイッククリーン"; +"Dừng" = "停止"; +"Dữ liệu có thể tạo lại và được chuyển vào Trash." = "再生成可能なデータをゴミ箱に移動します。"; +"Dữ liệu cần xem xét" = "確認するデータ"; +"Dữ liệu tạm của các ứng dụng" = "アプリの一時データ"; +"Dự án" = "プロジェクト"; +"GIỮ" = "保持"; +"GIỮ LẠI" = "保持"; +"Giới thiệu Diskora" = "Orvenについて"; +"Gỡ %@?" = "%@を削除しますか?"; +"Gỡ phiên bản runtime?" = "ランタイムのバージョンを削除しますか?"; +"Gỡ ứng dụng" = "アプリ"; +"Gỡ…" = "削除…"; +"Gửi notification với dung lượng cần xem xét" = "確認可能な容量がある場合に通知"; +"Hiển thị trong Finder" = "Finderで表示"; +"Hiển thị tệp thứ hai" = "2つ目のファイルを表示"; +"Hiển thị tệp thứ nhất" = "1つ目のファイルを表示"; +"Hình ảnh" = "画像"; +"Hạng mục" = "カテゴリ"; +"Hệ sinh thái" = "エコシステム"; +"Hợp lệ" = "有効"; +"Hủy" = "キャンセル"; +"Khác" = "その他"; +"Khôi phục" = "復元"; +"Khôi phục %lld mục…" = "%lld項目を復元…"; +"Khôi phục từ Trash?" = "ゴミ箱から復元しますか?"; +"Không có Bundle ID" = "Bundle IDなし"; +"Không có artifact phù hợp" = "一致する成果物はありません"; +"Không có source, manifest, lockfile hoặc thư mục chưa biết nào được đề xuất." = "ソース、manifest、lockfile、不明なフォルダは提案しません。"; +"Không hợp lệ" = "無効"; +"Không thể" = "実行できません"; +"Không thể cập nhật lịch quét" = "スケジュールを更新できません"; +"Không thể hoàn tất quá trình quét" = "スキャンを完了できません"; +"Không thể phân tích" = "分析できません"; +"Không thể quét ảnh" = "写真をスキャンできません"; +"Không thể đọc" = "読み取り不可"; +"Không tìm thấy mục phù hợp." = "一致する項目はありません。"; +"Không tìm thấy tệp trùng lặp" = "重複ファイルはありません"; +"Không tự chọn, không chuyển Trash và không xóa" = "自動選択、ゴミ箱への移動、自動削除は行いません"; +"Kiểm tra .nvmrc của dự án" = "プロジェクトの.nvmrcを確認"; +"Kiểm tra .python-version của dự án" = "プロジェクトの.python-versionを確認"; +"Kèm dữ liệu còn sót" = "残存データを含める"; +"Language / Ngôn ngữ" = "言語"; +"Launch item tham chiếu Bundle ID" = "起動項目がBundle IDを参照"; +"Launch item tham chiếu tên ứng dụng" = "起動項目がアプリ名を参照"; +"Làm mới" = "更新"; +"Lần chạy gần nhất" = "最終実行"; +"Lịch quét" = "定期スキャン"; +"Lịch quét chỉ thông báo; Diskora không tự xóa dữ liệu." = "定期スキャンは通知のみです。Orvenはデータを自動削除しません。"; +"Lịch quét chỉ thông báo; Orven không tự xóa dữ liệu." = "定期スキャンは通知のみです。Orvenはデータを自動削除しません。"; +"Lịch sử" = "履歴"; +"Lịch sử này không có thông tin vị trí trong Trash." = "この履歴にはゴミ箱内の位置情報がありません。"; +"Lịch sử, vị trí Trash và khôi phục có kiểm tra xung đột" = "履歴、ゴミ箱内の位置、競合を防ぐ復元"; +"Môi trường có thể chứa package riêng" = "環境に固有のパッケージが含まれる可能性があります"; +"Một số mục không thể khôi phục" = "復元できなかった項目があります"; +"Một số mục không thể xóa" = "一部の項目を削除できませんでした"; +"Một số thao tác không thành công" = "一部の操作に失敗しました"; +"Mở Trash" = "ゴミ箱を開く"; +"Mở báo cáo" = "レポートを開く"; +"Mục chỉ được khôi phục khi vị trí gốc chưa có dữ liệu mới." = "元の場所に新しいデータがない場合のみ復元します。"; +"Mức an toàn" = "安全性"; +"Nguy hiểm" = "危険"; +"Nguyên tắc" = "方針"; +"Nhóm trùng" = "重複グループ"; +"Nhật ký ứng dụng" = "アプリのログ"; +"Nội dung sẽ được chuyển vào Trash. Mục 'Cần xem lại' có thể phải tải hoặc tạo lại; các mục nguy hiểm không bao giờ được dọn tự động." = "内容をゴミ箱に移動します。確認が必要な項目は再ダウンロードや再生成が必要な場合があります。危険な項目は自動削除しません。"; +"Phiên bản runtime" = "ランタイムのバージョン"; +"Phân loại dữ liệu" = "データの分類"; +"Phân loại theo mức rủi ro" = "リスク別に分類"; +"Phân tích dung lượng" = "ストレージ分析"; +"Phân tích dung lượng • Tệp trùng lặp • Dọn dẹp Developer" = "ストレージ分析・重複ファイル・開発データのクリーンアップ"; +"Pods có thể tải lại" = "Podsは再ダウンロードできます"; +"Project source, manifest, lockfile và .git không được chọn." = "プロジェクトのソース、manifest、lockfile、.gitは選択しません。"; +"Quét" = "スキャン"; +"Quét chuyên sâu" = "詳細スキャン"; +"Quét dung lượng" = "ストレージをスキャン"; +"Quét lại" = "再スキャン"; +"Quét mỗi" = "スキャン間隔"; +"Quét nền và thông báo, không tự động xóa" = "バックグラウンドスキャンと通知。自動削除は行いません"; +"Quét thư mục người dùng" = "ホームフォルダをスキャン"; +"Quét thư mục người dùng hoặc chọn một ổ đĩa khác để tìm nơi đang chiếm dung lượng." = "ホームフォルダをスキャンするか別のボリュームを選択して、容量の使用状況を確認してください。"; +"Quét thư mục người dùng ở mục Phân tích dung lượng để thống kê môi trường dev." = "ストレージ分析でホームフォルダをスキャンすると、開発環境を確認できます。"; +"Quét ảnh" = "写真をスキャン"; +"Quét ứng dụng" = "アプリをスキャン"; +"Quét ứng dụng để tìm dữ liệu còn sót" = "アプリをスキャンして残存データを確認"; +"Receipt hệ thống chỉ dùng làm bằng chứng; Diskora không tự xóa" = "システムの受領記録は証拠としてのみ扱い、Orvenは自動削除しません"; +"Receipt hệ thống chỉ dùng làm bằng chứng; Orven không tự xóa" = "システムの受領記録は証拠としてのみ扱い、Orvenは自動削除しません"; +"SDK, runtime, simulator và môi trường lập trình" = "SDK、ランタイム、シミュレータ、開発環境"; +"Sản phẩm build và chỉ mục có thể tạo lại" = "再生成可能なビルド成果物とインデックス"; +"Sẵn sàng quét" = "スキャンの準備完了"; +"Sẵn sàng quét chuyên sâu" = "詳細スキャンの準備完了"; +"Sẵn sàng quét các tệp có thể dọn dẹp" = "削除可能なファイルをスキャンできます"; +"Thay đổi từ lần quét trước" = "前回スキャンからの変更"; +"Thêm project…" = "プロジェクトを追加…"; +"Thùng rác" = "ゴミ箱"; +"Thư mục lớn nhất" = "大きいフォルダ"; +"Thời gian" = "時刻"; +"Orven chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Orvenは確認後にのみクリーンアップを行い、各ツールの公式コマンドを優先します。"; +"Orven chỉ quét thư mục bạn chọn và chỉ đề xuất artifact có marker hệ sinh thái rõ ràng." = "Orvenは選択したフォルダのみをスキャンし、エコシステムのマーカーがある成果物だけを提案します。"; +"Orven sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Orvenは表示された許可リスト内のコマンドをそのまま実行します。ゴミ箱は使用せず、取り消せません。"; +"Orven sẽ nhận diện output có thể tạo lại từ marker dự án." = "Orvenはプロジェクトのマーカーから再生成可能な出力を識別します。"; +"TÊN KHÁC • NỘI DUNG GIỐNG HỆT" = "名前は異なり、内容は同一"; +"Tài liệu" = "書類"; +"Tác giả" = "作成者"; +"Tên giống %lld%% • Nội dung khác nhau, không phải duplicate" = "名前の類似度%lld%%・内容は異なり、重複ではありません"; +"Tên thành phần khớp; cần xem lại" = "コンポーネント名が一致・確認が必要"; +"Tìm thấy" = "検出"; +"Tìm đường dẫn" = "パスを検索"; +"Tất cả" = "すべて"; +"Tất cả hệ sinh thái" = "すべてのエコシステム"; +"Tần suất" = "頻度"; +"Tắt" = "オフ"; +"Tắt lịch" = "スケジュールを無効にする"; +"Tệp lớn trên 100 MB" = "100 MBを超えるファイル"; +"Tệp nén" = "アーカイブ"; +"Tệp trùng lặp" = "重複ファイル"; +"Tệp đã chuyển vào Thùng rác" = "ゴミ箱に移動したファイル"; +"Tệp đã quét" = "スキャン済みファイル"; +"Tổng quan" = "概要"; +"Undo Center không ghi đè dữ liệu đang tồn tại ở vị trí gốc." = "復元センターは元の場所にあるデータを上書きしません。"; +"Vision so sánh ảnh chụp gần nhau trong cùng thư mục; ảnh được đề xuất giữ dựa trên chất lượng tệp cao hơn." = "Visionで同じフォルダ内の近い写真を比較し、品質が高いファイルを保持するよう提案します。"; +"Volume có thể chứa database quan trọng" = "ボリュームに重要なデータベースが含まれる可能性があります"; +"Xác nhận dọn chuyên sâu" = "詳細クリーンアップを確認"; +"Xác nhận dọn dẹp" = "クリーンアップを確認"; +"Xóa simulator runtime không còn được Xcode hỗ trợ bằng simctl." = "simctlで非対応のシミュレータランタイムを削除します。"; +"có thể xem xét" = "確認可能"; +"dự án" = "プロジェクト"; +"giờ" = "時間"; +"hoàn tất" = "完了"; +"không còn trong Trash" = "ゴミ箱に存在しません"; +"không có" = "なし"; +"không có lịch" = "スケジュールなし"; +"mục" = "項目"; +"mục vào Trash" = "項目をゴミ箱へ"; +"nhóm" = "グループ"; +"nội dung đã thay đổi, thao tác bị hủy" = "内容が変更されたため、操作をキャンセルしました"; +"sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Diskora không xóa database hệ thống" = "sfltoolがログイン/バックグラウンド項目を検出しました。システムデータベースを削除せずに証拠を表示します"; +"sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Orven không xóa database hệ thống" = "sfltoolがログイン/バックグラウンド項目を検出しました。システムデータベースを削除せずに証拠を表示します"; +"thao tác" = "操作"; +"thất bại" = "失敗"; +"tệp" = "ファイル"; +"tệp vào Trash" = "ファイルをゴミ箱へ"; +"vào Trash" = "ゴミ箱へ"; +"vị trí gốc đã có dữ liệu" = "元の場所にデータが存在します"; +"Âm thanh" = "オーディオ"; +"Đang chuyển artifact đã xác nhận vào Trash…" = "確認済みの成果物をゴミ箱に移動中…"; +"Đang chạy" = "実行中"; +"Đang dọn dẹp…" = "クリーンアップ中…"; +"Đang lập chỉ mục" = "インデックス作成中"; +"Đang lọc bằng partial hash" = "部分ハッシュで絞り込み中"; +"Đang nhóm theo kích thước và kiểm tra nội dung…" = "サイズ別に分類し、内容を確認中…"; +"Đang phân tích" = "分析中"; +"Đang phân tích đặc trưng hình ảnh bằng Vision…" = "Visionで画像の特徴を分析中…"; +"Đang phân tích ứng dụng và dữ liệu liên quan…" = "アプリと関連データを分析中…"; +"Đang quét dữ liệu chuyên sâu…" = "詳細クリーンアップ用データをスキャン中…"; +"Đang quét…" = "スキャン中…"; +"Đang tìm artifact có thể tạo lại…" = "再生成可能な成果物を検索中…"; +"Đang xác minh SHA-256" = "SHA-256を検証中"; +"Đã bật" = "有効"; +"Đã chuyển" = "移動済み"; +"Đã chọn" = "選択済み"; +"Đã dừng quét" = "スキャンを停止しました"; +"Đã khôi phục" = "復元済み"; +"Đã khôi phục hoặc không còn mục trong Trash" = "復元済み、またはゴミ箱に存在しません"; +"Đã quét" = "スキャン済み"; +"Đã quét xong" = "スキャン完了"; +"Đã quét xong • Đã chọn %@" = "スキャン完了・%@を選択"; +"Đã tắt" = "無効"; +"Đã xử lý" = "処理済み"; +"Đóng" = "閉じる"; +"Được bảo vệ" = "保護対象"; +"Được tham chiếu bởi %lld dự án" = "%lld件のプロジェクトが参照"; +"đang được chọn" = "選択中"; +"đã chọn" = "選択中"; +"đã nhận diện" = "確認済み"; +"đã phân tích" = "分析済み"; +"đường dẫn không an toàn" = "安全でないパス"; +"Ảnh tương tự" = "類似写真"; +"Ảnh tương tự không phải bản sao tuyệt đối. Hãy kiểm tra kỹ trước khi chuyển %lld ảnh vào Trash." = "類似写真は完全なコピーではありません。%lld枚をゴミ箱に移動する前に、慎重に確認してください。"; +"ảnh" = "写真"; +"Ứng dụng" = "アプリ"; +"Ứng dụng và dữ liệu còn sót" = "アプリと残存データ"; +"Ứng dụng và dữ liệu đã chọn sẽ được chuyển vào Trash. Một số ứng dụng hệ thống có thể yêu cầu quyền quản trị." = "選択したアプリとデータをゴミ箱に移動します。一部のシステムアプリには管理者権限が必要な場合があります。"; +"ứng dụng" = "アプリ"; diff --git a/apps/toolbox/Resources/vi.lproj/InfoPlist.strings b/apps/toolbox/Resources/vi.lproj/InfoPlist.strings new file mode 100644 index 0000000..e045336 --- /dev/null +++ b/apps/toolbox/Resources/vi.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSLocationUsageDescription" = "Orven dùng quyền vị trí để hiển thị tên mạng Wi-Fi và định danh điểm truy cập khi bạn kiểm tra hoặc quét Wi-Fi."; +"NSLocationWhenInUseUsageDescription" = "Orven dùng quyền vị trí để hiển thị tên mạng Wi-Fi và định danh điểm truy cập khi bạn kiểm tra hoặc quét Wi-Fi."; +"NSLocalNetworkUsageDescription" = "Orven kết nối tới gateway hoặc đích cục bộ bạn chọn khi bắt đầu chẩn đoán mạng."; diff --git a/apps/toolbox/Resources/vi.lproj/Localizable.strings b/apps/toolbox/Resources/vi.lproj/Localizable.strings index 5cd341c..3ba5be0 100644 --- a/apps/toolbox/Resources/vi.lproj/Localizable.strings +++ b/apps/toolbox/Resources/vi.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"About Toolbox" = "Giới thiệu Toolbox"; +"About Orven" = "Giới thiệu Orven"; "Home" = "Trang chủ"; "Storage" = "Dung lượng"; "Projects" = "Dự án"; @@ -7,7 +7,7 @@ "Recovery" = "Khôi phục"; "Language" = "Ngôn ngữ"; "Your Mac, explained." = "Hiểu rõ máy Mac của bạn."; -"See what changed. Reclaim space safely." = "Biết điều gì đã thay đổi. Giải phóng dung lượng an toàn."; +"Understand your Mac. Act with context." = "Hiểu rõ Mac. Quyết định có cơ sở."; "Recover space safely" = "Giải phóng dung lượng an toàn"; "See reclaimable files before anything moves to Trash." = "Xem tệp có thể dọn trước khi chuyển bất kỳ thứ gì vào Trash."; "Scan storage" = "Quét dung lượng"; @@ -18,12 +18,12 @@ "Review recoverable actions and restore without overwriting files." = "Xem thao tác có thể hoàn tác và khôi phục mà không ghi đè tệp."; "Open Recovery" = "Mở Khôi phục"; "Runs locally. No telemetry. Nothing is deleted automatically." = "Chạy cục bộ. Không telemetry. Không tự động xóa dữ liệu."; -"Welcome to Toolbox" = "Chào mừng đến với Toolbox"; +"Welcome to Orven" = "Chào mừng đến với Orven"; "Local evidence for safer cleanup and app changes." = "Bằng chứng cục bộ để dọn dẹp và theo dõi thay đổi an toàn hơn."; "Private by default" = "Riêng tư ngay từ đầu"; -"Scans, snapshots, and activity stay on this Mac. Toolbox has no telemetry and never cleans automatically." = "Kết quả quét, snapshot và hoạt động chỉ lưu trên máy Mac này. Toolbox không telemetry và không tự động dọn dẹp."; +"Scans, snapshots, and activity stay on this Mac. Orven has no telemetry and never cleans automatically." = "Kết quả quét, snapshot và hoạt động chỉ lưu trên máy Mac này. Orven không telemetry và không tự động dọn dẹp."; "First-launch approval" = "Phê duyệt lần mở đầu"; -"If macOS blocks Toolbox, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper." = "Nếu macOS chặn Toolbox, hãy thử mở ứng dụng một lần, sau đó vào System Settings → Privacy & Security → Open Anyway. Không bao giờ tắt Gatekeeper."; +"If macOS blocks Orven, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper." = "Nếu macOS chặn Orven, hãy thử mở ứng dụng một lần, sau đó vào System Settings → Privacy & Security → Open Anyway. Không bao giờ tắt Gatekeeper."; "Open installation guide" = "Mở hướng dẫn cài đặt"; "Scan coverage" = "Phạm vi quét"; "Sentinel folders are readable." = "Các thư mục kiểm tra có thể đọc được."; @@ -38,7 +38,7 @@ "Retry import" = "Thử nhập lại"; "No legacy data found." = "Không tìm thấy dữ liệu cũ."; "You can change these choices later in Settings." = "Bạn có thể thay đổi các lựa chọn này trong Settings."; -"Continue to Toolbox" = "Tiếp tục vào Toolbox"; +"Continue to Orven" = "Tiếp tục vào Orven"; "Migration error" = "Lỗi migration"; "Close" = "Đóng"; "Recoverable" = "Có thể khôi phục"; @@ -58,19 +58,19 @@ "12 hours" = "12 giờ"; "24 hours" = "24 giờ"; "7 days" = "7 ngày"; -"Replace with Toolbox schedule…" = "Thay bằng lịch Toolbox…"; +"Replace with Orven schedule…" = "Thay bằng lịch Orven…"; "No Diskora schedule needs migration." = "Không có lịch Diskora cần migrate."; "The old agent remains untouched unless the new scan-only agent bootstraps successfully." = "Agent cũ được giữ nguyên nếu agent chỉ-quét mới chưa bootstrap thành công."; "Updates" = "Cập nhật"; -"Only when you click the button, Toolbox requests public release metadata from GitHub. No scan or device data is sent." = "Chỉ khi bạn bấm nút, Toolbox mới lấy metadata bản phát hành công khai từ GitHub. Không gửi dữ liệu quét hoặc thiết bị."; +"Only when you click the button, Orven requests public release metadata from GitHub. No scan or device data is sent." = "Chỉ khi bạn bấm nút, Orven mới lấy metadata bản phát hành công khai từ GitHub. Không gửi dữ liệu quét hoặc thiết bị."; "Check for Updates" = "Kiểm tra cập nhật"; "Replace the Diskora scheduled scan?" = "Thay lịch quét Diskora?"; "Replace after verification" = "Thay sau khi xác minh"; "Cancel" = "Hủy"; -"Toolbox first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds." = "Toolbox cài và bootstrap agent chỉ-quét trước. Plist Diskora chỉ bị gỡ sau khi bước đó thành công."; +"Orven first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds." = "Orven cài và bootstrap agent chỉ-quét trước. Plist Diskora chỉ bị gỡ sau khi bước đó thành công."; "Settings error" = "Lỗi Settings"; -"Legacy schedule replaced after Toolbox bootstrap succeeded." = "Đã thay lịch cũ sau khi Toolbox bootstrap thành công."; -"Toolbox %@ is available." = "Toolbox %@ đã có bản phát hành."; +"Legacy schedule replaced after Orven bootstrap succeeded." = "Đã thay lịch cũ sau khi Orven bootstrap thành công."; +"Orven %@ is available." = "Orven %@ đã có bản phát hành."; "You are using the latest public release." = "Bạn đang dùng bản phát hành công khai mới nhất."; "Finish the active scan before checking for updates." = "Hãy hoàn tất lượt quét đang chạy trước khi kiểm tra cập nhật."; "Cleanup" = "Dọn dẹp"; @@ -83,3 +83,638 @@ "Succeeded" = "Thành công"; "Failed" = "Thất bại"; "Cancelled" = "Đã hủy"; + +"Live monitoring" = "Giám sát trực tiếp"; + +"CPU • all cores" = "CPU • toàn bộ lõi"; + +"Memory • includes caches" = "Bộ nhớ • gồm cache"; + +"Read-only. Samples stay in memory for up to 60 seconds." = "Chỉ đọc. Mẫu được giữ trong bộ nhớ tối đa 60 giây."; + +"Warming up" = "Đang lấy mẫu ban đầu"; + +"Available" = "Có dữ liệu"; + +"Stale" = "Dữ liệu cũ"; + +"Unsupported" = "Không hỗ trợ"; + +"Permission required" = "Cần quyền truy cập"; + +"Temporarily unavailable" = "Tạm thời không có dữ liệu"; + +"Read failed" = "Đọc thất bại"; + +"Processes" = "Tiến trình"; + +"Search processes or PID" = "Tìm tiến trình hoặc PID"; + +"Clear search" = "Xóa tìm kiếm"; + +"Sort by" = "Sắp xếp theo"; + +"CPU" = "CPU"; + +"Memory" = "Bộ nhớ"; + +"Name" = "Tên"; + +"%d processes shown" = "Đang hiển thị %d tiến trình"; + +"CPU • one core" = "CPU • một lõi"; + +"Resident memory" = "Bộ nhớ resident"; + +"No process data available." = "Chưa có dữ liệu tiến trình."; + +"No matching processes." = "Không có tiến trình khớp."; + +"Select a process to inspect its verified identity and recent activity." = "Chọn tiến trình để kiểm tra identity đã xác minh và hoạt động gần đây."; + +"100% process CPU is one logical core. Restricted processes remain visible. No termination actions." = "CPU tiến trình 100% tương ứng một lõi logic. Tiến trình hạn chế quyền vẫn hiển thị. Không có thao tác kết thúc tiến trình."; + +"Review in Applications" = "Xem trong Ứng dụng"; + +"Observed parent PID: %d" = "PID cha quan sát được: %d"; + +"Process exited or identity changed." = "Tiến trình đã thoát hoặc identity đã thay đổi."; + +"Threads: %d" = "Thread: %d"; + +"Revalidating process identity…" = "Đang xác minh lại identity tiến trình…"; + +"Disk reads • observed drivers" = "Đọc đĩa • driver quan sát được"; + +"Disk writes • observed drivers" = "Ghi đĩa • driver quan sát được"; + +"Network received • physical" = "Mạng nhận • interface vật lý"; + +"Network sent • physical" = "Mạng gửi • interface vật lý"; + +"Startup volume available" = "Dung lượng trống volume khởi động"; + +"Battery" = "Pin"; + +"Charging" = "Đang sạc"; + +"On battery" = "Dùng pin"; + +"External power" = "Nguồn điện ngoài"; + +"Nominal" = "Bình thường"; + +"Fair" = "Hơi nóng"; + +"Serious" = "Nóng đáng kể"; + +"Critical" = "Nghiêm trọng"; + +"Unknown" = "Chưa xác định"; + +"Network" = "Mạng"; + +"Monitoring" = "Giám sát"; + +"Menu bar quick diagnostics" = "Diagnostics nhanh trên menu bar"; + +"Menu bar indicator" = "Chỉ báo menu bar"; + +"Disabled by default. When enabled, menu bar and windows share one sampler. Background sampling runs every 5 seconds; Quit stops it." = "Mặc định tắt. Khi bật, menu bar và cửa sổ dùng chung một sampler. Lấy mẫu nền mỗi 5 giây; Thoát sẽ dừng lấy mẫu."; + +"Last sample" = "Mẫu gần nhất"; + +"Top CPU consumers • one core" = "Tiến trình dùng CPU nhiều nhất • một lõi"; + +"Thermal state" = "Trạng thái nhiệt"; + +"Automatic alerts are not available." = "Chưa có cảnh báo tự động."; + +"Overview" = "Tổng quan"; + +"Disable menu bar" = "Tắt menu bar"; + +"Quit Orven" = "Thoát Orven"; + +"Seconds before latest sample" = "Giây trước mẫu gần nhất"; + +"Previous sample" = "Mẫu trước"; + +"Next sample" = "Mẫu tiếp"; + +"Unavailable" = "Không có dữ liệu"; + +"No samples yet." = "Chưa có mẫu."; + +"%d valid samples. Minimum %.1f, maximum %.1f %@. Missing samples are gaps." = "%d mẫu hợp lệ. Nhỏ nhất %.1f, lớn nhất %.1f %@. Mẫu thiếu là khoảng trống."; + +"System CPU • all cores" = "CPU hệ thống • toàn bộ lõi"; + +"Occupied memory • includes caches" = "Bộ nhớ chiếm dụng • gồm cache"; + +"Physical memory" = "Bộ nhớ vật lý"; + +"Wired memory" = "Bộ nhớ wired"; + +"Compressor storage" = "Dung lượng bộ nén"; + +"Swap used" = "Swap đã dùng"; + +"Memory pressure • last OS event" = "Áp lực bộ nhớ • sự kiện OS gần nhất"; + +"Occupied memory is physical memory minus free pages and includes caches. It does not measure reclaimable RAM. Pressure is an OS event, not an inferred score." = "Bộ nhớ chiếm dụng là bộ nhớ vật lý trừ trang trống và bao gồm cache; không đo RAM có thể giải phóng. Pressure là sự kiện OS, không phải điểm suy luận."; + +"GPU utilization and memory: Unsupported" = "Mức sử dụng và bộ nhớ GPU: Không hỗ trợ"; + +"Normal" = "Bình thường"; + +"Warning" = "Cảnh báo"; + +"Review Storage" = "Xem Dung lượng"; + +"Review Projects" = "Xem Dự án"; + +"APFS volumes share container capacity. Capacity is shown for the startup volume once; I/O is per observed storage driver, including virtual devices when exposed." = "Volume APFS chia sẻ dung lượng container. Chỉ hiển thị capacity volume khởi động một lần; I/O theo driver quan sát được, gồm device ảo nếu có."; + +"Disk I/O by device" = "I/O đĩa theo device"; + +"Read" = "Đọc"; + +"Write" = "Ghi"; + +"Show loopback, tunnels and other interfaces" = "Hiển thị loopback, tunnel và interface khác"; + +"Physical summary includes active en* interfaces. Tunnel and loopback counters are shown separately and are never added to that summary." = "Tổng hợp vật lý gồm interface en* đang active. Counter tunnel/loopback được hiển thị riêng và không cộng vào tổng hợp này."; + +"Received" = "Nhận"; + +"Sent" = "Gửi"; + +"Thermal state is macOS's coarse system condition, not a temperature in °C. Macs without an internal battery report Unsupported." = "Thermal state là trạng thái coarse của macOS, không phải nhiệt độ °C. Mac không có pin nội bộ hiển thị Không hỗ trợ."; + +"Temperature, fan sensors and per-process energy: Unsupported" = "Nhiệt độ, cảm biến quạt và năng lượng từng tiến trình: Không hỗ trợ"; + +"CPU & Memory" = "CPU & Bộ nhớ"; + +"Disk & Network" = "Đĩa & Mạng"; + +"Battery & Thermal" = "Pin & Nhiệt"; + +"Management" = "Quản lý"; + +"History" = "Lịch sử"; + +"Recent CPU activity" = "Hoạt động CPU gần đây"; + +"Process CPU • one core" = "CPU tiến trình • một lõi"; + +"Read-only. Recent trends stay on this Mac and are not saved." = "Chỉ đọc. Trend gần đây giữ trên Mac này và không được lưu."; + +"Sampling paused" = "Tạm dừng lấy mẫu"; + +"Sampling paused while the main window is inactive." = "Tạm dừng lấy mẫu khi cửa sổ chính không active."; +"Your Mac, at a glance" = "Nhìn rõ tình trạng máy Mac"; +"Read-only" = "Chỉ đọc dữ liệu"; +"Using the most CPU" = "Tiến trình dùng nhiều CPU"; +"Show all processes" = "Xem mọi tiến trình"; +"Process" = "Tiến trình"; +"Inspect process" = "Xem tiến trình"; +"Thermal state is not a temperature in °C." = "Trạng thái nhiệt không phải nhiệt độ theo °C."; +"Used storage" = "Dung lượng đã dùng"; +"Open storage" = "Xem dung lượng"; +"Inspect what's running on your Mac. Monitoring is read-only." = "Xem tiến trình đang chạy trên máy Mac. Giám sát chỉ đọc dữ liệu."; +"Close details" = "Đóng chi tiết"; +"Settings" = "Cài đặt"; +"About Orven" = "Giới thiệu Orven"; +"Appearance" = "Giao diện"; +"System" = "Theo hệ thống"; +"Light" = "Sáng"; +"Dark" = "Tối"; +"%d of %d processes have CPU samples." = "%d trên %d tiến trình có mẫu CPU."; + +"Giới thiệu Diskora" = "Giới thiệu Orven"; +"Receipt hệ thống chỉ dùng làm bằng chứng; Diskora không tự xóa" = "Receipt hệ thống chỉ dùng làm bằng chứng; Orven không tự xóa"; +"Diskora chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Orven chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ."; +"Diskora sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Orven sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo."; +"Lịch quét chỉ thông báo; Diskora không tự xóa dữ liệu." = "Lịch quét chỉ thông báo; Orven không tự xóa dữ liệu."; +"Lịch quét chỉ thông báo; Orven không tự xóa dữ liệu." = "Lịch quét chỉ thông báo; Orven không tự xóa dữ liệu."; +"Receipt hệ thống chỉ dùng làm bằng chứng; Orven không tự xóa" = "Receipt hệ thống chỉ dùng làm bằng chứng; Orven không tự xóa"; +"Orven chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Orven chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ."; +"Orven sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Orven sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo."; +"Orven chỉ quét thư mục bạn chọn và chỉ đề xuất artifact có marker hệ sinh thái rõ ràng." = "Orven chỉ quét thư mục bạn chọn và chỉ đề xuất artifact có marker hệ sinh thái rõ ràng."; +"Orven sẽ nhận diện output có thể tạo lại từ marker dự án." = "Orven sẽ nhận diện output có thể tạo lại từ marker dự án."; +"Orven chỉ đọc loại file và đường dẫn; installer chỉ mở sau khi baseline đã lưu." = "Orven chỉ đọc loại file và đường dẫn; installer chỉ mở sau khi baseline đã lưu."; +"Coverage giảm vì Orven không chạy trong một phần của phiên." = "Coverage giảm vì Orven không chạy trong một phần của phiên."; +"Snapshot và FSEvents chỉ lưu metadata cục bộ. Orven không mount, execute hoặc bypass Gatekeeper." = "Snapshot và FSEvents chỉ lưu metadata cục bộ. Orven không mount, execute hoặc bypass Gatekeeper."; +"Orven chỉ nhận .dmg, .pkg hoặc .app" = "Orven chỉ nhận .dmg, .pkg hoặc .app"; +"Minh bạch về dữ liệu Orven đọc, lưu và không thể quan sát." = "Minh bạch về dữ liệu Orven đọc, lưu và không thể quan sát."; +"Orven đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại." = "Orven đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại."; + +"Sleep Inspector" = "Kiểm tra yêu cầu giữ thức"; +"Refresh sleep requests" = "Làm mới yêu cầu giữ thức"; +"A snapshot of active macOS power requests. Requests can be legitimate; they do not explain every sleep or wake event." = "Ảnh chụp các yêu cầu nguồn đang hoạt động trên macOS. Yêu cầu có thể hợp lệ; chúng không giải thích mọi lần máy ngủ hoặc thức."; +"Reading sleep requests" = "Đang đọc yêu cầu giữ thức"; +"Observed at" = "Quan sát lúc"; +"No active requests in this snapshot" = "Không có yêu cầu đang hoạt động trong ảnh chụp này"; +"Process identity unavailable; ownership is unverified" = "Không đọc được identity tiến trình; chưa xác minh owner"; +"Some requests could not be displayed" = "Một số yêu cầu không thể hiển thị"; +"Refresh to inspect current requests. No background polling." = "Làm mới để xem yêu cầu hiện tại. Không tự đọc trong nền."; +"No readable requests in this snapshot" = "Không đọc được yêu cầu nào trong ảnh chụp này"; + +/* Application inspection and management improvements. */ +"Search apps or Bundle ID" = "Tìm ứng dụng hoặc Bundle ID"; +"Sort by name" = "Sắp xếp theo tên"; +"No matching applications" = "Không có ứng dụng phù hợp"; +"App Dossier" = "Hồ sơ ứng dụng"; +"Bundle ID" = "Bundle ID"; +"Application path" = "Đường dẫn ứng dụng"; +"Version" = "Phiên bản"; +"Minimum macOS" = "macOS tối thiểu"; +"Application bundle size" = "Dung lượng bundle ứng dụng"; +"Related data observed" = "Dữ liệu liên quan đã quan sát"; +"Last application scan" = "Lần quét ứng dụng gần nhất"; +"Sizes are from the last scan. Related data is attributed by scan evidence, not proof of exclusive ownership. Unreadable locations may be missing." = "Dung lượng lấy từ lần quét gần nhất. Dữ liệu liên quan được quy theo bằng chứng quét, không chứng minh sở hữu riêng. Vị trí không đọc được có thể bị thiếu."; +"No related data found within scan coverage." = "Không tìm thấy dữ liệu liên quan trong phạm vi quét."; +"Largest first" = "Lớn nhất trước"; +"Application Support" = "Dữ liệu hỗ trợ ứng dụng"; +"Cache" = "Bộ nhớ đệm"; +"Preference" = "Tùy chọn"; +"Log" = "Nhật ký"; +"Container" = "Container"; +"LaunchAgent" = "LaunchAgent"; +"Login Item / Background Task" = "Mục đăng nhập / Tác vụ nền"; +"Package receipt" = "Receipt gói cài đặt"; +"Clear filters or change the search query." = "Thử bỏ bộ lọc hoặc thay đổi từ khóa tìm kiếm."; +"Background-item evidence unavailable. The macOS helper did not complete; scan coverage is partial." = "Không lấy được bằng chứng mục chạy nền. Helper macOS không hoàn tất; phạm vi quét chưa đầy đủ."; +"Remove project %@" = "Bỏ project %@"; +"%@ was identified from %@ markers and can be regenerated." = "%@ được nhận diện từ marker %@ và có thể tạo lại."; +"Found %d artifacts in %d projects." = "Tìm thấy %d artifact trong %d project."; + +"Explain My Mac" = "Giải thích tình trạng Mac"; +"Export diagnostics…" = "Xuất chẩn đoán…"; +"Evidence from recent samples. These observations do not establish causality and do not change your Mac." = "Bằng chứng từ các mẫu gần đây. Quan sát này không xác định quan hệ nhân quả và không thay đổi máy."; +"No matching findings in fresh, available samples. Missing data is not a health check." = "Chưa có nhận định phù hợp trong các mẫu còn mới và khả dụng. Thiếu dữ liệu không có nghĩa máy hoạt động tốt."; +"Sampling is paused. Resume active monitoring to evaluate current evidence." = "Đang tạm dừng lấy mẫu. Tiếp tục giám sát để đánh giá bằng chứng hiện tại."; +"Review evidence" = "Xem bằng chứng"; +"Memory pressure needs attention" = "Cần chú ý memory pressure"; +"macOS reports memory pressure. Occupied memory alone does not establish a problem." = "macOS báo memory pressure. Chỉ riêng dung lượng RAM đã dùng không chứng minh có vấn đề."; +"Sustained CPU activity" = "CPU hoạt động cao kéo dài"; +"System CPU stayed at or above 85% for at least 30 seconds. Review the measured processes before attributing a cause." = "CPU hệ thống ở mức 85% trở lên ít nhất 30 giây. Xem các process đã đo trước khi xác định nguyên nhân."; +"Startup volume is nearly full" = "Ổ khởi động gần đầy"; +"Available space is below 10% of reported capacity. APFS volumes share container space; inspect coverage and snapshots before cleanup." = "Dung lượng khả dụng dưới 10% capacity được báo. Các volume APFS dùng chung container; kiểm tra coverage và snapshots trước khi dọn."; +"Elevated thermal state" = "Thermal state tăng cao"; +"macOS reports thermal pressure. This is an OS state, not a temperature measurement or proof of a particular app's impact." = "macOS báo thermal pressure. Đây là trạng thái OS, không phải nhiệt độ đo được hay bằng chứng tác động của một app cụ thể."; +"Local Diagnostics Export" = "Xuất chẩn đoán cục bộ"; +"Preview the exact JSON before saving. Paths, username, hostname and network endpoints are excluded. Nothing is uploaded." = "Xem chính xác JSON trước khi lưu. Không chứa path, username, hostname và endpoint mạng. Không upload dữ liệu."; +"Include process names" = "Kèm tên process"; +"Refresh preview" = "Làm mới bản xem trước"; +"Save JSON…" = "Lưu JSON…"; +"Diagnostics saved." = "Đã lưu chẩn đoán."; +"Could not save diagnostics. Choose a writable location and try again." = "Không thể lưu chẩn đoán. Chọn vị trí có quyền ghi rồi thử lại."; +"Could not generate diagnostics from the current sample. Refresh and try again." = "Không thể tạo chẩn đoán từ mẫu hiện tại. Làm mới rồi thử lại."; +"Network Inspector" = "Kiểm tra kết nối mạng"; +"Refresh connections" = "Làm mới kết nối"; +"Explicit TCP/UDP socket snapshot from lsof. Restricted processes and short-lived connections may be missing. No traffic history, domain attribution, byte totals or firewall control." = "Snapshot socket TCP/UDP theo yêu cầu từ lsof. Có thể thiếu process bị giới hạn quyền và kết nối tồn tại ngắn. Không có lịch sử traffic, quy domain, tổng byte hay điều khiển firewall."; +"Search process, PID or endpoint" = "Tìm process, PID hoặc endpoint"; +"Connection inspection failed. Refresh to try again; coverage remains unknown." = "Không thể kiểm tra kết nối. Làm mới để thử lại; coverage chưa xác định."; +"%d observed sockets; %d omitted by the display cap." = "Quan sát %d socket; bỏ qua %d mục do giới hạn hiển thị."; +"Protocol" = "Giao thức"; +"Endpoint" = "Endpoint"; +"State" = "Trạng thái"; +"Details" = "Chi tiết"; +"Only a revalidated PID/start identity can be inspected." = "Chỉ xem chi tiết khi đã kiểm tra lại identity PID/thời điểm bắt đầu."; +"No matching sockets within observable coverage. This does not prove the Mac has no connections." = "Không có socket phù hợp trong phạm vi quan sát. Điều này không chứng minh Mac không có kết nối."; +"Refresh to inspect current connections." = "Làm mới để kiểm tra kết nối hiện tại."; +"Hidden Space & Snapshots" = "Dung lượng ẩn và snapshots"; +"Inspect APFS space" = "Kiểm tra dung lượng APFS"; +"Startup Data volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." = "Chỉ volume Data của ổ khởi động. Các volume dùng chung capacity APFS container; quét thư mục, dung lượng khả dụng và size snapshot là các phép đo khác nhau."; +"APFS inspection failed or is unsupported. Refresh to try again." = "Kiểm tra APFS thất bại hoặc không được hỗ trợ. Làm mới để thử lại."; +"APFS container capacity" = "Capacity APFS container"; +"APFS container free" = "Dung lượng trống APFS container"; +"Data volume used" = "Dung lượng Data volume đã dùng"; +"Unaccounted space is not measured here. It may include unreadable files, other volumes, filesystem metadata, clones or snapshots; Orven does not attribute the remainder to one cause." = "Chưa đo được phần dung lượng chưa giải thích. Có thể gồm tệp không đọc được, volume khác, metadata filesystem, clones hoặc snapshots; Orven không quy phần còn lại cho một nguyên nhân."; +"APFS snapshots" = "APFS snapshots"; +"No APFS snapshots reported for the Data volume. Other volumes may have snapshots." = "OS không báo APFS snapshot trên volume Data. Các volume khác có thể có snapshots."; +"OS marks this snapshot purgeable." = "OS đánh dấu snapshot có thể purge."; +"OS does not mark this snapshot purgeable." = "OS không đánh dấu snapshot có thể purge."; +"Snapshot purgeability unavailable." = "Chưa xác định snapshot có thể purge."; +"%d snapshot entries omitted." = "Đã bỏ qua %d mục snapshot."; +"Exclusive snapshot bytes are unavailable from this inspection. Snapshot count does not measure reclaimable space. No snapshots are created, mounted, thinned or deleted." = "Kiểm tra này không cung cấp số byte riêng của snapshot. Số snapshot không đo dung lượng có thể thu hồi. Không tạo, mount, thin hay xóa snapshot."; +"Inspect to read container space and Data volume snapshots." = "Kiểm tra để đọc dung lượng container và snapshots của volume Data."; +"CPU scheduling" = "Scheduling CPU"; +"Lower priority raises nice to at least 10 for this same-user process. It is not a CPU percentage cap. Raising priority back may require privileges; relaunch the app to reset. Child processes are not individually controlled." = "Hạ priority sẽ tăng nice lên ít nhất 10 cho process cùng user. Đây không phải CPU cap theo phần trăm. Tăng priority trở lại có thể cần quyền; khởi động lại app để reset. Không điều khiển riêng process con."; +"Lower process priority…" = "Hạ priority process…"; +"Lower process priority?" = "Hạ priority process?"; +"Lower priority" = "Hạ priority"; +"Process priority lowered and rechecked. This does not guarantee lower CPU usage." = "Đã hạ và kiểm tra lại priority process. Không bảo đảm CPU usage sẽ giảm."; +"Priority change could not be verified. The process may have exited, changed identity or denied access. No retry is automatic." = "Chưa xác minh được thay đổi priority. Process có thể đã thoát, đổi identity hoặc từ chối quyền. Không tự động thử lại."; +"This changes scheduling for the selected process. Restoring the previous priority is not guaranteed without relaunching it." = "Thao tác thay đổi scheduling của process đã chọn. Không bảo đảm khôi phục priority cũ nếu chưa khởi động lại process."; +"Storage Growth Detective" = "Theo dõi tăng trưởng dung lượng"; +"Baseline saved. Scan the same location again to compare growth." = "Đã lưu baseline. Quét lại cùng vị trí để so sánh thay đổi."; +"Scans are not comparable: scope, identity, coverage or checkpoint format differs. A new baseline was saved." = "Không thể so sánh các lần quét: scope, identity, coverage hoặc format checkpoint khác nhau. Đã lưu baseline mới."; +"Comparable observed folder changes. Decreases do not prove APFS space was freed; changes do not identify their cause." = "Thay đổi thư mục quan sát được từ hai lần quét tương thích. Giảm size không chứng minh APFS đã giải phóng space; thay đổi không xác định nguyên nhân."; +"No comparable folder deltas to display." = "Không có delta thư mục tương thích để hiển thị."; +"Developer Workspace" = "Workspace lập trình"; +"Recognized projects within selected roots. Manifest names are evidence of an ecosystem, not proof that its runtime is installed. Source size and runtime versions are not measured." = "Dự án nhận diện trong các root đã chọn. Tên manifest là evidence hệ sinh thái, không chứng minh đã cài runtime. Chưa đo size source và version runtime."; +"%d scan issues; results may be partial." = "Có %d vấn đề khi quét; kết quả có thể chưa đầy đủ."; +"%d recognized artifacts; size is observed artifact data only." = "Có %d artifact nhận diện; size chỉ là dữ liệu artifact đã quan sát."; +"No recognized project manifests within scan coverage." = "Không có manifest dự án nhận diện trong phạm vi quét."; +"Only the first 100 projects are displayed. Artifact filters remain available below." = "Chỉ hiển thị 100 dự án đầu. Các bộ lọc artifact vẫn ở phía dưới."; +"App Activity Timeline" = "Lịch sử hoạt động app"; +"Recent in-memory CPU trends for running processes mapped by bundle path and launch identity. Helper processes, older activity and unverified ownership may be missing. Related-data sizes remain scan-time observations." = "CPU trend gần đây trong RAM cho process đang chạy được map bằng bundle path và launch identity. Có thể thiếu helper, hoạt động cũ và ownership chưa xác minh. Size data liên quan vẫn là quan sát lúc quét."; +"No running process could be mapped within current coverage." = "Không map được process đang chạy trong coverage hiện tại."; +"Inspect app sleep requests" = "Kiểm tra sleep requests của app"; +"No sleep requests attributed to mapped processes within this snapshot's coverage." = "Không có sleep request được quy cho process đã map trong coverage của snapshot này."; +"Some assertion entries were omitted; coverage is partial." = "Một số assertion bị bỏ qua; coverage chưa đầy đủ."; +"warning" = "Cảnh báo"; +"critical" = "Nghiêm trọng"; +"serious" = "Cao"; + +"%@, PID %d, %@, %@, %@" = "%@, PID %d, %@, %@, %@"; +"Measure workspace" = "Đo workspace"; +"Observed workspace size" = "Dung lượng workspace đã quan sát"; +"Other observed project data" = "Dữ liệu dự án khác đã quan sát"; + +"Startup volume" = "Volume khởi động"; +"Data volume" = "Volume Data"; +"System volume" = "Volume System"; +"Selected volume used" = "Dung lượng volume đã chọn đã dùng"; +"Selected startup volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." = "Chỉ volume khởi động đã chọn. Các volume dùng chung capacity APFS container; quét thư mục, dung lượng khả dụng và size snapshot là các phép đo khác nhau."; +"No APFS snapshots reported for the selected volume. Other volumes may have snapshots." = "OS không báo APFS snapshot trên volume đã chọn. Các volume khác có thể có snapshots."; +"Inspect to read container space and selected-volume snapshots." = "Kiểm tra để đọc dung lượng container và snapshots của volume đã chọn."; + +"Stop" = "Dừng"; + +"Inspect selected process" = "Xem process đã chọn"; + +"Volumes sharing this container" = "Các volume dùng chung container"; +"Unattributed container allocation" = "Allocation container chưa quy cho volume"; +"Container remainder uses OS-reported capacity minus free space and listed volume usage. It is not hidden-file size or exclusive snapshot space. Incomplete or inconsistent accounting stays unknown." = "Phần allocation còn lại được tính từ capacity OS báo trừ free space và usage các volume liệt kê. Đây không phải size file ẩn hay exclusive snapshot space. Accounting thiếu hoặc không nhất quán giữ unknown."; +"%d volume entries omitted." = "Đã bỏ qua %d mục volume."; + +"Preview the exact JSON before saving. No dedicated path, username, hostname or network endpoint fields are included. Opt-in process names may contain identifying text. Nothing is uploaded." = "Xem chính xác JSON trước khi lưu. Không kèm field riêng cho path, username, hostname hay endpoint mạng. Tên process khi bật tùy chọn có thể chứa thông tin nhận diện. Không upload dữ liệu."; + +"Hardware Inspector" = "Kiểm tra phần cứng"; +"Hardware profile" = "Hồ sơ phần cứng"; +"Hardware report" = "Báo cáo phần cứng"; +"Refresh hardware" = "Đọc lại phần cứng"; +"Export hardware report…" = "Xuất báo cáo phần cứng…"; +"Reading hardware profile…" = "Đang đọc hồ sơ phần cứng…"; +"Model identifier" = "Mã model"; +"Operating system" = "Hệ điều hành"; +"Processor" = "Bộ xử lý"; +"Physical cores" = "Core vật lý"; +"Logical processors" = "Bộ xử lý logic"; +"Reported nominal frequency" = "Tần số danh định do OS báo"; +"Page size" = "Kích thước page"; +"Cache line size" = "Kích thước cache line"; +"L1 instruction cache" = "Cache lệnh L1"; +"L1 data cache" = "Cache dữ liệu L1"; +"L2 cache" = "Cache L2"; +"L3 cache" = "Cache L3"; +"Performance level" = "Nhóm hiệu năng"; +"CPU level %d" = "Nhóm CPU %d"; +"Cores sharing L2" = "Core dùng chung L2"; +"Metal device" = "Thiết bị Metal"; +"Unified memory" = "Bộ nhớ hợp nhất"; +"Recommended GPU working set" = "Working set GPU khuyến nghị"; +"Performance" = "Hiệu năng"; +"Efficiency" = "Tiết kiệm điện"; +"Yes" = "Có"; +"No" = "Không"; +"Recent sensor range" = "Khoảng giá trị cảm biến gần đây"; +"Reset range" = "Đặt lại khoảng đo"; +"Sensor" = "Chỉ số"; +"Current" = "Hiện tại"; +"Minimum" = "Thấp nhất"; +"Maximum" = "Cao nhất"; +"Samples" = "Mẫu"; +"Disk read" = "Đọc ổ đĩa"; +"Disk write" = "Ghi ổ đĩa"; +"Network receive" = "Nhận qua mạng"; +"Network send" = "Gửi qua mạng"; +"Cache sizes are OS-reported values, not summed totals. Nominal frequency is not a live core clock. The GPU working set is a recommendation, not VRAM capacity or current usage." = "Kích thước cache là giá trị OS báo, không phải tổng đã cộng. Tần số danh định không phải xung core đang chạy. Working set GPU là khuyến nghị, không phải dung lượng VRAM hay mức sử dụng hiện tại."; +"Min/max covers available observations in the latest sampling generation, up to 60 seconds. Missing readings are gaps. Reset affects this range only; it does not clear monitoring history." = "Min/max dùng các mẫu có dữ liệu trong sampling generation mới nhất, tối đa 60 giây. Mẫu thiếu là khoảng trống. Đặt lại chỉ tác động khoảng đo này, không xóa lịch sử theo dõi."; +"Sensor coverage: temperatures in °C, fan RPM, voltage, package power, live core clocks, SPD timings and SMART health are not collected. Thermal state is the macOS condition, not a temperature. No fan control, overclocking or stress test is performed." = "Phạm vi cảm biến: chưa thu thập nhiệt độ °C, RPM quạt, điện áp, công suất package, xung core trực tiếp, timing SPD và sức khỏe SMART. Thermal state là trạng thái macOS, không phải nhiệt độ. Không điều khiển quạt, ép xung hay stress test."; + +"Preview the hardware profile and recent range before saving. No serial, hostname, paths, process names or network endpoints are included. Nothing is uploaded." = "Xem hồ sơ phần cứng và khoảng đo gần đây trước khi lưu. Không có serial, hostname, path, tên process hay endpoint mạng. Không upload dữ liệu."; + +"Hardware architecture" = "Kiến trúc phần cứng"; + +"System information" = "Thông tin hệ thống"; + +"Temperature & Fans" = "Nhiệt độ & Quạt"; +"CPU sensors" = "Cảm biến CPU"; +"GPU sensors" = "Cảm biến GPU"; +"Fans" = "Quạt"; +"Battery sensors" = "Cảm biến pin"; +"System power" = "Công suất hệ thống"; +"CPU • hottest mapped sensor" = "CPU • sensor đã map nóng nhất"; +"GPU • hottest mapped sensor" = "GPU • sensor đã map nóng nhất"; +"SMC reports no fans. This is not a fan-speed measurement." = "SMC báo không có quạt. Đây không phải phép đo tốc độ quạt."; +"Fan count unavailable; no fan speeds inferred." = "Không đọc được số quạt; không suy ra tốc độ quạt."; +"Fan coverage is capped at 8 reported fans." = "Phạm vi quạt giới hạn ở8 quạt do SMC báo."; +"Read-only SMC sensors refresh every 10 seconds while monitoring runs. Coverage is limited and model-dependent. CPU/GPU labels use community mappings, not verified physical core IDs. Missing readings are never zero." = "Cảm biến SMC chỉ đọc, cập nhật mỗi10 giây khi monitoring chạy. Phạm vi giới hạn và tùy model. Label CPU/GPU dùng mapping cộng đồng, không phải ID core vật lý đã xác minh. Mẫu thiếu không được coi là0."; +"Temperatures are individual mapped readings, not CPU/GPU package averages or a safety diagnosis. Fan speeds are observed RPM only; Orven does not change fan modes or targets. Voltage, live clocks, SPD and SMART are not collected." = "Nhiệt độ là từng mẫu sensor đã map, không phải trung bình package CPU/GPU hay chẩn đoán an toàn. Quạt chỉ hiển thị RPM quan sát; Orven không đổi mode/target quạt. Chưa thu điện áp, xung trực tiếp, SPD và SMART."; +"SMC temperature, fan and power coverage is shown above. Thermal state remains a separate macOS condition. Voltage, live core clocks, SPD timings, SMART health, fan control, overclocking and stress tests are not provided." = "Phạm vi nhiệt độ, quạt và công suất SMC hiển thị phía trên. Thermal state vẫn là trạng thái macOS riêng. Chưa cung cấp điện áp, xung core trực tiếp, timing SPD, SMART, điều khiển quạt, ép xung hay stress test."; + +"Review temperatures and fans" = "Xem nhiệt độ và quạt"; +"Per-process energy: Unsupported" = "Năng lượng theo process: Chưa hỗ trợ"; + +/* Health history, alerts, recording and connection changes. */ +"Health History & Baseline" = "Lịch sử sức khỏe & Mốc tham chiếu"; +"Record health history locally" = "Lưu lịch sử sức khỏe trên máy"; +"One observed sample per minute, up to seven days. No process names, paths or network endpoints. Paused, sleep and missing samples are gaps; enabling history does not enable background monitoring." = "Lưu một mẫu quan sát mỗi phút, tối đa bảy ngày. Không lưu tên tiến trình, đường dẫn hay địa chỉ mạng. Tạm dừng, ngủ và mẫu thiếu tạo khoảng trống; bật lịch sử không tự bật giám sát nền."; +"History retention" = "Thời gian giữ lịch sử"; +"%d recorded observations" = "%d mẫu quan sát đã lưu"; +"Time range" = "Khoảng thời gian"; +"1 hour" = "1 giờ"; +"Export history JSON…" = "Xuất lịch sử JSON…"; +"Baseline comparison" = "So sánh với mốc tham chiếu"; +"Capture baseline" = "Lưu mốc tham chiếu"; +"Capture the latest 30–60 recorded samples as a reference. Compare medians with at least ten newer samples per metric. Workload differences can explain changes; this is not causal diagnosis." = "Dùng 30–60 mẫu đã lưu gần nhất làm mốc tham chiếu. So sánh trung vị khi mỗi chỉ số có ít nhất mười mẫu mới hơn. Khối lượng công việc khác nhau có thể tạo chênh lệch; đây không phải chẩn đoán nguyên nhân."; +"Not enough baseline or newer observations yet. Keep recording during comparable workloads." = "Chưa đủ mẫu tham chiếu hoặc mẫu mới. Tiếp tục ghi trong các khối lượng công việc tương đương."; +"Baseline median" = "Trung vị tham chiếu"; +"Recent median" = "Trung vị gần đây"; +"Chart metric" = "Chỉ số trên biểu đồ"; +"Occupied memory" = "Bộ nhớ chiếm dụng"; +"Temperature labels use community mappings; missing readings are gaps. Fan RPM and system power are observations, not fan control or CPU package power." = "Nhãn nhiệt độ dùng mapping cộng đồng; mẫu thiếu tạo khoảng trống. RPM quạt và công suất hệ thống là số liệu quan sát, không phải điều khiển quạt hay công suất package CPU."; +"Sensor Charts & Recording" = "Biểu đồ & Phiên ghi cảm biến"; +"Record existing samples every ten seconds for 15, 30 or 60 minutes. Sleep extends the wall-clock duration. The session stays in memory until Quit. Sampling pauses when the app is inactive unless menu bar monitoring is enabled; no readings are backfilled." = "Ghi mẫu hiện có mỗi mười giây trong 15, 30 hoặc 60 phút. Thời gian ngủ kéo dài thời lượng thực tế. Phiên chỉ giữ trong bộ nhớ đến khi thoát. Lấy mẫu tạm dừng khi app không hoạt động, trừ khi bật giám sát menu bar; không điền bù mẫu thiếu."; +"Recording duration" = "Thời lượng phiên ghi"; +"15 minutes" = "15 phút"; +"30 minutes" = "30 phút"; +"60 minutes" = "60 phút"; +"Start recording" = "Bắt đầu ghi"; +"Stop recording" = "Dừng ghi"; +"Recording observed samples" = "Đang ghi mẫu quan sát"; +"Recording stopped" = "Đã dừng ghi"; +"Recording paused until monitoring resumes" = "Phiên ghi tạm dừng đến khi giám sát tiếp tục"; +"Export session JSON…" = "Xuất phiên ghi JSON…"; +"Export session CSV…" = "Xuất phiên ghi CSV…"; +"Smart Alerts" = "Cảnh báo thông minh"; +"Enable health alerts" = "Bật cảnh báo sức khỏe"; +"Off by default. Fresh active evidence only: sustained CPU, memory pressure, low startup space and elevated thermal state. A recovered condition must stay clear for 30 seconds; repeat alerts wait at least 15 minutes. Nothing is changed automatically." = "Mặc định tắt. Chỉ dùng bằng chứng mới khi giám sát hoạt động: CPU cao kéo dài, áp lực bộ nhớ, ổ khởi động ít trống và trạng thái nhiệt cao. Trạng thái phục hồi phải duy trì 30 giây; cảnh báo lặp cách ít nhất 15 phút. Không tự thay đổi máy."; +"Allow macOS notifications…" = "Cho phép thông báo macOS…"; +"Snooze for one hour" = "Tạm hoãn một giờ"; +"No alert events in this app session." = "Chưa có cảnh báo trong phiên app này."; +"Recent pressure and thermal observations" = "Quan sát áp lực bộ nhớ và nhiệt gần đây"; +"History could not be read. The existing file is preserved; recording to disk is blocked." = "Không đọc được lịch sử. Giữ nguyên file hiện có; đã chặn ghi xuống đĩa."; +"History could not be saved. Existing data is preserved; check the local storage location." = "Không lưu được lịch sử. Giữ nguyên dữ liệu hiện có; kiểm tra vị trí lưu trên máy."; +"Baseline needs at least 30 recorded samples and writable local storage." = "Mốc tham chiếu cần ít nhất 30 mẫu đã lưu và vị trí lưu có quyền ghi."; +"History retention could not be saved. Existing data is preserved." = "Không lưu được thời gian giữ lịch sử. Dữ liệu hiện có được giữ nguyên."; +"Notifications require the Orven app bundle." = "Thông báo cần chạy từ bundle app Orven."; +"Notification permission granted." = "Đã được cho phép thông báo."; +"Notifications are not allowed. In-app alerts remain available." = "Thông báo chưa được cho phép. Vẫn xem được cảnh báo trong app."; +"Notification permission could not be requested. In-app alerts remain available." = "Không yêu cầu được quyền thông báo. Vẫn xem được cảnh báo trong app."; +"Alerts snoozed for one hour." = "Đã tạm hoãn cảnh báo một giờ."; +"Notification delivery failed. Review the in-app alert evidence." = "Không gửi được thông báo. Xem bằng chứng cảnh báo trong app."; +"Network Connection Changes" = "Thay đổi kết nối mạng"; +"Track observed connection changes this session" = "Theo dõi thay đổi kết nối trong phiên này"; +"Explicit Refresh compares observable sockets with verified PID/start identities. New or no-longer-observed does not mean opened or closed at this time. Short connections and restricted processes may be missed. Process names and numeric endpoints stay in memory, capped at 200 events; nothing is uploaded or saved to disk." = "Bấm Làm mới để so sánh socket quan sát được với PID/start đã xác minh. Mới thấy hoặc không còn thấy không chứng minh thời điểm mở/đóng. Có thể bỏ lỡ kết nối ngắn và tiến trình bị hạn chế. Tên tiến trình và địa chỉ số chỉ giữ trong bộ nhớ, tối đa 200 sự kiện; không upload hay lưu đĩa."; +"Connection baseline established" = "Đã có mốc tham chiếu kết nối"; +"Refresh to establish a connection baseline" = "Làm mới để tạo mốc tham chiếu kết nối"; +"%d unverified sockets excluded; %d older events omitted." = "Loại %d socket chưa xác minh; bỏ %d sự kiện cũ."; +"Clear connection history" = "Xóa lịch sử kết nối"; +"No connection changes observed yet." = "Chưa quan sát thấy thay đổi kết nối."; +"Showing the latest 50 change events." = "Hiển thị 50 thay đổi gần nhất."; +"Newly observed" = "Mới quan sát thấy"; +"No longer observed" = "Không còn quan sát thấy"; +"State changed" = "Trạng thái thay đổi"; +"Local report export" = "Xuất báo cáo trên máy"; +"Preview the exact local report before saving. Nothing is uploaded." = "Xem chính xác báo cáo trước khi lưu. Không upload dữ liệu."; +"Save report…" = "Lưu báo cáo…"; +"normal" = "Bình thường"; +"nominal" = "Bình thường"; +"fair" = "Hơi cao"; +"unknown" = "Chưa rõ"; + +"Change" = "Thay đổi"; +"Memory pressure" = "Áp lực bộ nhớ"; +"System CPU" = "CPU hệ thống"; + +"%d older observations omitted by the file-size budget." = "Bỏ %d mẫu cũ do giới hạn dung lượng file."; + +"Health History" = "Lịch sử sức khỏe"; + +"Could not prepare the local report. Stop recording and try again." = "Không chuẩn bị được báo cáo. Dừng phiên ghi rồi thử lại."; +"Waiting for macOS notification permission. Respond to the system prompt if it appears." = "Đang chờ quyền thông báo macOS. Phản hồi hộp thoại hệ thống nếu xuất hiện."; + +"This report is frozen when opened. Close and reopen for newer observations. Preview the exact content before saving; nothing is uploaded." = "Báo cáo được chốt khi mở. Đóng rồi mở lại để lấy mẫu mới hơn. Xem chính xác nội dung trước khi lưu; không upload dữ liệu."; +"Report saved." = "Đã lưu báo cáo."; +"Could not save the report. Choose a writable location and try again." = "Không lưu được báo cáo. Chọn vị trí có quyền ghi rồi thử lại."; + +/* Wi-Fi and LAN tools */ +"Wi-Fi & LAN" = "Wi‑Fi & LAN"; +"Network Overview" = "Tổng quan mạng"; +"Wi-Fi Inspector" = "Kiểm tra Wi‑Fi"; +"Ethernet Inspector" = "Kiểm tra Ethernet"; +"Connection Diagnostics" = "Chẩn đoán kết nối"; +"Latency & Stability" = "Độ trễ & ổn định"; +"Nearby Wi-Fi" = "Wi‑Fi xung quanh"; +"Network Change Timeline" = "Thay đổi mạng"; +"Network tool" = "Công cụ mạng"; +"Refresh network status" = "Làm mới trạng thái mạng"; +"Last network observation" = "Lần quan sát mạng gần nhất"; +"Path interfaces" = "Interface của đường mạng"; +"Low Data Mode" = "Chế độ dữ liệu thấp"; +"Expensive path" = "Đường mạng có chi phí cao"; +"An available path does not prove that an Internet destination is reachable." = "Đường mạng khả dụng chưa chứng minh có thể truy cập một đích Internet."; +"Primary interface" = "Interface chính"; +"IPv4 gateway" = "Gateway IPv4"; +"IPv6 gateway" = "Gateway IPv6"; +"DNS servers" = "Máy chủ DNS"; +"No matching network adapter was observed." = "Không quan sát được adapter mạng phù hợp."; +"IP addresses" = "Địa chỉ IP"; +"MTU" = "MTU"; +"Negotiated media" = "Liên kết được thương lượng"; +"Received / sent bytes" = "Byte nhận / gửi"; +"Input / output packets" = "Packet nhận / gửi"; +"Input / output errors" = "Lỗi nhận / gửi"; +"Input queue drops" = "Packet bị loại ở hàng đợi nhận"; +"Adapter counters are cumulative observations, not session traffic or Internet speed. Packet/error counters may wrap. Live rates reuse the existing sampler." = "Bộ đếm adapter là số tích lũy, không phải lưu lượng phiên hoặc tốc độ Internet. Bộ đếm packet/lỗi có thể quay vòng. Tốc độ hiện tại dùng sampler hiện có."; +"%d adapters omitted by the display limit." = "%d adapter không hiển thị do giới hạn."; +"Refresh to inspect Wi-Fi and LAN." = "Làm mới để kiểm tra Wi‑Fi và LAN."; +"SSID and BSSID can be hidden by macOS location permissions. Missing identity does not mean Wi-Fi is disconnected." = "macOS có thể ẩn SSID/BSSID theo quyền vị trí. Thiếu định danh không có nghĩa Wi‑Fi bị ngắt."; +"Request Wi-Fi identity permission" = "Xin quyền xem định danh Wi‑Fi"; +"No Wi-Fi interface was observed." = "Không quan sát được interface Wi‑Fi."; +"Unavailable or permission restricted" = "Không có dữ liệu hoặc bị hạn chế quyền"; +"SSID" = "SSID"; +"BSSID" = "BSSID"; +"RSSI" = "RSSI"; +"Noise" = "Nhiễu"; +"SNR" = "SNR"; +"Channel" = "Kênh"; +"Band" = "Băng tần"; +"Channel width" = "Độ rộng kênh"; +"Transmit link rate" = "Tốc độ liên kết gửi"; +"CoreWLAN reports local radio observations. Link rate is not measured Internet throughput; SNR is RSSI minus noise from this observation." = "CoreWLAN cung cấp thông số vô tuyến cục bộ. Tốc độ liên kết không phải tốc độ Internet được đo; SNR là RSSI trừ noise của lần quan sát này."; +"Destination hostname or IP address" = "Hostname hoặc IP đích"; +"Example: example.com or 192.168.1.1" = "Ví dụ: example.com hoặc 192.168.1.1"; +"Measurements send traffic to the displayed destination. Connection diagnostics also tests the recently observed gateway, resolves the destination and opens TCP port 443. No payload or speed-test download is sent." = "Phép đo gửi lưu lượng tới đích đang hiển thị. Chẩn đoán cũng thử gateway mới quan sát, phân giải đích và mở kết nối TCP cổng 443. Không gửi payload hoặc tải dữ liệu đo tốc độ."; +"Check connection" = "Kiểm tra kết nối"; +"Measure 20 ICMP probes" = "Đo 20 probe ICMP"; +"Refresh network status first to include a gateway observed within 60 seconds. Each stage is bounded; total connection check is at most 30 seconds." = "Làm mới trạng thái trước để đo gateway được quan sát trong 60 giây. Từng bước có giới hạn; tổng thời gian kiểm tra tối đa 30 giây."; +"Destination" = "Đích"; +"Elapsed" = "Thời gian"; +"ICMP may be blocked while Internet access works. DNS results may be cached. A successful TCP connection does not verify HTTP, TLS, captive portals or every application." = "ICMP có thể bị chặn dù Internet vẫn hoạt động. DNS có thể dùng cache. Kết nối TCP thành công chưa xác minh HTTP, TLS, captive portal hoặc mọi ứng dụng."; +"20 probes, one second apart, at most 28 seconds. Loss uses the ICMP summary; jitter is the mean absolute difference between adjacent successful replies. Timeouts break the chart." = "20 probe cách nhau một giây, tối đa 28 giây. Mất packet dùng tổng kết ICMP; jitter là trung bình chênh lệch tuyệt đối giữa hai phản hồi thành công liền kề. Timeout tạo khoảng trống trên biểu đồ."; +"Mean latency" = "Độ trễ trung bình"; +"Jitter" = "Jitter"; +"Packet loss" = "Mất packet"; +"ICMP did not complete successfully; missing results are unknown or blocked." = "ICMP không hoàn tất thành công; kết quả thiếu chưa rõ hoặc bị chặn."; +"ICMP round-trip latency" = "Độ trễ khứ hồi ICMP"; +"Scan nearby Wi-Fi" = "Quét Wi‑Fi xung quanh"; +"Scan is explicit and controlled by macOS. Stop suppresses results after the OS scan returns; it cannot interrupt CoreWLAN. Identity may require location permission. Results stay in this window only." = "Quét theo yêu cầu và do macOS điều khiển. Dừng sẽ bỏ kết quả sau khi OS trả về, không ngắt được CoreWLAN. Định danh có thể cần quyền vị trí. Kết quả chỉ giữ trong cửa sổ này."; +"Filter SSID, BSSID or channel" = "Lọc SSID, BSSID hoặc kênh"; +"%d visible networks; %d omitted." = "%d mạng đang hiển thị; %d mạng bị giới hạn."; +"No matching Wi-Fi network was observed." = "Không quan sát được mạng Wi‑Fi phù hợp."; +"Hidden or permission restricted" = "Bị ẩn hoặc hạn chế quyền"; +"Channel / band" = "Kênh / băng tần"; +"RSSI / noise" = "RSSI / nhiễu"; +"Observed networks on this channel" = "Mạng quan sát trên kênh này"; +"Channel counts describe this scan, not airtime utilization or interference. Different bands can reuse channel numbers; wider channels can overlap. Signal bars are a relative RSSI display." = "Số mạng trên kênh chỉ mô tả lần quét, không phải mức sử dụng airtime hay nhiễu. Các băng tần có thể trùng số kênh; kênh rộng có thể chồng lấn. Thanh tín hiệu biểu diễn RSSI tương đối."; +"Track network changes in this window" = "Theo dõi thay đổi mạng trong cửa sổ"; +"Clear network changes" = "Xóa thay đổi mạng"; +"Path events are observed while this page is open. IP, gateway, DNS and Wi-Fi identity changes compare explicit successful refreshes within ten minutes. Missing identities and adapter disappearance are not inferred as changes. First refresh establishes a baseline. No history is written to disk." = "Quan sát sự kiện đường mạng khi trang mở. IP, gateway, DNS và định danh Wi‑Fi được so sánh giữa các lần làm mới thành công trong mười phút. Không suy diễn thay đổi khi thiếu định danh hoặc adapter biến mất. Lần đầu tạo baseline. Không ghi lịch sử xuống đĩa."; +"%d status changes and %d path events; %d older events omitted." = "%d thay đổi trạng thái và %d sự kiện đường mạng; %d sự kiện cũ bị giới hạn."; +"No network changes observed in this session." = "Chưa quan sát thay đổi mạng trong phiên."; +"Before" = "Trước"; +"After" = "Sau"; +"Reading network status" = "Đang đọc trạng thái mạng"; +"Scanning Wi-Fi" = "Đang quét Wi‑Fi"; +"Measuring latency" = "Đang đo độ trễ"; +"Checking connection" = "Đang kiểm tra kết nối"; +"Stopping after the macOS Wi-Fi scan returns." = "Đang dừng sau khi macOS trả kết quả quét Wi‑Fi."; +"Stopping measurement." = "Đang dừng phép đo."; +"Enter a hostname or IP address without a URL, port or spaces." = "Nhập hostname hoặc IP, không nhập URL, cổng hoặc khoảng trắng."; +"Observation completed." = "Đã hoàn tất quan sát."; +"Cancelled. Previous results remain timestamped." = "Đã hủy. Kết quả trước vẫn giữ timestamp."; +"Network observation failed or timed out. Check permissions and retry." = "Quan sát mạng thất bại hoặc timeout. Kiểm tra quyền và thử lại."; +"Not requested" = "Chưa yêu cầu"; +"Authorized" = "Đã cấp quyền"; +"Denied" = "Bị từ chối"; +"Restricted" = "Bị hạn chế"; +"Available path" = "Đường mạng khả dụng"; +"Connection required" = "Cần thiết lập kết nối"; +"No available path" = "Không có đường mạng khả dụng"; +"Gateway ICMP" = "ICMP tới gateway"; +"DNS resolution" = "Phân giải DNS"; +"TCP connection" = "Kết nối TCP"; +"Failed or blocked" = "Thất bại hoặc bị chặn"; +"Timed out or unavailable" = "Timeout hoặc không có dữ liệu"; +"Link state" = "Trạng thái link"; +"Ethernet" = "Ethernet"; +"Other" = "Khác"; +"Global DNS configuration is shown; scoped VPN and per-domain resolvers may differ." = "Hiển thị cấu hình DNS toàn cục; DNS riêng cho VPN hoặc từng miền có thể khác."; +"Observed networks by channel" = "Mạng quan sát theo kênh"; +"Wi-Fi" = "Wi-Fi"; +"Not applicable to a numeric IP" = "Không áp dụng cho địa chỉ IP"; +"Active" = "Hoạt động"; +"Inactive" = "Không hoạt động"; +"Carrier state" = "Trạng thái carrier"; +"Link detected" = "Phát hiện link"; +"No link detected" = "Không phát hiện link"; +"Up / running" = "Up / running"; +"Down / not running" = "Down / không running"; +"Interface flags" = "Cờ interface"; diff --git a/apps/toolbox/Resources/zh-Hans.lproj/Changes.strings b/apps/toolbox/Resources/zh-Hans.lproj/Changes.strings new file mode 100644 index 0000000..31e49e1 --- /dev/null +++ b/apps/toolbox/Resources/zh-Hans.lproj/Changes.strings @@ -0,0 +1,123 @@ +/* Orven zh-Hans localization. */ +"%lld thay đổi" = "%lld 项更改"; +"Application Support, Cache và Container được quan sát sâu hai cấp để nhận diện thay đổi bên trong thư mục ứng dụng." = "观察 Application Support、Cache 和 Container 目录的两层结构,以检测内部更改。"; +"Application bundle thay đổi; kiểm tra version, Team ID và chữ ký." = "应用包已更改。请检查版本、Team ID 和签名。"; +"Baseline có" = "基线包含"; +"Baseline tin cậy" = "可信基线"; +"Baseline vẫn còn nguyên. Có thể tiếp tục với coverage giảm hoặc bỏ phiên này." = "基线完整。可在缩小覆盖范围后继续,或放弃此追踪。"; +"Baseline đã lưu. macOS có thể mở installer an toàn." = "已保存基线。macOS 现在可以打开安装程序。"; +"Browser extension có thể đọc hoặc thay đổi dữ liệu trong trình duyệt." = "浏览器扩展可能读取或修改浏览器数据。"; +"Bắt đầu một phiên thay đổi" = "开始更改会话"; +"Bỏ chọn" = "清除"; +"Bỏ phiên" = "放弃追踪"; +"Changeora chỉ đọc metadata cục bộ, không đọc nội dung tài liệu, không telemetry và không tự động xóa hoặc hoàn tác thay đổi." = "Changeora 仅读取本地元数据,不读取文档内容,不进行遥测,不删除数据,也不自动撤销更改。"; +"Changeora sẽ lưu snapshot và bật FSEvents. Sau đó bạn cài, cập nhật hoặc gỡ ứng dụng như bình thường rồi quay lại để so sánh." = "Changeora 保存快照并启动 FSEvents。安装、更新或卸载应用后,返回进行比较。"; +"Changeora đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại." = "Changeora 提供技术证据,不会判定应用具有恶意。"; +"Chưa có lịch sử" = "暂无历史记录"; +"Chưa có phiên so sánh." = "尚未选择比较会话。"; +"Chưa có thay đổi" = "暂无更改"; +"Chỉ đọc metadata như đường dẫn, thời gian, kích thước, Bundle ID và chữ ký." = "读取路径、时间戳、大小、Bundle ID 和签名等元数据。"; +"Chọn DMG, PKG hoặc ứng dụng" = "选择 DMG、PKG 或应用"; +"Chọn installer…" = "选择安装程序…"; +"Chọn phiên" = "选择会话"; +"Chụp trạng thái ban đầu" = "记录初始状态"; +"Configuration profile có thể thay đổi policy và thiết lập quản trị của macOS." = "配置描述文件可能更改 macOS 策略和管理设置。"; +"Coverage giảm vì Orven không chạy trong một phần của phiên." = "Orven 在部分会话期间未运行,因此覆盖范围有所缩小。"; +"Cài đặt / cập nhật / gỡ ứng dụng" = "安装/更新/卸载应用"; +"Các phiên so sánh hoàn tất sẽ xuất hiện tại đây." = "已完成的比较会话将显示在此处。"; +"Dữ liệu người dùng hoặc metadata ứng dụng thay đổi trong Library." = "Library 中的用户数据或应用元数据已更改。"; +"FSEvent" = "FSEvents"; +"FSEvents và snapshot ghi nhận quá trình cài đặt, cập nhật hoặc uninstall." = "FSEvents 和快照记录安装、更新和卸载活动。"; +"Giới thiệu Changeora" = "关于 Changeora"; +"Hiển thị trong Finder" = "在 Finder 中显示"; +"Hoàn tất" = "完成"; +"Hoàn tất và so sánh" = "完成并比较"; +"Hãy hoàn thành một phiên theo dõi cài đặt để xem kết quả trước và sau." = "完成安装监测会话后,可比较前后状态。"; +"Hủy" = "取消"; +"Hủy phiên" = "取消会话"; +"Install Trace bị gián đoạn" = "安装追踪已中断"; +"Install Trace đã lưu" = "已保存安装追踪"; +"Kernel extension tác động ở mức hệ thống và cần được xem xét cẩn thận." = "内核扩展在系统级别运行,需要仔细检查。"; +"Không có Install Trace đang hoạt động." = "没有正在进行的安装追踪。"; +"Không có kết quả phù hợp" = "没有匹配结果"; +"Không phát hiện" = "无"; +"Không phát hiện thay đổi trong phạm vi theo dõi." = "监测范围内未检测到更改。"; +"Không thể" = "无法"; +"Không thể đọc" = "无法读取"; +"Không tự xóa, tắt service hoặc sửa System Settings." = "不会删除数据、停用服务或修改系统设置。"; +"Không đọc nội dung tài liệu cá nhân và không gửi dữ liệu ra mạng." = "不会读取个人文档内容或通过网络发送数据。"; +"Kết quả gần nhất" = "最新结果"; +"Language / Ngôn ngữ" = "语言"; +"LaunchDaemon mới có thể chạy nền với quyền hệ thống/root." = "新的 LaunchDaemon 可能以系统/root 权限在后台运行。"; +"Làm mới" = "刷新"; +"Lưu baseline và mở installer" = "保存基线并打开安装程序"; +"Lịch sử" = "历史记录"; +"Minh bạch về dữ liệu Changeora đọc, lưu và không thể quan sát." = "说明 Changeora 读取、保存及无法观察的数据。"; +"Minh bạch về dữ liệu Orven đọc, lưu và không thể quan sát." = "说明 Orven 读取、保存及无法观察的数据。"; +"Mọi báo cáo được lưu cục bộ trên máy Mac này." = "所有报告均保存在此 Mac 本地。"; +"Một Install Trace đang hoạt động." = "已有正在进行的安装追踪。"; +"Một số vùng được macOS bảo vệ có thể cần Full Disk Access để quan sát đầy đủ." = "完整检查受 macOS 保护的位置可能需要完全磁盘访问权限。"; +"Mở báo cáo" = "打开报告"; +"Mục thay đổi trong phiên gần nhất" = "最近会话中的更改"; +"Mức" = "风险"; +"Nguyên tắc an toàn" = "安全原则"; +"Nên xem" = "需检查"; +"Package receipt là bằng chứng cài đặt do Installer ghi lại." = "软件包收据是 Installer 记录的安装证据。"; +"Phiên sau" = "较晚的会话"; +"Phiên trước" = "较早的会话"; +"Phạm vi & quyền riêng tư" = "覆盖范围与隐私"; +"Quan trọng" = "重要"; +"Shell/PATH thay đổi có thể tác động mọi terminal session và command được thực thi." = "Shell/PATH 更改可能影响所有终端会话和执行的命令。"; +"Snapshot ban đầu có %lld mục. FSEvents đang ghi thay đổi sâu; hãy hoàn tất thao tác trước khi tạo snapshot thứ hai." = "初始快照包含 %lld 项。FSEvents 正在记录深层更改;请完成操作后再获取第二份快照。"; +"Snapshot và FSEvents chỉ lưu metadata cục bộ. Orven không mount, execute hoặc bypass Gatekeeper." = "快照和 FSEvents 仅保存本地元数据。Orven 不会挂载、执行或绕过 Gatekeeper。"; +"Snapshot đang theo dõi" = "活动快照"; +"So sánh hai phiên" = "比较两个会话"; +"So sánh hai phiên có" = "会话比较包含"; +"So sánh với baseline" = "与基线比较"; +"Thay đổi" = "更改"; +"Theo dõi thay đổi" = "追踪更改"; +"Theo dõi và giải thích thay đổi hệ thống sau khi cài hoặc cập nhật ứng dụng." = "记录并解释安装或更新应用后的系统更改。"; +"Thành phần persistence có thể tự chạy khi đăng nhập hoặc trong nền." = "持久驻留组件可能在登录时或后台启动。"; +"Thành phần đặc quyền hoặc system extension có thể chạy ngoài tiến trình ứng dụng." = "特权组件或系统扩展可能在应用进程之外运行。"; +"Thông tin" = "参考信息"; +"Thả .dmg, .pkg hoặc .app vào đây" = "将 .dmg、.pkg 或 .app 拖放到此处"; +"Thả installer, chụp trước/sau và xem bằng chứng thay đổi" = "拖放安装程序,记录前后状态并检查更改证据"; +"Thử bỏ bộ lọc hoặc thay đổi từ khóa tìm kiếm." = "请清除筛选条件或更改搜索词。"; +"Tiếp tục với coverage giảm" = "缩小覆盖范围后继续"; +"Orven chỉ nhận .dmg, .pkg hoặc .app" = "Orven 仅接受 .dmg、.pkg 或 .app"; +"Orven chỉ đọc loại file và đường dẫn; installer chỉ mở sau khi baseline đã lưu." = "Orven 仅读取文件类型和路径;保存基线后才会打开安装程序。"; +"Orven đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại." = "Orven 提供技术证据,不会判定应用具有恶意。"; +"Trạng thái hiện tại" = "当前状态"; +"Tác giả" = "作者"; +"Tên phiên" = "会话名称"; +"Tìm tên, đường dẫn hoặc chủ sở hữu" = "搜索名称、路径或所有者"; +"Tạo baseline hiện tại" = "创建当前基线"; +"Tất cả mức" = "所有风险"; +"Tổng thay đổi" = "更改总数"; +"không telemetry" = "无遥测"; +"kết quả đang hiển thị" = "显示的结果"; +"mục" = "项"; +"phiên" = "会话"; +"phiên đã lưu" = "已保存的会话"; +"thay đổi" = "更改"; +"trong phạm vi theo dõi" = "在监测范围内"; +"từ snapshot và" = "来自快照及"; +"Đang chụp" = "正在记录"; +"Đang chụp trạng thái sau thay đổi…" = "正在记录更改后的状态…"; +"Đang chụp trạng thái trước khi mở installer…" = "正在记录打开安装程序前的状态…"; +"Đang quét…" = "正在扫描…"; +"Đang theo dõi" = "正在监测"; +"Đang theo dõi installer" = "正在追踪安装程序"; +"Đã gỡ" = "已移除"; +"Đã hủy" = "已取消"; +"Đã hủy Install Trace." = "已取消安装追踪。"; +"Đã lưu" = "已保存"; +"Đã phát hiện" = "已检测"; +"Đã thay đổi" = "已修改"; +"Đã thêm" = "已添加"; +"Đã tiếp tục theo dõi; thời gian app không chạy có coverage giảm." = "已恢复追踪;应用未运行期间的覆盖范围有所缩小。"; +"Đã đổi" = "已修改"; +"Đóng" = "关闭"; +"đường dẫn" = "路径"; +"Ứng dụng" = "应用"; +"…và %lld đường dẫn khác" = "…以及另外 %lld 条路径"; diff --git a/apps/toolbox/Resources/zh-Hans.lproj/InfoPlist.strings b/apps/toolbox/Resources/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 0000000..a5cfcce --- /dev/null +++ b/apps/toolbox/Resources/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSLocationUsageDescription" = "Orven 使用位置权限,在检查或扫描 Wi-Fi 时显示网络名称和接入点标识。"; +"NSLocationWhenInUseUsageDescription" = "Orven 使用位置权限,在检查或扫描 Wi-Fi 时显示网络名称和接入点标识。"; +"NSLocalNetworkUsageDescription" = "启动网络诊断时,Orven 会连接您选择的网关或本地目标。"; diff --git a/apps/toolbox/Resources/zh-Hans.lproj/Localizable.strings b/apps/toolbox/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..52e6497 --- /dev/null +++ b/apps/toolbox/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,607 @@ +/* Orven zh-Hans localization. */ +"%d of %d processes have CPU samples." = "%d 个进程具有 CPU 样本,共 %d 个进程。"; +"%d processes shown" = "显示 %d 个进程"; +"%d valid samples. Minimum %.1f, maximum %.1f %@. Missing samples are gaps." = "%d 个有效样本。最小 %.1f,最大 %.1f %@。缺失样本显示为空段。"; +"%lld cleanup records and %lld trace sessions are ready to import." = "可导入 %lld 条清理记录和 %lld 个追踪会话。"; +"100% process CPU is one logical core. Restricted processes remain visible. No termination actions." = "进程 CPU 使用率 100% 表示占用一个逻辑核心。受限制的进程仍会显示;不提供终止操作。"; +"12 hours" = "12 小时"; +"24 hours" = "24 小时"; +"6 hours" = "6 小时"; +"7 days" = "7 天"; +"A Diskora scheduled scan is installed." = "已安装 Diskora 定时扫描。"; +"A snapshot of active macOS power requests. Requests can be legitimate; they do not explain every sleep or wake event." = "这是 macOS 活动电源请求的快照。请求可能是正常的,不能解释所有睡眠或唤醒事件。"; +"APFS volumes share container capacity. Capacity is shown for the startup volume once; I/O is per observed storage driver, including virtual devices when exposed." = "APFS 卷共享容器容量。容量仅针对启动卷显示一次;I/O 按观察到的存储驱动显示,包括可获取的虚拟设备。"; +"About Orven" = "关于 Orven"; +"About Orven" = "关于 Orven"; +"Appearance" = "外观"; +"Applications" = "应用"; +"Automatic alerts are not available." = "不支持自动警报。"; +"Available" = "可用"; +"Battery" = "电池"; +"Battery & Thermal" = "电池与热状态"; +"CPU" = "CPU"; +"CPU & Memory" = "CPU 与内存"; +"CPU • all cores" = "CPU・所有核心"; +"CPU • one core" = "CPU・单核心基准"; +"Cancel" = "取消"; +"Cancelled" = "已取消"; +"Change Timeline" = "更改时间线"; +"Charging" = "正在充电"; +"Check again" = "重新检查"; +"Check for Updates" = "检查更新"; +"Cleanup" = "清理"; +"Clear search" = "清除搜索"; +"Close" = "关闭"; +"Close details" = "关闭详情"; +"Compare local snapshots around an install, update, or removal." = "比较安装、更新或卸载前后的本地快照。"; +"Compressor storage" = "压缩器存储"; +"Continue to Orven" = "进入 Orven"; +"Coverage" = "覆盖范围"; +"Coverage is reduced until protected folders are readable." = "在可读取受保护文件夹之前,覆盖范围有所缩小。"; +"Critical" = "危急"; +"Dark" = "深色"; +"Developer command" = "开发者命令"; +"Disable menu bar" = "关闭菜单栏"; +"Disabled by default. When enabled, menu bar and windows share one sampler. Background sampling runs every 5 seconds; Quit stops it." = "默认关闭。启用后,菜单栏和窗口共享一个采样器;后台每 5 秒采样一次,退出时停止。"; +"Disk & Network" = "磁盘与网络"; +"Disk I/O by device" = "按设备显示磁盘 I/O"; +"Disk reads • observed drivers" = "磁盘读取・已观察驱动"; +"Disk writes • observed drivers" = "磁盘写入・已观察驱动"; +"Diskora and Changeora files remain untouched." = "不会更改 Diskora 和 Changeora 文件。"; +"Evidence path" = "证据路径"; +"Export" = "导出"; +"External power" = "外接电源"; +"Failed" = "失败"; +"Fair" = "略高"; +"Finish the active scan before checking for updates." = "请完成当前扫描后再检查更新。"; +"First-launch approval" = "首次启动授权"; +"Full" = "完整"; +"GPU utilization and memory: Unsupported" = "GPU 使用率和内存:不支持"; +"History" = "历史记录"; +"Home" = "首页"; +"If macOS blocks Orven, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper." = "如果 macOS 阻止 Orven,请先打开一次,再前往“系统设置 → 隐私与安全性 → 仍要打开”。不要关闭 Gatekeeper。"; +"Import and verify" = "导入并验证"; +"Inspect process" = "检查进程"; +"Inspect what's running on your Mac. Monitoring is read-only." = "检查 Mac 上正在运行的进程。监测为只读。"; +"Install Trace" = "安装追踪"; +"Keep your existing history" = "保留现有历史记录"; +"Language" = "语言"; +"Last sample" = "最新样本"; +"Legacy data was copied and verified." = "已复制并验证旧版数据。"; +"Legacy schedule replaced after Orven bootstrap succeeded." = "Orven 代理启动成功后,已替换旧版计划。"; +"Legacy scheduled scan" = "旧版定时扫描"; +"Light" = "浅色"; +"Live monitoring" = "实时监测"; +"Local evidence for safer cleanup and app changes." = "为安全清理和应用更改提供本地证据。"; +"Management" = "管理"; +"Memory" = "内存"; +"Memory pressure • last OS event" = "内存压力・最近的系统事件"; +"Memory • includes caches" = "内存・包含缓存"; +"Menu bar indicator" = "菜单栏指示器"; +"Menu bar quick diagnostics" = "菜单栏快速诊断"; +"Migration" = "迁移"; +"Migration error" = "迁移错误"; +"Monitoring" = "监测"; +"Name" = "名称"; +"Needs attention" = "需要关注"; +"Network" = "网络"; +"Network received • physical" = "网络接收・物理接口"; +"Network sent • physical" = "网络发送・物理接口"; +"Next sample" = "下一个样本"; +"No Diskora schedule needs migration." = "没有需要迁移的 Diskora 计划。"; +"No active requests in this snapshot" = "此快照中没有活动请求"; +"No legacy data found." = "未找到旧版数据。"; +"No matching processes." = "没有匹配的进程。"; +"No process data available." = "没有可用的进程数据。"; +"No readable requests in this snapshot" = "此快照中没有可读取的请求"; +"No samples yet." = "暂无样本。"; +"Nominal" = "正常"; +"Normal" = "正常"; +"Not selected or cleaned automatically." = "不会自动选择或清理。"; +"Observed at" = "获取时间"; +"Observed parent PID: %d" = "观察到的父进程 PID:%d"; +"Occupied memory is physical memory minus free pages and includes caches. It does not measure reclaimable RAM. Pressure is an OS event, not an inferred score." = "已占用内存为物理内存减去空闲页面,包含缓存,不代表可回收 RAM。压力来自系统事件,不是推算评分。"; +"Occupied memory • includes caches" = "已占用内存・包含缓存"; +"On battery" = "使用电池"; +"Only when you click the button, Orven requests public release metadata from GitHub. No scan or device data is sent." = "仅在点击按钮时,Orven 才会向 GitHub 请求公开发布信息。不会发送扫描或设备数据。"; +"Open Full Disk Access" = "打开完全磁盘访问权限"; +"Open Recovery" = "打开恢复"; +"Open installation guide" = "打开安装指南"; +"Open storage" = "打开存储空间"; +"Overview" = "概览"; +"Permission required" = "需要权限"; +"Physical memory" = "物理内存"; +"Physical summary includes active en* interfaces. Tunnel and loopback counters are shown separately and are never added to that summary." = "物理接口汇总包含活动的 en* 接口。隧道和回环计数单独显示,不计入该汇总。"; +"Previous sample" = "上一个样本"; +"Privacy and coverage" = "隐私与覆盖范围"; +"Private by default" = "默认保护隐私"; +"Process" = "进程"; +"Process CPU • one core" = "进程 CPU・单核心基准"; +"Process exited or identity changed." = "进程已退出或身份已更改。"; +"Process identity unavailable; ownership is unverified" = "无法获取进程身份;归属未经验证"; +"Processes" = "进程"; +"Projects" = "项目"; +"Quit Orven" = "退出 Orven"; +"Read" = "读取"; +"Read failed" = "读取失败"; +"Read-only" = "只读"; +"Read-only. Recent trends stay on this Mac and are not saved." = "只读。近期趋势仅保留在此 Mac 内存中,不保存到磁盘。"; +"Read-only. Samples stay in memory for up to 60 seconds." = "只读。样本在内存中最多保留 60 秒。"; +"Reading sleep requests" = "正在读取睡眠请求"; +"Received" = "接收"; +"Recent CPU activity" = "近期 CPU 活动"; +"Recover space safely" = "安全释放空间"; +"Recoverable" = "可恢复"; +"Recovery" = "恢复"; +"Reduced" = "已缩小"; +"Refresh sleep requests" = "刷新睡眠请求"; +"Refresh to inspect current requests. No background polling." = "刷新以查看当前请求。不会在后台轮询。"; +"Replace after verification" = "验证后替换"; +"Replace the Diskora scheduled scan?" = "替换 Diskora 定时扫描?"; +"Replace with Orven schedule…" = "替换为 Orven 计划…"; +"Resident memory" = "驻留内存"; +"Restore" = "恢复"; +"Retry import" = "重试导入"; +"Revalidating process identity…" = "正在重新验证进程身份…"; +"Review Projects" = "检查项目"; +"Review Storage" = "检查存储空间"; +"Review in Applications" = "在应用中检查"; +"Review in Storage" = "在存储空间中检查"; +"Review recoverable actions and restore without overwriting files." = "检查可恢复的操作,并在不覆盖现有文件的情况下恢复。"; +"Run every" = "运行间隔"; +"Runs locally. No telemetry. Nothing is deleted automatically." = "本地运行,无遥测,不自动删除任何内容。"; +"Sampling paused" = "采样已暂停"; +"Sampling paused while the main window is inactive." = "主窗口不活动时暂停采样。"; +"Scan coverage" = "扫描覆盖范围"; +"Scan storage" = "扫描存储空间"; +"Scans, snapshots, and activity stay on this Mac. Orven has no telemetry and never cleans automatically." = "扫描、快照及操作记录保留在此 Mac 上。Orven 无遥测,也不会自动清理。"; +"Search processes or PID" = "搜索进程或 PID"; +"Seconds before latest sample" = "距最新样本的秒数"; +"See reclaimable files before anything moves to Trash." = "在移入废纸篓前,先查看可释放空间的文件。"; +"Understand your Mac. Act with context." = "了解你的 Mac。确认后再操作。"; +"Select a process to inspect its verified identity and recent activity." = "选择进程以检查已验证的身份和近期活动。"; +"Sent" = "发送"; +"Sentinel folders are readable." = "可读取覆盖范围检查文件夹。"; +"Serious" = "严重"; +"Settings" = "设置"; +"Settings error" = "设置错误"; +"Show all processes" = "显示所有进程"; +"Show in Finder" = "在 Finder 中显示"; +"Show loopback, tunnels and other interfaces" = "显示回环、隧道及其他接口"; +"Sleep Inspector" = "睡眠请求检查"; +"Some requests could not be displayed" = "部分请求无法显示"; +"Sort by" = "排序依据"; +"Stale" = "数据已过期"; +"Start a trace" = "开始追踪"; +"Started" = "已开始"; +"Startup volume available" = "启动卷可用空间"; +"Storage" = "存储空间"; +"Succeeded" = "成功"; +"Swap used" = "已用交换空间"; +"System" = "跟随系统"; +"System CPU • all cores" = "系统 CPU・所有核心"; +"Temperature, fan sensors and per-process energy: Unsupported" = "温度、风扇传感器和各进程能耗:不支持"; +"Temporarily unavailable" = "暂时不可用"; +"The old agent remains untouched unless the new scan-only agent bootstraps successfully." = "新的仅扫描代理成功启动之前,不会更改旧代理。"; +"Thermal state" = "热状态"; +"Thermal state is macOS's coarse system condition, not a temperature in °C. Macs without an internal battery report Unsupported." = "热状态是 macOS 提供的粗略系统状态,不是摄氏温度。没有内置电池的 Mac 显示“不支持”。"; +"Thermal state is not a temperature in °C." = "热状态不是摄氏温度。"; +"Threads: %d" = "线程数:%d"; +"Orven %@ is available." = "Orven %@ 可用。"; +"Orven first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds." = "Orven 会先安装并启动仅扫描代理,成功后才会移除 Diskora 的 plist。"; +"Top CPU consumers • one core" = "高 CPU 占用进程・单核心基准"; +"Unavailable" = "不可用"; +"Understand app changes" = "了解应用更改"; +"Undo before regret" = "及时撤销"; +"Unknown" = "未知"; +"Unsupported" = "不支持"; +"Updates" = "更新"; +"Used storage" = "已用存储空间"; +"Using the most CPU" = "CPU 占用最高的进程"; +"Warming up" = "正在获取初始样本"; +"Warning" = "警告"; +"Welcome to Orven" = "欢迎使用 Orven"; +"Wired memory" = "联动内存"; +"Write" = "写入"; +"You are using the latest public release." = "你正在使用最新公开版本。"; +"You can change these choices later in Settings." = "你可以稍后在设置中更改这些选项。"; +"Your Mac, at a glance" = "一览 Mac 状态"; +"Your Mac, explained." = "读懂你的 Mac。"; + +/* Application inspection and management improvements. */ +"Search apps or Bundle ID" = "搜索应用或 Bundle ID"; +"Sort by name" = "按名称排序"; +"No matching applications" = "没有匹配的应用"; +"App Dossier" = "应用档案"; +"Bundle ID" = "Bundle ID"; +"Application path" = "应用路径"; +"Version" = "版本"; +"Minimum macOS" = "最低 macOS 版本"; +"Application bundle size" = "应用包大小"; +"Related data observed" = "已观察的关联数据"; +"Last application scan" = "最近应用扫描"; +"Sizes are from the last scan. Related data is attributed by scan evidence, not proof of exclusive ownership. Unreadable locations may be missing." = "大小来自最近一次扫描。关联数据依据扫描证据归属,不证明独占所有权。无法读取的位置可能未包含。"; +"No related data found within scan coverage." = "扫描范围内未找到关联数据。"; +"Largest first" = "最大优先"; +"Application Support" = "应用支持数据"; +"Cache" = "缓存"; +"Preference" = "偏好设置"; +"Log" = "日志"; +"Container" = "容器"; +"LaunchAgent" = "LaunchAgent"; +"Login Item / Background Task" = "登录项/后台任务"; +"Package receipt" = "软件包收据"; +"Clear filters or change the search query." = "请清除筛选条件或更改搜索词。"; +"Background-item evidence unavailable. The macOS helper did not complete; scan coverage is partial." = "无法获取后台项目证据。macOS 辅助程序未完成,扫描覆盖范围不完整。"; +"Remove project %@" = "移除项目 %@"; +"%@ was identified from %@ markers and can be regenerated." = "%@ 根据 %@ 标记识别,可重新生成。"; +"Found %d artifacts in %d projects." = "找到 %d 项产物,分布在 %d 个项目中。"; + +"Explain My Mac" = "了解 Mac 状态"; +"Export diagnostics…" = "导出诊断…"; +"Evidence from recent samples. These observations do not establish causality and do not change your Mac." = "依据近期采样。这些观察不能确定因果关系,也不会更改 Mac。"; +"No matching findings in fresh, available samples. Missing data is not a health check." = "最新可用采样中没有符合条件的发现。缺少数据不代表设备健康。"; +"Sampling is paused. Resume active monitoring to evaluate current evidence." = "采样已暂停。恢复监控以评估当前数据。"; +"Review evidence" = "查看依据"; +"Memory pressure needs attention" = "需要关注内存压力"; +"macOS reports memory pressure. Occupied memory alone does not establish a problem." = "macOS 报告内存压力。仅凭已用内存不能确定存在问题。"; +"Sustained CPU activity" = "CPU 持续高负载"; +"System CPU stayed at or above 85% for at least 30 seconds. Review the measured processes before attributing a cause." = "系统 CPU 使用率至少连续 30 秒达到 85%。确定原因前请查看已测量的进程。"; +"Startup volume is nearly full" = "启动卷空间不足"; +"Available space is below 10% of reported capacity. APFS volumes share container space; inspect coverage and snapshots before cleanup." = "可用空间低于报告容量的 10%。APFS 卷共享容器空间,清理前请检查覆盖范围和快照。"; +"Elevated thermal state" = "温控状态升高"; +"macOS reports thermal pressure. This is an OS state, not a temperature measurement or proof of a particular app's impact." = "macOS 报告温控压力。这是系统状态,并非温度测量值,也不能证明某个应用造成影响。"; +"Local Diagnostics Export" = "导出本地诊断"; +"Preview the exact JSON before saving. Paths, username, hostname and network endpoints are excluded. Nothing is uploaded." = "保存前预览实际 JSON。排除路径、用户名、主机名和网络端点,不上传任何数据。"; +"Include process names" = "包含进程名称"; +"Refresh preview" = "刷新预览"; +"Save JSON…" = "保存 JSON…"; +"Diagnostics saved." = "诊断已保存。"; +"Could not save diagnostics. Choose a writable location and try again." = "无法保存诊断。请选择可写位置后重试。"; +"Could not generate diagnostics from the current sample. Refresh and try again." = "无法根据当前采样生成诊断。请刷新后重试。"; +"Network Inspector" = "网络连接检查"; +"Refresh connections" = "刷新连接"; +"Explicit TCP/UDP socket snapshot from lsof. Restricted processes and short-lived connections may be missing. No traffic history, domain attribution, byte totals or firewall control." = "按需读取 lsof 的 TCP/UDP 套接字快照。受限进程和短暂连接可能缺失。不提供流量历史、域名归属、字节总量或防火墙控制。"; +"Search process, PID or endpoint" = "搜索进程、PID 或端点"; +"Connection inspection failed. Refresh to try again; coverage remains unknown." = "连接检查失败。请刷新重试;覆盖范围仍未知。"; +"%d observed sockets; %d omitted by the display cap." = "观察到 %d 个套接字;显示上限省略 %d 个。"; +"Protocol" = "协议"; +"Endpoint" = "端点"; +"State" = "状态"; +"Details" = "详情"; +"Only a revalidated PID/start identity can be inspected." = "仅可检查重新验证过 PID 和启动时间的进程。"; +"No matching sockets within observable coverage. This does not prove the Mac has no connections." = "可观察范围内没有匹配套接字。这不能证明 Mac 没有连接。"; +"Refresh to inspect current connections." = "刷新以检查当前连接。"; +"Hidden Space & Snapshots" = "隐藏空间与快照"; +"Inspect APFS space" = "检查 APFS 空间"; +"Startup Data volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." = "仅检查启动盘 Data 卷。APFS 容器容量由各卷共享;文件夹扫描、可用空间和快照大小是不同的测量指标。"; +"APFS inspection failed or is unsupported. Refresh to try again." = "APFS 检查失败或不受支持。请刷新重试。"; +"APFS container capacity" = "APFS 容器容量"; +"APFS container free" = "APFS 容器空闲空间"; +"Data volume used" = "Data 卷已用空间"; +"Unaccounted space is not measured here. It may include unreadable files, other volumes, filesystem metadata, clones or snapshots; Orven does not attribute the remainder to one cause." = "此处未测量未解释空间。它可能包含不可读文件、其他卷、文件系统元数据、克隆或快照;Orven 不会将剩余空间归因于单一原因。"; +"APFS snapshots" = "APFS 快照"; +"No APFS snapshots reported for the Data volume. Other volumes may have snapshots." = "Data 卷未报告 APFS 快照。其他卷可能存在快照。"; +"OS marks this snapshot purgeable." = "系统将此快照标记为可清除。"; +"OS does not mark this snapshot purgeable." = "系统未将此快照标记为可清除。"; +"Snapshot purgeability unavailable." = "快照可清除状态未知。"; +"%d snapshot entries omitted." = "省略了 %d 个快照条目。"; +"Exclusive snapshot bytes are unavailable from this inspection. Snapshot count does not measure reclaimable space. No snapshots are created, mounted, thinned or deleted." = "此检查无法获取快照独占字节数。快照数量不代表可回收空间。不创建、挂载、精简或删除快照。"; +"Inspect to read container space and Data volume snapshots." = "点击检查以读取容器空间和 Data 卷快照。"; +"CPU scheduling" = "CPU 调度"; +"Lower priority raises nice to at least 10 for this same-user process. It is not a CPU percentage cap. Raising priority back may require privileges; relaunch the app to reset. Child processes are not individually controlled." = "将同一用户进程的 nice 值提高至至少 10,以降低优先级。这不是 CPU 百分比上限。恢复优先级可能需要权限;重新启动应用可重置。不会单独控制子进程。"; +"Lower process priority…" = "降低进程优先级…"; +"Lower process priority?" = "降低进程优先级?"; +"Lower priority" = "降低优先级"; +"Process priority lowered and rechecked. This does not guarantee lower CPU usage." = "进程优先级已降低并重新检查。这不保证 CPU 使用率下降。"; +"Priority change could not be verified. The process may have exited, changed identity or denied access. No retry is automatic." = "无法验证优先级更改。进程可能已退出、身份改变或拒绝访问。不会自动重试。"; +"This changes scheduling for the selected process. Restoring the previous priority is not guaranteed without relaunching it." = "这将更改所选进程的调度。若不重新启动,无法保证恢复原优先级。"; +"Storage Growth Detective" = "存储增长分析"; +"Baseline saved. Scan the same location again to compare growth." = "基线已保存。再次扫描同一位置以比较变化。"; +"Scans are not comparable: scope, identity, coverage or checkpoint format differs. A new baseline was saved." = "扫描无法比较:范围、身份、覆盖范围或检查点格式不同。已保存新基线。"; +"Comparable observed folder changes. Decreases do not prove APFS space was freed; changes do not identify their cause." = "来自可比较扫描的文件夹变化。大小减少不能证明 APFS 空间已释放;变化也不能确定原因。"; +"No comparable folder deltas to display." = "没有可显示的可比较文件夹变化。"; +"Developer Workspace" = "开发工作区"; +"Recognized projects within selected roots. Manifest names are evidence of an ecosystem, not proof that its runtime is installed. Source size and runtime versions are not measured." = "识别所选根目录中的项目。清单文件名是生态系统依据,不证明已安装运行时。未测量源码大小或运行时版本。"; +"%d scan issues; results may be partial." = "扫描问题 %d 个;结果可能不完整。"; +"%d recognized artifacts; size is observed artifact data only." = "识别到 %d 个构建产物;大小仅含已观察到的产物数据。"; +"No recognized project manifests within scan coverage." = "扫描范围内没有已识别的项目清单。"; +"Only the first 100 projects are displayed. Artifact filters remain available below." = "仅显示前 100 个项目。下方仍可使用产物筛选。"; +"App Activity Timeline" = "应用活动时间线"; +"Recent in-memory CPU trends for running processes mapped by bundle path and launch identity. Helper processes, older activity and unverified ownership may be missing. Related-data sizes remain scan-time observations." = "显示通过包路径和启动身份匹配的运行中进程的近期 CPU 趋势,仅保留在内存中。辅助进程、旧活动和未验证归属可能缺失。相关数据大小仍为扫描时观察值。"; +"No running process could be mapped within current coverage." = "当前覆盖范围内无法匹配运行中进程。"; +"Inspect app sleep requests" = "检查应用睡眠阻止请求"; +"No sleep requests attributed to mapped processes within this snapshot's coverage." = "此快照覆盖范围内没有归属于已匹配进程的睡眠阻止请求。"; +"Some assertion entries were omitted; coverage is partial." = "部分断言条目被省略;覆盖范围不完整。"; +"warning" = "警告"; +"critical" = "严重"; +"serious" = "较高"; + +"%@, PID %d, %@, %@, %@" = "%@,PID %d,%@,%@,%@"; +"Measure workspace" = "测量工作区"; +"Observed workspace size" = "已观察工作区大小"; +"Other observed project data" = "其他已观察项目数据"; + +"Startup volume" = "启动卷"; +"Data volume" = "Data 卷"; +"System volume" = "System 卷"; +"Selected volume used" = "所选卷已用空间"; +"Selected startup volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." = "仅检查所选启动卷。APFS 容器容量由各卷共享;文件夹扫描、可用空间和快照大小是不同的测量指标。"; +"No APFS snapshots reported for the selected volume. Other volumes may have snapshots." = "所选卷未报告 APFS 快照。其他卷可能存在快照。"; +"Inspect to read container space and selected-volume snapshots." = "点击检查以读取容器空间和所选卷快照。"; + +"Stop" = "停止"; + +"Inspect selected process" = "检查所选进程"; + +"Volumes sharing this container" = "共享此容器的卷"; +"Unattributed container allocation" = "未归属卷的容器分配空间"; +"Container remainder uses OS-reported capacity minus free space and listed volume usage. It is not hidden-file size or exclusive snapshot space. Incomplete or inconsistent accounting stays unknown." = "剩余分配空间由系统报告容量减去空闲空间及列出的卷使用量计算得出。它不是隐藏文件大小或快照独占空间。若数据不完整或不一致,则保持未知。"; +"%d volume entries omitted." = "省略了 %d 个卷条目。"; + +"Preview the exact JSON before saving. No dedicated path, username, hostname or network endpoint fields are included. Opt-in process names may contain identifying text. Nothing is uploaded." = "保存前预览实际 JSON。不包含专用路径、用户名、主机名或网络端点字段。选择包含进程名称时,名称本身可能包含身份信息。不上传任何数据。"; + +"Hardware Inspector" = "硬件检查"; +"Hardware profile" = "硬件信息"; +"Hardware report" = "硬件报告"; +"Refresh hardware" = "刷新硬件信息"; +"Export hardware report…" = "导出硬件报告…"; +"Reading hardware profile…" = "正在读取硬件信息…"; +"Model identifier" = "型号标识符"; +"Operating system" = "操作系统"; +"Processor" = "处理器"; +"Physical cores" = "物理核心数"; +"Logical processors" = "逻辑处理器数"; +"Reported nominal frequency" = "系统报告的标称频率"; +"Page size" = "页大小"; +"Cache line size" = "缓存行大小"; +"L1 instruction cache" = "L1指令缓存"; +"L1 data cache" = "L1数据缓存"; +"L2 cache" = "L2缓存"; +"L3 cache" = "L3缓存"; +"Performance level" = "性能分组"; +"CPU level %d" = "CPU分组 %d"; +"Cores sharing L2" = "共享L2的核心数"; +"Metal device" = "Metal设备"; +"Unified memory" = "统一内存"; +"Recommended GPU working set" = "GPU建议工作集"; +"Performance" = "性能"; +"Efficiency" = "能效"; +"Yes" = "是"; +"No" = "否"; +"Recent sensor range" = "近期监测范围"; +"Reset range" = "重置监测范围"; +"Sensor" = "监测项"; +"Current" = "当前值"; +"Minimum" = "最小值"; +"Maximum" = "最大值"; +"Samples" = "样本数"; +"Disk read" = "磁盘读取"; +"Disk write" = "磁盘写入"; +"Network receive" = "网络接收"; +"Network send" = "网络发送"; +"Cache sizes are OS-reported values, not summed totals. Nominal frequency is not a live core clock. The GPU working set is a recommendation, not VRAM capacity or current usage." = "缓存大小为系统报告值,并非累加总量。标称频率不是核心的实时频率。GPU工作集是建议值,不是显存容量或当前使用量。"; +"Min/max covers available observations in the latest sampling generation, up to 60 seconds. Missing readings are gaps. Reset affects this range only; it does not clear monitoring history." = "最小值和最大值仅包含最新采样代次中最多60秒的有效观测。缺失读数不计入。重置仅影响此范围,不清除监测历史。"; +"Sensor coverage: temperatures in °C, fan RPM, voltage, package power, live core clocks, SPD timings and SMART health are not collected. Thermal state is the macOS condition, not a temperature. No fan control, overclocking or stress test is performed." = "监测范围:不采集摄氏温度、风扇转速、电压、封装功耗、实时核心频率、SPD时序或SMART健康信息。热状态是macOS状态,不是温度。不执行风扇控制、超频或压力测试。"; + +"Preview the hardware profile and recent range before saving. No serial, hostname, paths, process names or network endpoints are included. Nothing is uploaded." = "保存前预览硬件信息和近期监测范围。不包含序列号、主机名、路径、进程名称或网络端点。不上传数据。"; + +"Hardware architecture" = "硬件架构"; + +"System information" = "系统信息"; + +"Temperature & Fans" = "温度与风扇"; +"CPU sensors" = "CPU传感器"; +"GPU sensors" = "GPU传感器"; +"Fans" = "风扇"; +"Battery sensors" = "电池传感器"; +"System power" = "系统功耗"; +"CPU • hottest mapped sensor" = "CPU・已映射传感器最高温度"; +"GPU • hottest mapped sensor" = "GPU・已映射传感器最高温度"; +"SMC reports no fans. This is not a fan-speed measurement." = "SMC报告风扇数量为0。这不是转速测量结果。"; +"Fan count unavailable; no fan speeds inferred." = "风扇数量不可用;不推断风扇转速。"; +"Fan coverage is capped at 8 reported fans." = "风扇覆盖范围最多包含SMC报告的8个风扇。"; +"Read-only SMC sensors refresh every 10 seconds while monitoring runs. Coverage is limited and model-dependent. CPU/GPU labels use community mappings, not verified physical core IDs. Missing readings are never zero." = "监测运行时每10秒只读刷新SMC传感器。覆盖范围有限且取决于机型。CPU/GPU分类基于社区映射,不是已验证的物理核心ID。缺失读数不按0处理。"; +"Temperatures are individual mapped readings, not CPU/GPU package averages or a safety diagnosis. Fan speeds are observed RPM only; Orven does not change fan modes or targets. Voltage, live clocks, SPD and SMART are not collected." = "温度为已映射传感器的独立读数,不是CPU/GPU封装平均温度或安全诊断。风扇仅显示观测转速,Orven不更改模式或目标转速。不采集电压、实时频率、SPD或SMART。"; +"SMC temperature, fan and power coverage is shown above. Thermal state remains a separate macOS condition. Voltage, live core clocks, SPD timings, SMART health, fan control, overclocking and stress tests are not provided." = "SMC温度、风扇和功耗的覆盖范围如上所示。热状态仍是独立的macOS状态。不提供电压、实时核心频率、SPD时序、SMART健康信息、风扇控制、超频或压力测试。"; + +"Review temperatures and fans" = "查看温度与风扇"; +"Per-process energy: Unsupported" = "进程能耗:不支持"; + +/* Health history, alerts, recording and connection changes. */ +"Health History & Baseline" = "健康历史与基准"; +"Record health history locally" = "在本机记录健康历史"; +"One observed sample per minute, up to seven days. No process names, paths or network endpoints. Paused, sleep and missing samples are gaps; enabling history does not enable background monitoring." = "每分钟保存一次观测,最多七天。不保存进程名、路径或网络端点。暂停、睡眠和缺失样本会留下空白;启用历史不会启用后台监控。"; +"History retention" = "历史保留时间"; +"%d recorded observations" = "已记录 %d 次观测"; +"Time range" = "时间范围"; +"1 hour" = "1 小时"; +"Export history JSON…" = "导出历史 JSON…"; +"Baseline comparison" = "基准对比"; +"Capture baseline" = "保存基准"; +"Capture the latest 30–60 recorded samples as a reference. Compare medians with at least ten newer samples per metric. Workload differences can explain changes; this is not causal diagnosis." = "将最近 30–60 个样本保存为基准。每项指标至少有十个较新样本后比较中位数。工作负载差异可能导致变化;这不是因果诊断。"; +"Not enough baseline or newer observations yet. Keep recording during comparable workloads." = "基准或较新观测不足。请在相近工作负载下继续记录。"; +"Baseline median" = "基准中位数"; +"Recent median" = "近期中位数"; +"Chart metric" = "图表指标"; +"Occupied memory" = "已占用内存"; +"Temperature labels use community mappings; missing readings are gaps. Fan RPM and system power are observations, not fan control or CPU package power." = "温度标签使用社区映射;缺失读数显示为空白。风扇转速和系统功率是观测值,并非风扇控制或 CPU 封装功率。"; +"Sensor Charts & Recording" = "传感器图表与记录"; +"Record existing samples every ten seconds for 15, 30 or 60 minutes. Sleep extends the wall-clock duration. The session stays in memory until Quit. Sampling pauses when the app is inactive unless menu bar monitoring is enabled; no readings are backfilled." = "每十秒记录现有样本,持续 15、30 或 60 分钟。睡眠会延长实际时长。记录仅在内存中保留至退出。未启用菜单栏监控时,应用不活跃会暂停采样;不补填缺失读数。"; +"Recording duration" = "记录时长"; +"15 minutes" = "15 分钟"; +"30 minutes" = "30 分钟"; +"60 minutes" = "60 分钟"; +"Start recording" = "开始记录"; +"Stop recording" = "停止记录"; +"Recording observed samples" = "正在记录观测样本"; +"Recording stopped" = "记录已停止"; +"Recording paused until monitoring resumes" = "记录已暂停,等待监控恢复"; +"Export session JSON…" = "导出记录 JSON…"; +"Export session CSV…" = "导出记录 CSV…"; +"Smart Alerts" = "智能提醒"; +"Enable health alerts" = "启用健康提醒"; +"Off by default. Fresh active evidence only: sustained CPU, memory pressure, low startup space and elevated thermal state. A recovered condition must stay clear for 30 seconds; repeat alerts wait at least 15 minutes. Nothing is changed automatically." = "默认关闭。仅使用活跃监控中的最新证据:持续 CPU 高负载、内存压力、启动磁盘空间不足和较高热状态。恢复状态须保持 30 秒;重复提醒至少间隔 15 分钟。不自动更改系统。"; +"Allow macOS notifications…" = "允许 macOS 通知…"; +"Snooze for one hour" = "暂停提醒一小时"; +"No alert events in this app session." = "本次应用会话中没有提醒事件。"; +"Recent pressure and thermal observations" = "近期内存压力与热状态观测"; +"History could not be read. The existing file is preserved; recording to disk is blocked." = "无法读取历史。已保留现有文件并阻止写入磁盘。"; +"History could not be saved. Existing data is preserved; check the local storage location." = "无法保存历史。现有数据已保留,请检查本机存储位置。"; +"Baseline needs at least 30 recorded samples and writable local storage." = "基准需要至少 30 个已记录样本和可写的本机存储。"; +"History retention could not be saved. Existing data is preserved." = "无法保存历史保留设置。现有数据已保留。"; +"Notifications require the Orven app bundle." = "通知需要从 Orven 应用包运行。"; +"Notification permission granted." = "已获得通知权限。"; +"Notifications are not allowed. In-app alerts remain available." = "尚未允许通知。应用内提醒仍可使用。"; +"Notification permission could not be requested. In-app alerts remain available." = "无法请求通知权限。应用内提醒仍可使用。"; +"Alerts snoozed for one hour." = "已暂停提醒一小时。"; +"Notification delivery failed. Review the in-app alert evidence." = "通知发送失败。请查看应用内提醒证据。"; +"Network Connection Changes" = "网络连接变化"; +"Track observed connection changes this session" = "在本次会话跟踪连接变化"; +"Explicit Refresh compares observable sockets with verified PID/start identities. New or no-longer-observed does not mean opened or closed at this time. Short connections and restricted processes may be missed. Process names and numeric endpoints stay in memory, capped at 200 events; nothing is uploaded or saved to disk." = "点击刷新后比较具有已验证 PID/启动时间身份的可观测套接字。新观测到或不再观测到不代表此时打开或关闭。可能遗漏短暂连接和受限进程。进程名和数字端点仅在内存中保留,最多 200 个事件;不上传或写入磁盘。"; +"Connection baseline established" = "已建立连接基准"; +"Refresh to establish a connection baseline" = "刷新以建立连接基准"; +"%d unverified sockets excluded; %d older events omitted." = "已排除 %d 个未验证套接字;已省略 %d 个较旧事件。"; +"Clear connection history" = "清除连接历史"; +"No connection changes observed yet." = "尚未观测到连接变化。"; +"Showing the latest 50 change events." = "显示最新 50 个变化事件。"; +"Newly observed" = "新观测到"; +"No longer observed" = "不再观测到"; +"State changed" = "状态变化"; +"Local report export" = "导出本机报告"; +"Preview the exact local report before saving. Nothing is uploaded." = "保存前预览确切报告内容。不上传数据。"; +"Save report…" = "保存报告…"; +"normal" = "正常"; +"nominal" = "正常"; +"fair" = "稍高"; +"unknown" = "未知"; + +"Change" = "变化"; +"Memory pressure" = "内存压力"; +"System CPU" = "系统 CPU"; + +"%d older observations omitted by the file-size budget." = "文件大小限制已省略 %d 个较旧观测。"; + +"Health History" = "健康历史"; + +"Could not prepare the local report. Stop recording and try again." = "无法准备本机报告。请停止记录后重试。"; +"Waiting for macOS notification permission. Respond to the system prompt if it appears." = "正在等待 macOS 通知权限。如出现系统提示,请进行选择。"; + +"This report is frozen when opened. Close and reopen for newer observations. Preview the exact content before saving; nothing is uploaded." = "报告内容在打开时固定。如需较新观测,请关闭后重新打开。保存前可预览确切内容;不上传数据。"; +"Report saved." = "报告已保存。"; +"Could not save the report. Choose a writable location and try again." = "无法保存报告。请选择可写位置后重试。"; + +/* Wi-Fi and LAN tools */ +"Wi-Fi & LAN" = "Wi-Fi 与局域网"; +"Network Overview" = "网络概览"; +"Wi-Fi Inspector" = "Wi-Fi 检查"; +"Ethernet Inspector" = "以太网检查"; +"Connection Diagnostics" = "连接诊断"; +"Latency & Stability" = "延迟与稳定性"; +"Nearby Wi-Fi" = "附近的 Wi-Fi"; +"Network Change Timeline" = "网络变化记录"; +"Network tool" = "网络工具"; +"Refresh network status" = "刷新网络状态"; +"Last network observation" = "最近网络观测"; +"Path interfaces" = "路径接口"; +"Low Data Mode" = "低数据模式"; +"Expensive path" = "可能产生额外费用的路径"; +"An available path does not prove that an Internet destination is reachable." = "路径可用不代表能访问某个互联网目标。"; +"Primary interface" = "主接口"; +"IPv4 gateway" = "IPv4 网关"; +"IPv6 gateway" = "IPv6 网关"; +"DNS servers" = "DNS 服务器"; +"No matching network adapter was observed." = "未观测到匹配的网络适配器。"; +"IP addresses" = "IP 地址"; +"MTU" = "MTU"; +"Negotiated media" = "协商的连接模式"; +"Received / sent bytes" = "接收/发送字节"; +"Input / output packets" = "接收/发送数据包"; +"Input / output errors" = "接收/发送错误"; +"Input queue drops" = "接收队列丢弃数据包"; +"Adapter counters are cumulative observations, not session traffic or Internet speed. Packet/error counters may wrap. Live rates reuse the existing sampler." = "适配器计数器是累计观测值,不是会话流量或互联网速度。数据包和错误计数器可能回绕。实时速率复用现有采样器。"; +"%d adapters omitted by the display limit." = "显示上限省略了 %d 个适配器。"; +"Refresh to inspect Wi-Fi and LAN." = "请刷新以检查 Wi-Fi 和局域网。"; +"SSID and BSSID can be hidden by macOS location permissions. Missing identity does not mean Wi-Fi is disconnected." = "macOS 位置权限可能隐藏 SSID 和 BSSID。缺少标识不代表 Wi-Fi 已断开。"; +"Request Wi-Fi identity permission" = "请求 Wi-Fi 标识访问权限"; +"No Wi-Fi interface was observed." = "未观测到 Wi-Fi 接口。"; +"Unavailable or permission restricted" = "不可用或权限受限"; +"SSID" = "SSID"; +"BSSID" = "BSSID"; +"RSSI" = "RSSI"; +"Noise" = "噪声"; +"SNR" = "SNR"; +"Channel" = "信道"; +"Band" = "频段"; +"Channel width" = "信道宽度"; +"Transmit link rate" = "发送链路速率"; +"CoreWLAN reports local radio observations. Link rate is not measured Internet throughput; SNR is RSSI minus noise from this observation." = "CoreWLAN 提供本地无线观测值。链路速率不是实测互联网吞吐量;SNR 为本次 RSSI 减去噪声。"; +"Destination hostname or IP address" = "目标主机名或 IP 地址"; +"Example: example.com or 192.168.1.1" = "例如:example.com 或 192.168.1.1"; +"Measurements send traffic to the displayed destination. Connection diagnostics also tests the recently observed gateway, resolves the destination and opens TCP port 443. No payload or speed-test download is sent." = "测量会向显示的目标发送流量。连接诊断还会测试最近观测的网关、解析目标并连接 TCP 443 端口。不发送应用载荷或下载测速数据。"; +"Check connection" = "检查连接"; +"Measure 20 ICMP probes" = "测量 20 次 ICMP 探测"; +"Refresh network status first to include a gateway observed within 60 seconds. Each stage is bounded; total connection check is at most 30 seconds." = "请先刷新网络状态以包含 60 秒内观测的网关。每个阶段都有时限,连接检查总计最多 30 秒。"; +"Destination" = "目标"; +"Elapsed" = "耗时"; +"ICMP may be blocked while Internet access works. DNS results may be cached. A successful TCP connection does not verify HTTP, TLS, captive portals or every application." = "互联网正常时 ICMP 也可能被阻止。DNS 结果可能来自缓存。TCP 连接成功不代表 HTTP、TLS、认证门户或所有应用均正常。"; +"20 probes, one second apart, at most 28 seconds. Loss uses the ICMP summary; jitter is the mean absolute difference between adjacent successful replies. Timeouts break the chart." = "每秒一次,共 20 次,最多 28 秒。丢包率来自 ICMP 汇总;抖动为相邻成功响应差值绝对值的平均数。超时显示为图表空隙。"; +"Mean latency" = "平均延迟"; +"Jitter" = "抖动"; +"Packet loss" = "丢包率"; +"ICMP did not complete successfully; missing results are unknown or blocked." = "ICMP 未成功完成;缺失结果原因未知或可能被阻止。"; +"ICMP round-trip latency" = "ICMP 往返延迟"; +"Scan nearby Wi-Fi" = "扫描附近的 Wi-Fi"; +"Scan is explicit and controlled by macOS. Stop suppresses results after the OS scan returns; it cannot interrupt CoreWLAN. Identity may require location permission. Results stay in this window only." = "扫描由用户触发并由 macOS 控制。停止会在系统扫描返回后丢弃结果,无法中断 CoreWLAN。标识信息可能需要位置权限。结果仅保留在当前窗口中。"; +"Filter SSID, BSSID or channel" = "按 SSID、BSSID 或信道筛选"; +"%d visible networks; %d omitted." = "显示 %d 个网络;省略 %d 个。"; +"No matching Wi-Fi network was observed." = "未观测到匹配的 Wi-Fi 网络。"; +"Hidden or permission restricted" = "隐藏或权限受限"; +"Channel / band" = "信道/频段"; +"RSSI / noise" = "RSSI/噪声"; +"Observed networks on this channel" = "本信道观测到的网络"; +"Channel counts describe this scan, not airtime utilization or interference. Different bands can reuse channel numbers; wider channels can overlap. Signal bars are a relative RSSI display." = "信道计数仅描述本次扫描,不代表空口利用率或干扰。不同频段可复用信道编号,宽信道可能重叠。信号条为 RSSI 的相对显示。"; +"Track network changes in this window" = "在当前窗口记录网络变化"; +"Clear network changes" = "清除网络变化"; +"Path events are observed while this page is open. IP, gateway, DNS and Wi-Fi identity changes compare explicit successful refreshes within ten minutes. Missing identities and adapter disappearance are not inferred as changes. First refresh establishes a baseline. No history is written to disk." = "页面打开时观测路径事件。IP、网关、DNS 和 Wi-Fi 标识变化比较十分钟内成功的手动刷新。缺少标识或适配器消失不会推断为变化。首次刷新建立基线。不将历史写入磁盘。"; +"%d status changes and %d path events; %d older events omitted." = "%d 个状态变化、%d 个路径事件;省略了 %d 个旧事件。"; +"No network changes observed in this session." = "本会话尚未观测到网络变化。"; +"Before" = "之前"; +"After" = "之后"; +"Reading network status" = "正在读取网络状态"; +"Scanning Wi-Fi" = "正在扫描 Wi-Fi"; +"Measuring latency" = "正在测量延迟"; +"Checking connection" = "正在检查连接"; +"Stopping after the macOS Wi-Fi scan returns." = "等待 macOS Wi-Fi 扫描返回后停止。"; +"Stopping measurement." = "正在停止测量。"; +"Enter a hostname or IP address without a URL, port or spaces." = "请输入主机名或 IP 地址,不要包含 URL、端口或空格。"; +"Observation completed." = "观测已完成。"; +"Cancelled. Previous results remain timestamped." = "已取消。保留之前结果及其时间戳。"; +"Network observation failed or timed out. Check permissions and retry." = "网络观测失败或超时。请检查权限后重试。"; +"Not requested" = "尚未请求"; +"Authorized" = "已授权"; +"Denied" = "已拒绝"; +"Restricted" = "受限"; +"Available path" = "路径可用"; +"Connection required" = "需要建立连接"; +"No available path" = "无可用路径"; +"Gateway ICMP" = "网关 ICMP"; +"DNS resolution" = "DNS 解析"; +"TCP connection" = "TCP 连接"; +"Failed or blocked" = "失败或被阻止"; +"Timed out or unavailable" = "超时或不可用"; +"Link state" = "链路状态"; +"Ethernet" = "以太网"; +"Other" = "其他"; +"Global DNS configuration is shown; scoped VPN and per-domain resolvers may differ." = "显示全局 DNS 配置;VPN 和各域专用解析器可能不同。"; +"Observed networks by channel" = "按信道统计观测网络"; +"Wi-Fi" = "Wi-Fi"; +"Not applicable to a numeric IP" = "不适用于数字 IP 地址"; +"Active" = "活动"; +"Inactive" = "不活动"; +"Carrier state" = "载波状态"; +"Link detected" = "检测到链路"; +"No link detected" = "未检测到链路"; +"Up / running" = "已启用/运行中"; +"Down / not running" = "已关闭/未运行"; +"Interface flags" = "接口标志"; diff --git a/apps/toolbox/Resources/zh-Hans.lproj/Storage.strings b/apps/toolbox/Resources/zh-Hans.lproj/Storage.strings new file mode 100644 index 0000000..2455ec5 --- /dev/null +++ b/apps/toolbox/Resources/zh-Hans.lproj/Storage.strings @@ -0,0 +1,270 @@ +/* Orven zh-Hans localization. */ +"%lld bản giống nhau • %@/tệp" = "%lld 个相同副本・每个文件 %@"; +"%lld tệp (%@) sẽ được chuyển vào Trash. Một bản mới nhất trong mỗi nhóm luôn được giữ lại." = "%lld 个文件(%@)将移到废纸篓。每组均保留最新副本。"; +"%lld ảnh • tương đồng %lld%%" = "%lld 张照片・相似度 %lld%%"; +"%lld×%lld • nét %@" = "%lld×%lld・清晰度 %@"; +"12 giờ" = "12 小时"; +"24 giờ" = "24 小时"; +"6 giờ" = "6 小时"; +"7 ngày" = "7 天"; +"An toàn" = "安全"; +"App chưa tìm thấy file cấu hình dự án tham chiếu phiên bản này. Việc quét không thể đảm bảo bao phủ mọi dự án, hãy kiểm tra trước khi tiếp tục." = "未找到引用此版本的项目配置。扫描无法覆盖所有项目,请在继续前检查。"; +"App chỉ hash các tệp từ 1 MB và bỏ qua package ứng dụng, Trash cùng cache hệ thống." = "仅对至少 1 MB 的文件计算哈希;排除应用包、废纸篓和系统缓存。"; +"App sẽ tìm bundle trong Applications và đối chiếu Bundle ID với dữ liệu trong Library." = "Orven 查找 Applications 中的应用包,并将其 Bundle ID 与 Library 数据匹配。"; +"Archive có thể cần để symbolicate crash hoặc phát hành lại ứng dụng." = "归档可能用于解析崩溃符号或重新发布应用。"; +"Artifact có thể tạo lại, chỉ trong project root bạn chọn" = "仅限所选项目根目录内可重新生成的产物"; +"Artifact vượt khỏi project root đã chọn nên bị chặn." = "产物超出所选项目根目录,操作已被阻止。"; +"Bundle ID khớp chính xác" = "Bundle ID 完全匹配"; +"Bản lưu trữ ứng dụng; nên kiểm tra trước khi xóa" = "应用归档;移除前请检查"; +"Bản đồ dung lượng" = "存储分布图"; +"Bật" = "开启"; +"Bật lịch quét" = "启用计划"; +"Bỏ chọn" = "取消选择"; +"Bộ nhớ đệm" = "缓存"; +"Bộ nhớ đệm gói Python" = "Python 软件包缓存"; +"Bộ nhớ đệm gói Yarn" = "Yarn 软件包缓存"; +"Bộ nhớ đệm gói npm" = "npm 软件包缓存"; +"Cache Swift Package Manager" = "Swift Package Manager 缓存"; +"Cache của Xcode Simulator" = "Xcode 模拟器缓存"; +"Cache, log và dữ liệu build có thể tạo lại" = "可重新生成的缓存、日志和构建数据"; +"Chuyển artifact đã chọn vào Trash?" = "将所选产物移到废纸篓?"; +"Chuyển khoảng %@ từ %lld hạng mục vào Trash. Dọn chính Trash sẽ xóa vĩnh viễn nội dung và thực sự giải phóng dung lượng." = "将约 %@ 从 %lld 个类别移到废纸篓。清倒废纸篓会永久删除内容并释放空间。"; +"Chuyển tệp trùng lặp vào Trash?" = "将重复文件移到废纸篓?"; +"Chuyển vào Trash" = "移到废纸篓"; +"Chuyển vào Trash…" = "移到废纸篓…"; +"Chuyển ảnh đã chọn vào Trash?" = "将所选照片移到废纸篓?"; +"Chưa chọn project root" = "尚未选择项目根目录"; +"Chưa có dữ liệu phân tích" = "尚未分析"; +"Chưa có lịch sử" = "暂无历史记录"; +"Chưa có nhóm ảnh tương tự" = "没有相似照片组"; +"Chưa nhận diện dữ liệu Developer" = "未识别到开发数据"; +"Chưa quét tệp trùng lặp" = "尚未扫描重复文件"; +"Chưa quét ứng dụng" = "尚未扫描应用"; +"Chưa tìm thấy file cấu hình sử dụng" = "未找到引用的配置文件"; +"Chạy" = "运行"; +"Chạy công cụ dọn dẹp?" = "运行清理工具?"; +"Chạy…" = "运行…"; +"Chỉ quét các mục được phân loại An toàn" = "仅扫描分类为“安全”的项目"; +"Chọn Quét tệp trùng lặp để bắt đầu" = "选择“扫描重复文件”以开始"; +"Chọn bản sao" = "选择副本"; +"Chọn nơi tìm tệp trùng lặp" = "选择查找重复文件的位置"; +"Chọn project root" = "选择项目根目录"; +"Chọn project root để bắt đầu." = "选择项目根目录以开始。"; +"Chọn project…" = "选择项目…"; +"Chọn thư mục ảnh để bắt đầu" = "选择照片文件夹以开始"; +"Chọn thư mục…" = "选择文件夹…"; +"Chọn vị trí…" = "选择位置…"; +"Chọn ảnh đề xuất loại" = "选择建议的照片"; +"Các gói đã tải về" = "已下载的软件包"; +"Các thao tác dọn dẹp và gỡ ứng dụng sẽ xuất hiện tại đây." = "清理和应用移除操作将显示在此处。"; +"Các tệp cùng kích thước sẽ được xác minh bằng SHA-256. App không so sánh chỉ dựa trên tên tệp." = "使用 SHA-256 验证大小相同的文件,不仅凭文件名判断。"; +"Các tệp log trong tài khoản người dùng" = "用户账户中的日志文件"; +"Có thể chứa dữ liệu ứng dụng thử nghiệm" = "可能包含测试应用数据"; +"Có thể cần tải lại khi debug thiết bị cũ" = "调试旧设备时可能需要重新下载"; +"Có thể giải phóng" = "可释放"; +"Có thể là bản sao dữ liệu duy nhất" = "可能是此数据的唯一副本"; +"Có thể xem xét" = "可检查"; +"Công cụ lưu trữ" = "存储工具"; +"Công cụ phân tích và quản lý dung lượng dành cho macOS." = "适用于 macOS 的存储分析与管理。"; +"Cảnh báo tên dễ nhầm — không được chọn để xóa" = "名称相似警告・未选择删除"; +"Cần xem lại" = "需检查"; +"Cập nhật lịch" = "更新计划"; +"Dependency và distribution có thể tải lại" = "依赖项和发行包可重新下载"; +"Developer Cleanup thất bại" = "开发数据清理失败"; +"Diskora chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Orven 仅在确认后清理,并优先使用各工具的官方命令。"; +"Diskora sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Orven 将原样执行显示的允许列表命令。该操作不使用废纸篓且无法撤销。"; +"Dung lượng" = "大小"; +"Dùng Homebrew để xóa download cũ và phiên bản package không còn cần thiết." = "使用 Homebrew 删除旧下载和不再需要的软件包版本。"; +"Dùng npm để dọn cache có thể tải lại." = "使用 npm 清理可重新获取的缓存。"; +"Dùng pip để dọn wheel và HTTP cache." = "使用 pip 清理 wheel 和 HTTP 缓存。"; +"Dọn build cache, network và container đã dừng; không xóa volume." = "清理构建缓存、网络和已停止的容器,不删除卷。"; +"Dọn bằng công cụ chính thức" = "使用官方工具清理"; +"Dọn chuyên sâu" = "深度清理"; +"Dọn chính Trash là thao tác vĩnh viễn và không thể hoàn tác." = "清倒废纸篓会永久删除内容,无法撤销。"; +"Dọn dẹp" = "清理"; +"Dọn dẹp…" = "清理…"; +"Dọn mục đã chọn…" = "清理所选项目…"; +"Dọn nhanh" = "快速清理"; +"Dừng" = "停止"; +"Dữ liệu có thể tạo lại và được chuyển vào Trash." = "将可重新生成的数据移到废纸篓。"; +"Dữ liệu cần xem xét" = "待检查数据"; +"Dữ liệu tạm của các ứng dụng" = "应用临时数据"; +"Dự án" = "项目"; +"GIỮ" = "保留"; +"GIỮ LẠI" = "保留"; +"Giới thiệu Diskora" = "关于 Orven"; +"Gỡ %@?" = "移除 %@?"; +"Gỡ phiên bản runtime?" = "移除运行时版本?"; +"Gỡ ứng dụng" = "应用"; +"Gỡ…" = "移除…"; +"Gửi notification với dung lượng cần xem xét" = "有可检查空间时通知"; +"Hiển thị trong Finder" = "在 Finder 中显示"; +"Hiển thị tệp thứ hai" = "显示第二个文件"; +"Hiển thị tệp thứ nhất" = "显示第一个文件"; +"Hình ảnh" = "图像"; +"Hạng mục" = "类别"; +"Hệ sinh thái" = "生态系统"; +"Hợp lệ" = "有效"; +"Hủy" = "取消"; +"Khác" = "其他"; +"Khôi phục" = "恢复"; +"Khôi phục %lld mục…" = "恢复 %lld 项…"; +"Khôi phục từ Trash?" = "从废纸篓恢复?"; +"Không có Bundle ID" = "没有 Bundle ID"; +"Không có artifact phù hợp" = "没有匹配的产物"; +"Không có source, manifest, lockfile hoặc thư mục chưa biết nào được đề xuất." = "不会建议清理源码、manifest、lockfile 或未知文件夹。"; +"Không hợp lệ" = "无效"; +"Không thể" = "无法"; +"Không thể cập nhật lịch quét" = "无法更新计划"; +"Không thể hoàn tất quá trình quét" = "无法完成扫描"; +"Không thể phân tích" = "无法分析"; +"Không thể quét ảnh" = "无法扫描照片"; +"Không thể đọc" = "无法读取"; +"Không tìm thấy mục phù hợp." = "没有匹配项目。"; +"Không tìm thấy tệp trùng lặp" = "未找到重复文件"; +"Không tự chọn, không chuyển Trash và không xóa" = "不会自动选择、移到废纸篓或删除"; +"Kiểm tra .nvmrc của dự án" = "检查项目的 .nvmrc 文件"; +"Kiểm tra .python-version của dự án" = "检查项目的 .python-version 文件"; +"Kèm dữ liệu còn sót" = "包含残留数据"; +"Language / Ngôn ngữ" = "语言"; +"Launch item tham chiếu Bundle ID" = "启动项引用 Bundle ID"; +"Launch item tham chiếu tên ứng dụng" = "启动项引用应用名称"; +"Làm mới" = "刷新"; +"Lần chạy gần nhất" = "最近运行"; +"Lịch quét" = "定时扫描"; +"Lịch quét chỉ thông báo; Diskora không tự xóa dữ liệu." = "定时扫描仅发送通知;Orven 不会自动删除数据。"; +"Lịch quét chỉ thông báo; Orven không tự xóa dữ liệu." = "定时扫描仅发送通知;Orven 不会自动删除数据。"; +"Lịch sử" = "历史记录"; +"Lịch sử này không có thông tin vị trí trong Trash." = "此历史记录没有废纸篓位置信息。"; +"Lịch sử, vị trí Trash và khôi phục có kiểm tra xung đột" = "历史记录、废纸篓位置及避免冲突的恢复"; +"Môi trường có thể chứa package riêng" = "环境可能包含独有软件包"; +"Một số mục không thể khôi phục" = "部分项目无法恢复"; +"Một số mục không thể xóa" = "部分项目无法清理"; +"Một số thao tác không thành công" = "部分操作失败"; +"Mở Trash" = "打开废纸篓"; +"Mở báo cáo" = "打开报告"; +"Mục chỉ được khôi phục khi vị trí gốc chưa có dữ liệu mới." = "仅在原位置没有新数据时恢复项目。"; +"Mức an toàn" = "安全性"; +"Nguy hiểm" = "危险"; +"Nguyên tắc" = "原则"; +"Nhóm trùng" = "重复组"; +"Nhật ký ứng dụng" = "应用日志"; +"Nội dung sẽ được chuyển vào Trash. Mục 'Cần xem lại' có thể phải tải hoặc tạo lại; các mục nguy hiểm không bao giờ được dọn tự động." = "内容将移到废纸篓。需检查的项目可能需要重新下载或生成;危险项目不会自动清理。"; +"Phiên bản runtime" = "运行时版本"; +"Phân loại dữ liệu" = "数据类别"; +"Phân loại theo mức rủi ro" = "按风险级别分组"; +"Phân tích dung lượng" = "存储分析"; +"Phân tích dung lượng • Tệp trùng lặp • Dọn dẹp Developer" = "存储分析・重复文件・开发数据清理"; +"Pods có thể tải lại" = "Pods 可重新下载"; +"Project source, manifest, lockfile và .git không được chọn." = "不会选择项目源码、manifest、lockfile 或 .git。"; +"Quét" = "扫描"; +"Quét chuyên sâu" = "深度扫描"; +"Quét dung lượng" = "扫描存储空间"; +"Quét lại" = "重新扫描"; +"Quét mỗi" = "扫描间隔"; +"Quét nền và thông báo, không tự động xóa" = "后台扫描和通知;不会自动删除"; +"Quét thư mục người dùng" = "扫描个人文件夹"; +"Quét thư mục người dùng hoặc chọn một ổ đĩa khác để tìm nơi đang chiếm dung lượng." = "扫描个人文件夹或选择其他卷,查看空间占用情况。"; +"Quét thư mục người dùng ở mục Phân tích dung lượng để thống kê môi trường dev." = "在存储分析中扫描个人文件夹,以列出开发环境。"; +"Quét ảnh" = "扫描照片"; +"Quét ứng dụng" = "扫描应用"; +"Quét ứng dụng để tìm dữ liệu còn sót" = "扫描应用以检查残留数据"; +"Receipt hệ thống chỉ dùng làm bằng chứng; Diskora không tự xóa" = "系统收据仅作为证据;Orven 不会自动删除"; +"Receipt hệ thống chỉ dùng làm bằng chứng; Orven không tự xóa" = "系统收据仅作为证据;Orven 不会自动删除"; +"SDK, runtime, simulator và môi trường lập trình" = "SDK、运行时、模拟器和开发环境"; +"Sản phẩm build và chỉ mục có thể tạo lại" = "可重新生成的构建产物和索引"; +"Sẵn sàng quét" = "已准备好扫描"; +"Sẵn sàng quét chuyên sâu" = "已准备好进行深度扫描"; +"Sẵn sàng quét các tệp có thể dọn dẹp" = "已准备好扫描可清理文件"; +"Thay đổi từ lần quét trước" = "自上次扫描以来的更改"; +"Thêm project…" = "添加项目…"; +"Thùng rác" = "废纸篓"; +"Thư mục lớn nhất" = "最大的文件夹"; +"Thời gian" = "时间"; +"Orven chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ." = "Orven 仅在确认后清理,并优先使用各工具的官方命令。"; +"Orven chỉ quét thư mục bạn chọn và chỉ đề xuất artifact có marker hệ sinh thái rõ ràng." = "Orven 仅扫描所选文件夹,仅在存在生态系统标记时建议清理产物。"; +"Orven sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." = "Orven 将原样执行显示的允许列表命令。该操作不使用废纸篓且无法撤销。"; +"Orven sẽ nhận diện output có thể tạo lại từ marker dự án." = "Orven 根据项目标记识别可重新生成的输出。"; +"TÊN KHÁC • NỘI DUNG GIỐNG HỆT" = "名称不同・内容完全相同"; +"Tài liệu" = "文稿"; +"Tác giả" = "作者"; +"Tên giống %lld%% • Nội dung khác nhau, không phải duplicate" = "名称相似度 %lld%%・内容不同,不是重复文件"; +"Tên thành phần khớp; cần xem lại" = "组件名称匹配;需检查"; +"Tìm thấy" = "已找到"; +"Tìm đường dẫn" = "搜索路径"; +"Tất cả" = "全部"; +"Tất cả hệ sinh thái" = "所有生态系统"; +"Tần suất" = "频率"; +"Tắt" = "关闭"; +"Tắt lịch" = "停用计划"; +"Tệp lớn trên 100 MB" = "大于 100 MB 的文件"; +"Tệp nén" = "归档"; +"Tệp trùng lặp" = "重复文件"; +"Tệp đã chuyển vào Thùng rác" = "已移入废纸篓的文件"; +"Tệp đã quét" = "已扫描文件"; +"Tổng quan" = "概览"; +"Undo Center không ghi đè dữ liệu đang tồn tại ở vị trí gốc." = "恢复中心不会覆盖原位置的数据。"; +"Vision so sánh ảnh chụp gần nhau trong cùng thư mục; ảnh được đề xuất giữ dựa trên chất lượng tệp cao hơn." = "Vision 比较同一文件夹中的相近照片,建议保留质量较高的文件。"; +"Volume có thể chứa database quan trọng" = "卷可能包含重要数据库"; +"Xác nhận dọn chuyên sâu" = "确认深度清理"; +"Xác nhận dọn dẹp" = "确认清理"; +"Xóa simulator runtime không còn được Xcode hỗ trợ bằng simctl." = "使用 simctl 删除不再受支持的模拟器运行时。"; +"có thể xem xét" = "可检查"; +"dự án" = "个项目"; +"giờ" = "小时"; +"hoàn tất" = "已完成"; +"không còn trong Trash" = "已不在废纸篓中"; +"không có" = "无"; +"không có lịch" = "未安排计划"; +"mục" = "项"; +"mục vào Trash" = "项到废纸篓"; +"nhóm" = "组"; +"nội dung đã thay đổi, thao tác bị hủy" = "内容已更改,操作已取消"; +"sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Diskora không xóa database hệ thống" = "sfltool 检测到登录/后台项目;仅显示证据,不删除系统数据库"; +"sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Orven không xóa database hệ thống" = "sfltool 检测到登录/后台项目;仅显示证据,不删除系统数据库"; +"thao tác" = "次操作"; +"thất bại" = "失败"; +"tệp" = "个文件"; +"tệp vào Trash" = "个文件到废纸篓"; +"vào Trash" = "到废纸篓"; +"vị trí gốc đã có dữ liệu" = "原位置已有数据"; +"Âm thanh" = "音频"; +"Đang chuyển artifact đã xác nhận vào Trash…" = "正在将已确认的产物移到废纸篓…"; +"Đang chạy" = "正在运行"; +"Đang dọn dẹp…" = "正在清理…"; +"Đang lập chỉ mục" = "正在建立索引"; +"Đang lọc bằng partial hash" = "正在使用部分哈希筛选"; +"Đang nhóm theo kích thước và kiểm tra nội dung…" = "正在按大小分组并检查内容…"; +"Đang phân tích" = "正在分析"; +"Đang phân tích đặc trưng hình ảnh bằng Vision…" = "正在使用 Vision 分析图像特征…"; +"Đang phân tích ứng dụng và dữ liệu liên quan…" = "正在分析应用及相关数据…"; +"Đang quét dữ liệu chuyên sâu…" = "正在扫描深度清理数据…"; +"Đang quét…" = "正在扫描…"; +"Đang tìm artifact có thể tạo lại…" = "正在查找可重新生成的产物…"; +"Đang xác minh SHA-256" = "正在验证 SHA-256"; +"Đã bật" = "已启用"; +"Đã chuyển" = "已移动"; +"Đã chọn" = "已选择"; +"Đã dừng quét" = "扫描已停止"; +"Đã khôi phục" = "已恢复"; +"Đã khôi phục hoặc không còn mục trong Trash" = "已恢复或已不在废纸篓中"; +"Đã quét" = "已扫描"; +"Đã quét xong" = "扫描完成"; +"Đã quét xong • Đã chọn %@" = "扫描完成・已选择 %@"; +"Đã tắt" = "已停用"; +"Đã xử lý" = "已处理"; +"Đóng" = "关闭"; +"Được bảo vệ" = "受保护"; +"Được tham chiếu bởi %lld dự án" = "由 %lld 个项目引用"; +"đang được chọn" = "已选择"; +"đã chọn" = "已选择"; +"đã nhận diện" = "已识别"; +"đã phân tích" = "已分析"; +"đường dẫn không an toàn" = "不安全路径"; +"Ảnh tương tự" = "相似照片"; +"Ảnh tương tự không phải bản sao tuyệt đối. Hãy kiểm tra kỹ trước khi chuyển %lld ảnh vào Trash." = "相似照片不是完全相同的副本。将 %lld 张照片移到废纸篓前,请仔细检查。"; +"ảnh" = "张照片"; +"Ứng dụng" = "应用"; +"Ứng dụng và dữ liệu còn sót" = "应用与残留数据"; +"Ứng dụng và dữ liệu đã chọn sẽ được chuyển vào Trash. Một số ứng dụng hệ thống có thể yêu cầu quyền quản trị." = "所选应用和数据将移到废纸篓。部分系统应用可能需要管理员权限。"; +"ứng dụng" = "个应用"; diff --git a/apps/toolbox/Sources/Toolbox/APFSSpaceInspectorView.swift b/apps/toolbox/Sources/Toolbox/APFSSpaceInspectorView.swift new file mode 100644 index 0000000..28d13b8 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/APFSSpaceInspectorView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import ToolboxCore + +struct APFSSpaceInspectorView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @State private var volume = APFSStartupVolume.data + @State private var reading: APFSSpaceInspection? + @State private var isReading = false + @State private var failed = false + @State private var task: Task? + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.text("Hidden Space & Snapshots")).font(.title2.bold()) + Spacer() + if isReading { ProgressView().controlSize(.small) } + Button(L10n.text("Inspect APFS space")) { refresh() }.disabled(isReading) + } + Picker(L10n.text("Startup volume"), selection: $volume) { + Text(L10n.text("Data volume")).tag(APFSStartupVolume.data) + Text(L10n.text("System volume")).tag(APFSStartupVolume.system) + }.pickerStyle(.menu).disabled(isReading) + .onChange(of: volume) { _ in + reading = nil + failed = false + } + Text( + L10n.text( + "Selected startup volume only. APFS container capacity is shared by volumes; folder scans, available space and snapshot sizes are different measurements." + ) + ) + .font(.caption).foregroundStyle(.secondary) + if failed { + Text(L10n.text("APFS inspection failed or is unsupported. Refresh to try again.")) + } + if let reading { + Text(reading.observedAt, style: .time).font(.caption) + HStack(alignment: .top, spacing: 20) { + field("APFS container capacity", reading.containerTotal) + field("APFS container free", reading.containerFree) + field("Selected volume used", reading.volumeUsed) + } + if !reading.volumes.isEmpty { + Text(L10n.text("Volumes sharing this container")).font(.headline) + ForEach(reading.volumes) { volume in + HStack { + Text(volume.name).textSelection(.enabled) + Text(volume.roles.joined(separator: " · ")).font(.caption).foregroundStyle(.secondary) + Spacer() + Text(volume.bytes.map(ByteCount.string) ?? L10n.text("Unavailable")).monospacedDigit() + } + } + if reading.omittedVolumes > 0 { + Text(String(format: L10n.text("%d volume entries omitted."), reading.omittedVolumes)) + } + field("Unattributed container allocation", reading.containerRemainder) + Text( + L10n.text( + "Container remainder uses OS-reported capacity minus free space and listed volume usage. It is not hidden-file size or exclusive snapshot space. Incomplete or inconsistent accounting stays unknown." + ) + ) + .font(.caption).foregroundStyle(.secondary) + } + Text( + L10n.text( + "Unaccounted space is not measured here. It may include unreadable files, other volumes, filesystem metadata, clones or snapshots; Orven does not attribute the remainder to one cause." + )) + Text(L10n.text("APFS snapshots")).font(.headline) + if reading.snapshots.isEmpty { + Text( + L10n.text( + "No APFS snapshots reported for the selected volume. Other volumes may have snapshots." + )) + } + ForEach(reading.snapshots) { snapshot in + VStack(alignment: .leading, spacing: 4) { + Text(snapshot.name).font(.caption.monospaced()).textSelection(.enabled) + Text( + L10n.text( + snapshot.purgeable.map { + $0 + ? "OS marks this snapshot purgeable." + : "OS does not mark this snapshot purgeable." + } ?? "Snapshot purgeability unavailable.") + ) + .font(.caption).foregroundStyle(.secondary) + } + } + if reading.omitted > 0 { + Text(String(format: L10n.text("%d snapshot entries omitted."), reading.omitted)) + } + Text( + L10n.text( + "Exclusive snapshot bytes are unavailable from this inspection. Snapshot count does not measure reclaimable space. No snapshots are created, mounted, thinned or deleted." + ) + ) + .font(.caption).foregroundStyle(.secondary) + } else if !isReading { + Text(L10n.text("Inspect to read container space and selected-volume snapshots.")) + } + }.orvenSurface().onDisappear { task?.cancel() } + } + private func field(_ title: String, _ bytes: Int64?) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(L10n.text(title)).font(.caption).foregroundStyle(.secondary) + Text(bytes.map(ByteCount.string) ?? L10n.text("Unavailable")).font(.title3.monospacedDigit()) + }.frame(maxWidth: .infinity, alignment: .leading) + } + private func refresh() { + isReading = true + failed = false + task = Task { + let target = volume + let worker = Task.detached(priority: .utility) { + try APFSSpaceInspection.read(volume: target) + } + defer { isReading = false } + do { + let value = try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + try Task.checkCancellation() + reading = value + } catch is CancellationError { + } catch { failed = true } + } + } +} diff --git a/apps/toolbox/Sources/Toolbox/AppActivityTimelineView.swift b/apps/toolbox/Sources/Toolbox/AppActivityTimelineView.swift new file mode 100644 index 0000000..7a59dfe --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/AppActivityTimelineView.swift @@ -0,0 +1,103 @@ +import AppKit +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct AppActivityTimelineView: View { + let applicationURL: URL + @ObservedObject var model: MonitoringViewModel + @State private var sleepReading: MetricSample? + @State private var inspecting = false + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + private var mappedProcesses: [ProcessSample] { + (model.snapshot?.processes.value ?? []).filter { process in + guard let identity = process.identity, + let app = NSRunningApplication(processIdentifier: process.pid), + let url = app.bundleURL, url.standardizedFileURL == applicationURL.standardizedFileURL, + let launch = app.launchDate, + abs(launch.timeIntervalSince(identity.startDate)) < 1 + else { return false } + return true + } + } + var body: some View { + let processes = mappedProcesses + VStack(alignment: .leading, spacing: 12) { + Text(L10n.text("App Activity Timeline")).font(.title2.bold()) + Text( + L10n.text( + "Recent in-memory CPU trends for running processes mapped by bundle path and launch identity. Helper processes, older activity and unverified ownership may be missing. Related-data sizes remain scan-time observations." + ) + ) + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text(L10n.text(model.samplingMode == .active ? "Active monitoring" : "Sampling paused")) + .font(.caption) + if let snapshot = model.snapshot { Text(snapshot.time.wall, style: .time).font(.caption) } + if processes.isEmpty { + Text(L10n.text("No running process could be mapped within current coverage.")) + } + ForEach(Array(processes.prefix(8))) { process in + HStack { + Text(process.name + " · PID \(process.pid)").font(.headline) + Spacer() + VStack(alignment: .trailing) { + Text(process.memory.value.map(monitoringBytes) ?? L10n.text("Unavailable")) + Text( + availabilityLabel( + model.samplingMode == .active + ? process.memory.freshness( + at: ProcessInfo.processInfo.systemUptime, maximumAge: 6) + : .stale) + ).font(.caption) + } + Button(L10n.text("Inspect process")) { + if let identity = process.identity { + ToolboxRuntime.shared.navigation.open(.inspectProcess(identity)) + } + } + } + MonitoringChart( + title: L10n.text("Process CPU • one core"), unit: "%", + data: process.cpuHistory.map { ChartDatum(time: $0.time, value: $0.cpu) }, + embedded: true, height: 110) + } + Button(L10n.text("Inspect app sleep requests")) { + inspecting = true + Task { + sleepReading = await model.engine.inspectSleepRequests() + inspecting = false + } + }.disabled(inspecting) + if let sleepReading { + Text(sleepReading.time.wall, style: .time).font(.caption) + Text( + availabilityLabel( + sleepReading.freshness( + at: ProcessInfo.processInfo.systemUptime, + maximumAge: 30)) + ).font(.caption) + let identities = Set(processes.compactMap(\.identity)) + let requests = + sleepReading.value?.requests.filter { + $0.owner.map { identities.contains($0.identity) } ?? false + } ?? [] + ForEach(requests) { request in + Text(request.type + " · " + request.name).font(.caption).textSelection(.enabled) + } + if requests.isEmpty { + Text( + L10n.text( + "No sleep requests attributed to mapped processes within this snapshot's coverage.") + ) + .font(.caption).foregroundStyle(.secondary) + } + if (sleepReading.value?.omittedCount ?? 0) > 0 { + Text(L10n.text("Some assertion entries were omitted; coverage is partial.")) + .font(.caption) + } + } + }.padding(16).background(OrvenTheme.canvas, in: RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/apps/toolbox/Sources/Toolbox/BatteryThermalView.swift b/apps/toolbox/Sources/Toolbox/BatteryThermalView.swift new file mode 100644 index 0000000..9e10d46 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/BatteryThermalView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct BatteryThermalView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @ObservedObject var model: MonitoringViewModel + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + OrvenPageHeader(title: L10n.text("Battery & Thermal")) + OrvenSamplingStatus(model: model) + if let hardware = model.snapshot?.hardware { + HStack(alignment: .top, spacing: 28) { + MetricReadout( + title: L10n.text("Battery"), sample: hardware.reading.battery, + format: { + monitoringNumber($0.percent, digits: 0) + "% • " + + batteryState($0) + }, maximumAge: 30) + MetricReadout( + title: L10n.text("Thermal state"), sample: hardware.reading.thermal, + format: thermalLabel, maximumAge: 6) + }.orvenSurface() + } else { + Text(L10n.text("Warming up")) + } + Text( + L10n.text( + "Thermal state is macOS's coarse system condition, not a temperature in °C. Macs without an internal battery report Unsupported." + ) + ) + .font(.callout).foregroundStyle(.secondary) + Button(L10n.text("Review temperatures and fans")) { + ToolboxRuntime.shared.navigation.open(.section(.hardware)) + }.buttonStyle(.bordered) + SleepInspectorView(engine: model.engine) + Label( + L10n.text("Per-process energy: Unsupported"), + systemImage: "questionmark.circle" + ) + .foregroundStyle(.secondary) + }.padding(28) + }.environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } +} diff --git a/apps/toolbox/Sources/Toolbox/CPUMemoryView.swift b/apps/toolbox/Sources/Toolbox/CPUMemoryView.swift new file mode 100644 index 0000000..8f60d20 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/CPUMemoryView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct CPUMemoryView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @ObservedObject var model: MonitoringViewModel + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + OrvenPageHeader(title: L10n.text("CPU & Memory")) + OrvenSamplingStatus(model: model) + MonitoringChart( + title: L10n.text("System CPU • all cores"), unit: "%", + data: (model.snapshot?.history ?? []).map { + ChartDatum(time: $0.time, value: $0.cpuPercent, generation: $0.generation) + }, percentScale: true) + MonitoringChart( + title: L10n.text("Occupied memory • includes caches"), unit: "%", + data: (model.snapshot?.history ?? []).map { + ChartDatum(time: $0.time, value: $0.memoryPercent, generation: $0.generation) + }, percentScale: true) + if let snapshot = model.snapshot { + HStack(alignment: .top) { + MetricReadout( + title: L10n.text("Physical memory"), sample: snapshot.memory, + format: { monitoringBytes($0.totalBytes) }) + MetricReadout( + title: L10n.text("Wired memory"), sample: snapshot.memory, + format: { monitoringBytes($0.wiredBytes) }) + MetricReadout( + title: L10n.text("Compressor storage"), sample: snapshot.memory, + format: { monitoringBytes($0.compressedBytes) }) + }.orvenSurface() + HStack(alignment: .top) { + MetricReadout( + title: L10n.text("Swap used"), sample: snapshot.hardware.reading.swap, + format: { monitoringBytes($0) }, maximumAge: 6) + MetricReadout( + title: L10n.text("Memory pressure • last OS event"), + sample: snapshot.hardware.reading.pressure, format: pressureLabel, maximumAge: 6) + }.orvenSurface() + } + Text( + L10n.text( + "Occupied memory is physical memory minus free pages and includes caches. It does not measure reclaimable RAM. Pressure is an OS event, not an inferred score." + ) + ) + .font(.caption).foregroundStyle(.secondary) + Button(L10n.text("Review temperatures and fans")) { + ToolboxRuntime.shared.navigation.open(.section(.hardware)) + }.buttonStyle(.bordered) + Label( + L10n.text("GPU utilization and memory: Unsupported"), systemImage: "questionmark.circle" + ) + .font(.callout).foregroundStyle(.secondary) + }.padding(28) + }.environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } +} + +func pressureLabel(_ value: MemoryPressureLevel) -> String { + switch value { + case .normal: L10n.text("Normal") + case .warning: L10n.text("Warning") + case .critical: L10n.text("Critical") + } +} diff --git a/apps/toolbox/Sources/Toolbox/DiagnosticInsights.swift b/apps/toolbox/Sources/Toolbox/DiagnosticInsights.swift new file mode 100644 index 0000000..6f857ee --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/DiagnosticInsights.swift @@ -0,0 +1,158 @@ +import Foundation +import ToolboxMonitoring + +struct DiagnosticInsight: Identifiable, Equatable { + let id: String + let title: String + let explanation: String + let evidence: String + let section: ToolboxSection +} + +enum DiagnosticInsights { + static func evaluate(_ snapshot: MonitoringSnapshot?, now: Double, active: Bool) + -> [DiagnosticInsight] + { + guard active, let snapshot, now >= snapshot.time.monotonic, + now - snapshot.time.monotonic <= 6 + else { return [] } + var result: [DiagnosticInsight] = [] + let pressure = snapshot.hardware.reading.pressure + if pressure.freshness(at: now, maximumAge: 6) == .available, + let level = pressure.value, level != .normal + { + result.append( + .init( + id: "memory", title: "Memory pressure needs attention", + explanation: + "macOS reports memory pressure. Occupied memory alone does not establish a problem.", + evidence: level.rawValue, section: .cpuMemory)) + } + let currentGeneration = snapshot.history.last?.generation + let points = snapshot.history.suffix(31) + if snapshot.cpu.freshness(at: now, maximumAge: 3) == .available, + let first = points.first, let last = points.last, + last.time.monotonic - first.time.monotonic >= 30, + now - last.time.monotonic <= 3, + points.allSatisfy({ + $0.generation == currentGeneration && ($0.cpuPercent ?? -1).isFinite + && ($0.cpuPercent ?? -1) >= 85 && ($0.cpuPercent ?? -1) <= 100 + }), + zip(points, points.dropFirst()).allSatisfy({ + let interval = $1.time.monotonic - $0.time.monotonic + return interval > 0 && interval <= 2.5 + }) + { + result.append( + .init( + id: "cpu", title: "Sustained CPU activity", + explanation: + "System CPU stayed at or above 85% for at least 30 seconds. Review the measured processes before attributing a cause.", + evidence: "≥85% · ≥30s", section: .processes)) + } + let capacity = snapshot.hardware.reading.capacity + if capacity.freshness(at: now, maximumAge: 30) == .available, + let value = capacity.value, value.totalBytes > 0, + value.availableBytes <= value.totalBytes, + Double(value.availableBytes) / Double(value.totalBytes) < 0.1 + { + result.append( + .init( + id: "storage", title: "Startup volume is nearly full", + explanation: + "Available space is below 10% of reported capacity. APFS volumes share container space; inspect coverage and snapshots before cleanup.", + evidence: monitoringBytes(value.availableBytes) + " / " + + monitoringBytes(value.totalBytes), + section: .storage)) + } + let thermal = snapshot.hardware.reading.thermal + if thermal.freshness(at: now, maximumAge: 6) == .available, + let level = thermal.value, level == .serious || level == .critical + { + result.append( + .init( + id: "thermal", title: "Elevated thermal state", + explanation: + "macOS reports thermal pressure. This is an OS state, not a temperature measurement or proof of a particular app's impact.", + evidence: level.rawValue, section: .batteryThermal)) + } + return result + } +} + +enum LocalDiagnosticsReport { + static func encode(_ snapshot: MonitoringSnapshot?, active: Bool, includeProcessNames: Bool) + throws -> Data + { + let now = ProcessInfo.processInfo.systemUptime + var report: [String: Any] = [ + "schemaVersion": 1, "generatedAt": ISO8601DateFormatter().string(from: Date()), + "samplingActive": active, "processNamesIncluded": includeProcessNames, + "scope": + "Recent monitoring only; no dedicated path, username, hostname, network endpoint or scan-store fields. Process names default off; opt-in names may contain identifying text.", + ] + guard let snapshot else { + report["availability"] = "loading" + return try JSONSerialization.data( + withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + } + report["observedAt"] = ISO8601DateFormatter().string(from: snapshot.time.wall) + func metric(_ sample: MetricSample, age: Double, value: Any?) -> [String: Any] { + var field: [String: Any] = [ + "availability": active + ? sample.freshness(at: now, maximumAge: age).rawValue : "stale", "source": sample.source, + ] + if let value { field["observedValue"] = value } + return field + } + report["cpuPercent"] = metric(snapshot.cpu, age: 3, value: snapshot.cpu.value) + report["occupiedMemoryPercent"] = metric( + snapshot.memory, age: 3, + value: snapshot.memory.value?.occupiedPercent) + report["availableBytes"] = metric( + snapshot.hardware.reading.capacity, age: 30, + value: snapshot.hardware.reading.capacity.value?.availableBytes) + report["memoryPressure"] = metric( + snapshot.hardware.reading.pressure, age: 6, + value: snapshot.hardware.reading.pressure.value?.rawValue) + report["thermalState"] = metric( + snapshot.hardware.reading.thermal, age: 6, + value: snapshot.hardware.reading.thermal.value?.rawValue) + report["processesAvailability"] = + active + ? snapshot.processes.freshness(at: now, maximumAge: 6).rawValue : "stale" + report["processes"] = Array( + (snapshot.processes.value ?? []).sorted { + ($0.cpu.value ?? -1) > ($1.cpu.value ?? -1) + }.prefix(100) + ).map { process -> [String: Any] in + var fields: [String: Any] = [ + "pid": process.pid, + "cpuAvailability": + (active + ? process.cpu.freshness(at: now, maximumAge: 6) + : (process.cpu.availability == .available ? .stale : process.cpu.availability)).rawValue, + "memoryAvailability": + (active + ? process.memory.freshness(at: now, maximumAge: 6) + : (process.memory.availability == .available ? .stale : process.memory.availability)) + .rawValue, + ] + if let cpu = process.cpu.value { fields["cpuOneCorePercent"] = cpu } + if let memory = process.memory.value { fields["residentBytes"] = memory } + if includeProcessNames { fields["name"] = process.name } + return fields + } + report["history"] = snapshot.history.suffix(60).map { point -> [String: Any] in + var fields: [String: Any] = [ + "observedAt": ISO8601DateFormatter().string(from: point.time.wall), + "generation": point.generation, + ] + if let cpu = point.cpuPercent { fields["cpuPercent"] = cpu } + if let memory = point.memoryPercent { fields["occupiedMemoryPercent"] = memory } + return fields + } + return try JSONSerialization.data( + withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + } +} diff --git a/apps/toolbox/Sources/Toolbox/DiskNetworkView.swift b/apps/toolbox/Sources/Toolbox/DiskNetworkView.swift new file mode 100644 index 0000000..30bac0d --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/DiskNetworkView.swift @@ -0,0 +1,103 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct DiskNetworkView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @ObservedObject var model: MonitoringViewModel + @State private var showsVirtualInterfaces = false + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + OrvenPageHeader(title: L10n.text("Disk & Network")) + OrvenSamplingStatus(model: model) + NetworkInspectorView() + APFSSpaceInspectorView() + if let hardware = model.snapshot?.hardware { + MetricReadout( + title: L10n.text("Startup volume available"), sample: hardware.reading.capacity, + format: { + monitoringBytes($0.availableBytes) + " / " + + monitoringBytes($0.totalBytes) + }, maximumAge: 30 + ).orvenSurface() + HStack { + Button(L10n.text("Review Storage")) { + ToolboxRuntime.shared.navigation.open(.section(.storage)) + } + Button(L10n.text("Review Projects")) { + ToolboxRuntime.shared.navigation.open(.section(.projects)) + } + } + Text( + L10n.text( + "APFS volumes share container capacity. Capacity is shown for the startup volume once; I/O is per observed storage driver, including virtual devices when exposed." + ) + ) + .font(.caption).foregroundStyle(.secondary) + Text(L10n.text("Disk I/O by device")).font(.headline) + if let disks = hardware.disks.value { + VStack(alignment: .leading, spacing: 18) { + ForEach(disks) { disk in + HStack(alignment: .top) { + MetricReadout( + title: disk.name + " • " + L10n.text("Read"), sample: disk.input, + format: rateText, maximumAge: 6) + MetricReadout( + title: L10n.text("Write"), sample: disk.output, format: rateText, maximumAge: 6) + } + if disk.id != disks.last?.id { Divider() } + } + }.orvenSurface() + } else { + Text(availabilityLabel(hardware.disks.availability)) + } + if let reason = hardware.disks.reason { Text(reason).font(.caption) } + Toggle( + L10n.text("Show loopback, tunnels and other interfaces"), isOn: $showsVirtualInterfaces) + Text( + L10n.text( + "Physical summary includes active en* interfaces. Tunnel and loopback counters are shown separately and are never added to that summary." + ) + ) + .font(.caption).foregroundStyle(.secondary) + if let interfaces = hardware.interfaces.value { + VStack(alignment: .leading, spacing: 18) { + ForEach(interfaces.filter { showsVirtualInterfaces || $0.includeInAggregate }) { + interface in + HStack(alignment: .top) { + MetricReadout( + title: interface.name + " • " + L10n.text("Received"), sample: interface.input, + format: rateText, maximumAge: 6) + MetricReadout( + title: L10n.text("Sent"), sample: interface.output, format: rateText, + maximumAge: 6) + } + Divider() + } + }.orvenSurface() + } else { + Text(availabilityLabel(hardware.interfaces.availability)) + } + } else { + Text(L10n.text("Warming up")) + } + MonitoringChart( + title: L10n.text("Network received • physical"), unit: "MB/s", + data: (model.snapshot?.history ?? []).filter(\.ioSampled).map { + ChartDatum( + time: $0.time, value: $0.networkInput.map { $0 / 1_000_000 }, + generation: $0.generation) + }) + MonitoringChart( + title: L10n.text("Disk writes • observed drivers"), unit: "MB/s", + data: (model.snapshot?.history ?? []).filter(\.ioSampled).map { + ChartDatum( + time: $0.time, value: $0.diskOutput.map { $0 / 1_000_000 }, generation: $0.generation) + }) + }.padding(28) + }.environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } +} diff --git a/apps/toolbox/Sources/Toolbox/ExplainMyMacView.swift b/apps/toolbox/Sources/Toolbox/ExplainMyMacView.swift new file mode 100644 index 0000000..f0626d2 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/ExplainMyMacView.swift @@ -0,0 +1,142 @@ +import AppKit +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct ExplainMyMacView: View { + @ObservedObject var model: MonitoringViewModel + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @State private var showsExport = false + var body: some View { + let insights = DiagnosticInsights.evaluate( + model.snapshot, + now: ProcessInfo.processInfo.systemUptime, active: model.samplingMode == .active) + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.text("Explain My Mac")).font(.title2.bold()) + Spacer() + Button(L10n.text("Export diagnostics…")) { showsExport = true } + } + Text( + L10n.text( + "Evidence from recent samples. These observations do not establish causality and do not change your Mac." + ) + ) + .font(.caption).foregroundStyle(.secondary) + if insights.isEmpty { + Text( + L10n.text( + model.samplingMode == .active + ? "No matching findings in fresh, available samples. Missing data is not a health check." + : "Sampling is paused. Resume active monitoring to evaluate current evidence.") + ) + .foregroundStyle(.secondary) + } + ForEach(insights) { insight in + VStack(alignment: .leading, spacing: 7) { + HStack { + Text(L10n.text(insight.title)).font(.headline) + Spacer() + Button(L10n.text("Review evidence")) { + ToolboxRuntime.shared.navigation.open(.section(insight.section)) + } + } + Text(L10n.text(insight.explanation)) + Text(L10n.text(insight.evidence)).font(.caption.monospaced()).foregroundStyle(.secondary) + }.padding(.vertical, 6) + } + }.orvenSurface() + .sheet(isPresented: $showsExport) { DiagnosticsExportView(model: model) } + } +} + +struct DiagnosticsExportView: View { + @ObservedObject var model: MonitoringViewModel + var preparedReport: String? = nil + var reportName = "Orven-health.json" + var hardwareProfile: HardwareProfile? = nil + var rangeSince: Double? = nil + @Environment(\.dismiss) private var dismiss + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @State private var includeNames = false + @State private var preview = "" + @State private var status: String? + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text( + L10n.text( + preparedReport != nil + ? "Local report export" + : (hardwareProfile == nil ? "Local Diagnostics Export" : "Hardware report")) + ).font( + .title2.bold()) + Text( + L10n.text( + preparedReport != nil + ? "This report is frozen when opened. Close and reopen for newer observations. Preview the exact content before saving; nothing is uploaded." + : hardwareProfile == nil + ? "Preview the exact JSON before saving. No dedicated path, username, hostname or network endpoint fields are included. Opt-in process names may contain identifying text. Nothing is uploaded." + : "Preview the hardware profile and recent range before saving. No serial, hostname, paths, process names or network endpoints are included. Nothing is uploaded." + )) + if hardwareProfile == nil && preparedReport == nil { + Toggle(L10n.text("Include process names"), isOn: $includeNames) + .onChange(of: includeNames) { _ in refreshPreview() } + } + ScrollView { + Text(preview).font(.caption.monospaced()).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: .infinity).padding(12).background(OrvenTheme.canvas) + if let status { Text(L10n.text(status)).foregroundStyle(.secondary) } + HStack { + Button(L10n.text("Close")) { dismiss() }.keyboardShortcut(.cancelAction) + if preparedReport == nil { Button(L10n.text("Refresh preview")) { refreshPreview() } } + Spacer() + Button(L10n.text(preparedReport != nil ? "Save report…" : "Save JSON…")) { + let panel = NSSavePanel() + panel.nameFieldStringValue = + preparedReport != nil + ? reportName + : (hardwareProfile == nil ? "Orven-diagnostics.json" : "Orven-hardware.json") + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try Data(preview.utf8).write(to: url, options: .atomic) + status = preparedReport != nil ? "Report saved." : "Diagnostics saved." + } catch { + status = + preparedReport != nil + ? "Could not save the report. Choose a writable location and try again." + : "Could not save diagnostics. Choose a writable location and try again." + } + }.disabled(preview.isEmpty).buttonStyle(.borderedProminent) + } + }.padding(24).frame(width: 640, height: 560).background(OrvenTheme.surface) + .onAppear { refreshPreview() } + } + private func refreshPreview() { + do { + if let preparedReport { + preview = preparedReport + status = nil + return + } + if let hardwareProfile { + preview = String( + decoding: try HardwareReport.encode( + hardwareProfile, snapshot: model.snapshot, + active: model.samplingMode == .active, since: rangeSince), as: UTF8.self) + status = nil + return + } + preview = String( + decoding: try LocalDiagnosticsReport.encode( + model.snapshot, + active: model.samplingMode == .active, includeProcessNames: includeNames), as: UTF8.self) + status = nil + } catch { + preview = "" + status = "Could not generate diagnostics from the current sample. Refresh and try again." + } + } +} diff --git a/apps/toolbox/Sources/Toolbox/HardwareInspectorView.swift b/apps/toolbox/Sources/Toolbox/HardwareInspectorView.swift new file mode 100644 index 0000000..1071417 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/HardwareInspectorView.swift @@ -0,0 +1,219 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct HardwareInspectorView: View { + let model: MonitoringViewModel + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @State private var profile: HardwareProfile? + @State private var refreshToken = 0 + @State private var loading = true + @State private var resetTime: Double? + @State private var showsExport = false + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + OrvenPageHeader(title: L10n.text("Hardware Inspector")) + OrvenSamplingStatus(model: model) + PhysicalSensorPanel(model: model, since: resetTime) + HStack { + Text(L10n.text("Hardware profile")).font(.title2.bold()) + Spacer() + Button(L10n.text("Refresh hardware")) { refreshToken += 1 }.disabled(loading) + Button(L10n.text("Export hardware report…")) { showsExport = true }.disabled( + profile == nil) + } + if loading { Text(L10n.text("Reading hardware profile…")).foregroundStyle(.secondary) } + if let profile { + HardwareProfileDetails(profile: profile, languageCode: languageCode).equatable() + } + Text( + L10n.text( + "Cache sizes are OS-reported values, not summed totals. Nominal frequency is not a live core clock. The GPU working set is a recommendation, not VRAM capacity or current usage." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + HStack { + Text(L10n.text("Recent sensor range")).font(.title2.bold()) + Spacer() + Button { + resetTime = model.snapshot?.time.monotonic ?? ProcessInfo.processInfo.systemUptime + } label: { + Text(L10n.text("Reset range")) + } + .buttonStyle(.bordered) + .accessibilityLabel(Text(L10n.text("Reset range"))) + .accessibilityIdentifier("hardware-reset-range") + .help(L10n.text("Reset range")) + } + Text( + L10n.text( + "Min/max covers available observations in the latest sampling generation, up to 60 seconds. Missing readings are gaps. Reset affects this range only; it does not clear monitoring history." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + HardwareLiveRange(model: model, since: resetTime, languageCode: languageCode) + Text( + L10n.text( + "SMC temperature, fan and power coverage is shown above. Thermal state remains a separate macOS condition. Voltage, live core clocks, SPD timings, SMART health, fan control, overclocking and stress tests are not provided." + ) + ) + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + }.padding(28) + } + .environment(\.locale, locale) + .task(id: refreshToken) { + loading = true + let result = await Task.detached(priority: .utility) { HardwareProfile.read() }.value + guard !Task.isCancelled else { return } + profile = result + loading = false + } + .sheet(isPresented: $showsExport) { + DiagnosticsExportView(model: model, hardwareProfile: profile, rangeSince: resetTime) + } + } + + private var locale: Locale { + AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + } + +} + +private struct HardwareProfileDetails: View, Equatable { + let profile: HardwareProfile + let languageCode: String + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.languageCode == rhs.languageCode && lhs.profile.observedAt == rhs.profile.observedAt + && lhs.profile.fields.count == rhs.profile.fields.count + && zip(lhs.profile.fields, rhs.profile.fields).allSatisfy { a, b in + a.id == b.id && a.section == b.section && a.label == b.label && a.value == b.value + && a.unit.rawValue == b.unit.rawValue && a.source == b.source + } + } + + var body: some View { + LazyVStack(alignment: .leading, spacing: 20) { + Text( + profile.observedAt.formatted( + .dateTime.year().month().day().hour().minute().second().locale(locale)) + ) + .font(.caption).foregroundStyle(.secondary) + ForEach(sections(profile), id: \.self) { section in + VStack(alignment: .leading, spacing: 12) { + Text(sectionTitle(section)).font(.headline) + ForEach(profile.fields.filter { $0.section == section }) { field in + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 3) { + Text(L10n.text(field.label)) + Text(field.source).font(.caption).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity, alignment: .leading) + Text(fieldValue(field)).monospacedDigit().textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .trailing) + } + if field.id != profile.fields.last(where: { $0.section == section })?.id { + Divider() + } + } + }.orvenSurface() + } + } + } + + private var locale: Locale { + AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + } + private func sections(_ profile: HardwareProfile) -> [String] { + profile.fields.reduce(into: []) { if !$0.contains($1.section) { $0.append($1.section) } } + } + private func sectionTitle(_ section: String) -> String { + if section.hasPrefix("CPU level "), let index = Int(section.dropFirst(10)) { + return String(format: L10n.text("CPU level %d"), locale: locale, index) + } + return L10n.text(section) + } + private func fieldValue(_ field: HardwareField) -> String { + guard let value = field.value else { return L10n.text("Unavailable") } + switch field.unit { + case .bytes: return UInt64(value).map(monitoringBytes) ?? L10n.text("Unavailable") + case .hertz: + return Double(value).map { monitoringNumber($0 / 1_000_000_000, digits: 2) + " GHz" } + ?? L10n.text("Unavailable") + case .count: return Int64(value)?.formatted(.number.locale(locale)) ?? L10n.text("Unavailable") + case .text: + return ["Yes", "No", "Performance", "Efficiency"].contains(value) ? L10n.text(value) : value + } + } +} + +private struct HardwareLiveRange: View { + @ObservedObject var model: MonitoringViewModel + let since: Double? + let languageCode: String + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { + GridRow { + Text(L10n.text("Sensor")) + Text(L10n.text("Current")) + Text(L10n.text("Minimum")) + Text(L10n.text("Maximum")) + Text(L10n.text("Samples")) + }.font(.caption.bold()).foregroundStyle(.secondary) + ForEach(SensorWindow.extrema(model.snapshot?.history ?? [], since: since)) { range in + GridRow { + Text(L10n.text(range.sensor.title)).frame(maxWidth: .infinity, alignment: .leading) + Text(current(range.sensor)).fixedSize(horizontal: false, vertical: true) + Text(sensorValue(range.minimum, range.sensor)) + Text(sensorValue(range.maximum, range.sensor)) + Text(range.validSamples.formatted(.number.locale(locale))) + }.font(.callout).monospacedDigit() + } + }.orvenSurface() + if let reading = model.snapshot?.hardware.reading { + HStack(alignment: .top) { + MetricReadout( + title: L10n.text("Battery"), sample: reading.battery, + format: { monitoringNumber($0.percent, digits: 0) + "% • " + batteryState($0) }, + maximumAge: 30) + MetricReadout( + title: L10n.text("Thermal state"), sample: reading.thermal, format: thermalLabel, + maximumAge: 6) + }.orvenSurface() + } + } + } + + private var locale: Locale { + AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + } + private func sensorValue(_ value: Double?, _ sensor: WindowSensor) -> String { + guard let value else { return "—" } + return sensor.isPercent ? monitoringNumber(value, digits: 1) + "%" : rateText(value) + } + private func current(_ sensor: WindowSensor) -> String { + guard let snapshot = model.snapshot else { return L10n.text("Warming up") } + let sample: MetricSample + switch sensor { + case .cpu: sample = snapshot.cpu + case .memory: + sample = MetricSample( + value: snapshot.memory.value?.occupiedPercent, availability: snapshot.memory.availability, + unit: .percent, time: snapshot.memory.time, source: snapshot.memory.source) + case .diskRead, .diskWrite, .networkReceive, .networkSend: + sample = snapshot.hardware.aggregate( + network: sensor == .networkReceive || sensor == .networkSend, + output: sensor == .diskWrite || sensor == .networkSend) + } + let state = sample.freshness(at: ProcessInfo.processInfo.systemUptime, maximumAge: 6) + guard state == .available, model.samplingMode != .paused else { + return availabilityLabel(state == .available ? .stale : state) + } + return sensorValue(sample.value, sensor) + } +} diff --git a/apps/toolbox/Sources/Toolbox/HealthHistoryView.swift b/apps/toolbox/Sources/Toolbox/HealthHistoryView.swift new file mode 100644 index 0000000..a7b9259 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/HealthHistoryView.swift @@ -0,0 +1,406 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct HealthHistoryView: View { + @ObservedObject var model: HealthHistoryViewModel + @ObservedObject var alerts: SmartAlertsViewModel + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @State private var hours = 24 + @State private var report: LocalReportPreview? + @State private var exportError = false + + private var records: [HealthRecord] { + let cutoff = Date().addingTimeInterval(-Double(hours) * 3600) + return model.archive.records.filter { $0.observedAt >= cutoff } + } + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + OrvenPageHeader(title: L10n.text("Health History & Baseline")) + OrvenSamplingStatus(model: ToolboxRuntime.shared.monitoring) + VStack(alignment: .leading, spacing: 12) { + Toggle(L10n.text("Record health history locally"), isOn: $model.enabled) + Text( + L10n.text( + "One observed sample per minute, up to seven days. No process names, paths or network endpoints. Paused, sleep and missing samples are gaps; enabling history does not enable background monitoring." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Picker(L10n.text("History retention"), selection: $model.retentionHours) { + Text(L10n.text("24 hours")).tag(24) + Text(L10n.text("7 days")).tag(168) + }.frame(maxWidth: 280) + Text(String(format: L10n.text("%d recorded observations"), model.archive.records.count)) + Text( + String( + format: L10n.text("%d older observations omitted by the file-size budget."), + model.archive.omittedRecords) + ).font(.caption) + if let status = model.status { Text(L10n.text(status)).foregroundStyle(.secondary) } + Picker(L10n.text("Time range"), selection: $hours) { + Text(L10n.text("1 hour")).tag(1) + Text(L10n.text("24 hours")).tag(24) + Text(L10n.text("7 days")).tag(168) + }.pickerStyle(.segmented) + HealthHistoryChartSection( + records: records, hours: hours, languageCode: languageCode + ).equatable() + if exportError { + Text(L10n.text("Could not prepare the local report. Stop recording and try again.")) + } + Button(L10n.text("Export history JSON…")) { export(records) }.disabled(records.isEmpty) + }.orvenSurface() + HealthBaselineView(archive: model.archive, languageCode: languageCode) { + Task { await model.captureBaseline() } + }.equatable() + SensorRecordingView(model: model) + SmartAlertsView(model: alerts) + }.padding(28) + }.environment(\.locale, AppLanguage(rawValue: languageCode)?.locale ?? .current) + .sheet(item: $report) { report in + DiagnosticsExportView( + model: ToolboxRuntime.shared.monitoring, preparedReport: report.text, + reportName: report.filename) + } + } + private func export(_ records: [HealthRecord]) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + report = LocalReportPreview( + text: String(decoding: try encoder.encode(records), as: UTF8.self), + filename: "Orven-health.json") + exportError = false + } catch { + report = nil + exportError = true + } + } +} + +private struct HealthHistoryChartSection: View, Equatable { + let records: [HealthRecord] + let hours: Int + let languageCode: String + @State private var key = "cpu" + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.records == rhs.records && lhs.hours == rhs.hours && lhs.languageCode == rhs.languageCode + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HealthMetricChart( + records: records, selectedKey: $key, maximumGap: 90, + windowSeconds: Double(hours) * 3600) + Text(L10n.text("Recent pressure and thermal observations")).font(.headline) + ForEach(records.suffix(12).reversed()) { record in + HStack { + Text(record.observedAt, style: .time) + Text( + L10n.text("Memory pressure") + ": " + L10n.text(record.pressure ?? "Unavailable")) + Text(L10n.text("Thermal state") + ": " + L10n.text(record.thermal ?? "Unavailable")) + }.font(.caption) + } + } + } +} + +private struct HealthBaselineView: View, Equatable { + let archive: HealthArchive + let languageCode: String + let captureBaseline: () -> Void + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.archive == rhs.archive && lhs.languageCode == rhs.languageCode + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.text("Baseline comparison")).font(.title2.bold()) + Spacer() + Button(L10n.text("Capture baseline")) { captureBaseline() } + .disabled(archive.records.count < 30) + } + Text( + L10n.text( + "Capture the latest 30–60 recorded samples as a reference. Compare medians with at least ten newer samples per metric. Workload differences can explain changes; this is not causal diagnosis." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + if let baseline = archive.baseline { + Text(baseline.capturedAt, style: .date) + Text(baseline.capturedAt, style: .time) + } + let comparisons = HealthAnalysis.comparisons(archive) + if comparisons.isEmpty { + Text( + L10n.text( + "Not enough baseline or newer observations yet. Keep recording during comparable workloads." + ) + ) + .foregroundStyle(.secondary) + } + ForEach(comparisons) { comparison in + HealthComparisonRow(comparison: comparison) + } + }.orvenSurface() + } +} + +private struct HealthComparisonRow: View { + let comparison: HealthComparison + + private var medianText: String { + let baseline = monitoringNumber(comparison.baselineMedian, digits: 1) + let recent = monitoringNumber(comparison.currentMedian, digits: 1) + return + "\(L10n.text("Baseline median")): \(baseline) \(comparison.unit) → \(L10n.text("Recent median")): \(recent) \(comparison.unit)" + } + + private var changeText: String { + let change = monitoringNumber(comparison.currentMedian - comparison.baselineMedian, digits: 1) + return + "\(L10n.text("Change")): \(change) \(comparison.unit) · \(comparison.baselineCount) / \(comparison.currentCount)" + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(healthMetricTitle(comparison.title)).font(.headline) + Text(medianText) + Text(changeText).font(.caption).monospacedDigit() + } + } +} + +struct HealthMetricChart: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + let records: [HealthRecord] + @Binding var selectedKey: String + let maximumGap: Double + let windowSeconds: Double + private var metrics: [HealthValue] { + var latest: [String: HealthValue] = [:] + for record in records { + for value in record.values { latest[value.id] = value } + } + return latest.values.sorted { $0.id < $1.id } + } + var body: some View { + let availableMetrics = metrics + return VStack(alignment: .leading, spacing: 12) { + Picker(L10n.text("Chart metric"), selection: $selectedKey) { + Text(L10n.text("System CPU")).tag("cpu") + ForEach(availableMetrics.filter { $0.id != "cpu" }) { metric in + Text(healthMetricTitle(metric.title)).tag(metric.id) + } + }.frame(maxWidth: 400) + let metric = availableMetrics.first { $0.id == selectedKey } + HealthChartPlot( + title: healthMetricTitle(metric?.title ?? "System CPU"), unit: metric?.unit ?? "%", + data: data, percentScale: (metric?.unit ?? "%") == "%", + windowSeconds: max(60, windowSeconds), maximumGap: maximumGap, languageCode: languageCode + ).equatable() + if let metric { Text(metric.source).font(.caption).foregroundStyle(.secondary) } + Text( + L10n.text( + "Temperature labels use community mappings; missing readings are gaps. Fan RPM and system power are observations, not fan control or CPU package power." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + }.environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } + private var data: [ChartDatum] { + var segments: [UUID: Int] = [:] + var seen: Set = [] + return records.map { record in + if segments[record.segment] == nil { segments[record.segment] = segments.count } + let field = record.values.first { $0.id == selectedKey } + let fresh = field.map { seen.insert($0.observedAt).inserted } ?? false + return ChartDatum( + time: SampleTime( + wall: record.observedAt, monotonic: record.observedAt.timeIntervalSince1970), + value: fresh && field?.availability == .available ? field?.value : nil, + generation: segments[record.segment] ?? 0) + } + } +} + +private struct HealthChartPlot: View, Equatable { + let title: String + let unit: String + let data: [ChartDatum] + let percentScale: Bool + let windowSeconds: Double + let maximumGap: Double + let languageCode: String + + var body: some View { + MonitoringChart( + title: title, unit: unit, data: data, percentScale: percentScale, + embedded: true, height: 180, windowSeconds: windowSeconds, maximumGap: maximumGap + ) + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } +} + +struct SensorRecordingView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @ObservedObject var model: HealthHistoryViewModel + @State private var key = "cpu" + @State private var report: LocalReportPreview? + @State private var exportError = false + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.text("Sensor Charts & Recording")).font(.title2.bold()) + Text( + L10n.text( + "Record existing samples every ten seconds for 15, 30 or 60 minutes. Sleep extends the wall-clock duration. The session stays in memory until Quit. Sampling pauses when the app is inactive unless menu bar monitoring is enabled; no readings are backfilled." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + HStack { + Picker(L10n.text("Recording duration"), selection: $model.recordingMinutes) { + Text(L10n.text("15 minutes")).tag(15) + Text(L10n.text("30 minutes")).tag(30) + Text(L10n.text("60 minutes")).tag(60) + }.frame(maxWidth: 220).disabled(model.recording) + Button(L10n.text(model.recording ? "Stop recording" : "Start recording")) { + if model.recording { + model.stopRecording() + } else { + model.startRecording(minutes: model.recordingMinutes) + } + } + } + Text( + L10n.text( + model.recording + ? (model.samplingPaused + ? "Recording paused until monitoring resumes" : "Recording observed samples") + : "Recording stopped") + + " · " + String(model.session.count)) + HealthMetricChart( + records: model.session, selectedKey: $key, maximumGap: 15, + windowSeconds: Double(model.recordingMinutes) * 60) + if exportError { + Text(L10n.text("Could not prepare the local report. Stop recording and try again.")) + } + HStack { + Button(L10n.text("Export session JSON…")) { export(csv: false) }.disabled( + model.session.isEmpty) + Button(L10n.text("Export session CSV…")) { export(csv: true) }.disabled( + model.session.isEmpty) + } + }.orvenSurface() + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + ) + .sheet(item: $report) { report in + DiagnosticsExportView( + model: ToolboxRuntime.shared.monitoring, preparedReport: report.text, + reportName: report.filename) + } + } + private func export(csv: Bool) { + do { + if csv { + report = LocalReportPreview( + text: HealthSessionExport.csv(model.session), filename: "Orven-sensors.csv") + } else { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + report = LocalReportPreview( + text: String(decoding: try encoder.encode(model.session), as: UTF8.self), + filename: "Orven-sensors.json") + } + exportError = false + } catch { + report = nil + exportError = true + } + } +} + +enum HealthSessionExport { + static func csv(_ records: [HealthRecord]) -> String { + let formatter = ISO8601DateFormatter() + func quote(_ value: String) -> String { + "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + var rows = ["observedAt,metricObservedAt,segment,metric,unit,value,availability,source"] + for record in records { + for metric in record.values { + rows.append( + [ + formatter.string(from: record.observedAt), formatter.string(from: metric.observedAt), + record.segment.uuidString, metric.id, + metric.unit, metric.value.map { String($0) } ?? "", metric.availability.rawValue, + metric.source, + ] + .map(quote).joined(separator: ",")) + } + } + return rows.joined(separator: "\n") + "\n" + } +} + +struct SmartAlertsView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @ObservedObject var model: SmartAlertsViewModel + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.text("Smart Alerts")).font(.title2.bold()) + Toggle(L10n.text("Enable health alerts"), isOn: $model.enabled) + Text( + L10n.text( + "Off by default. Fresh active evidence only: sustained CPU, memory pressure, low startup space and elevated thermal state. A recovered condition must stay clear for 30 seconds; repeat alerts wait at least 15 minutes. Nothing is changed automatically." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + HStack { + Button(L10n.text("Allow macOS notifications…")) { Task { await model.requestPermission() } } + .disabled(model.requestingPermission) + Button(L10n.text("Snooze for one hour")) { model.snooze() }.disabled(!model.enabled) + } + if let status = model.status { Text(L10n.text(status)).foregroundStyle(.secondary) } + if model.events.isEmpty { Text(L10n.text("No alert events in this app session.")) } + ForEach(model.events) { event in + HStack(alignment: .top) { + VStack(alignment: .leading) { + Text(L10n.text(event.title)).font(.headline) + Text(event.observedAt, style: .time) + Text(L10n.text(event.evidence)).font(.caption) + } + Spacer() + Button(L10n.text("Review evidence")) { + ToolboxRuntime.shared.navigation.open(.section(event.section)) + } + } + } + }.orvenSurface() + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } +} + +private func healthMetricTitle(_ title: String) -> String { + let pieces = title.components(separatedBy: " • ") + return pieces.count == 2 ? L10n.text(pieces[0]) + " • " + pieces[1] : L10n.text(title) +} + +private struct LocalReportPreview: Identifiable { + let id = UUID() + let text: String + let filename: String +} diff --git a/apps/toolbox/Sources/Toolbox/HealthHistoryViewModel.swift b/apps/toolbox/Sources/Toolbox/HealthHistoryViewModel.swift new file mode 100644 index 0000000..03e8402 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/HealthHistoryViewModel.swift @@ -0,0 +1,126 @@ +import AppKit +import Foundation +import ToolboxCore +import ToolboxMonitoring + +@MainActor +final class HealthHistoryViewModel: ObservableObject { + @Published private(set) var archive = HealthArchive() + @Published private(set) var session: [HealthRecord] = [] + @Published private(set) var recording = false + @Published var recordingMinutes = 15 + @Published private(set) var samplingPaused = true + @Published private(set) var status: String? + @Published var enabled: Bool { + didSet { defaults.set(enabled, forKey: "toolbox.healthHistory.enabled") } + } + @Published var retentionHours: Int { + didSet { + defaults.set(retentionHours, forKey: "toolbox.healthHistory.retentionHours") + Task { await refreshRetention() } + } + } + private let defaults: UserDefaults + private let store: HealthHistoryStore + private var segment = UUID() + private var lastGeneration: Int? + private var lastUptime: Double? + private var lastWall: Date? + private var historyTick: Double? + private var sessionTick: Double? + private var recordingStart: Double? + private var recordingSeconds = 900.0 + + init( + defaults: UserDefaults = .standard, directory: URL = AppMetadata.applicationSupportDirectory() + ) { + self.defaults = defaults + store = HealthHistoryStore(url: directory.appendingPathComponent("health-history-v1.json")) + enabled = defaults.bool(forKey: "toolbox.healthHistory.enabled") + retentionHours = + defaults.integer(forKey: "toolbox.healthHistory.retentionHours") == 24 ? 24 : 168 + } + + func load() async { + do { archive = try await store.read(now: Date(), retentionHours: retentionHours) } catch { + status = + "History could not be read. The existing file is preserved; recording to disk is blocked." + } + } + + func consume(_ snapshot: MonitoringSnapshot, active: Bool) async { + guard active else { + markGap() + return + } + if samplingPaused { samplingPaused = false } + let tick = snapshot.time.monotonic + if recording, let recordingStart, tick - recordingStart >= recordingSeconds { + recording = false + } + let generation = snapshot.history.last?.generation + if let lastUptime, let lastWall, + tick <= lastUptime || tick - lastUptime > 15 || generation != lastGeneration + || abs(snapshot.time.wall.timeIntervalSince(lastWall) - (tick - lastUptime)) > 5 + { + segment = UUID() + } + lastUptime = tick + lastWall = snapshot.time.wall + lastGeneration = generation + let historyDue = enabled && (historyTick == nil || tick - (historyTick ?? 0) >= 60) + let sessionDue = recording && (sessionTick == nil || tick - (sessionTick ?? 0) >= 10) + guard historyDue || sessionDue else { return } + let point = HealthRecord( + snapshot: snapshot, segment: segment, now: ProcessInfo.processInfo.systemUptime) + if recording, let recordingStart { + if tick - recordingStart >= recordingSeconds { + recording = false + } else if sessionTick == nil || tick - (sessionTick ?? 0) >= 10 { + session.append(point) + session = Array(session.suffix(360)) + sessionTick = tick + } + } + if historyDue { + historyTick = tick + do { + archive = try await store.append(point, retentionHours: retentionHours) + status = nil + } catch { + status = + "History could not be saved. Existing data is preserved; check the local storage location." + } + } + } + + func markGap() { + if !samplingPaused { samplingPaused = true } + segment = UUID() + lastUptime = nil + lastWall = nil + lastGeneration = nil + } + func startRecording(minutes: Int) { + session = [] + sessionTick = nil + segment = UUID() + recordingStart = ProcessInfo.processInfo.systemUptime + recordingMinutes = [15, 30, 60].contains(minutes) ? minutes : 15 + recordingSeconds = Double(recordingMinutes) * 60 + recording = true + } + func stopRecording() { recording = false } + func captureBaseline() async { + do { + archive = try await store.captureBaseline(now: Date(), retentionHours: retentionHours) + status = nil + } catch { status = "Baseline needs at least 30 recorded samples and writable local storage." } + } + private func refreshRetention() async { + do { + archive = try await store.setRetention(now: Date(), hours: retentionHours) + status = nil + } catch { status = "History retention could not be saved. Existing data is preserved." } + } +} diff --git a/apps/toolbox/Sources/Toolbox/HomeView.swift b/apps/toolbox/Sources/Toolbox/HomeView.swift index ea796af..4ebef6e 100644 --- a/apps/toolbox/Sources/Toolbox/HomeView.swift +++ b/apps/toolbox/Sources/Toolbox/HomeView.swift @@ -2,124 +2,27 @@ import SwiftUI import ToolboxCore struct HomeView: View { + // L10n returns plain strings, so static page copy must observe language changes. + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue @ObservedObject var coordinator: ToolboxCoordinator - var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 24) { - header - - HStack(spacing: 12) { - HomeMetric( - title: L10n.text("Recoverable"), value: ByteCount.string(coordinator.recoverableBytes), - symbol: "trash") - HomeMetric( - title: L10n.text("Needs attention"), value: "\(coordinator.attentionCount)", - symbol: "exclamationmark.shield") - HomeMetric( - title: L10n.text("Coverage"), - value: L10n.text(coordinator.reducedCoverage ? "Reduced" : "Full"), - symbol: "externaldrive.badge.checkmark") - } - - LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 16)], spacing: 16) { - HomeActionCard( - title: L10n.text("Recover space safely"), - detail: L10n.text("See reclaimable files before anything moves to Trash."), - action: L10n.text("Scan storage"), - symbol: "internaldrive", - destination: .storage, coordinator: coordinator) - HomeActionCard( - title: L10n.text("Understand app changes"), - detail: L10n.text("Compare local snapshots around an install, update, or removal."), - action: L10n.text("Start a trace"), - symbol: "scope", - destination: .changes, coordinator: coordinator) - HomeActionCard( - title: L10n.text("Undo before regret"), - detail: L10n.text("Review recoverable actions and restore without overwriting files."), - action: L10n.text("Open Recovery"), - symbol: "clock.arrow.circlepath", - destination: .recovery, coordinator: coordinator) - } - - Label( - L10n.text("Runs locally. No telemetry. Nothing is deleted automatically."), - systemImage: "lock.shield" - ) - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(32) - .frame(maxWidth: 1_100, alignment: .leading) - } - .onAppear { coordinator.refreshSummaries() } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.text("Your Mac, explained.")) - .font(.system(size: 34, weight: .bold)) - Text(L10n.text(AppMetadata.tagline)) - .font(.title3) - .foregroundStyle(.secondary) - } - } -} - -private struct HomeActionCard: View { - let title: String - let detail: String - let action: String - let symbol: String - let destination: ToolboxSection - @ObservedObject var coordinator: ToolboxCoordinator - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Image(systemName: symbol) - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(.tint) - .frame(width: 44, height: 44) - .background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 11)) - - Text(title) - .font(.title2.weight(.semibold)) - Text(detail) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 4) - - Button(action) { coordinator.open(.section(destination)) } - .buttonStyle(.borderedProminent) - } - .padding(20) - .frame(maxWidth: .infinity, minHeight: 230, alignment: .leading) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) - .overlay { - RoundedRectangle(cornerRadius: 16) - .stroke(.separator.opacity(0.45), lineWidth: 1) - } - } -} - -private struct HomeMetric: View { - let title: String - let value: String - let symbol: String - - var body: some View { - HStack(spacing: 10) { - Image(systemName: symbol).foregroundStyle(.tint).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(value).font(.title3.weight(.semibold)) - Text(title).font(.caption).foregroundStyle(.secondary) - } - Spacer() - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + VStack(alignment: .leading, spacing: 20) { + OrvenPageHeader(title: L10n.text("Your Mac, at a glance")) + MonitoringOverviewView(model: ToolboxRuntime.shared.monitoring) + HStack(spacing: 18) { + Button(L10n.text("Change Timeline")) { coordinator.open(.section(.changes)) } + Button(L10n.text("Recovery")) { coordinator.open(.section(.recovery)) } + Spacer() + Text(L10n.text("Runs locally. No telemetry. Nothing is deleted automatically.")) + .font(.caption).foregroundStyle(OrvenTheme.secondary) + }.buttonStyle(.link) + }.padding(28).frame(maxWidth: 1400, alignment: .leading) + }.background(OrvenTheme.canvas) + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + ) + .onAppear { coordinator.refreshSummaries() } } } diff --git a/apps/toolbox/Sources/Toolbox/MainWindowMarker.swift b/apps/toolbox/Sources/Toolbox/MainWindowMarker.swift new file mode 100644 index 0000000..71ffa61 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/MainWindowMarker.swift @@ -0,0 +1,64 @@ +import AppKit +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct MainWindowMarker: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { MarkedMainWindowView() } + func updateNSView(_ nsView: NSView, context: Context) {} +} + +private final class MarkedMainWindowView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { return } + window.identifier = NSUserInterfaceItemIdentifier("toolbox-main") + ToolboxRuntime.shared.monitoring.setMainWindowVisible( + monitoringWindowInUse(window)) + } +} + +@MainActor +func monitoringWindowInUse(_ window: NSWindow) -> Bool { + // An attached inspector/export sheet remains part of the monitoring workspace. + // An independent Settings window does not create monitoring demand. + window.isVisible && !window.isMiniaturized + && (window.isKeyWindow || window.attachedSheet?.isKeyWindow == true) +} + +struct MonitoringDisplayState: Equatable { + var paused = false + var minimumMaximumAge = 0.0 + + func availability( + of sample: MetricSample, at now: Double, maximumAge: Double + ) -> MetricAvailability { + let state = sample.freshness(at: now, maximumAge: max(maximumAge, minimumMaximumAge)) + return paused && state == .available ? .stale : state + } +} + +private struct MonitoringDisplayStateKey: EnvironmentKey { + static let defaultValue = MonitoringDisplayState() +} + +extension EnvironmentValues { + var monitoringDisplayState: MonitoringDisplayState { + get { self[MonitoringDisplayStateKey.self] } + set { self[MonitoringDisplayStateKey.self] = newValue } + } +} + +struct ToolboxFallbackRootView: View { + @AppStorage(AppAppearance.storageKey) private var appearanceCode = AppAppearance.system.rawValue + @ObservedObject private var monitoring = ToolboxRuntime.shared.monitoring + var body: some View { + ToolboxShellView() + .preferredColorScheme((AppAppearance(rawValue: appearanceCode) ?? .system).scheme) + .environment( + \.monitoringDisplayState, + MonitoringDisplayState( + paused: monitoring.samplingMode == .paused, + minimumMaximumAge: monitoring.samplingMode == .menuBar ? 15 : 0)) + } +} diff --git a/apps/toolbox/Sources/Toolbox/MenuBarDiagnosticsView.swift b/apps/toolbox/Sources/Toolbox/MenuBarDiagnosticsView.swift new file mode 100644 index 0000000..6d84d2b --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/MenuBarDiagnosticsView.swift @@ -0,0 +1,83 @@ +import AppKit +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct MenuBarDiagnosticsView: View { + @ObservedObject var model: MonitoringViewModel + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + + var body: some View { + TimelineView(.periodic(from: .now, by: 5)) { _ in + VStack(alignment: .leading, spacing: 12) { + Text(AppMetadata.displayName).font(.headline) + Text(model.menuBarTitle).font(.title2.monospacedDigit()) + if let snapshot = model.snapshot { + HStack { + Text(L10n.text("Last sample")) + Text(snapshot.time.wall, style: .time) + Text( + availabilityLabel( + snapshot.cpu.freshness(at: ProcessInfo.processInfo.systemUptime, maximumAge: 15))) + }.font(.caption).foregroundStyle(.secondary) + Divider() + Text(L10n.text("Top CPU consumers • one core")).font(.subheadline.weight(.semibold)) + if snapshot.processes.freshness(at: ProcessInfo.processInfo.systemUptime, maximumAge: 15) + == .available + { + ForEach(ProcessOrdering.cpu.sorted(snapshot.processes.value ?? []).prefix(5)) { + process in + HStack { + Text(process.name).lineLimit(1).help(process.name) + Spacer() + Text( + process.cpu.value.map { + monitoringNumber($0, digits: 1) + "%" + } ?? availabilityLabel(process.cpu.availability) + ).monospacedDigit() + }.font(.caption) + } + } else { + Text( + availabilityLabel( + snapshot.processes.freshness( + at: ProcessInfo.processInfo.systemUptime, maximumAge: 15)) + ).font(.caption) + } + HStack { + Text(L10n.text("Thermal state")) + let thermal = snapshot.hardware.reading.thermal + let state = thermal.freshness( + at: ProcessInfo.processInfo.systemUptime, maximumAge: 15) + Text( + state == .available + ? thermal.value.map(thermalLabel) ?? L10n.text("Unavailable") + : availabilityLabel(state)) + }.font(.caption) + } + Text(L10n.text("Automatic alerts are not available.")).font(.caption).foregroundStyle( + .secondary) + Divider() + HStack { + Button(L10n.text("Overview")) { open(.home) } + Button(L10n.text("Processes")) { open(.processes) } + Button(L10n.text("Storage")) { open(.storage) } + } + HStack { + Button(L10n.text("Disable menu bar")) { model.menuBarEnabled = false } + Spacer() + Button(L10n.text("Quit Orven")) { NSApp.terminate(nil) } + } + }.padding(16).frame(width: 360) + } + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } + + private func open(_ section: ToolboxSection) { + ToolboxRuntime.shared.navigation.open(.section(section)) + NSApp.activate(ignoringOtherApps: true) + _ = NSApp.delegate?.applicationShouldHandleReopen?(NSApp, hasVisibleWindows: false) + } +} diff --git a/apps/toolbox/Sources/Toolbox/MonitoringChart.swift b/apps/toolbox/Sources/Toolbox/MonitoringChart.swift new file mode 100644 index 0000000..e3c85d6 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/MonitoringChart.swift @@ -0,0 +1,179 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct ChartDatum: Identifiable, Equatable { + var id: Double { time.monotonic } + let time: SampleTime + let value: Double? + let generation: Int + init(time: SampleTime, value: Double?, generation: Int = 0) { + self.time = time + self.value = value + self.generation = generation + } +} + +private struct ChartMark: Identifiable { + var id: Double { time.monotonic } + let time: SampleTime + let value: Double + let segment: Int +} + +struct MonitoringChart: View { + @Environment(\.locale) private var locale + let title: String + let unit: String + let data: [ChartDatum] + var percentScale = false + var embedded = false + var height: CGFloat = 150 + var windowSeconds: Double = 60 + var maximumGap: Double = 6 + @State private var inspectedTime: Double? + + private var points: [ChartMark] { + var segment = 0 + var previous: Double? + var generation: Int? + return data.compactMap { point in + defer { + previous = point.time.monotonic + generation = point.generation + } + if let generation, generation != point.generation { segment += 1 } + guard let value = point.value, value.isFinite else { + segment += 1 + return nil + } + if let previous, point.time.monotonic - previous > maximumGap { segment += 1 } + return ChartMark(time: point.time, value: value, segment: segment) + } + } + private var index: Int { + data.firstIndex { $0.id == inspectedTime } ?? max(0, data.count - 1) + } + private var latest: Double { data.last?.time.monotonic ?? 0 } + + var body: some View { + let timeAxisTitle = L10n.text("Seconds before latest sample") + return VStack(alignment: .leading, spacing: 8) { + Text(title).font(.title2.weight(.semibold)) + plot + .frame(height: height) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityValue(summary) + Text(timeAxisTitle).font(.caption).foregroundStyle(OrvenTheme.secondary) + if !data.isEmpty { + HStack { + Button(L10n.text("Previous sample")) { inspectedTime = data[max(0, index - 1)].id } + .disabled(index == 0) + Button(L10n.text("Next sample")) { + inspectedTime = data[min(data.count - 1, index + 1)].id + } + .disabled(index == data.count - 1) + Text(data[index].time.wall.formatted(.dateTime.hour().minute().locale(locale))) + Text( + data[index].value.map { + monitoringNumber($0, digits: 1) + " " + unit + } ?? L10n.text("Unavailable") + ) + .monospacedDigit() + }.font(.caption) + } else { + Text(L10n.text("No samples yet.")).font(.caption).foregroundStyle(.secondary) + } + } + .padding(embedded ? 0 : 20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(embedded ? Color.clear : OrvenTheme.surface, in: RoundedRectangle(cornerRadius: 16)) + .overlay { + if !embedded { + RoundedRectangle(cornerRadius: 16).stroke(OrvenTheme.border, lineWidth: 1).allowsHitTesting( + false) + } + } + } + + // A bounded, linear Canvas avoids rebuilding a framework mark tree every sample. + // Each contiguous segment gets its own path, so missing samples never become a line. + private var plot: some View { + let marks = points + let last = latest + let upper = percentScale ? 100 : max(1, (marks.map(\.value).max() ?? 1) * 1.1) + return Canvas { context, size in + let bounds = CGRect( + x: 8, y: 24, width: max(1, size.width - 46), + height: max(1, size.height - 48)) + for tick in 0...4 { + let fraction = CGFloat(tick) / 4 + let y = bounds.maxY - fraction * bounds.height + var grid = Path() + grid.move(to: CGPoint(x: bounds.minX, y: y)) + grid.addLine(to: CGPoint(x: bounds.maxX, y: y)) + context.stroke(grid, with: .color(OrvenTheme.border), lineWidth: 1) + context.draw( + Text( + monitoringNumber(Double(fraction) * upper, digits: upper < 2 ? 2 : (upper < 10 ? 1 : 0)) + ) + .font(.caption).foregroundColor(OrvenTheme.secondary), + at: CGPoint(x: bounds.maxX + 8, y: y), anchor: .leading) + } + for fraction in [0.0, 1.0 / 3, 2.0 / 3, 1.0] { + let seconds = Int((fraction - 1) * windowSeconds) + let x = bounds.minX + CGFloat(fraction) * bounds.width + var grid = Path() + grid.move(to: CGPoint(x: x, y: bounds.minY)) + grid.addLine(to: CGPoint(x: x, y: bounds.maxY)) + context.stroke( + grid, with: .color(OrvenTheme.border), + style: StrokeStyle(lineWidth: 1, dash: [3, 3])) + context.draw( + Text(String(seconds)).font(.caption).foregroundColor(OrvenTheme.secondary), + at: CGPoint(x: x, y: bounds.maxY + 8), + anchor: fraction == 0 ? .topLeading : (fraction == 1 ? .topTrailing : .top)) + } + context.draw( + Text(unit).font(.caption).foregroundColor(OrvenTheme.secondary), + at: CGPoint(x: bounds.maxX + 8, y: 0), anchor: .topLeading) + for segment in Dictionary(grouping: marks, by: \.segment).values { + let coordinates = segment.compactMap { mark -> CGPoint? in + let seconds = mark.time.monotonic - last + guard seconds >= -windowSeconds, seconds <= 0 else { return nil } + return CGPoint( + x: bounds.minX + CGFloat((seconds + windowSeconds) / windowSeconds) * bounds.width, + y: bounds.maxY - CGFloat(min(upper, max(0, mark.value)) / upper) * bounds.height) + } + guard let first = coordinates.first, let final = coordinates.last else { continue } + var line = Path() + line.move(to: first) + for coordinate in coordinates.dropFirst() { line.addLine(to: coordinate) } + if coordinates.count == 1 { + context.fill( + Path(ellipseIn: CGRect(x: first.x - 2, y: first.y - 2, width: 4, height: 4)), + with: .color(OrvenTheme.accent)) + } else { + var area = line + area.addLine(to: CGPoint(x: final.x, y: bounds.maxY)) + area.addLine(to: CGPoint(x: first.x, y: bounds.maxY)) + area.closeSubpath() + context.fill(area, with: .color(OrvenTheme.accent.opacity(0.10))) + context.stroke(line, with: .color(OrvenTheme.accent), lineWidth: 2) + } + } + } + } + + private var summary: String { + guard let minimum = points.map(\.value).min(), let maximum = points.map(\.value).max() else { + return L10n.text("No samples yet.") + } + return String( + format: L10n.text( + "%d valid samples. Minimum %.1f, maximum %.1f %@. Missing samples are gaps."), + locale: locale, points.count, + minimum, maximum, unit) + } +} diff --git a/apps/toolbox/Sources/Toolbox/MonitoringOverviewView.swift b/apps/toolbox/Sources/Toolbox/MonitoringOverviewView.swift new file mode 100644 index 0000000..17dd4a8 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/MonitoringOverviewView.swift @@ -0,0 +1,291 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct MonitoringOverviewView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @Environment(\.monitoringDisplayState) private var displayState + @ObservedObject var model: MonitoringViewModel + private var topProcesses: [ProcessSample] { + Array( + ProcessOrdering.cpu.sorted( + (model.snapshot?.processes.value ?? []).filter { $0.cpu.value != nil }, query: "" + ).prefix(3)) + } + var body: some View { + let processes = topProcesses + return VStack(alignment: .leading, spacing: 18) { + OrvenSamplingStatus(model: model) + OverviewMetricsLayout { + cpuChart + summary + }.orvenSurface() + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(L10n.text("Using the most CPU")).font(.title2.weight(.semibold)) + Spacer() + Button(L10n.text("Show all processes")) { + ToolboxRuntime.shared.navigation.open(.section(.processes)) + }.buttonStyle(.link).tint(OrvenTheme.accent) + } + HStack { + Text(L10n.text("Process")).frame(maxWidth: .infinity, alignment: .leading) + Text(L10n.text("CPU • one core")).frame(width: 110, alignment: .trailing) + Text(L10n.text("Resident memory")).frame(width: 110, alignment: .trailing) + Color.clear.frame(width: 130, height: 1) + }.font(.caption).foregroundStyle(OrvenTheme.secondary) + if processes.isEmpty { + Text( + L10n.text( + model.snapshot?.processes.value == nil ? "No process data available." : "Warming up" + ) + ).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 100) + } + ForEach(processes, id: \.id) { process in + VStack(spacing: 0) { + processRow(process) + if process.id != processes.last?.id { Divider() } + } + } + if let sample = model.snapshot?.processes { + Text( + String( + format: L10n.text("%d of %d processes have CPU samples."), + sample.value?.filter { $0.cpu.value != nil }.count ?? 0, + sample.value?.count ?? 0) + ).font(.caption).foregroundStyle(OrvenTheme.secondary) + Text( + availabilityLabel( + displayState.availability( + of: sample, at: ProcessInfo.processInfo.systemUptime, maximumAge: 6)) + ) + .font(.caption).foregroundStyle(OrvenTheme.secondary) + } + }.orvenSurface() + storageStrip.orvenSurface() + ExplainMyMacView(model: model) + Text(L10n.text("Read-only. Recent trends stay on this Mac and are not saved.")) + .font(.caption).foregroundStyle(OrvenTheme.secondary) + }.environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } + + private func processRow(_ process: ProcessSample) -> some View { + HStack(spacing: 12) { + OrvenProcessIcon(process: process) + VStack(alignment: .leading, spacing: 3) { + Text(process.name).font(.body.weight(.semibold)).lineLimit(1).help(process.name) + Text("PID \(process.pid)").font(.caption).foregroundStyle(OrvenTheme.secondary) + }.frame(maxWidth: .infinity, alignment: .leading) + Text(processText(process.cpu, format: { monitoringNumber($0, digits: 1) + "%" })) + .monospacedDigit().lineLimit(2).fixedSize(horizontal: false, vertical: true) + .frame(width: 110, alignment: .trailing) + Text(processText(process.memory, format: monitoringBytes)) + .monospacedDigit().lineLimit(2).fixedSize(horizontal: false, vertical: true) + .frame(width: 110, alignment: .trailing) + Button(L10n.text("Inspect process")) { + if let identity = process.identity { + ToolboxRuntime.shared.navigation.open(.inspectProcess(identity)) + } + }.buttonStyle(.bordered).disabled(process.identity == nil).frame(width: 130) + }.padding(.vertical, 8) + } + + private var cpuChart: some View { + MonitoringChart( + title: L10n.text("System CPU • all cores"), unit: "%", + data: (model.snapshot?.history ?? []).map { + ChartDatum(time: $0.time, value: $0.cpuPercent, generation: $0.generation) + }, percentScale: true, embedded: true, height: 190) + } + + private var summary: some View { + VStack(alignment: .leading, spacing: 10) { + MetricReadout( + title: L10n.text("CPU • all cores"), sample: model.snapshot?.cpu, + format: { monitoringNumber($0, digits: 1) + "%" }, valueFont: .system(size: 32)) + Divider() + MetricReadout( + title: L10n.text("Memory • includes caches"), sample: model.snapshot?.memory, + format: { value in + guard value.occupiedPercent != nil else { return L10n.text("Unavailable") } + return monitoringBytes(value.totalBytes - value.freeBytes) + " / " + + monitoringBytes(value.totalBytes) + }) + Divider() + MetricReadout( + title: L10n.text("Thermal state"), sample: model.snapshot?.hardware.reading.thermal, + format: thermalLabel, maximumAge: 6) + Text(L10n.text("Thermal state is not a temperature in °C.")).font(.caption).foregroundStyle( + OrvenTheme.secondary) + } + } + + private var storageStrip: some View { + HStack(spacing: 18) { + Image(systemName: "internaldrive").font(.system(size: 32)).foregroundStyle( + OrvenTheme.secondary) + VStack(alignment: .leading, spacing: 7) { + MetricReadout( + title: L10n.text("Startup volume available"), + sample: model.snapshot?.hardware.reading.capacity, + format: { monitoringBytes($0.availableBytes) + " / " + monitoringBytes($0.totalBytes) }, + maximumAge: 30) + if let capacity = model.snapshot?.hardware.reading.capacity.value, + capacity.totalBytes > 0, capacity.availableBytes <= capacity.totalBytes + { + ProgressView( + value: Double(capacity.totalBytes - capacity.availableBytes), + total: Double(capacity.totalBytes) + ) + .accessibilityLabel(L10n.text("Used storage")) + } + } + Spacer(minLength: 8) + Button(L10n.text("Open storage")) { + ToolboxRuntime.shared.navigation.open(.section(.storage)) + } + .buttonStyle(.bordered) + } + } + + private func processText(_ sample: MetricSample, format: (Value) -> String) + -> String + { + let state = displayState.availability( + of: sample, at: ProcessInfo.processInfo.systemUptime, maximumAge: 6) + guard let value = sample.value, state == .available || state == .stale else { + return availabilityLabel(state) + } + return format(value) + } +} + +struct MetricReadout: View { + @Environment(\.monitoringDisplayState) private var displayState + let title: String + let sample: MetricSample? + let format: (Value) -> String + var maximumAge: Double = 3 + var valueFont: Font = .title2 + + private var state: MetricAvailability { + sample.map { + displayState.availability( + of: $0, at: ProcessInfo.processInfo.systemUptime, maximumAge: maximumAge) + } ?? .loading + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.callout).foregroundStyle(.secondary) + if let value = sample?.value, state == .available || state == .stale { + Text(format(value)).font(valueFont.monospacedDigit().weight(.semibold)) + } else { + Text(availabilityLabel(state)).font(.title3) + } + if let sample { + HStack(spacing: 6) { + Text(availabilityLabel(state)) + Text(sample.time.wall, style: .time) + } + .font(.caption).foregroundStyle(.secondary) + if let reason = sample.reason { Text(reason).font(.caption).foregroundStyle(.secondary) } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +func availabilityLabel(_ state: MetricAvailability) -> String { + switch state { + case .loading: L10n.text("Warming up") + case .available: L10n.text("Available") + case .stale: L10n.text("Stale") + case .unsupported: L10n.text("Unsupported") + case .permissionRequired: L10n.text("Permission required") + case .temporarilyUnavailable: L10n.text("Temporarily unavailable") + case .error: L10n.text("Read failed") + } +} + +func monitoringNumber(_ value: Double, digits: Int) -> String { + let locale = + AppLanguage(rawValue: UserDefaults.standard.string(forKey: AppLanguage.storageKey) ?? "")? + .locale ?? AppLanguage.defaultLanguage.locale + return value.formatted(.number.locale(locale).precision(.fractionLength(digits))) +} + +func monitoringBytes(_ value: UInt64) -> String { byteText(Double(value)) } + +private func byteText(_ value: Double) -> String { + guard value.isFinite, value >= 0 else { return L10n.text("Unavailable") } + let units = ["B", "KB", "MB", "GB", "TB"] + var scaled = value + var index = 0 + while scaled >= 1000 && index < units.count - 1 { + scaled /= 1000 + index += 1 + } + let locale = + AppLanguage(rawValue: UserDefaults.standard.string(forKey: AppLanguage.storageKey) ?? "")? + .locale ?? AppLanguage.defaultLanguage.locale + return scaled.formatted(.number.locale(locale).precision(.significantDigits(1...3))) + " " + + units[index] +} + +func rateText(_ value: Double) -> String { byteText(value) + "/s" } + +func batteryState(_ battery: BatteryInfo) -> String { + if battery.charging { return L10n.text("Charging") } + return L10n.text(battery.onBattery ? "On battery" : "External power") +} + +func thermalLabel(_ thermal: ThermalLevel) -> String { + switch thermal { + case .nominal: L10n.text("Nominal") + case .fair: L10n.text("Fair") + case .serious: L10n.text("Serious") + case .critical: L10n.text("Critical") + case .unknown: L10n.text("Unknown") + } +} + +// Measure and place a single chart instance; hidden fallback charts still incur mark layout work. +private struct OverviewMetricsLayout: Layout { + private let summaryWidth: CGFloat = 245 + private let gap: CGFloat = 24 + private func horizontal(_ width: CGFloat) -> Bool { width >= 410 + summaryWidth + gap } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? 900 + let chartWidth = horizontal(width) ? width - summaryWidth - gap : width + let chart = subviews[0].sizeThatFits(ProposedViewSize(width: chartWidth, height: nil)) + let summary = subviews[1].sizeThatFits( + ProposedViewSize(width: horizontal(width) ? summaryWidth : width, height: nil)) + return CGSize( + width: width, + height: horizontal(width) + ? max(chart.height, summary.height) : chart.height + gap + summary.height) + } + + func placeSubviews( + in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () + ) { + let wide = horizontal(bounds.width) + let chartWidth = wide ? bounds.width - summaryWidth - gap : bounds.width + let chartProposal = ProposedViewSize(width: chartWidth, height: nil) + subviews[0].place(at: bounds.origin, anchor: .topLeading, proposal: chartProposal) + let origin = + wide + ? CGPoint(x: bounds.maxX - summaryWidth, y: bounds.minY) + : CGPoint( + x: bounds.minX, y: bounds.minY + subviews[0].sizeThatFits(chartProposal).height + gap) + subviews[1].place( + at: origin, anchor: .topLeading, + proposal: ProposedViewSize(width: wide ? summaryWidth : bounds.width, height: nil)) + } +} diff --git a/apps/toolbox/Sources/Toolbox/MonitoringViewModel.swift b/apps/toolbox/Sources/Toolbox/MonitoringViewModel.swift new file mode 100644 index 0000000..d87cd0f --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/MonitoringViewModel.swift @@ -0,0 +1,189 @@ +import AppKit +import Foundation +import ToolboxCore +import ToolboxMonitoring + +@MainActor +final class MonitoringViewModel: ObservableObject { + @Published private(set) var samplingMode = MonitoringMode.paused + @Published private(set) var snapshot: MonitoringSnapshot? + @Published private(set) var processDetails: ProcessObservation? + @Published private(set) var canOpenApplication = false + private var detailIdentity: ProcessIdentity? + let health = HealthHistoryViewModel() + let alerts = SmartAlertsViewModel() + let engine: MonitoringCoordinator + private var observation: Task? + private var lastPublishedTime: Double? + @Published var menuBarEnabled: Bool { + didSet { + defaults.set(menuBarEnabled, forKey: "toolbox.monitoring.menuBar.enabled") + updateDemand() + } + } + @Published var menuBarIndicator: MenuBarIndicator { + didSet { + defaults.set(menuBarIndicator.rawValue, forKey: "toolbox.monitoring.menuBar.indicator") + } + } + private let defaults: UserDefaults + private var demandTask: Task? + private var mainWindowVisible = false + private var active = false + private var sleeping = false + + init(engine: MonitoringCoordinator = MonitoringCoordinator(), defaults: UserDefaults = .standard) + { + self.engine = engine + self.defaults = defaults + menuBarEnabled = defaults.bool(forKey: "toolbox.monitoring.menuBar.enabled") + menuBarIndicator = + MenuBarIndicator( + rawValue: defaults.string(forKey: "toolbox.monitoring.menuBar.indicator") ?? "") ?? .cpu + } + + private var requestedMode: MonitoringMode { + SamplingPolicy.mode( + appActive: active, mainWindowVisible: mainWindowVisible, sleeping: sleeping, + menuBarEnabled: menuBarEnabled) + } + + func setMainWindowVisible(_ visible: Bool) { + mainWindowVisible = visible + updateDemand() + } + + func start() { + guard observation == nil else { return } + observation = Task { + await health.load() + let stream = await engine.snapshots() + await engine.setMode(requestedMode) + for await snapshot in stream { + guard !Task.isCancelled else { return } + await health.consume(snapshot, active: samplingMode != .paused) + alerts.consume(snapshot, active: samplingMode == .active) + // Keep one-second samples/history, while limiting scene updates to four seconds. + if let lastPublishedTime, snapshot.time.monotonic - lastPublishedTime < 4 { continue } + lastPublishedTime = snapshot.time.monotonic + self.snapshot = snapshot + } + } + } + + func setActive(_ active: Bool) { + self.active = active + updateDemand() + } + + func setSleeping(_ sleeping: Bool) { + self.sleeping = sleeping + updateDemand() + } + + private func updateDemand() { + let mode = requestedMode + if samplingMode != mode { + lastPublishedTime = nil + samplingMode = mode + if mode == .paused { health.markGap() } + } + demandTask?.cancel() + demandTask = Task { + guard !Task.isCancelled else { return } + await engine.setMode(mode) + } + } + + func loadDetails(for identity: ProcessIdentity?) async { + detailIdentity = identity + processDetails = nil + canOpenApplication = false + guard let identity else { return } + let detail = await engine.inspectProcess(identity) + guard !Task.isCancelled, detailIdentity == identity else { return } + processDetails = detail + guard detail.availability == .available, + let app = NSRunningApplication(processIdentifier: identity.pid), + app.bundleIdentifier != nil, app.bundleURL != nil, + let launch = app.launchDate, abs(launch.timeIntervalSince(identity.startDate)) < 1 + else { return } + let revalidated = await engine.inspectProcess(identity) + guard !Task.isCancelled, detailIdentity == identity, revalidated.availability == .available + else { return } + canOpenApplication = true + } + + func details(for identity: ProcessIdentity) -> ProcessObservation? { + detailIdentity == identity ? processDetails : nil + } + + func applicationMapped(to identity: ProcessIdentity) -> Bool { + canOpenApplication && detailIdentity == identity + } + + func openMappedApplication(for identity: ProcessIdentity) { + guard applicationMapped(to: identity) else { return } + Task { + await loadDetails(for: identity) + guard canOpenApplication, detailIdentity == identity else { return } + ToolboxRuntime.shared.navigation.open(.section(.applications)) + } + } + + var menuBarTitle: String { + guard let snapshot else { + return L10n.text(samplingMode == .paused ? "Sampling paused" : "Warming up") + } + if samplingMode == .paused { return L10n.text("Stale") } + let now = ProcessInfo.processInfo.systemUptime + switch menuBarIndicator { + case .cpu: + guard snapshot.cpu.freshness(at: now, maximumAge: 15) == .available, + let value = snapshot.cpu.value + else { + return L10n.text("CPU") + " " + + availabilityLabel(snapshot.cpu.freshness(at: now, maximumAge: 15)) + } + return L10n.text("CPU") + " " + monitoringNumber(value, digits: 0) + "%" + case .memory: + let metric = MetricSample( + value: snapshot.memory.value?.occupiedPercent, availability: snapshot.memory.availability, + unit: .percent, time: snapshot.memory.time, source: snapshot.memory.source) + guard metric.freshness(at: now, maximumAge: 15) == .available, + let value = metric.value + else { + return L10n.text("Memory") + " " + + availabilityLabel(metric.freshness(at: now, maximumAge: 15)) + } + return L10n.text("Memory") + " " + + monitoringNumber(value, digits: 0) + "%" + case .network: + let metric = snapshot.hardware.aggregate(network: true, output: false) + guard metric.freshness(at: now, maximumAge: 15) == .available, let value = metric.value else { + return L10n.text("Network") + " " + + availabilityLabel(metric.freshness(at: now, maximumAge: 15)) + } + return "↓ " + rateText(value) + } + } + + func stop() async { + if samplingMode != .paused { samplingMode = .paused } + demandTask?.cancel() + await demandTask?.value + demandTask = nil + await engine.stop() + observation?.cancel() + await observation?.value + observation = nil + } +} + +@MainActor +final class ToolboxRuntime { + static let shared = ToolboxRuntime() + let navigation = ToolboxCoordinator() + let monitoring = MonitoringViewModel() + let networkChanges = NetworkChangesViewModel() +} diff --git a/apps/toolbox/Sources/Toolbox/NetworkChangesView.swift b/apps/toolbox/Sources/Toolbox/NetworkChangesView.swift new file mode 100644 index 0000000..0f59bf4 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/NetworkChangesView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +@MainActor +final class NetworkChangesViewModel: ObservableObject { + @Published var enabled = false { + didSet { if !enabled { tracker.clear() } else { tracker.invalidate() } } + } + @Published private(set) var tracker = NetworkChangeTracker() + func consume(_ inspection: NetworkInspection) { if enabled { tracker.update(inspection) } } + func invalidate() { tracker.invalidate() } + func clear() { tracker.clear() } +} + +struct NetworkChangesView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @ObservedObject var model: NetworkChangesViewModel + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.text("Network Connection Changes")).font(.title2.bold()) + Toggle(L10n.text("Track observed connection changes this session"), isOn: $model.enabled) + Text( + L10n.text( + "Explicit Refresh compares observable sockets with verified PID/start identities. New or no-longer-observed does not mean opened or closed at this time. Short connections and restricted processes may be missed. Process names and numeric endpoints stay in memory, capped at 200 events; nothing is uploaded or saved to disk." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + if model.enabled { + Text( + L10n.text( + model.tracker.baselineEstablished + ? "Connection baseline established" : "Refresh to establish a connection baseline")) + Text( + String( + format: L10n.text("%d unverified sockets excluded; %d older events omitted."), + model.tracker.unverifiedSockets, model.tracker.omittedEvents) + ).font(.caption) + Button(L10n.text("Clear connection history")) { model.clear() } + if model.tracker.events.isEmpty { Text(L10n.text("No connection changes observed yet.")) } + ForEach(model.tracker.events.prefix(50)) { event in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(L10n.text(event.kind)).font(.headline) + Text(event.observedAt, style: .time) + } + Text( + event.socket.processName + " · PID " + String(event.socket.pid) + " · " + + event.socket.transport) + Text(event.socket.endpoint).font(.caption.monospaced()).textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + if let previous = event.previousState { + Text(L10n.text(previous) + " → " + L10n.text(event.socket.state)).font(.caption) + } + }.padding(.vertical, 4) + } + if model.tracker.events.count > 50 { + Text(L10n.text("Showing the latest 50 change events.")).font(.caption) + } + } + }.environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } +} diff --git a/apps/toolbox/Sources/Toolbox/NetworkInspectorView.swift b/apps/toolbox/Sources/Toolbox/NetworkInspectorView.swift new file mode 100644 index 0000000..0d21102 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/NetworkInspectorView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct NetworkInspectorView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @ObservedObject private var changes = ToolboxRuntime.shared.networkChanges + @State private var reading: NetworkInspection? + @State private var isReading = false + @State private var hasReadFailure = false + @State private var task: Task? + @State private var query = "" + @State private var selectedSocketID: String? + @FocusState private var searchFocused: Bool + private var sockets: [NetworkSocket] { + (reading?.sockets ?? []).filter { + query.isEmpty || $0.processName.localizedCaseInsensitiveContains(query) + || $0.endpoint.localizedCaseInsensitiveContains(query) || String($0.pid).contains(query) + } + } + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.text("Network Inspector")).font(.title2.bold()) + Spacer() + if isReading { ProgressView().controlSize(.small) } + Button(L10n.text("Refresh connections")) { refresh() }.disabled(isReading) + } + Text( + L10n.text( + "Explicit TCP/UDP socket snapshot from lsof. Restricted processes and short-lived connections may be missing. No traffic history, domain attribution, byte totals or firewall control." + ) + ) + .font(.caption).foregroundStyle(.secondary) + HStack { + TextField(L10n.text("Search process, PID or endpoint"), text: $query).textFieldStyle( + .roundedBorder + ).focused($searchFocused) + Button(L10n.text("Clear search")) { + query = "" + searchFocused = true + }.disabled(query.isEmpty) + } + if hasReadFailure { + Text( + L10n.text("Connection inspection failed. Refresh to try again; coverage remains unknown.") + ) + } + if let reading { + Text(reading.observedAt, style: .time).font(.caption) + Text( + String( + format: L10n.text("%d observed sockets; %d omitted by the display cap."), + reading.sockets.count, reading.omitted) + ).font(.caption) + Table(sockets, selection: $selectedSocketID) { + TableColumn(L10n.text("Process")) { socket in + VStack(alignment: .leading) { + Text(socket.processName).help(socket.processName) + Text("PID " + String(socket.pid)).font(.caption) + }.accessibilityElement(children: .ignore) + .accessibilityLabel( + String( + format: L10n.text("%@, PID %d, %@, %@, %@"), + socket.processName, socket.pid, socket.transport, socket.endpoint, + L10n.text(socket.state))) + } + TableColumn(L10n.text("Protocol"), value: \.transport).width(60) + TableColumn(L10n.text("Endpoint")) { + Text($0.endpoint).textSelection(.enabled).help($0.endpoint) + } + TableColumn(L10n.text("State")) { Text(L10n.text($0.state)) }.width(105) + }.frame(height: 320) + Button(L10n.text("Inspect selected process")) { + if let identity = sockets.first(where: { $0.id == selectedSocketID })?.identity { + ToolboxRuntime.shared.navigation.open(.inspectProcess(identity)) + } + }.disabled( + isReading || sockets.first(where: { $0.id == selectedSocketID })?.identity == nil + ) + .help(L10n.text("Only a revalidated PID/start identity can be inspected.")) + if sockets.isEmpty { + Text( + L10n.text( + "No matching sockets within observable coverage. This does not prove the Mac has no connections." + ) + ) + .foregroundStyle(.secondary) + } + } else if !isReading { + Text(L10n.text("Refresh to inspect current connections.")) + } + Divider() + NetworkChangesView(model: changes) + }.orvenSurface().onDisappear { + task?.cancel() + changes.invalidate() + } + } + private func refresh() { + isReading = true + selectedSocketID = nil + hasReadFailure = false + task = Task { + let worker = Task.detached(priority: .utility) { try NetworkInspection.read() } + defer { isReading = false } + do { + let value = try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + try Task.checkCancellation() + reading = value + changes.consume(value) + } catch is CancellationError { + changes.invalidate() + } catch { + hasReadFailure = true + changes.invalidate() + } + } + } +} diff --git a/apps/toolbox/Sources/Toolbox/NetworkToolsView.swift b/apps/toolbox/Sources/Toolbox/NetworkToolsView.swift new file mode 100644 index 0000000..04433b4 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/NetworkToolsView.swift @@ -0,0 +1,405 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct NetworkToolsView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @StateObject private var model = NetworkToolsViewModel() + @ObservedObject var monitoring: MonitoringViewModel + @State private var tab = "Network Overview" + @State private var target = "" + @State private var query = "" + @FocusState private var searchFocused: Bool + private var viewLocale: Locale { + AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + } + private let tabs = [ + "Network Overview", "Wi-Fi Inspector", "Ethernet Inspector", "Connection Diagnostics", + "Latency & Stability", "Nearby Wi-Fi", "Network Change Timeline", + ] + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + OrvenPageHeader(title: L10n.text("Wi-Fi & LAN")) + OrvenSamplingStatus(model: monitoring) + HStack { + Picker(L10n.text("Network tool"), selection: $tab) { + ForEach(tabs, id: \.self) { Text(L10n.text($0)).tag($0) } + }.frame(maxWidth: 340) + Spacer() + Button(L10n.text("Refresh network status")) { model.refresh() }.disabled( + model.busy != nil) + if model.busy != nil { + ProgressView().controlSize(.small) + Button(L10n.text("Stop")) { model.cancel() } + } + } + Text(L10n.text(model.status)).font(.callout).fixedSize(horizontal: false, vertical: true) + if let overview = model.overview { + HStack { + Text(L10n.text("Last network observation")) + Text(overview.observedAt, style: .time) + }.font(.caption).foregroundStyle(.secondary) + } + switch tab { + case "Wi-Fi Inspector": wifiPanel + case "Ethernet Inspector": adapters(kind: "Ethernet") + case "Connection Diagnostics", "Latency & Stability": measurements + case "Nearby Wi-Fi": nearby + case "Network Change Timeline": timeline + default: overviewPanel + } + }.padding(28) + } + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + ) + .onAppear { model.appear() } + .onDisappear { model.disappear() } + .onChange(of: model.tracksChanges) { _ in model.resetTracking() } + } + private var overviewPanel: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.text(model.pathStatus)).font(.title2.bold()) + row( + "Path interfaces", + model.pathInterfaces.isEmpty ? L10n.text("Unavailable") : model.pathInterfaces) + row("Low Data Mode", L10n.text(model.constrained ? "Yes" : "No")) + row("Expensive path", L10n.text(model.expensive ? "Yes" : "No")) + Text( + L10n.text("An available path does not prove that an Internet destination is reachable.") + ).font(.caption) + Text( + L10n.text( + "Global DNS configuration is shown; scoped VPN and per-domain resolvers may differ.") + ).font(.caption) + if let value = model.overview { + Divider() + row("Primary interface", value.primary ?? L10n.text("Unavailable")) + row("IPv4 gateway", value.gateway4 ?? L10n.text("Unavailable")) + row("IPv6 gateway", value.gateway6 ?? L10n.text("Unavailable")) + row( + "DNS servers", + value.dns.isEmpty ? L10n.text("Unavailable") : value.dns.joined(separator: ", ")) + } + }.orvenSurface() + adapters(kind: nil) + } + } + @ViewBuilder private func adapters(kind: String?) -> some View { + if let value = model.overview { + let entries = value.adapters.filter { kind == nil || $0.kind == kind } + if entries.isEmpty { Text(L10n.text("No matching network adapter was observed.")) } + ForEach(entries) { adapter in + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(adapter.name + " • " + L10n.text(adapter.kind)).font(.headline) + Spacer() + Text(L10n.text(adapter.active ? "Up / running" : "Down / not running")) + } + row( + "IP addresses", + adapter.addresses.isEmpty + ? L10n.text("Unavailable") : adapter.addresses.joined(separator: ", ")) + row("MTU", adapter.mtu > 0 ? String(adapter.mtu) : L10n.text("Unavailable")) + if adapter.kind == "Ethernet" { + row( + "Carrier state", + adapter.carrier.map { + L10n.text( + $0 == "active" + ? "Link detected" : $0 == "inactive" ? "No link detected" : "Unknown") + } ?? L10n.text("Unavailable")) + row("Negotiated media", adapter.media ?? L10n.text("Unavailable")) + } + row("Received / sent bytes", number(adapter.received) + " / " + number(adapter.sent)) + row( + "Input / output packets", + number(adapter.inputPackets) + " / " + number(adapter.outputPackets)) + row( + "Input / output errors", + number(adapter.inputErrors) + " / " + number(adapter.outputErrors)) + row("Input queue drops", number(adapter.drops)) + if let rates = monitoring.snapshot?.hardware.interfaces.value, + let rate = rates.first(where: { $0.name == adapter.name }) + { + HStack { + MetricReadout( + title: L10n.text("Received"), sample: rate.input, format: rateText, maximumAge: 6) + MetricReadout( + title: L10n.text("Sent"), sample: rate.output, format: rateText, maximumAge: 6) + } + } + }.orvenSurface() + } + Text( + L10n.text( + "Adapter counters are cumulative observations, not session traffic or Internet speed. Packet/error counters may wrap. Live rates reuse the existing sampler." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + if value.omittedAdapters > 0 { + Text( + String( + format: L10n.text("%d adapters omitted by the display limit."), value.omittedAdapters)) + } + } else { + Text(L10n.text("Refresh to inspect Wi-Fi and LAN.")) + } + } + private var wifiPanel: some View { + VStack(alignment: .leading, spacing: 16) { + Text( + L10n.text( + "SSID and BSSID can be hidden by macOS location permissions. Missing identity does not mean Wi-Fi is disconnected." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + HStack { + Button(L10n.text("Request Wi-Fi identity permission")) { + model.requestWiFiIdentityPermission() + } + .disabled(model.locationStatus != "Not requested") + Text(L10n.text(model.locationStatus)) + } + if let value = model.overview { + if value.wifi.isEmpty { Text(L10n.text("No Wi-Fi interface was observed.")) } + ForEach(value.wifi, id: \.interface) { wifi in + VStack(alignment: .leading, spacing: 10) { + Text(wifi.interface).font(.headline) + row("SSID", wifi.ssid ?? L10n.text("Unavailable or permission restricted")) + row("BSSID", wifi.bssid ?? L10n.text("Unavailable or permission restricted")) + row("RSSI", integer(wifi.rssi, unit: "dBm")) + row("Noise", integer(wifi.noise, unit: "dBm")) + row("SNR", integer(wifi.snr, unit: "dB")) + row("Channel", integer(wifi.channel, unit: "")) + row("Band", wifi.band.map { L10n.text($0) } ?? L10n.text("Unavailable")) + row("Channel width", integer(wifi.widthMHz, unit: "MHz")) + row( + "Transmit link rate", + wifi.transmitMbps.map { + $0.formatted(.number.locale(viewLocale).precision(.fractionLength(1))) + " Mbps" + } ?? L10n.text("Unavailable")) + }.orvenSurface() + } + } else { + Text(L10n.text("Refresh to inspect Wi-Fi and LAN.")) + } + Text( + L10n.text( + "CoreWLAN reports local radio observations. Link rate is not measured Internet throughput; SNR is RSSI minus noise from this observation." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + } + } + private var measurements: some View { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.text("Destination hostname or IP address")).font(.headline) + TextField(L10n.text("Example: example.com or 192.168.1.1"), text: $target).textFieldStyle( + .roundedBorder + ).disabled(model.busy != nil) + Text( + L10n.text( + "Measurements send traffic to the displayed destination. Connection diagnostics also tests the recently observed gateway, resolves the destination and opens TCP port 443. No payload or speed-test download is sent." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + HStack { + if tab == "Connection Diagnostics" { + Button(L10n.text("Check connection")) { model.diagnose(target) }.disabled( + model.busy != nil || target.isEmpty) + } else { + Button(L10n.text("Measure 20 ICMP probes")) { model.measure(target) }.disabled( + model.busy != nil || target.isEmpty) + } + } + if tab == "Connection Diagnostics" { + Text( + L10n.text( + "Refresh network status first to include a gateway observed within 60 seconds. Each stage is bounded; total connection check is at most 30 seconds." + ) + ).font(.caption) + ForEach(model.checks) { check in + VStack(alignment: .leading, spacing: 8) { + Text(L10n.text(check.id)).font(.headline) + row( + "Destination", check.target == "Unavailable" ? L10n.text("Unavailable") : check.target + ) + Text(L10n.text(check.detail)) + Text(check.observedAt, style: .time).font(.caption) + if check.succeeded != nil { + row( + "Elapsed", + check.durationMilliseconds.formatted( + .number.locale(viewLocale).precision(.fractionLength(1))) + " ms") + } + }.orvenSurface() + } + Text( + L10n.text( + "ICMP may be blocked while Internet access works. DNS results may be cached. A successful TCP connection does not verify HTTP, TLS, captive portals or every application." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + } else { + Text( + L10n.text( + "20 probes, one second apart, at most 28 seconds. Loss uses the ICMP summary; jitter is the mean absolute difference between adjacent successful replies. Timeouts break the chart." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + if let value = model.latency { + VStack(alignment: .leading, spacing: 10) { + row("Destination", value.target) + Text(value.observedAt, style: .time).font(.caption) + row("Mean latency", decimal(value.meanMilliseconds, unit: "ms")) + row("Jitter", decimal(value.jitterMilliseconds, unit: "ms")) + row("Packet loss", decimal(value.lossPercent, unit: "%")) + if value.exitStatus != 0 { + Text( + L10n.text( + "ICMP did not complete successfully; missing results are unknown or blocked.")) + } + }.orvenSurface() + MonitoringChart( + title: L10n.text("ICMP round-trip latency"), unit: "ms", + data: value.samples.map { + ChartDatum( + time: SampleTime( + wall: value.observedAt.addingTimeInterval( + Double($0.sequence - max(0, value.samples.count - 1))), + monotonic: Double($0.sequence)), value: $0.milliseconds) + }, windowSeconds: 20, maximumGap: 1.5) + } + } + } + } + private var nearby: some View { + VStack(alignment: .leading, spacing: 16) { + Button(L10n.text("Scan nearby Wi-Fi")) { model.scanNearby() }.disabled(model.busy != nil) + Text( + L10n.text( + "Scan is explicit and controlled by macOS. Stop suppresses results after the OS scan returns; it cannot interrupt CoreWLAN. Identity may require location permission. Results stay in this window only." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + HStack { + TextField(L10n.text("Filter SSID, BSSID or channel"), text: $query).textFieldStyle( + .roundedBorder + ).focused($searchFocused) + Button(L10n.text("Clear search")) { + query = "" + searchFocused = true + }.disabled(query.isEmpty) + } + if let scan = model.scan { + Text(scan.observedAt, style: .time).font(.caption) + let channels = Dictionary( + grouping: scan.networks.filter { $0.channel != nil && $0.band != nil }, + by: { ($0.band ?? "Unknown") + " • " + String($0.channel ?? 0) }) + VStack(alignment: .leading, spacing: 8) { + Text(L10n.text("Observed networks by channel")).font(.headline) + ForEach(channels.keys.sorted(), id: \.self) { channel in + let count = channels[channel]?.count ?? 0 + HStack { + Text(channel).monospacedDigit().frame(width: 130, alignment: .leading) + ProgressView( + value: Double(count), total: Double(max(1, channels.values.map(\.count).max() ?? 1)) + ) + .accessibilityLabel(channel + ": " + String(count)) + Text(count.formatted(.number.locale(viewLocale))).monospacedDigit() + } + } + }.orvenSurface() + let filtered = scan.networks.filter { + query.isEmpty || ($0.ssid ?? "").localizedCaseInsensitiveContains(query) + || ($0.bssid ?? "").localizedCaseInsensitiveContains(query) + || ($0.channel.map(String.init) ?? "").contains(query) + } + Text( + String( + format: L10n.text("%d visible networks; %d omitted."), filtered.count, scan.omitted)) + if filtered.isEmpty { Text(L10n.text("No matching Wi-Fi network was observed.")) } + ForEach(filtered) { network in + VStack(alignment: .leading, spacing: 6) { + Text(network.ssid ?? L10n.text("Hidden or permission restricted")).font(.headline) + row("BSSID", network.bssid ?? L10n.text("Unavailable or permission restricted")) + row( + "Channel / band", + integer(network.channel, unit: "") + " / " + L10n.text(network.band ?? "Unknown")) + row( + "RSSI / noise", + integer(network.rssi, unit: "dBm") + " / " + integer(network.noise, unit: "dBm")) + let same = scan.networks.filter { + $0.channel != nil && $0.channel == network.channel && $0.band == network.band + }.count + row("Observed networks on this channel", String(same)) + if let rssi = network.rssi { + ProgressView(value: Double(max(0, min(100, rssi + 100))), total: 100) + .accessibilityLabel(L10n.text("RSSI") + " \(rssi) dBm") + } + }.orvenSurface() + } + } + Text( + L10n.text( + "Channel counts describe this scan, not airtime utilization or interference. Different bands can reuse channel numbers; wider channels can overlap. Signal bars are a relative RSSI display." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + } + } + private var timeline: some View { + VStack(alignment: .leading, spacing: 16) { + Toggle(L10n.text("Track network changes in this window"), isOn: $model.tracksChanges) + Button(L10n.text("Clear network changes")) { model.clearChanges() } + Text( + L10n.text( + "Path events are observed while this page is open. IP, gateway, DNS and Wi-Fi identity changes compare explicit successful refreshes within ten minutes. Missing identities and adapter disappearance are not inferred as changes. First refresh establishes a baseline. No history is written to disk." + ) + ).font(.caption).fixedSize(horizontal: false, vertical: true) + Text( + String( + format: L10n.text("%d status changes and %d path events; %d older events omitted."), + model.changes.events.count, model.pathEvents.count, + model.changes.omitted + model.omittedPathEvents)) + if model.changes.events.isEmpty && model.pathEvents.isEmpty { + Text(L10n.text("No network changes observed in this session.")) + } + ForEach(model.pathEvents.reversed()) { event in + VStack(alignment: .leading) { + Text(L10n.text(event.state)).font(.headline) + Text(event.interfaces).textSelection(.enabled) + Text(event.observedAt, style: .time).font(.caption) + }.orvenSurface() + } + ForEach(model.changes.events.reversed()) { event in + VStack(alignment: .leading, spacing: 6) { + Text(localizedField(event.field)).font(.headline) + row("Before", L10n.text(event.before)) + row("After", L10n.text(event.after)) + Text(event.observedAt, style: .time).font(.caption) + }.orvenSurface() + } + } + } + private func localizedField(_ field: String) -> String { + let parts = field.components(separatedBy: " • ") + return parts.count == 2 ? parts[0] + " • " + L10n.text(parts[1]) : L10n.text(field) + } + private func row(_ title: String, _ value: String) -> some View { + HStack(alignment: .top, spacing: 16) { + Text(L10n.text(title)).foregroundStyle(.secondary).frame(width: 180, alignment: .leading) + Text(value).monospacedDigit().textSelection(.enabled).frame( + maxWidth: .infinity, alignment: .leading) + }.fixedSize(horizontal: false, vertical: true) + } + private func number(_ value: UInt64?) -> String { + value.map { $0.formatted(.number.locale(viewLocale)) } ?? L10n.text("Unavailable") + } + private func integer(_ value: Int?, unit: String) -> String { + value.map { $0.formatted(.number.locale(viewLocale)) + (unit.isEmpty ? "" : " " + unit) } + ?? L10n.text("Unavailable") + } + private func decimal(_ value: Double?, unit: String) -> String { + value.map { + $0.formatted(.number.locale(viewLocale).precision(.fractionLength(1))) + " " + unit + } + ?? L10n.text("Unavailable") + } +} diff --git a/apps/toolbox/Sources/Toolbox/NetworkToolsViewModel.swift b/apps/toolbox/Sources/Toolbox/NetworkToolsViewModel.swift new file mode 100644 index 0000000..f6709d9 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/NetworkToolsViewModel.swift @@ -0,0 +1,191 @@ +import CoreLocation +import Foundation +import Network +import ToolboxMonitoring + +@MainActor +final class NetworkToolsViewModel: NSObject, ObservableObject, CLLocationManagerDelegate { + @Published private(set) var overview: NetworkOverview? + @Published private(set) var scan: NearbyWiFiScan? + @Published private(set) var checks: [ConnectionCheck] = [] + @Published private(set) var latency: LatencyReport? + @Published private(set) var busy: String? + @Published private(set) var status = "Refresh to inspect Wi-Fi and LAN." + @Published private(set) var pathStatus = "Unknown" + @Published private(set) var pathInterfaces = "" + @Published private(set) var constrained = false + @Published private(set) var expensive = false + @Published private(set) var changes = NetworkOverviewChanges() + @Published private(set) var pathEvents: [PathObservation] = [] + @Published private(set) var omittedPathEvents = 0 + @Published var tracksChanges = false + @Published private(set) var locationStatus = "Not requested" + private var task: Task? + private var monitor: NWPathMonitor? + private var generation = UUID() + private var lastPath: String? + private var stopRequested = false + private let location = CLLocationManager() + + struct PathObservation: Identifiable { + let id = UUID() + let observedAt: Date + let state: String + let interfaces: String + } + override init() { + super.init() + location.delegate = self + updateLocationStatus() + } + func appear() { + guard monitor == nil else { return } + let monitor = NWPathMonitor() + self.monitor = monitor + let token = UUID() + generation = token + monitor.pathUpdateHandler = { [weak self] path in + let state = + path.status == .satisfied + ? "Available path" + : path.status == .requiresConnection ? "Connection required" : "No available path" + let names = Array( + Set(path.availableInterfaces.filter { path.usesInterfaceType($0.type) }.map(\.name)) + ) + .sorted().joined(separator: ", ") + let constrained = path.isConstrained + let expensive = path.isExpensive + Task { @MainActor [weak self] in + guard let self, self.generation == token else { return } + self.pathStatus = state + self.pathInterfaces = names + self.constrained = constrained + self.expensive = expensive + let key = "\(state)|\(names)|\(constrained)|\(expensive)" + if self.tracksChanges, let previous = self.lastPath, previous != key { + self.pathEvents.append(.init(observedAt: Date(), state: state, interfaces: names)) + if self.pathEvents.count > 200 { + self.omittedPathEvents += self.pathEvents.count - 200 + self.pathEvents.removeFirst(self.pathEvents.count - 200) + } + } + self.lastPath = key + } + } + monitor.start(queue: DispatchQueue(label: "com.thang.toolbox.network-path")) + } + func disappear() { + generation = UUID() + monitor?.cancel() + monitor = nil + lastPath = nil + changes.reset() + cancel() + } + func resetTracking() { + changes.reset() + lastPath = nil + } + func clearChanges() { + changes.clear() + pathEvents = [] + omittedPathEvents = 0 + lastPath = nil + } + func cancel() { + guard busy != nil else { return } + stopRequested = true + status = + busy == "Scanning Wi-Fi" + ? "Stopping after the macOS Wi-Fi scan returns." : "Stopping measurement." + task?.cancel() + } + func refresh() { + run("Reading network status") { + try NetworkOverviewProvider.read() + } publish: { value in + self.overview = value + if self.tracksChanges { self.changes.consume(value) } else { self.changes.reset() } + } + } + func scanNearby() { + run("Scanning Wi-Fi") { + try NetworkOverviewProvider.scan() + } publish: { + self.scan = $0 + } + } + func measure(_ target: String) { + guard let host = NetworkTarget.validate(target) else { + status = "Enter a hostname or IP address without a URL, port or spaces." + return + } + run("Measuring latency") { + try NetworkDiagnostics.latency(target: host) + } publish: { + self.latency = $0 + } + } + func diagnose(_ target: String) { + guard let host = NetworkTarget.validate(target) else { + status = "Enter a hostname or IP address without a URL, port or spaces." + return + } + // An old gateway observation must not silently become the target of a new test. + let current = overview.flatMap { Date().timeIntervalSince($0.observedAt) <= 60 ? $0 : nil } + let gateway = current?.gateway4 ?? current?.gateway6 + run("Checking connection") { + try NetworkDiagnostics.connection(target: host, gateway: gateway) + } publish: { + self.checks = $0 + } + } + private func run( + _ label: String, operation: @escaping @Sendable () throws -> T, + publish: @escaping @MainActor (T) -> Void + ) { + guard busy == nil else { return } + busy = label + status = label + stopRequested = false + let worker = Task.detached(priority: .utility) { try operation() } + task = Task { + defer { + busy = nil + task = nil + } + do { + let value = try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + try Task.checkCancellation() + publish(value) + status = "Observation completed." + } catch is CancellationError { + if label == "Reading network status" { changes.reset() } + status = "Cancelled. Previous results remain timestamped." + } catch { + if label == "Reading network status" { changes.reset() } + status = + stopRequested + ? "Cancelled. Previous results remain timestamped." + : "Network observation failed or timed out. Check permissions and retry." + } + } + } + func requestWiFiIdentityPermission() { location.requestWhenInUseAuthorization() } + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task { @MainActor [weak self] in self?.updateLocationStatus() } + } + private func updateLocationStatus() { + switch location.authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: locationStatus = "Authorized" + case .denied: locationStatus = "Denied" + case .restricted: locationStatus = "Restricted" + case .notDetermined: locationStatus = "Not requested" + @unknown default: locationStatus = "Unknown" + } + } +} diff --git a/apps/toolbox/Sources/Toolbox/OnboardingView.swift b/apps/toolbox/Sources/Toolbox/OnboardingView.swift index d43ac2a..b30d0f6 100644 --- a/apps/toolbox/Sources/Toolbox/OnboardingView.swift +++ b/apps/toolbox/Sources/Toolbox/OnboardingView.swift @@ -91,7 +91,7 @@ struct OnboardingView: View { var body: some View { VStack(alignment: .leading, spacing: 22) { VStack(alignment: .leading, spacing: 6) { - Text(L10n.text("Welcome to Toolbox")) + Text(L10n.text("Welcome to Orven")) .font(.system(size: 28, weight: .bold)) Text(L10n.text("Local evidence for safer cleanup and app changes.")) .font(.title3) @@ -107,7 +107,7 @@ struct OnboardingView: View { .font(.caption) .foregroundStyle(.secondary) Spacer() - Button(L10n.text("Continue to Toolbox"), action: onComplete) + Button(L10n.text("Continue to Orven"), action: onComplete) .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } @@ -131,7 +131,7 @@ struct OnboardingView: View { VStack(alignment: .leading, spacing: 8) { Text( L10n.text( - "Scans, snapshots, and activity stay on this Mac. Toolbox has no telemetry and never cleans automatically." + "Scans, snapshots, and activity stay on this Mac. Orven has no telemetry and never cleans automatically." ) ) .foregroundStyle(.secondary) @@ -140,7 +140,7 @@ struct OnboardingView: View { .font(.subheadline.weight(.semibold)) Text( L10n.text( - "If macOS blocks Toolbox, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper." + "If macOS blocks Orven, open it once, then use System Settings → Privacy & Security → Open Anyway. Never disable Gatekeeper." ) ) .foregroundStyle(.secondary) diff --git a/apps/toolbox/Sources/Toolbox/OrvenWorkspace.swift b/apps/toolbox/Sources/Toolbox/OrvenWorkspace.swift new file mode 100644 index 0000000..852719b --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/OrvenWorkspace.swift @@ -0,0 +1,61 @@ +import AppKit +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct OrvenPageHeader: View { + let title: String + var subtitle: String? = nil + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.system(size: 36, weight: .bold)).foregroundStyle(OrvenTheme.ink) + .fixedSize(horizontal: false, vertical: true) + if let subtitle { + Text(subtitle).font(.callout).foregroundStyle(OrvenTheme.secondary) + .fixedSize(horizontal: false, vertical: true) + } + }.frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct OrvenSamplingStatus: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @ObservedObject var model: MonitoringViewModel + var body: some View { + HStack(spacing: 8) { + Image(systemName: model.samplingMode == .paused ? "pause.circle" : "waveform.path.ecg") + .foregroundStyle(model.samplingMode == .paused ? Color.secondary : OrvenTheme.accent) + Text(L10n.text(model.samplingMode == .paused ? "Sampling paused" : "Live monitoring")) + if let snapshot = model.snapshot { + Text(snapshot.time.wall, style: .time).monospacedDigit() + } + Spacer(minLength: 0) + Text(L10n.text("Read-only")).font(.caption) + }.font(.callout).foregroundStyle(OrvenTheme.secondary) + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) + } +} + +struct OrvenProcessIcon: View { + let process: ProcessSample + var size: CGFloat = 36 + private var icon: NSImage? { + guard let identity = process.identity, + let app = NSRunningApplication(processIdentifier: identity.pid), + let launch = app.launchDate, abs(launch.timeIntervalSince(identity.startDate)) < 1 + else { return nil } + return app.icon + } + var body: some View { + Group { + if let icon { + Image(nsImage: icon).resizable().scaledToFit() + } else { + Image(systemName: "app").resizable().scaledToFit().foregroundStyle(OrvenTheme.secondary) + .padding(5) + } + }.frame(width: size, height: size).accessibilityHidden(true) + } +} diff --git a/apps/toolbox/Sources/Toolbox/PhysicalSensorPanel.swift b/apps/toolbox/Sources/Toolbox/PhysicalSensorPanel.swift new file mode 100644 index 0000000..df6cbf4 --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/PhysicalSensorPanel.swift @@ -0,0 +1,138 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct PhysicalSensorPanel: View { + @ObservedObject var model: MonitoringViewModel + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + let since: Double? + @State private var expandedGroups: Set = [] + private let groups = ["CPU sensors", "GPU sensors", "Fans", "Battery sensors", "System power"] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.text("Temperature & Fans")).font(.title2.bold()) + Text( + L10n.text( + "Read-only SMC sensors refresh every 10 seconds while monitoring runs. Coverage is limited and model-dependent. CPU/GPU labels use community mappings, not verified physical core IDs. Missing readings are never zero." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + if let sample = model.snapshot?.hardware.reading.physicalSensors { + if let snapshot = sample.value { + HStack(alignment: .top, spacing: 20) { + summary( + "CPU sensors", title: "CPU • hottest mapped sensor", sample: sample, + readings: snapshot.readings) + summary( + "GPU sensors", title: "GPU • hottest mapped sensor", sample: sample, + readings: snapshot.readings) + } + Text( + L10n.text("Observed at") + " " + + sample.time.wall.formatted(.dateTime.hour().minute().second().locale(locale)) + ) + .font(.caption).foregroundStyle(.secondary) + if snapshot.reportedFanCount == 0 { + Text(L10n.text("SMC reports no fans. This is not a fan-speed measurement.")) + } else if snapshot.reportedFanCount == nil { + Text(L10n.text("Fan count unavailable; no fan speeds inferred.")) + } else if snapshot.reportedFanCount! > 8 { + Text(L10n.text("Fan coverage is capped at 8 reported fans.")) + } + ForEach(groups, id: \.self) { group in + let rows = snapshot.readings.filter { $0.group == group } + if !rows.isEmpty { + DisclosureGroup( + isExpanded: Binding( + get: { expandedGroups.contains(group) }, + set: { expanded in + if expanded { + expandedGroups.insert(group) + } else { + expandedGroups.remove(group) + } + }) + ) { + if expandedGroups.contains(group) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { + GridRow { + Text(L10n.text("Sensor")) + Text(L10n.text("Current")) + Text(L10n.text("Minimum")) + Text(L10n.text("Maximum")) + Text(L10n.text("Samples")) + }.font(.caption.bold()).foregroundStyle(.secondary) + ForEach(rows) { reading in + let range = SensorWindow.physicalExtrema( + model.snapshot?.history ?? [], sensor: reading, since: since) + GridRow { + Text(reading.key).font(.callout.monospaced()).help( + "AppleSMC/" + reading.key + " • " + (reading.dataType ?? "?")) + Text(current(reading, sample)).fixedSize(horizontal: false, vertical: true) + Text(value(range.minimum, reading.unit)) + Text(value(range.maximum, reading.unit)) + Text(monitoringNumber(Double(range.validSamples), digits: 0)) + }.monospacedDigit() + } + }.padding(.top, 12) + } + } label: { + HStack { + Text(L10n.text(group)).font(.headline) + Spacer() + Text("\(rows.filter { $0.availability == .available }.count)/\(rows.count)") + .font(.caption.monospaced()).foregroundStyle(.secondary) + } + } + } + } + } else { + Text(availabilityLabel(sample.availability)).foregroundStyle(.secondary) + } + } else { + Text(L10n.text("Warming up")) + } + Text( + L10n.text( + "Temperatures are individual mapped readings, not CPU/GPU package averages or a safety diagnosis. Fan speeds are observed RPM only; Orven does not change fan modes or targets. Voltage, live clocks, SPD and SMART are not collected." + ) + ) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + }.orvenSurface() + } + + private var locale: Locale { + AppLanguage(rawValue: UserDefaults.standard.string(forKey: AppLanguage.storageKey) ?? "")? + .locale + ?? AppLanguage.defaultLanguage.locale + } + private func value(_ value: Double?, _ unit: PhysicalSensorUnit) -> String { + guard let value else { return "—" } + return monitoringNumber(value, digits: unit == .rpm ? 0 : 1) + " " + unit.rawValue + } + private func current( + _ reading: PhysicalSensorReading, _ sample: MetricSample + ) -> String { + guard reading.availability == .available else { return availabilityLabel(reading.availability) } + let state = sample.freshness(at: ProcessInfo.processInfo.systemUptime, maximumAge: 20) + guard state == .available, model.samplingMode != .paused else { + return availabilityLabel(state == .available ? .stale : state) + } + return value(reading.value, reading.unit) + } + private func summary( + _ group: String, title: String, sample: MetricSample, + readings: [PhysicalSensorReading] + ) -> some View { + let rows = readings.filter { $0.group == group && $0.availability == .available } + let hottest = rows.max { ($0.value ?? -.infinity) < ($1.value ?? -.infinity) } + return VStack(alignment: .leading, spacing: 6) { + Text(L10n.text(title)).font(.headline) + Text(hottest.map { current($0, sample) } ?? L10n.text("Unavailable")) + .font(.title2.monospacedDigit()) + if let hottest { Text("AppleSMC/" + hottest.key).font(.caption).foregroundStyle(.secondary) } + }.frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/apps/toolbox/Sources/Toolbox/ProcessPriorityView.swift b/apps/toolbox/Sources/Toolbox/ProcessPriorityView.swift new file mode 100644 index 0000000..5357a8e --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/ProcessPriorityView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct ProcessPriorityView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + let identity: ProcessIdentity + let name: String + @State private var confirming = false + @State private var working = false + @State private var result: String? + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.text("CPU scheduling")).font(.headline) + Text( + L10n.text( + "Lower priority raises nice to at least 10 for this same-user process. It is not a CPU percentage cap. Raising priority back may require privileges; relaunch the app to reset. Child processes are not individually controlled." + ) + ) + .font(.caption).foregroundStyle(.secondary) + Button(L10n.text("Lower process priority…")) { confirming = true }.disabled(working) + if let result { Text(L10n.text(result)).font(.caption) } + } + .alert(L10n.text("Lower process priority?"), isPresented: $confirming) { + Button(L10n.text("Cancel"), role: .cancel) {} + Button(L10n.text("Lower priority")) { + working = true + Task { + defer { working = false } + let worker = Task.detached { try ProcessPriority.lower(identity) } + do { + _ = try await worker.value + result = + "Process priority lowered and rechecked. This does not guarantee lower CPU usage." + } catch { + result = + "Priority change could not be verified. The process may have exited, changed identity or denied access. No retry is automatic." + } + } + } + } message: { + Text( + name + " · PID \(identity.pid)\n" + + L10n.text( + "This changes scheduling for the selected process. Restoring the previous priority is not guaranteed without relaunching it." + )) + } + .id(identity) + } +} diff --git a/apps/toolbox/Sources/Toolbox/ProcessesView.swift b/apps/toolbox/Sources/Toolbox/ProcessesView.swift new file mode 100644 index 0000000..350faab --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/ProcessesView.swift @@ -0,0 +1,192 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct ProcessesView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + @Environment(\.locale) private var locale + @Environment(\.monitoringDisplayState) private var displayState + @ObservedObject var model: MonitoringViewModel + var focus: ProcessIdentity? = nil + @State private var query = "" + @State private var order = ProcessOrdering.cpu + @State private var selection: ProcessRowID? + @State private var showsCompactDetails = false + @FocusState private var searchFocused: Bool + + private var samples: [ProcessSample] { model.snapshot?.processes.value ?? [] } + private var rows: [ProcessSample] { order.sorted(samples, query: query) } + private var selected: ProcessSample? { samples.first { $0.id == selection } } + private var identity: ProcessIdentity? { + guard case .verified(let identity) = selection else { return nil } + return identity + } + + var body: some View { + GeometryReader { geometry in + let sideBySide = geometry.size.width >= 900 + VStack(alignment: .leading, spacing: 18) { + OrvenPageHeader( + title: L10n.text("Processes"), + subtitle: L10n.text("Inspect what's running on your Mac. Monitoring is read-only.")) + HStack(spacing: 16) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField(L10n.text("Search processes or PID"), text: $query) + .textFieldStyle(.plain).focused($searchFocused) + Button { + query = "" + searchFocused = true + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain).accessibilityLabel(L10n.text("Clear search")) + .disabled(query.isEmpty).opacity(query.isEmpty ? 0 : 1) + }.padding(10).background(OrvenTheme.surface, in: RoundedRectangle(cornerRadius: 9)) + .overlay { + RoundedRectangle(cornerRadius: 9).stroke(OrvenTheme.border, lineWidth: 1) + .allowsHitTesting(false) + } + Picker(L10n.text("Sort by"), selection: $order) { + Text(L10n.text("CPU")).tag(ProcessOrdering.cpu) + Text(L10n.text("Memory")).tag(ProcessOrdering.memory) + Text(L10n.text("Name")).tag(ProcessOrdering.name) + Text("PID").tag(ProcessOrdering.pid) + }.frame(width: 180) + } + HStack { + Text(String(format: L10n.text("%d processes shown"), rows.count)) + Spacer() + if !sideBySide, identity != nil { + Button(L10n.text("Inspect process")) { showsCompactDetails = true } + } + if let reading = model.snapshot?.processes { + Text( + availabilityLabel( + displayState.availability( + of: reading, at: ProcessInfo.processInfo.systemUptime, maximumAge: 6))) + Text(reading.time.wall, style: .time) + } + }.font(.caption).foregroundStyle(OrvenTheme.secondary) + if let reason = model.snapshot?.processes.reason { Text(reason).font(.caption) } + HStack(alignment: .top, spacing: 18) { + processTable.orvenSurface(padding: 0) + if sideBySide, let identity { + ScrollView { detail(identity: identity).padding(20) } + .frame(width: 300).orvenSurface(padding: 0) + } + }.frame(maxHeight: .infinity) + if identity == nil { + Text(L10n.text("Select a process to inspect its verified identity and recent activity.")) + .font(.callout).foregroundStyle(OrvenTheme.secondary) + } + Text( + L10n.text( + "100% process CPU is one logical core. Restricted processes remain visible. No termination actions." + ) + ) + .font(.caption).foregroundStyle(OrvenTheme.secondary) + }.padding(28) + .onChange(of: identity) { value in + showsCompactDetails = value != nil && !sideBySide + } + .sheet(isPresented: $showsCompactDetails) { + if let identity { + ScrollView { detail(identity: identity).padding(24) } + .frame(width: 440, height: 520).background(OrvenTheme.surface) + } + } + }.background(OrvenTheme.canvas).environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + ) + .task(id: focus) { if let focus { selection = .verified(focus) } } + .task(id: identity) { await model.loadDetails(for: identity) } + } + + private var processTable: some View { + Table(rows, selection: $selection) { + TableColumn(L10n.text("Name")) { Text($0.name).lineLimit(1).help($0.name) } + .width(min: 130, ideal: 200) + TableColumn("PID") { Text(String($0.pid)).monospacedDigit() }.width(60) + TableColumn(L10n.text("CPU • one core")) { process in + Text( + process.cpu.value.map { monitoringNumber($0, digits: 1) + "%" } + ?? availabilityLabel(process.cpu.availability) + ).monospacedDigit() + }.width(min: 110, ideal: 125) + TableColumn(L10n.text("Resident memory")) { process in + Text( + process.memory.value.map { monitoringBytes($0) } + ?? availabilityLabel(process.memory.availability) + ).monospacedDigit() + }.width(min: 110, ideal: 125) + } + .overlay { + if rows.isEmpty { + VStack(spacing: 10) { + Image(systemName: "magnifyingglass").font(.title) + Text(L10n.text(query.isEmpty ? "No process data available." : "No matching processes.")) + }.foregroundStyle(.secondary).allowsHitTesting(false) + } + } + } + + private func detail(identity: ProcessIdentity) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + if let selected { OrvenProcessIcon(process: selected, size: 40) } + Text(selected?.name ?? "PID \(identity.pid)").font(.title2.weight(.semibold)) + Spacer() + Button { + selection = nil + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.plain).accessibilityLabel(L10n.text("Close details")) + .keyboardShortcut(.cancelAction) + } + VStack(alignment: .leading, spacing: 8) { + Text("PID " + String(identity.pid)) + Text(identity.startDate.formatted(.dateTime.day().month().year().locale(locale))) + Text(identity.startDate.formatted(.dateTime.hour().minute().locale(locale))) + if let parent = selected?.parentPID { + Text(String(format: L10n.text("Observed parent PID: %d"), parent)) + } + }.font(.caption).textSelection(.enabled) + if selected == nil { + if let reading = model.snapshot?.processes, reading.availability != .available { + Text(availabilityLabel(reading.availability)) + } else { + Text(L10n.text("Process exited or identity changed.")) + } + } else if let details = model.details(for: identity), let selected { + Text( + availabilityLabel( + details.availability == .available + ? displayState.availability( + of: selected.memory, at: ProcessInfo.processInfo.systemUptime, maximumAge: 6) + : details.availability)) + if let threads = selected.threads { + Text(String(format: L10n.text("Threads: %d"), threads)) + } + } else { + Text(L10n.text("Revalidating process identity…")) + } + if let selected, !selected.cpuHistory.isEmpty { + MonitoringChart( + title: L10n.text("Process CPU • one core"), unit: "%", + data: selected.cpuHistory.map { ChartDatum(time: $0.time, value: $0.cpu) }, embedded: true + ) + + } + ProcessPriorityView(identity: identity, name: selected?.name ?? "PID \(identity.pid)").id( + identity) + if model.applicationMapped(to: identity) { + Button(L10n.text("Review in Applications")) { model.openMappedApplication(for: identity) } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/apps/toolbox/Sources/Toolbox/ReleaseUpdateChecker.swift b/apps/toolbox/Sources/Toolbox/ReleaseUpdateChecker.swift index e0b5def..ced2d90 100644 --- a/apps/toolbox/Sources/Toolbox/ReleaseUpdateChecker.swift +++ b/apps/toolbox/Sources/Toolbox/ReleaseUpdateChecker.swift @@ -65,7 +65,7 @@ struct ReleaseUpdateChecker: Sendable { var request = URLRequest(url: Self.endpoint) request.httpMethod = "GET" request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - request.setValue("Toolbox/\(AppMetadata.version)", forHTTPHeaderField: "User-Agent") + request.setValue("Orven/\(AppMetadata.version)", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(status) else { throw ReleaseUpdateError.invalidResponse(status) } diff --git a/apps/toolbox/Sources/Toolbox/SettingsView.swift b/apps/toolbox/Sources/Toolbox/SettingsView.swift index 6ed1d7b..0846348 100644 --- a/apps/toolbox/Sources/Toolbox/SettingsView.swift +++ b/apps/toolbox/Sources/Toolbox/SettingsView.swift @@ -1,5 +1,6 @@ import SwiftUI import ToolboxCore +import ToolboxMonitoring import ToolboxStorage @MainActor @@ -37,7 +38,7 @@ final class ToolboxSettingsViewModel: ObservableObject { Task { do { try await scheduledScan.replaceLegacyLaunchAgent(intervalHours: hours) - statusMessage = L10n.text("Legacy schedule replaced after Toolbox bootstrap succeeded.") + statusMessage = L10n.text("Legacy schedule replaced after Orven bootstrap succeeded.") } catch { errorMessage = error.localizedDescription } @@ -61,7 +62,7 @@ final class ToolboxSettingsViewModel: ObservableObject { statusMessage = if let latestVersion, let current, current < latestVersion { String( - format: L10n.text("Toolbox %@ is available."), latestVersion.description) + format: L10n.text("Orven %@ is available."), latestVersion.description) } else { L10n.text("You are using the latest public release.") } @@ -74,12 +75,33 @@ final class ToolboxSettingsViewModel: ObservableObject { } struct SettingsView: View { + @AppStorage(AppAppearance.storageKey) private var appearanceCode = AppAppearance.system.rawValue @StateObject private var model = ToolboxSettingsViewModel() + @ObservedObject private var monitoring = ToolboxRuntime.shared.monitoring @State private var confirmsScheduleReplacement = false @State private var showsError = false var body: some View { Form { + Section(L10n.text("Appearance")) { + Picker(L10n.text("Appearance"), selection: $appearanceCode) { + ForEach(AppAppearance.allCases) { Text($0.title).tag($0.rawValue) } + } + } + Section(L10n.text("Monitoring")) { + Toggle(L10n.text("Menu bar quick diagnostics"), isOn: $monitoring.menuBarEnabled) + Picker(L10n.text("Menu bar indicator"), selection: $monitoring.menuBarIndicator) { + Text(L10n.text("CPU")).tag(MenuBarIndicator.cpu) + Text(L10n.text("Memory")).tag(MenuBarIndicator.memory) + Text(L10n.text("Network")).tag(MenuBarIndicator.network) + }.disabled(!monitoring.menuBarEnabled) + Text( + L10n.text( + "Disabled by default. When enabled, menu bar and windows share one sampler. Background sampling runs every 5 seconds; Quit stops it." + ) + ) + .font(.caption).foregroundStyle(.secondary) + } Section(L10n.text("Privacy and coverage")) { switch model.coverage { case .full: @@ -110,7 +132,7 @@ struct SettingsView: View { Text(L10n.text("7 days")).tag(168) } .pickerStyle(.segmented) - Button(L10n.text("Replace with Toolbox schedule…")) { + Button(L10n.text("Replace with Orven schedule…")) { confirmsScheduleReplacement = true } .disabled(model.isReplacingSchedule) @@ -130,7 +152,7 @@ struct SettingsView: View { Section(L10n.text("Updates")) { Text( L10n.text( - "Only when you click the button, Toolbox requests public release metadata from GitHub. No scan or device data is sent." + "Only when you click the button, Orven requests public release metadata from GitHub. No scan or device data is sent." ) ) .foregroundStyle(.secondary) @@ -154,7 +176,10 @@ struct SettingsView: View { } } .formStyle(.grouped) - .frame(width: 620, height: 560) + .scrollContentBackground(.hidden) + .background(OrvenTheme.canvas) + .tint(OrvenTheme.action) + .frame(width: 660, height: 620) .onAppear { model.refresh() } .onChange(of: model.errorMessage) { showsError = $0 != nil } .confirmationDialog( @@ -166,7 +191,7 @@ struct SettingsView: View { } message: { Text( L10n.text( - "Toolbox first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds." + "Orven first installs and bootstraps its scan-only agent. The Diskora plist is removed only after that succeeds." )) } .alert(L10n.text("Settings error"), isPresented: $showsError) { diff --git a/apps/toolbox/Sources/Toolbox/SleepInspectorView.swift b/apps/toolbox/Sources/Toolbox/SleepInspectorView.swift new file mode 100644 index 0000000..fee680e --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/SleepInspectorView.swift @@ -0,0 +1,85 @@ +import SwiftUI +import ToolboxCore +import ToolboxMonitoring + +struct SleepInspectorView: View { + @Environment(\.locale) private var locale + let engine: MonitoringCoordinator + @State private var result: MetricSample? + @State private var requestID = 0 + @State private var reading = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(L10n.text("Sleep Inspector")).font(.title2.bold()) + Spacer() + Button(L10n.text("Refresh sleep requests")) { requestID += 1 } + .disabled(reading) + } + Text( + L10n.text( + "A snapshot of active macOS power requests. Requests can be legitimate; they do not explain every sleep or wake event." + ) + ) + .font(.callout).foregroundStyle(OrvenTheme.secondary) + if reading { + ProgressView(L10n.text("Reading sleep requests")) + } else if let result { + if let inspection = result.value { + Text( + L10n.text("Observed at") + " " + + result.time.wall.formatted(.dateTime.hour().minute().second().locale(locale)) + ) + .font(.caption).foregroundStyle(OrvenTheme.secondary) + if inspection.requests.isEmpty { + if inspection.omittedCount == 0 { + Text(L10n.text("No active requests in this snapshot")) + } else { + Text(L10n.text("No readable requests in this snapshot")) + } + } + ForEach(inspection.requests) { request in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(request.owner?.name ?? "PID \(request.pid)").font(.headline) + Spacer() + if let owner = request.owner { + Button(L10n.text("Inspect process")) { + ToolboxRuntime.shared.navigation.open(.inspectProcess(owner.identity)) + } + .accessibilityLabel( + L10n.text("Inspect process") + " " + owner.name + " PID \(owner.identity.pid)") + } + } + Text(request.type).font(.system(.callout, design: .monospaced)) + if !request.name.isEmpty { Text(request.name).font(.callout).textSelection(.enabled) } + if request.owner == nil { + Text(L10n.text("Process identity unavailable; ownership is unverified")) + .font(.caption).foregroundStyle(OrvenTheme.secondary) + } + } + Divider() + } + if inspection.omittedCount > 0 { + Text(L10n.text("Some requests could not be displayed") + ": \(inspection.omittedCount)") + .font(.caption).foregroundStyle(OrvenTheme.secondary) + } + } else { + Label(availabilityLabel(result.availability), systemImage: "exclamationmark.circle") + } + } else { + Text(L10n.text("Refresh to inspect current requests. No background polling.")) + .foregroundStyle(OrvenTheme.secondary) + } + } + .orvenSurface() + .task(id: requestID) { + guard requestID > 0 else { return } + reading = true + let sample = await engine.inspectSleepRequests() + if !Task.isCancelled { result = sample } + reading = false + } + } +} diff --git a/apps/toolbox/Sources/Toolbox/SmartAlerts.swift b/apps/toolbox/Sources/Toolbox/SmartAlerts.swift new file mode 100644 index 0000000..651d54a --- /dev/null +++ b/apps/toolbox/Sources/Toolbox/SmartAlerts.swift @@ -0,0 +1,204 @@ +import Foundation +import ToolboxCore +import ToolboxMonitoring +import UserNotifications + +// A re-arm requires observed recovery; missing/paused evidence cannot re-arm an alert. +struct AlertGate { + private var latched: Set = [] + private var clearSince: [String: Double] = [:] + private var delivered: [String: Double] = [:] + private var lastTime: Double? + var snoozedUntil = Date.distantPast + + mutating func evaluate( + findings: Set, recoveries: Set, now: Double, wall: Date = Date() + ) -> [String] { + guard now.isFinite else { return [] } + if let lastTime, now < lastTime { + latched = [] + clearSince = [:] + delivered = [:] + snoozedUntil = .distantPast + } + if let lastTime, now - lastTime > 15 { clearSince = [:] } + self.lastTime = now + for key in latched { + if recoveries.contains(key) { + if clearSince[key] == nil { clearSince[key] = now } + if now - (clearSince[key] ?? now) >= 30 { + latched.remove(key) + clearSince[key] = nil + } + } else { + clearSince[key] = nil + } + } + guard wall >= snoozedUntil else { return [] } + return findings.sorted().filter { key in + guard !latched.contains(key), now - (delivered[key] ?? -.infinity) >= 900 else { + return false + } + latched.insert(key) + delivered[key] = now + return true + } + } +} + +struct SmartAlertEvent: Identifiable { + let id = UUID() + let observedAt: Date + let title: String + let evidence: String + let section: ToolboxSection +} + +@MainActor +final class SmartAlertsViewModel: NSObject, ObservableObject, UNUserNotificationCenterDelegate { + @Published private(set) var events: [SmartAlertEvent] = [] + @Published private(set) var status: String? + @Published private(set) var requestingPermission = false + @Published var enabled: Bool { + didSet { + defaults.set(enabled, forKey: "toolbox.smartAlerts.enabled") + if !enabled { gate = AlertGate() } + } + } + private let defaults: UserDefaults + private var gate = AlertGate() + private var center: UNUserNotificationCenter? { + guard Bundle.main.bundleIdentifier == AppMetadata.bundleIdentifier else { return nil } + return UNUserNotificationCenter.current() + } + override convenience init() { self.init(defaults: .standard) } + init(defaults: UserDefaults) { + self.defaults = defaults + enabled = defaults.bool(forKey: "toolbox.smartAlerts.enabled") + super.init() + center?.delegate = self + } + + func requestPermission() async { + guard !requestingPermission else { return } + guard let center else { + status = "Notifications require the Orven app bundle." + return + } + requestingPermission = true + status = + "Waiting for macOS notification permission. Respond to the system prompt if it appears." + defer { requestingPermission = false } + do { + let allowed = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + center.requestAuthorization(options: [.alert, .sound]) { allowed, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: allowed) + } + } + } + status = + allowed + ? "Notification permission granted." + : "Notifications are not allowed. In-app alerts remain available." + } catch { + status = "Notification permission could not be requested. In-app alerts remain available." + } + } + func snooze() { + gate.snoozedUntil = Date().addingTimeInterval(3600) + status = "Alerts snoozed for one hour." + } + func consume(_ snapshot: MonitoringSnapshot, active: Bool) { + guard enabled, active else { return } + let now = ProcessInfo.processInfo.systemUptime + guard now >= snapshot.time.monotonic, now - snapshot.time.monotonic <= 6 else { return } + let insights = DiagnosticInsights.evaluate(snapshot, now: now, active: active) + var recovered: Set = [] + if snapshot.cpu.freshness(at: now, maximumAge: 6) == .available, + let cpu = snapshot.cpu.value, cpu < 70 + { + recovered.insert("cpu") + } + let h = snapshot.hardware.reading + if h.pressure.freshness(at: now, maximumAge: 6) == .available, + h.pressure.value == .normal + { + recovered.insert("memory") + } + if h.thermal.freshness(at: now, maximumAge: 6) == .available, + h.thermal.value == .nominal || h.thermal.value == .fair + { + recovered.insert("thermal") + } + if h.capacity.freshness(at: now, maximumAge: 30) == .available, + let capacity = h.capacity.value, capacity.totalBytes > 0, + Double(capacity.availableBytes) / Double(capacity.totalBytes) >= 0.15 + { + recovered.insert("storage") + } + let keys = gate.evaluate(findings: Set(insights.map(\.id)), recoveries: recovered, now: now) + for insight in insights where keys.contains(insight.id) { + events.insert( + SmartAlertEvent( + observedAt: snapshot.time.wall, title: insight.title, + evidence: insight.evidence, section: insight.section), at: 0) + events = Array(events.prefix(100)) + let content = UNMutableNotificationContent() + content.title = L10n.text(insight.title) + content.body = L10n.text(insight.explanation) + content.userInfo = ["section": insight.section.rawValue] + let request = UNNotificationRequest( + identifier: "orven.health." + insight.id, content: content, trigger: nil) + Task { + guard let center else { return } + // Older SDK settings objects are not Sendable; only transfer the authorization value. + let authorized = await withCheckedContinuation { + (continuation: CheckedContinuation) in + center.getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus == .authorized) + } + } + guard enabled, Date() >= gate.snoozedUntil, + ProcessInfo.processInfo.systemUptime - snapshot.time.monotonic <= 6, + authorized + else { + return + } + do { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + center.add(request) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + } catch { + status = "Notification delivery failed. Review the in-app alert evidence." + } + } + } + } + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let raw = response.notification.request.content.userInfo["section"] as? String + Task { @MainActor in + if let raw, let section = ToolboxSection(rawValue: raw) { + ToolboxRuntime.shared.navigation.open(.section(section)) + } + } + completionHandler() + } + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } +} diff --git a/apps/toolbox/Sources/Toolbox/ToolboxApp.swift b/apps/toolbox/Sources/Toolbox/ToolboxApp.swift index 0fee316..ced4f6a 100644 --- a/apps/toolbox/Sources/Toolbox/ToolboxApp.swift +++ b/apps/toolbox/Sources/Toolbox/ToolboxApp.swift @@ -7,27 +7,52 @@ import ToolboxStorage struct ToolboxApp: App { @NSApplicationDelegateAdaptor(ToolboxAppDelegate.self) private var appDelegate + @AppStorage(AppAppearance.storageKey) private var appearanceCode = AppAppearance.system.rawValue + private var preferredScheme: ColorScheme? { + (AppAppearance(rawValue: appearanceCode) ?? .system).scheme + } + + @StateObject private var monitoring = ToolboxRuntime.shared.monitoring + private var menuBarInsertion: Binding { + Binding( + get: { + monitoring.menuBarEnabled && !ProcessInfo.processInfo.arguments.contains("--scheduled-scan") + }, + set: { if monitoring.menuBarEnabled != $0 { monitoring.menuBarEnabled = $0 } }) + } + var body: some Scene { - WindowGroup("Toolbox") { + WindowGroup(AppMetadata.displayName) { ToolboxShellView() + .preferredColorScheme(preferredScheme) + .environment( + \.monitoringDisplayState, + MonitoringDisplayState( + paused: monitoring.samplingMode == .paused, + minimumMaximumAge: monitoring.samplingMode == .menuBar ? 15 : 0)) } .windowStyle(.titleBar) - .defaultSize(width: 1_120, height: 760) + .defaultSize(width: 1_320, height: 900) .commands { CommandGroup(replacing: .appInfo) { - Button(L10n.text("About Toolbox")) { showAboutPanel() } + Button(L10n.text("About Orven")) { showAboutPanel() } } CommandGroup(replacing: .newItem) {} } - Settings { - SettingsView() + Settings { SettingsView().preferredColorScheme(preferredScheme) } + + MenuBarExtra(isInserted: menuBarInsertion) { + MenuBarDiagnosticsView(model: monitoring).preferredColorScheme(preferredScheme) + } label: { + Text(monitoring.menuBarTitle).monospacedDigit() } + .menuBarExtraStyle(.window) } private func showAboutPanel() { NSApp.orderFrontStandardAboutPanel(options: [ - .applicationName: AppMetadata.name, + .applicationName: AppMetadata.displayName, .applicationVersion: AppMetadata.version, .version: "Build \(AppMetadata.build)", .credits: NSAttributedString(string: L10n.text(AppMetadata.tagline)), @@ -52,9 +77,35 @@ final class ToolboxAppDelegate: NSObject, NSApplicationDelegate { return } NSApp.setActivationPolicy(.regular) + ToolboxRuntime.shared.monitoring.start() + let center = NSWorkspace.shared.notificationCenter + center.addObserver( + self, selector: #selector(willSleep), name: NSWorkspace.willSleepNotification, object: nil) + center.addObserver( + self, selector: #selector(didWake), name: NSWorkspace.didWakeNotification, object: nil) + for name in [ + NSWindow.willCloseNotification, NSWindow.didMiniaturizeNotification, + NSWindow.didDeminiaturizeNotification, NSWindow.didBecomeKeyNotification, + NSWindow.didResignKeyNotification, + ] { + NotificationCenter.default.addObserver( + self, selector: #selector(windowActivityChanged), name: name, object: nil) + } + ToolboxRuntime.shared.monitoring.setActive(NSApp.isActive) ensureMainWindow() } + @objc private func windowActivityChanged(_ notification: Notification) { + DispatchQueue.main.async { self.refreshWindowVisibility() } + } + + private func refreshWindowVisibility() { + ToolboxRuntime.shared.monitoring.setMainWindowVisible( + NSApp.windows.contains { + $0.identifier?.rawValue == "toolbox-main" && monitoringWindowInUse($0) + }) + } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { guard !isScheduledScan else { return false } @@ -62,9 +113,35 @@ final class ToolboxAppDelegate: NSObject, NSApplicationDelegate { return true } + func applicationDidBecomeActive(_ notification: Notification) { + guard !isScheduledScan else { return } + ToolboxRuntime.shared.monitoring.setActive(true) + } + + func applicationDidResignActive(_ notification: Notification) { + guard !isScheduledScan else { return } + ToolboxRuntime.shared.monitoring.setActive(false) + } + + @objc private func willSleep() { ToolboxRuntime.shared.monitoring.setSleeping(true) } + @objc private func didWake() { ToolboxRuntime.shared.monitoring.setSleeping(false) } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard !isScheduledScan else { return .terminateNow } + Task { + await ToolboxRuntime.shared.monitoring.stop() + NSWorkspace.shared.notificationCenter.removeObserver(self) + NotificationCenter.default.removeObserver(self) + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } + private func ensureMainWindow() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - if let window = NSApp.windows.first(where: { $0.isVisible }) { + if let window = NSApp.windows.first(where: { + $0.identifier?.rawValue == "toolbox-main" + }) { window.isRestorable = false window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) @@ -78,13 +155,13 @@ final class ToolboxAppDelegate: NSObject, NSApplicationDelegate { return } - let hostingController = NSHostingController(rootView: ToolboxShellView()) + let hostingController = NSHostingController(rootView: ToolboxFallbackRootView()) let window = NSWindow(contentViewController: hostingController) - window.title = "Toolbox" - window.identifier = NSUserInterfaceItemIdentifier("toolbox-fallback-main") + window.title = AppMetadata.displayName + window.identifier = NSUserInterfaceItemIdentifier("toolbox-main") window.styleMask = [.titled, .closable, .miniaturizable, .resizable] - window.setContentSize(NSSize(width: 1_120, height: 760)) - window.minSize = NSSize(width: 960, height: 640) + window.setContentSize(NSSize(width: 1_320, height: 900)) + window.contentMinSize = NSSize(width: 960, height: 640) window.isRestorable = false window.center() diff --git a/apps/toolbox/Sources/Toolbox/ToolboxCoordinator.swift b/apps/toolbox/Sources/Toolbox/ToolboxCoordinator.swift index c4d5e86..080c87d 100644 --- a/apps/toolbox/Sources/Toolbox/ToolboxCoordinator.swift +++ b/apps/toolbox/Sources/Toolbox/ToolboxCoordinator.swift @@ -1,8 +1,10 @@ import Foundation import ToolboxCore +import ToolboxMonitoring enum ToolboxRoute: Equatable { case section(ToolboxSection) + case inspectProcess(ProcessIdentity) case reviewStorage(path: String) var storagePath: String? { @@ -15,6 +17,7 @@ enum ToolboxRoute: Equatable { final class ToolboxCoordinator: ObservableObject { @Published var selectedSection: ToolboxSection @Published private(set) var storageFocusPath: String? + @Published private(set) var processFocus: ProcessIdentity? @Published private(set) var recoverableBytes: Int64 = 0 @Published private(set) var attentionCount = 0 @Published private(set) var reducedCoverage = false @@ -39,12 +42,17 @@ final class ToolboxCoordinator: ObservableObject { func open(_ route: ToolboxRoute) { switch route { case .section(let section): - selectedSection = section + if selectedSection != section { selectedSection = section } + case .inspectProcess(let identity): + if processFocus != identity { processFocus = identity } + if selectedSection != .processes { selectedSection = .processes } case .reviewStorage: - storageFocusPath = route.storagePath - selectedSection = .storage + if storageFocusPath != route.storagePath { storageFocusPath = route.storagePath } + if selectedSection != .storage { selectedSection = .storage } + } + if defaults.string(forKey: "toolbox.selectedSection") != selectedSection.rawValue { + defaults.set(selectedSection.rawValue, forKey: "toolbox.selectedSection") } - defaults.set(selectedSection.rawValue, forKey: "toolbox.selectedSection") } func refreshSummaries() { diff --git a/apps/toolbox/Sources/Toolbox/ToolboxShellView.swift b/apps/toolbox/Sources/Toolbox/ToolboxShellView.swift index 3b57231..1506080 100644 --- a/apps/toolbox/Sources/Toolbox/ToolboxShellView.swift +++ b/apps/toolbox/Sources/Toolbox/ToolboxShellView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import ToolboxChanges import ToolboxCore @@ -5,20 +6,34 @@ import ToolboxStorage enum ToolboxSection: String, CaseIterable, Identifiable { case home + case processes + case cpuMemory + case diskNetwork + case networkTools + case batteryThermal + case hardware case storage case projects case applications case changes + case healthHistory case recovery var id: String { rawValue } var title: String { switch self { - case .home: L10n.text("Home") + case .home: L10n.text("Overview") + case .processes: L10n.text("Processes") + case .cpuMemory: L10n.text("CPU & Memory") + case .networkTools: L10n.text("Wi-Fi & LAN") + case .diskNetwork: L10n.text("Disk & Network") + case .hardware: L10n.text("Hardware Inspector") + case .batteryThermal: L10n.text("Battery & Thermal") case .storage: L10n.text("Storage") case .projects: L10n.text("Projects") case .applications: L10n.text("Applications") + case .healthHistory: L10n.text("Health History") case .changes: L10n.text("Change Timeline") case .recovery: L10n.text("Recovery") } @@ -27,9 +42,16 @@ enum ToolboxSection: String, CaseIterable, Identifiable { var symbol: String { switch self { case .home: "house" + case .processes: "list.bullet.rectangle" + case .cpuMemory: "cpu" + case .networkTools: "wifi" + case .diskNetwork: "network" + case .hardware: "desktopcomputer" + case .batteryThermal: "battery.100" case .storage: "internaldrive" case .projects: "hammer" case .applications: "app.dashed" + case .healthHistory: "chart.xyaxis.line" case .changes: "arrow.trianglehead.2.clockwise.rotate.90" case .recovery: "clock.arrow.circlepath" } @@ -40,8 +62,9 @@ struct ToolboxShellView: View { @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage .rawValue @AppStorage("toolbox.onboarding.completed.v2") private var onboardingCompleted = false - @StateObject private var coordinator = ToolboxCoordinator() + @ObservedObject private var coordinator = ToolboxRuntime.shared.navigation @State private var showsOnboarding = false + @State private var showsHelp = false private var selection: Binding { Binding( @@ -52,31 +75,89 @@ struct ToolboxShellView: View { var body: some View { NavigationSplitView { - List(ToolboxSection.allCases, selection: selection) { section in - Label(section.title, systemImage: section.symbol) - .tag(section) - } - .listStyle(.sidebar) - .navigationTitle("Toolbox") - .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260) - .safeAreaInset(edge: .bottom) { - languagePicker - .padding(.horizontal, 12) - .padding(.vertical, 8) + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(nsImage: NSImage(named: NSImage.applicationIconName) ?? NSImage()) + .resizable().scaledToFit().frame(width: 46, height: 46).accessibilityHidden(true) + Text(AppMetadata.displayName).font(.system(size: 27, weight: .bold)) + Spacer(minLength: 0) + }.padding(.horizontal, 20).padding(.top, 16).padding(.bottom, 20) + List(selection: selection) { + navigationRow(.home) + Section(L10n.text("Monitoring")) { + ForEach([ + ToolboxSection.processes, .cpuMemory, .diskNetwork, .networkTools, .batteryThermal, + .hardware, + ]) { + navigationRow($0) + } + } + Section(L10n.text("Management")) { + ForEach([ToolboxSection.storage, .projects, .applications]) { navigationRow($0) } + } + Section(L10n.text("History")) { + ForEach([ToolboxSection.healthHistory, .changes, .recovery]) { navigationRow($0) } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 40) + VStack(alignment: .leading, spacing: 12) { + Divider() + if #available(macOS 14.0, *) { + SettingsLink { Label(L10n.text("Settings"), systemImage: "gearshape") } + } else { + Button { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } label: { + Label(L10n.text("Settings"), systemImage: "gearshape") + } + } + Button { + showsHelp = true + } label: { + Label(L10n.text("About Orven"), systemImage: "questionmark.circle") + } + languagePicker + }.buttonStyle(.plain).font(.body).padding(20) } + .foregroundStyle(OrvenTheme.ink) + .background(OrvenTheme.sidebar) + .navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 280) } detail: { - detailView - .navigationTitle(coordinator.selectedSection.title) + GeometryReader { geometry in + detailView.frame(width: geometry.size.width, height: geometry.size.height) + } + .background(OrvenTheme.canvas) + .navigationTitle(coordinator.selectedSection.title) } .environment( \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale ) + .tint(OrvenTheme.action) + .background(MainWindowMarker()) .frame(minWidth: 960, minHeight: 640) .task { if !onboardingCompleted { showsOnboarding = true } coordinator.refreshSummaries() } + .sheet(isPresented: $showsHelp) { + VStack(alignment: .leading, spacing: 18) { + if let url = Bundle.main.url(forResource: "OrvenLogo", withExtension: "png"), + let logo = NSImage(contentsOf: url) + { + Image(nsImage: logo).resizable().scaledToFit().frame(width: 260, height: 88) + .accessibilityLabel(AppMetadata.displayName) + .background(Color.white, in: RoundedRectangle(cornerRadius: 8)) + } + Text(L10n.text(AppMetadata.tagline)).font(.title2) + Text(L10n.text("Read-only. Recent trends stay on this Mac and are not saved.")) + Text(L10n.text("Runs locally. No telemetry. Nothing is deleted automatically.")) + .foregroundStyle(.secondary) + Button(L10n.text("Close")) { showsHelp = false }.keyboardShortcut(.cancelAction) + }.padding(28).frame(width: 430).background(OrvenTheme.surface) + } .sheet(isPresented: $showsOnboarding) { OnboardingView { onboardingCompleted = true @@ -91,21 +172,51 @@ struct ToolboxShellView: View { switch coordinator.selectedSection { case .home: HomeView(coordinator: coordinator) + case .processes: + ProcessesView(model: ToolboxRuntime.shared.monitoring, focus: coordinator.processFocus) + case .cpuMemory: + CPUMemoryView(model: ToolboxRuntime.shared.monitoring) + case .networkTools: + NetworkToolsView(monitoring: ToolboxRuntime.shared.monitoring) + case .diskNetwork: + DiskNetworkView(model: ToolboxRuntime.shared.monitoring) + case .batteryThermal: + BatteryThermalView(model: ToolboxRuntime.shared.monitoring) + case .hardware: + HardwareInspectorView(model: ToolboxRuntime.shared.monitoring) case .storage: StorageModuleView(destination: .storage, focusPath: coordinator.storageFocusPath) + .orvenSurface(padding: 0).padding(20) case .projects: - StorageModuleView(destination: .projects) + StorageModuleView(destination: .projects).orvenSurface(padding: 0).padding(20) case .applications: - StorageModuleView(destination: .applications) + StorageModuleView( + destination: .applications, + applicationActivity: { url in + AnyView( + AppActivityTimelineView(applicationURL: url, model: ToolboxRuntime.shared.monitoring)) + } + ).orvenSurface(padding: 0).padding(20) + case .healthHistory: + HealthHistoryView( + model: ToolboxRuntime.shared.monitoring.health, + alerts: ToolboxRuntime.shared.monitoring.alerts) case .changes: ChangeTimelineModuleView { path in coordinator.open(.reviewStorage(path: path)) - } + }.orvenSurface(padding: 0).padding(20) case .recovery: - StorageModuleView(destination: .recovery) + StorageModuleView(destination: .recovery).orvenSurface(padding: 0).padding(20) } } + private func navigationRow(_ section: ToolboxSection) -> some View { + Label(section.title, systemImage: section.symbol).font(.system(size: 16)) + .padding(.vertical, 5) + .foregroundStyle(coordinator.selectedSection == section ? Color.white : OrvenTheme.ink) + .tag(section) + } + private var languagePicker: some View { Picker(L10n.text("Language"), selection: $languageCode) { ForEach(AppLanguage.allCases) { language in @@ -114,5 +225,6 @@ struct ToolboxShellView: View { } .labelsHidden() .pickerStyle(.menu) + .tint(OrvenTheme.accent) } } diff --git a/apps/toolbox/Sources/ToolboxChanges/Features/Snapshots/ChangeoraViewModel.swift b/apps/toolbox/Sources/ToolboxChanges/Features/Snapshots/ChangeoraViewModel.swift index 9ccc943..22e7cb8 100644 --- a/apps/toolbox/Sources/ToolboxChanges/Features/Snapshots/ChangeoraViewModel.swift +++ b/apps/toolbox/Sources/ToolboxChanges/Features/Snapshots/ChangeoraViewModel.swift @@ -176,7 +176,7 @@ final class ChangeoraViewModel: ObservableObject { func markdownReport(for session: WatchSession) -> String { let comparison = session.comparison var lines = [ - "# Toolbox Change Timeline report", + "# Orven Change Timeline report", "", "- Phiên: \(session.title)", "- Bắt đầu: \(session.startedAt.formatted(date: .numeric, time: .standard))", @@ -195,7 +195,7 @@ final class ChangeoraViewModel: ObservableObject { "| \(change.risk.title) | \(change.kind.rawValue) | \(item.category.rawValue) | \(escape(item.name)) | `\(escape(redact(item.path)))` | \(escape(change.attributedApplication ?? item.ownerHint ?? "—")) | \(escape(change.riskReason ?? "—")) |" } lines.append("") - lines.append("Generated locally by Toolbox \(AppMetadata.version).") + lines.append("Generated locally by Orven \(AppMetadata.version).") return lines.joined(separator: "\n") } diff --git a/apps/toolbox/Sources/ToolboxChanges/Features/Trace/InstallerMetadata.swift b/apps/toolbox/Sources/ToolboxChanges/Features/Trace/InstallerMetadata.swift index bb467ed..2984181 100644 --- a/apps/toolbox/Sources/ToolboxChanges/Features/Trace/InstallerMetadata.swift +++ b/apps/toolbox/Sources/ToolboxChanges/Features/Trace/InstallerMetadata.swift @@ -34,7 +34,7 @@ enum InstallTraceError: LocalizedError, Equatable { var errorDescription: String? { switch self { case .unsupportedType(let path): - "Toolbox chỉ nhận .dmg, .pkg hoặc .app: \(path)" + "Orven chỉ nhận .dmg, .pkg hoặc .app: \(path)" case .traceAlreadyActive: "Một Install Trace đang hoạt động." case .noActiveTrace: diff --git a/apps/toolbox/Sources/ToolboxChanges/Views/ContentView.swift b/apps/toolbox/Sources/ToolboxChanges/Views/ContentView.swift index 1b8e22e..b579652 100644 --- a/apps/toolbox/Sources/ToolboxChanges/Views/ContentView.swift +++ b/apps/toolbox/Sources/ToolboxChanges/Views/ContentView.swift @@ -58,7 +58,7 @@ public struct ChangeTimelineModuleView: View { } } .alert( - "Toolbox", + AppMetadata.displayName, isPresented: Binding( get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } } diff --git a/apps/toolbox/Sources/ToolboxChanges/Views/CoverageView.swift b/apps/toolbox/Sources/ToolboxChanges/Views/CoverageView.swift index cbec6c8..dff22d8 100644 --- a/apps/toolbox/Sources/ToolboxChanges/Views/CoverageView.swift +++ b/apps/toolbox/Sources/ToolboxChanges/Views/CoverageView.swift @@ -14,7 +14,7 @@ struct CoverageView: View { VStack(spacing: 0) { PageHeader( title: "Phạm vi & quyền riêng tư", - subtitle: "Minh bạch về dữ liệu Toolbox đọc, lưu và không thể quan sát.", + subtitle: "Minh bạch về dữ liệu Orven đọc, lưu và không thể quan sát.", symbol: "hand.raised", value: "Local", valueLabel: "không telemetry" @@ -64,7 +64,7 @@ struct CoverageView: View { "Một số vùng được macOS bảo vệ có thể cần Full Disk Access để quan sát đầy đủ.", systemImage: "lock") Label( - "Toolbox đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại.", + "Orven đưa ra bằng chứng kỹ thuật, không kết luận một ứng dụng là độc hại.", systemImage: "exclamationmark.bubble") } .foregroundStyle(.secondary) diff --git a/apps/toolbox/Sources/ToolboxChanges/Views/InstallTraceDropView.swift b/apps/toolbox/Sources/ToolboxChanges/Views/InstallTraceDropView.swift index c730f12..5f6f3e0 100644 --- a/apps/toolbox/Sources/ToolboxChanges/Views/InstallTraceDropView.swift +++ b/apps/toolbox/Sources/ToolboxChanges/Views/InstallTraceDropView.swift @@ -57,7 +57,7 @@ struct InstallTraceDropView: View { } } .alert( - "Toolbox", + AppMetadata.displayName, isPresented: Binding( get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } }) @@ -79,7 +79,7 @@ struct InstallTraceDropView: View { .foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary) Text("Thả .dmg, .pkg hoặc .app vào đây") .font(.title2.weight(.semibold)) - Text("Toolbox chỉ đọc loại file và đường dẫn; installer chỉ mở sau khi baseline đã lưu.") + Text("Orven chỉ đọc loại file và đường dẫn; installer chỉ mở sau khi baseline đã lưu.") .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button("Chọn installer…") { chooseInstaller() } @@ -163,7 +163,7 @@ struct InstallTraceDropView: View { } if coordinator.reducedCoverage { Label( - "Coverage giảm vì Toolbox không chạy trong một phần của phiên.", + "Coverage giảm vì Orven không chạy trong một phần của phiên.", systemImage: "exclamationmark.triangle" ) .foregroundStyle(.orange) @@ -230,7 +230,7 @@ struct InstallTraceDropView: View { private var privacyCard: some View { Label( - "Snapshot và FSEvents chỉ lưu metadata cục bộ. Toolbox không mount, execute hoặc bypass Gatekeeper.", + "Snapshot và FSEvents chỉ lưu metadata cục bộ. Orven không mount, execute hoặc bypass Gatekeeper.", systemImage: "lock.shield" ) .foregroundStyle(.secondary) diff --git a/apps/toolbox/Sources/ToolboxChanges/Views/SessionHistoryView.swift b/apps/toolbox/Sources/ToolboxChanges/Views/SessionHistoryView.swift index ed1c807..76eba22 100644 --- a/apps/toolbox/Sources/ToolboxChanges/Views/SessionHistoryView.swift +++ b/apps/toolbox/Sources/ToolboxChanges/Views/SessionHistoryView.swift @@ -135,7 +135,7 @@ private struct SessionDetailView: View { let panel = NSSavePanel() panel.allowedContentTypes = format == "json" ? [.json] : [.plainText] panel.nameFieldStringValue = - "Toolbox-Change-Timeline-\(session.finishedAt.formatted(.iso8601.year().month().day())).\(format)" + "Orven-Change-Timeline-\(session.finishedAt.formatted(.iso8601.year().month().day())).\(format)" guard panel.runModal() == .OK, let url = panel.url else { return } do { let report = diff --git a/apps/toolbox/Sources/ToolboxChanges/Views/Shared/SharedViews.swift b/apps/toolbox/Sources/ToolboxChanges/Views/Shared/SharedViews.swift index 49363b0..b255580 100644 --- a/apps/toolbox/Sources/ToolboxChanges/Views/Shared/SharedViews.swift +++ b/apps/toolbox/Sources/ToolboxChanges/Views/Shared/SharedViews.swift @@ -2,6 +2,8 @@ import SwiftUI import ToolboxCore struct PageHeader: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue let title: String let subtitle: String let symbol: String @@ -29,6 +31,8 @@ struct PageHeader: View { } } .padding(24) + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) } } @@ -75,6 +79,8 @@ struct RiskBadge: View { } struct EmptyStateView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue let title: String let symbol: String let detail: String @@ -90,6 +96,8 @@ struct EmptyStateView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(30) + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) } } diff --git a/apps/toolbox/Sources/ToolboxChanges/Views/WatchInstallationView.swift b/apps/toolbox/Sources/ToolboxChanges/Views/WatchInstallationView.swift index 5a2a5ca..14835ab 100644 --- a/apps/toolbox/Sources/ToolboxChanges/Views/WatchInstallationView.swift +++ b/apps/toolbox/Sources/ToolboxChanges/Views/WatchInstallationView.swift @@ -51,7 +51,7 @@ struct WatchInstallationView: View { Label("Bắt đầu một phiên thay đổi", systemImage: "camera.metering.matrix") .font(.title2).fontWeight(.semibold) Text( - "Toolbox sẽ lưu snapshot và bật FSEvents. Sau đó bạn cài, cập nhật hoặc gỡ ứng dụng như bình thường rồi quay lại để so sánh." + "Orven sẽ lưu snapshot và bật FSEvents. Sau đó bạn cài, cập nhật hoặc gỡ ứng dụng như bình thường rồi quay lại để so sánh." ) .foregroundStyle(.secondary) TextField("Tên phiên", text: $sessionTitle) @@ -129,7 +129,7 @@ struct WatchInstallationView: View { private var privacyNote: some View { Label { Text( - "Toolbox chỉ đọc metadata cục bộ, không đọc nội dung tài liệu, không telemetry và không tự động xóa hoặc hoàn tác thay đổi." + "Orven chỉ đọc metadata cục bộ, không đọc nội dung tài liệu, không telemetry và không tự động xóa hoặc hoàn tác thay đổi." ) } icon: { Image(systemName: "hand.raised.fill") diff --git a/apps/toolbox/Sources/ToolboxCore/APFSSpaceInspection.swift b/apps/toolbox/Sources/ToolboxCore/APFSSpaceInspection.swift new file mode 100644 index 0000000..142d9d0 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxCore/APFSSpaceInspection.swift @@ -0,0 +1,124 @@ +import CoreFoundation +import Foundation + +public enum APFSStartupVolume: String, CaseIterable, Identifiable, Sendable { + case data, system + public var id: String { rawValue } +} + +public struct APFSSnapshotEntry: Identifiable, Sendable { + public let id: String + public let name: String + public let purgeable: Bool? +} + +public struct APFSVolumeUsage: Identifiable, Sendable { + public let id: String + public let name: String + public let roles: [String] + public let bytes: Int64? +} + +public struct APFSSpaceInspection: Sendable { + public let observedAt: Date + public let containerTotal: Int64? + public let containerFree: Int64? + public let volumeUsed: Int64? + public let snapshots: [APFSSnapshotEntry] + public let omitted: Int + public let volumes: [APFSVolumeUsage] + public let containerRemainder: Int64? + public let omittedVolumes: Int + + public static func read(volume: APFSStartupVolume = .data) throws -> APFSSpaceInspection { + let info = try (volume == .data ? SystemInspectionRead.dataVolumeInfo : .systemVolumeInfo) + .read() + let snapshots = try + (volume == .data ? SystemInspectionRead.dataVolumeSnapshots : .systemVolumeSnapshots).read() + let containers = try SystemInspectionRead.apfsContainers.read() + return try parse(info: info, snapshots: snapshots, containers: containers) + } + + public static func parse(info: Data, snapshots: Data, containers: Data? = nil) throws + -> APFSSpaceInspection + { + guard + let volume = try PropertyListSerialization.propertyList(from: info, format: nil) + as? [String: Any], + let snapshotData = try PropertyListSerialization.propertyList(from: snapshots, format: nil) + as? [String: Any], let entries = snapshotData["Snapshots"] as? [[String: Any]] + else { throw CocoaError(.propertyListReadCorrupt) } + func numberBytes(_ value: Any?) -> Int64? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + let value = Int64(number.stringValue), value >= 0 + else { return nil } + return value + } + var container: [String: Any]? + if let containers { + guard + let document = try PropertyListSerialization.propertyList(from: containers, format: nil) + as? [String: Any], let rows = document["Containers"] as? [[String: Any]] + else { + throw CocoaError(.propertyListReadCorrupt) + } + if let reference = volume["APFSContainerReference"] as? String { + container = rows.first { $0["ContainerReference"] as? String == reference } + } + } + let total = + numberBytes(container?["CapacityCeiling"]) ?? numberBytes(volume["APFSContainerSize"]) + let free = numberBytes(container?["CapacityFree"]) ?? numberBytes(volume["APFSContainerFree"]) + let allVolumes = container?["Volumes"] as? [[String: Any]] + var omittedVolumes = max((allVolumes?.count ?? 0) - 64, 0) + let volumes = (allVolumes ?? []).prefix(64).enumerated().compactMap { + index, item -> APFSVolumeUsage? in + guard let name = item["Name"] as? String, name.count <= 256 else { + omittedVolumes += 1 + return nil + } + let id = item["DeviceIdentifier"] as? String ?? String(index) + return APFSVolumeUsage( + id: id + ":" + String(index), name: name, + roles: Array((item["Roles"] as? [String] ?? []).prefix(8)), + bytes: numberBytes(item["CapacityInUse"])) + } + var remainder: Int64? + if allVolumes != nil, !volumes.isEmpty, omittedVolumes == 0, + volumes.allSatisfy({ $0.bytes != nil }), let total, let free, free <= total + { + var used: Int64 = 0 + var overflow = false + for item in volumes { + let sum = used.addingReportingOverflow(item.bytes ?? 0) + used = sum.partialValue + overflow = overflow || sum.overflow + } + if !overflow, used <= total - free { remainder = total - free - used } + } + let validFree = total.flatMap { total in free.flatMap { $0 <= total ? $0 : nil } } + var omitted = max(entries.count - 256, 0) + let rows = entries.prefix(256).enumerated().compactMap { index, item -> APFSSnapshotEntry? in + guard let name = (item["SnapshotName"] ?? item["Name"]) as? String, !name.isEmpty, + name.count <= 2048 + else { + omitted += 1 + return nil + } + let uuid = item["SnapshotUUID"] as? String ?? item["UUID"] as? String + let purgeable: Bool? + if let number = item["Purgeable"] as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { + purgeable = number.boolValue + } else { + purgeable = nil + } + return APFSSnapshotEntry(id: "\(uuid ?? name):\(index)", name: name, purgeable: purgeable) + } + return APFSSpaceInspection( + observedAt: Date(), containerTotal: total, + containerFree: validFree, volumeUsed: numberBytes(volume["CapacityInUse"]), snapshots: rows, + omitted: omitted, volumes: volumes, containerRemainder: remainder, + omittedVolumes: omittedVolumes) + } +} diff --git a/apps/toolbox/Sources/ToolboxCore/AppMetadata.swift b/apps/toolbox/Sources/ToolboxCore/AppMetadata.swift index 6ab57c2..96cbb43 100644 --- a/apps/toolbox/Sources/ToolboxCore/AppMetadata.swift +++ b/apps/toolbox/Sources/ToolboxCore/AppMetadata.swift @@ -1,16 +1,20 @@ import Foundation public enum AppMetadata { + // Legacy storage namespace remains stable across display-name changes. public static let name = "Toolbox" - public static let tagline = "See what changed. Reclaim space safely." + public static var displayName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "Orven" + } + public static let tagline = "Understand your Mac. Act with context." public static let bundleIdentifier = "com.thang.toolbox" public static var version: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "2.0.0" + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "3.0.0" } public static var build: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "300" } public static func applicationSupportDirectory(base: URL? = nil) -> URL { diff --git a/apps/toolbox/Sources/ToolboxCore/Localization.swift b/apps/toolbox/Sources/ToolboxCore/Localization.swift index 6ae05c3..7785924 100644 --- a/apps/toolbox/Sources/ToolboxCore/Localization.swift +++ b/apps/toolbox/Sources/ToolboxCore/Localization.swift @@ -3,6 +3,8 @@ import Foundation public enum AppLanguage: String, CaseIterable, Identifiable, Sendable { case english = "en" case vietnamese = "vi" + case japanese = "ja" + case simplifiedChinese = "zh-Hans" public static let storageKey = "toolbox.appLanguage" public static let defaultLanguage = AppLanguage.english @@ -13,23 +15,32 @@ public enum AppLanguage: String, CaseIterable, Identifiable, Sendable { switch self { case .english: "English" case .vietnamese: "Tiếng Việt" + case .japanese: "日本語" + case .simplifiedChinese: "简体中文" } } } public enum L10n { + private static let languageBundles: [AppLanguage: Bundle] = Dictionary( + uniqueKeysWithValues: AppLanguage.allCases.compactMap { language in + guard let path = Bundle.main.path(forResource: language.rawValue, ofType: "lproj"), + let bundle = Bundle(path: path) + else { return nil } + return (language, bundle) + }) private static let phraseKeys = [ "Application bundle thay đổi; kiểm tra version, Team ID và chữ ký.", "Kernel extension tác động ở mức hệ thống và cần được xem xét cẩn thận.", "LaunchDaemon mới có thể chạy nền với quyền hệ thống/root.", "Thành phần persistence có thể tự chạy khi đăng nhập hoặc trong nền.", "Thành phần đặc quyền hoặc system extension có thể chạy ngoài tiến trình ứng dụng.", - "Toolbox chỉ nhận .dmg, .pkg hoặc .app", "Install Trace đã lưu", + "Orven chỉ nhận .dmg, .pkg hoặc .app", "Install Trace đã lưu", "Không thể", "Đã phát hiện", "Đã chuyển", "Đã khôi phục", "Đã quét xong", "Đã quét", "Đã xử lý", "Đã bật", "Đã tắt", "Đã chọn", "Đã lưu", "Đã hủy", "Đang chụp", "Đang phân tích", "Đang chạy", "Không phát hiện", "Tìm thấy", "Có thể giải phóng", "Có thể xem xét", "mục vào Trash", "tệp vào Trash", "vào Trash", "thay đổi", "phiên", - "tệp", "mục", "nhóm", "ảnh", "giờ", "đường dẫn", "không có", "thất bại", "hoàn tất", + "ứng dụng", "tệp", "mục", "nhóm", "ảnh", "giờ", "đường dẫn", "không có", "thất bại", "hoàn tất", "đường dẫn không an toàn", "vị trí gốc đã có dữ liệu", "dự án", "không còn trong Trash", "nội dung đã thay đổi, thao tác bị hủy", ].sorted { $0.count > $1.count } @@ -40,16 +51,17 @@ public enum L10n { rawValue: UserDefaults.standard.string(forKey: AppLanguage.storageKey) ?? AppLanguage.defaultLanguage.rawValue) ?? AppLanguage.defaultLanguage - guard let path = Bundle.main.path(forResource: language.rawValue, ofType: "lproj"), - let bundle = Bundle(path: path) - else { return source } + guard let bundle = languageBundles[language] else { return source } + // An English translation may equal its key. A distinct missing value avoids + // treating valid translations as misses and running the phrase fallback. + let missing = "\u{001F}toolbox-missing-localization\u{001F}" for table in ["Localizable", "Storage", "Changes"] { - let localized = bundle.localizedString(forKey: source, value: source, table: table) - if localized != source { return localized } + let localized = bundle.localizedString(forKey: source, value: missing, table: table) + if localized != missing { return localized } } - guard language == .english else { return source } + guard language != .vietnamese else { return source } return phraseKeys.reduce(source) { result, phrase in for table in ["Storage", "Changes"] { let translation = bundle.localizedString(forKey: phrase, value: phrase, table: table) diff --git a/apps/toolbox/Sources/ToolboxCore/OrvenTheme.swift b/apps/toolbox/Sources/ToolboxCore/OrvenTheme.swift new file mode 100644 index 0000000..4e0c7ba --- /dev/null +++ b/apps/toolbox/Sources/ToolboxCore/OrvenTheme.swift @@ -0,0 +1,56 @@ +import AppKit +import SwiftUI + +/// Shared native appearance for monitoring and legacy management workspaces. +public enum OrvenTheme { + public static let canvas: Color = adaptive(light: 0xF5F7FA, dark: 0x14283B) + public static let surface: Color = adaptive(light: 0xFFFFFF, dark: 0x1C3247) + public static let sidebar: Color = adaptive(light: 0xF0F4F9, dark: 0x172C40) + public static let accent: Color = adaptive(light: 0x2464DF, dark: 0x73A8FF) + public static var action: Color { Color(red: 36.0 / 255, green: 100.0 / 255, blue: 223.0 / 255) } + public static let ink: Color = adaptive(light: 0x17263C, dark: 0xE5EDF7) + public static let secondary: Color = adaptive(light: 0x586D8A, dark: 0xB0C0D6) + public static let border: Color = adaptive(light: 0xE2E8F1, dark: 0x375069) + + private static func adaptive(light: UInt32, dark: UInt32) -> Color { + Color( + nsColor: NSColor(name: nil) { appearance in + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light + return NSColor( + calibratedRed: Double((hex >> 16) & 255) / 255, + green: Double((hex >> 8) & 255) / 255, + blue: Double(hex & 255) / 255, alpha: 1) + }) + } +} + +extension View { + public func orvenSurface(padding: CGFloat = 20) -> some View { + self.padding(padding) + .background(OrvenTheme.surface, in: RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16).stroke(OrvenTheme.border, lineWidth: 1) + .allowsHitTesting(false) + } + } +} + +public enum AppAppearance: String, CaseIterable, Identifiable { + case system, light, dark + public static let storageKey = "toolbox.appearance" + public var id: String { rawValue } + public var scheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } + public var title: String { + switch self { + case .system: L10n.text("System") + case .light: L10n.text("Light") + case .dark: L10n.text("Dark") + } + } +} diff --git a/apps/toolbox/Sources/ToolboxCore/SystemInspectionRead.swift b/apps/toolbox/Sources/ToolboxCore/SystemInspectionRead.swift new file mode 100644 index 0000000..5dbcbcf --- /dev/null +++ b/apps/toolbox/Sources/ToolboxCore/SystemInspectionRead.swift @@ -0,0 +1,82 @@ +import Darwin +import Foundation + +/// Fixed read-only OS helpers. No shell or caller-supplied executable/arguments. +public enum SystemInspectionRead: Sendable { + case backgroundItems, networkSockets, dataVolumeInfo, dataVolumeSnapshots, systemVolumeInfo, + systemVolumeSnapshots, apfsContainers + + public func read() throws -> Data { + let process = Process() + let pipe = Pipe() + switch self { + case .backgroundItems: + process.executableURL = URL(fileURLWithPath: "/usr/bin/sfltool") + process.arguments = ["dumpbtm"] + case .networkSockets: + process.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") + process.arguments = ["-nP", "-i", "-FpcfPntT"] + case .dataVolumeInfo: + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["info", "-plist", "/System/Volumes/Data"] + case .dataVolumeSnapshots: + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["apfs", "listSnapshots", "/System/Volumes/Data", "-plist"] + case .apfsContainers: + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["apfs", "list", "-plist"] + case .systemVolumeInfo: + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["info", "-plist", "/"] + case .systemVolumeSnapshots: + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["apfs", "listSnapshots", "/", "-plist"] + } + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + defer { + if process.isRunning { + process.terminate() + // Only this owned helper is eligible for escalation; never an inspected process. + let end = ProcessInfo.processInfo.systemUptime + 0.25 + while process.isRunning && ProcessInfo.processInfo.systemUptime < end { + Thread.sleep(forTimeInterval: 0.01) + } + if process.isRunning { _ = kill(process.processIdentifier, SIGKILL) } + } + try? pipe.fileHandleForReading.close() + try? pipe.fileHandleForWriting.close() + } + try process.run() + try pipe.fileHandleForWriting.close() + let descriptor = pipe.fileHandleForReading.fileDescriptor + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + throw CocoaError(.fileReadUnknown) + } + let deadline = ProcessInfo.processInfo.systemUptime + 10 + var data = Data() + var buffer = [UInt8](repeating: 0, count: 65_536) + while ProcessInfo.processInfo.systemUptime < deadline { + try Task.checkCancellation() + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, $0.count) + } + if count > 0 { + guard data.count + count <= 8_000_000 else { throw CocoaError(.fileReadTooLarge) } + data.append(contentsOf: buffer.prefix(count)) + } else if !process.isRunning { + guard + process.terminationStatus == 0 + || (self == .networkSockets && process.terminationStatus == 1 && data.isEmpty) + else { throw CocoaError(.fileReadUnknown) } + return data + } else if count < 0 && errno != EAGAIN && errno != EINTR { + throw CocoaError(.fileReadUnknown) + } else { + Thread.sleep(forTimeInterval: 0.02) + } + } + throw CocoaError(.fileReadUnknown) + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/HardwareModels.swift b/apps/toolbox/Sources/ToolboxMonitoring/HardwareModels.swift new file mode 100644 index 0000000..008ebda --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/HardwareModels.swift @@ -0,0 +1,174 @@ +import Foundation + +public struct IOCounter: Sendable, Equatable { + public let id: String + public let name: String + public let inputBytes: UInt64 + public let outputBytes: UInt64 + public let includeInAggregate: Bool + public init( + id: String, name: String, inputBytes: UInt64, outputBytes: UInt64, + includeInAggregate: Bool = true + ) { + self.id = id + self.name = name + self.inputBytes = inputBytes + self.outputBytes = outputBytes + self.includeInAggregate = includeInAggregate + } +} + +public struct IORate: Sendable, Identifiable { + public let id: String + public let name: String + public let input: MetricSample + public let output: MetricSample + public let includeInAggregate: Bool +} + +public struct CounterRateTracker: Sendable { + private var previous: [String: (counter: IOCounter, time: Double)] = [:] + public init() {} + public mutating func reset() { previous.removeAll() } + public mutating func update(_ sample: MetricSample<[IOCounter]>) -> MetricSample<[IORate]> { + guard sample.availability == .available, let counters = sample.value else { + reset() + return MetricSample( + availability: sample.availability, unit: .bytesPerSecond, time: sample.time, + source: sample.source, reason: sample.reason) + } + guard Set(counters.map(\.id)).count == counters.count else { + reset() + return MetricSample( + availability: .error, unit: .bytesPerSecond, time: sample.time, source: sample.source, + reason: "Duplicate device identity") + } + var next: [String: (counter: IOCounter, time: Double)] = [:] + let rates = counters.map { counter in + var input: Double? + var output: Double? + if let before = previous[counter.id] { + let elapsed = sample.time.monotonic - before.time + input = RateCalculator.rate( + previous: before.counter.inputBytes, current: counter.inputBytes, elapsed: elapsed) + output = RateCalculator.rate( + previous: before.counter.outputBytes, current: counter.outputBytes, elapsed: elapsed) + if input == nil || output == nil { + input = nil + output = nil + } + } + next[counter.id] = (counter, sample.time.monotonic) + return IORate( + id: counter.id, name: counter.name, + input: MetricSample( + value: input, availability: input == nil ? .loading : .available, unit: .bytesPerSecond, + time: sample.time, source: sample.source), + output: MetricSample( + value: output, availability: output == nil ? .loading : .available, unit: .bytesPerSecond, + time: sample.time, source: sample.source), + includeInAggregate: counter.includeInAggregate) + } + previous = next + return MetricSample( + value: rates, availability: .available, unit: .bytesPerSecond, time: sample.time, + source: sample.source, reason: sample.reason) + } +} + +public struct DiskCapacity: Sendable, Equatable { + public let totalBytes: UInt64 + public let availableBytes: UInt64 + public init(totalBytes: UInt64, availableBytes: UInt64) { + self.totalBytes = totalBytes + self.availableBytes = availableBytes + } +} + +public struct BatteryInfo: Sendable, Equatable { + public let percent: Double + public let charging: Bool + public let onBattery: Bool + public init?(current: Double, maximum: Double, charging: Bool, onBattery: Bool) { + guard current.isFinite, maximum.isFinite, maximum > 0, current >= 0, current <= maximum else { + return nil + } + percent = current / maximum * 100 + self.charging = charging + self.onBattery = onBattery + } +} + +public enum ThermalLevel: String, Sendable { case nominal, fair, serious, critical, unknown } +public enum MemoryPressureLevel: String, Sendable { case normal, warning, critical } + +public struct HardwareReading: Sendable { + public let disks: MetricSample<[IOCounter]> + public let interfaces: MetricSample<[IOCounter]> + public let capacity: MetricSample + public let battery: MetricSample + public let thermal: MetricSample + public let swap: MetricSample + public let pressure: MetricSample + public let physicalSensors: MetricSample + + public init( + disks: MetricSample<[IOCounter]>, interfaces: MetricSample<[IOCounter]>, + capacity: MetricSample, battery: MetricSample, + thermal: MetricSample, swap: MetricSample, + pressure: MetricSample, + physicalSensors: MetricSample? = nil + ) { + self.disks = disks + self.interfaces = interfaces + self.capacity = capacity + self.battery = battery + self.thermal = thermal + self.swap = swap + self.pressure = pressure + self.physicalSensors = + physicalSensors + ?? MetricSample( + availability: .unsupported, unit: .collection, + time: thermal.time, source: "AppleSMC/read-only") + } + + public static func unsupported(at time: SampleTime) -> HardwareReading { + HardwareReading( + disks: MetricSample(availability: .unsupported, unit: .bytes, time: time, source: "IOKit"), + interfaces: MetricSample( + availability: .unsupported, unit: .bytes, time: time, source: "getifaddrs"), + capacity: MetricSample( + availability: .unsupported, unit: .bytes, time: time, source: "URLResourceValues"), + battery: MetricSample( + availability: .unsupported, unit: .percent, time: time, source: "IOPowerSources"), + thermal: MetricSample( + availability: .unsupported, unit: .state, time: time, source: "ProcessInfo"), + swap: MetricSample(availability: .unsupported, unit: .bytes, time: time, source: "sysctl"), + pressure: MetricSample( + availability: .unsupported, unit: .state, time: time, source: "DispatchSourceMemoryPressure" + )) + } +} + +public struct HardwareSnapshot: Sendable { + public let disks: MetricSample<[IORate]> + public let interfaces: MetricSample<[IORate]> + public let reading: HardwareReading + + public func aggregate(network: Bool, output: Bool) -> MetricSample { + let sample = network ? interfaces : disks + let rates = sample.value?.filter(\.includeInAggregate) ?? [] + let states = rates.map { output ? $0.output : $0.input } + let value = + !states.isEmpty && states.allSatisfy { $0.availability == .available } + ? states.reduce(0) { $0 + ($1.value ?? 0) } : nil + let availability: MetricAvailability = + sample.availability != .available + ? sample.availability + : (rates.isEmpty ? .unsupported : (value == nil ? .loading : .available)) + return MetricSample( + value: value, availability: availability, unit: .bytesPerSecond, time: sample.time, + source: sample.source, reason: sample.reason) + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/HardwareProfile.swift b/apps/toolbox/Sources/ToolboxMonitoring/HardwareProfile.swift new file mode 100644 index 0000000..3283aab --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/HardwareProfile.swift @@ -0,0 +1,102 @@ +import Darwin +import Foundation +import Metal + +public struct HardwareField: Sendable, Codable, Identifiable { + public enum Unit: String, Sendable, Codable { case text, count, bytes, hertz } + public let id: String + public let section: String + public let label: String + public let value: String? + public let unit: Unit + public let source: String +} + +public struct HardwareProfile: Sendable, Codable { + public let observedAt: Date + public let fields: [HardwareField] + + // A fixed sysctl/Metal inventory excludes serials, host names and registry dumps. + public static func read() -> HardwareProfile { + var fields: [HardwareField] = [] + func add(_ key: String, _ section: String, _ label: String, _ unit: HardwareField.Unit) { + fields.append( + HardwareField( + id: key, section: section, label: label, + value: unit == .text ? string(key) : integer(key).map(String.init), + unit: unit, source: "sysctl/" + key)) + } + add("hw.model", "System information", "Model identifier", .text) + fields.append( + HardwareField( + id: "os", section: "System information", label: "Operating system", + value: ProcessInfo.processInfo.operatingSystemVersionString, unit: .text, + source: "ProcessInfo")) + add("machdep.cpu.brand_string", "CPU", "Processor", .text) + add("hw.machine", "CPU", "Hardware architecture", .text) + add("hw.physicalcpu", "CPU", "Physical cores", .count) + add("hw.logicalcpu", "CPU", "Logical processors", .count) + add("hw.cpufrequency", "CPU", "Reported nominal frequency", .hertz) + add("hw.memsize", "Memory", "Physical memory", .bytes) + add("hw.pagesize", "Memory", "Page size", .bytes) + add("hw.cachelinesize", "Cache", "Cache line size", .bytes) + for (key, label) in [ + ("l1icachesize", "L1 instruction cache"), ("l1dcachesize", "L1 data cache"), + ("l2cachesize", "L2 cache"), ("l3cachesize", "L3 cache"), + ] { + add("hw." + key, "Cache", label, .bytes) + } + let levels = min(max(integer("hw.nperflevels") ?? 0, 0), 8) + for level in 0.. String? { + var size = 0 + guard sysctlbyname(key, nil, &size, nil, 0) == 0, size > 0, size <= 4096 else { return nil } + var buffer = [UInt8](repeating: 0, count: size) + guard sysctlbyname(key, &buffer, &size, nil, 0) == 0 else { return nil } + let value = String(decoding: buffer.prefix(size).prefix(while: { $0 != 0 }), as: UTF8.self) + return value.isEmpty ? nil : value + } + + private static func integer(_ key: String) -> Int64? { + var value: Int64 = 0 + var size = MemoryLayout.size + guard sysctlbyname(key, &value, &size, nil, 0) == 0 else { return nil } + let result: Int64 + switch size { + case 4: result = Int64(Int32(truncatingIfNeeded: value)) + case 8: result = value + default: return nil + } + return result > 0 ? result : nil + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/HardwareReport.swift b/apps/toolbox/Sources/ToolboxMonitoring/HardwareReport.swift new file mode 100644 index 0000000..14b9cf2 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/HardwareReport.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum HardwareReport { + public static func encode( + _ profile: HardwareProfile, snapshot: MonitoringSnapshot?, active: Bool, since: Double? + ) throws -> Data { + struct Report: Encodable { + let schemaVersion = 1 + let profile: HardwareProfile + let samplingActive: Bool + let latestSampleAt: Date? + let rangeResetMonotonic: Double? + let range: [SensorExtrema] + let physicalSensors: PhysicalSensorSnapshot? + let physicalSensorObservedAt: Date? + let physicalSensorAvailability: MetricAvailability + let physicalSensorRange: [String: PhysicalSensorExtrema] + let scope = + "Latest generation, at most60 seconds/60 points; missing readings excluded; slow I/O counted only when sampled. No serial, hostname, paths, process names or network endpoints. Physical sensor keys use limited community AppleSMC mappings; temperatures/RPM/power are observations, not calibrated physical core identity or safety claims. No voltage, live clocks, SPD, SMART, fan control or benchmark." + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let physical = snapshot?.hardware.reading.physicalSensors + let ranges = Dictionary( + uniqueKeysWithValues: (physical?.value?.readings ?? []).map { + ($0.key, SensorWindow.physicalExtrema(snapshot?.history ?? [], sensor: $0, since: since)) + }) + return try encoder.encode( + Report( + profile: profile, samplingActive: active, + latestSampleAt: snapshot?.time.wall, rangeResetMonotonic: since, + range: SensorWindow.extrema(snapshot?.history ?? [], since: since), + physicalSensors: physical?.value, physicalSensorObservedAt: physical?.time.wall, + physicalSensorAvailability: physical?.availability ?? .loading, physicalSensorRange: ranges) + ) + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/HealthHistory.swift b/apps/toolbox/Sources/ToolboxMonitoring/HealthHistory.swift new file mode 100644 index 0000000..d78864c --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/HealthHistory.swift @@ -0,0 +1,255 @@ +import Foundation + +public struct HealthValue: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let unit: String + public let value: Double? + public let availability: MetricAvailability + public let source: String + public let observedAt: Date + + public init( + id: String, title: String, unit: String, value: Double?, availability: MetricAvailability, + source: String, observedAt: Date + ) { + self.id = id + self.title = title + self.unit = unit + self.value = value + self.availability = availability + self.source = source + self.observedAt = observedAt + } +} + +public struct HealthRecord: Codable, Sendable, Equatable, Identifiable { + public let id: UUID + public let observedAt: Date + public let uptime: Double + public let segment: UUID + public let values: [HealthValue] + public let pressure: String? + public let thermal: String? + + public init(snapshot: MonitoringSnapshot, segment: UUID, now: Double) { + id = UUID() + observedAt = snapshot.time.wall + uptime = snapshot.time.monotonic + self.segment = segment + func field( + _ metric: MetricSample, id: String, title: String, unit: String, + age: Double, value: Double? + ) -> HealthValue { + let state = metric.freshness(at: now, maximumAge: age) + return HealthValue( + id: id, title: title, unit: unit, + value: state == .available ? value.flatMap { $0.isFinite ? $0 : nil } : nil, + availability: state, source: metric.source, observedAt: metric.time.wall) + } + var fields = [ + field( + snapshot.cpu, id: "cpu", title: "System CPU", unit: "%", age: 6, + value: snapshot.cpu.value), + field( + snapshot.memory, id: "memory", title: "Occupied memory", unit: "%", age: 6, + value: snapshot.memory.value?.occupiedPercent), + ] + let sensors = snapshot.hardware.reading.physicalSensors + if let readings = sensors.value?.readings { + for sensor in readings.prefix(34) { + let state = + sensors.freshness(at: now, maximumAge: 15) == .available + ? sensor.availability : sensors.freshness(at: now, maximumAge: 15) + fields.append( + HealthValue( + id: "smc:" + sensor.key, title: sensor.group + " • " + sensor.key, + unit: sensor.unit.rawValue, value: state == .available ? sensor.value : nil, + availability: state, source: sensors.source, observedAt: sensors.time.wall)) + } + } + values = fields + let hardware = snapshot.hardware.reading + pressure = + hardware.pressure.freshness(at: now, maximumAge: 6) == .available + ? hardware.pressure.value?.rawValue : nil + thermal = + hardware.thermal.freshness(at: now, maximumAge: 6) == .available + ? hardware.thermal.value?.rawValue : nil + } + + public init( + observedAt: Date, uptime: Double, segment: UUID, values: [HealthValue], + pressure: String? = nil, thermal: String? = nil + ) { + id = UUID() + self.observedAt = observedAt + self.uptime = uptime + self.segment = segment + self.values = values + self.pressure = pressure + self.thermal = thermal + } +} + +public struct HealthBaseline: Codable, Sendable, Equatable { + public let capturedAt: Date + public let records: [HealthRecord] +} + +public struct HealthArchive: Codable, Sendable, Equatable { + public var schemaVersion = 1 + public var records: [HealthRecord] = [] + public var omittedRecords = 0 + public var baseline: HealthBaseline? + public init() {} +} + +public struct HealthComparison: Sendable, Identifiable { + public let id: String + public let title: String + public let unit: String + public let baselineMedian: Double + public let currentMedian: Double + public let baselineCount: Int + public let currentCount: Int +} + +public enum HealthAnalysis { + public static func comparisons(_ archive: HealthArchive) -> [HealthComparison] { + guard let baseline = archive.baseline else { return [] } + let current = Array(archive.records.filter { $0.observedAt > baseline.capturedAt }.suffix(60)) + func values(_ records: [HealthRecord], key: String, unit: String) -> [Double] { + var seen: Set = [] + return records.compactMap { record in + guard + let metric = record.values.first(where: { + $0.id == key && $0.unit == unit && $0.availability == .available + }), seen.insert(metric.observedAt).inserted + else { return nil } + return metric.value.flatMap { $0.isFinite ? $0 : nil } + }.sorted() + } + func median(_ values: [Double]) -> Double { + let middle = values.count / 2 + return values.count.isMultiple(of: 2) + ? (values[middle - 1] + values[middle]) / 2 : values[middle] + } + let definitions = baseline.records.flatMap(\.values).reduce(into: [String: HealthValue]()) { + $0[$1.id] = $1 + } + return definitions.values.sorted { $0.id < $1.id }.compactMap { definition in + let a = values(baseline.records, key: definition.id, unit: definition.unit) + let b = values(current, key: definition.id, unit: definition.unit) + guard a.count >= 10, b.count >= 10 else { return nil } + return HealthComparison( + id: definition.id, title: definition.title, unit: definition.unit, + baselineMedian: median(a), currentMedian: median(b), baselineCount: a.count, + currentCount: b.count) + } + } +} + +// Bounded, serialized local storage. A corrupt/unknown archive is preserved and blocks writes. +public actor HealthHistoryStore { + public static let maximumRecords = 10_080 + private let url: URL + private var archive = HealthArchive() + private var loaded = false + private var blocked = false + public init(url: URL) { self.url = url } + + public func read(now: Date, retentionHours: Int) throws -> HealthArchive { + if blocked { throw CocoaError(.fileReadCorruptFile) } + if !loaded { + do { + if FileManager.default.fileExists(atPath: url.path) { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= 24 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) } + let decoded = try JSONDecoder().decode(HealthArchive.self, from: Data(contentsOf: url)) + guard decoded.schemaVersion == 1, decoded.records.count <= Self.maximumRecords, + decoded.omittedRecords >= 0, + (decoded.baseline?.records.count ?? 0) <= 60, + Set(decoded.records.map(\.id)).count == decoded.records.count, + zip(decoded.records, decoded.records.dropFirst()).allSatisfy({ + $0.observedAt < $1.observedAt + }), + decoded.baseline?.capturedAt.timeIntervalSince1970.isFinite ?? true, + (decoded.records + (decoded.baseline?.records ?? [])).allSatisfy(Self.valid) + else { throw CocoaError(.fileReadCorruptFile) } + archive = decoded + } + loaded = true + } catch { + blocked = true + throw error + } + } + prune(now: now, hours: retentionHours) + return archive + } + + public func append(_ record: HealthRecord, retentionHours: Int) throws -> HealthArchive { + _ = try read(now: record.observedAt, retentionHours: retentionHours) + guard Self.valid(record) else { throw CocoaError(.fileWriteInvalidFileName) } + if let last = archive.records.last { + guard record.observedAt > last.observedAt else { return archive } + if record.observedAt.timeIntervalSince(last.observedAt) < 60 { return archive } + } + archive.records.append(record) + prune(now: record.observedAt, hours: retentionHours) + try save() + return archive + } + + public func captureBaseline(now: Date, retentionHours: Int) throws -> HealthArchive { + _ = try read(now: now, retentionHours: retentionHours) + let records = Array(archive.records.suffix(60)) + guard records.count >= 30 else { throw CocoaError(.validationMissingMandatoryProperty) } + archive.baseline = HealthBaseline(capturedAt: now, records: records) + try save() + return archive + } + + public func setRetention(now: Date, hours: Int) throws -> HealthArchive { + _ = try read(now: now, retentionHours: hours) + try save() + return archive + } + + private func prune(now: Date, hours: Int) { + let cutoff = now.addingTimeInterval(-Double(hours == 24 ? 24 : 168) * 3600) + archive.records = Array( + archive.records.filter { $0.observedAt >= cutoff } + .suffix(Self.maximumRecords)) + if let baseline = archive.baseline, baseline.capturedAt < cutoff { archive.baseline = nil } + } + private func save() throws { + var data = try JSONEncoder().encode(archive) + while data.count > 24 * 1024 * 1024 && !archive.records.isEmpty { + let count = max(1, archive.records.count / 10) + archive.records.removeFirst(count) + archive.omittedRecords = min(Int.max - count, archive.omittedRecords) + count + data = try JSONEncoder().encode(archive) + } + guard data.count <= 24 * 1024 * 1024 else { throw CocoaError(.fileWriteOutOfSpace) } + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } + private static func valid(_ record: HealthRecord) -> Bool { + record.observedAt.timeIntervalSince1970.isFinite && record.uptime.isFinite + && record.uptime >= 0 && record.values.count <= 36 + && Set(record.values.map(\.id)).count == record.values.count + && (record.pressure == nil || ["normal", "warning", "critical"].contains(record.pressure!)) + && (record.thermal == nil + || ["nominal", "fair", "serious", "critical", "unknown"].contains(record.thermal!)) + && record.values.allSatisfy { + $0.id.count <= 64 && $0.title.count <= 100 && $0.source.count <= 150 + && $0.observedAt.timeIntervalSince1970.isFinite && $0.unit.count <= 10 + && ($0.value?.isFinite ?? true) + && ($0.availability == .available || $0.value == nil) + } + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/MetricModels.swift b/apps/toolbox/Sources/ToolboxMonitoring/MetricModels.swift new file mode 100644 index 0000000..f078201 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/MetricModels.swift @@ -0,0 +1,130 @@ +import Foundation + +public enum MetricAvailability: String, Sendable, CaseIterable, Codable { + case loading, available, stale, unsupported, permissionRequired, temporarilyUnavailable, error +} + +public enum MetricUnit: String, Sendable { + case percent, bytes, bytesPerSecond, state, ticks, collection +} + +public struct SampleTime: Sendable, Equatable { + public let wall: Date + public let monotonic: Double + + public init(wall: Date, monotonic: Double) { + self.wall = wall + self.monotonic = monotonic + } +} + +public struct MetricSample: Sendable { + public let value: Value? + public let availability: MetricAvailability + public let unit: MetricUnit + public let time: SampleTime + public let source: String + public let reason: String? + + public init( + value: Value? = nil, availability: MetricAvailability, unit: MetricUnit, + time: SampleTime, source: String, reason: String? = nil + ) { + self.value = availability == .available || availability == .stale ? value : nil + self.availability = + availability == .available && value == nil ? .temporarilyUnavailable : availability + self.unit = unit + self.time = time + self.source = source + self.reason = reason + } + + public func freshness(at now: Double, maximumAge: Double) -> MetricAvailability { + guard availability == .available else { return availability } + return now - time.monotonic > maximumAge || now < time.monotonic ? .stale : .available + } +} + +public struct CPUTicks: Sendable, Equatable { + public let user: UInt64 + public let system: UInt64 + public let nice: UInt64 + public let idle: UInt64 + + public init(user: UInt64, system: UInt64, nice: UInt64, idle: UInt64) { + self.user = user + self.system = system + self.nice = nice + self.idle = idle + } +} + +public struct MemoryInfo: Sendable, Equatable { + public let totalBytes: UInt64 + public let freeBytes: UInt64 + public let wiredBytes: UInt64 + public let compressedBytes: UInt64 + + // Non-free includes caches; this is not an estimate of reclaimable memory. + public var occupiedPercent: Double? { + guard totalBytes > 0, freeBytes <= totalBytes else { return nil } + return Double(totalBytes - freeBytes) / Double(totalBytes) * 100 + } + + public init(totalBytes: UInt64, freeBytes: UInt64, wiredBytes: UInt64, compressedBytes: UInt64) { + self.totalBytes = totalBytes + self.freeBytes = freeBytes + self.wiredBytes = wiredBytes + self.compressedBytes = compressedBytes + } +} + +public struct AggregateReading: Sendable { + public let cpu: MetricSample + public let memory: MetricSample + + public init(cpu: MetricSample, memory: MetricSample) { + self.cpu = cpu + self.memory = memory + } +} + +public struct TrendPoint: Sendable, Identifiable { + public var id: Double { time.monotonic } + public let time: SampleTime + public let generation: Int + public let ioSampled: Bool + public let cpuPercent: Double? + public let memoryPercent: Double? + public let networkInput: Double? + public let networkOutput: Double? + public let diskInput: Double? + public let diskOutput: Double? + public let physicalSensors: [PhysicalSensorReading]? + + public init( + time: SampleTime, generation: Int, ioSampled: Bool, cpuPercent: Double?, memoryPercent: Double?, + networkInput: Double?, networkOutput: Double?, diskInput: Double?, diskOutput: Double?, + physicalSensors: [PhysicalSensorReading]? = nil + ) { + self.time = time + self.generation = generation + self.ioSampled = ioSampled + self.cpuPercent = cpuPercent + self.memoryPercent = memoryPercent + self.networkInput = networkInput + self.networkOutput = networkOutput + self.diskInput = diskInput + self.diskOutput = diskOutput + self.physicalSensors = physicalSensors + } +} + +public struct MonitoringSnapshot: Sendable { + public let time: SampleTime + public let cpu: MetricSample + public let memory: MetricSample + public let history: [TrendPoint] + public let processes: MetricSample<[ProcessSample]> + public let hardware: HardwareSnapshot +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/MonitoringCoordinator.swift b/apps/toolbox/Sources/ToolboxMonitoring/MonitoringCoordinator.swift new file mode 100644 index 0000000..1515dcd --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/MonitoringCoordinator.swift @@ -0,0 +1,167 @@ +import Foundation + +public protocol MonitoringClock: Sendable { + func now() async -> SampleTime + func sleep(seconds: Double) async throws +} + +public struct SystemMonitoringClock: MonitoringClock { + public init() {} + public func now() -> SampleTime { + SampleTime(wall: Date(), monotonic: ProcessInfo.processInfo.systemUptime) + } + public func sleep(seconds: Double) async throws { + try await Task.sleep(for: .seconds(seconds)) + } +} + +public enum MonitoringMode: Sendable, Equatable { + case paused, active, menuBar + var interval: Double { self == .menuBar ? 5 : 1 } +} + +public actor MonitoringCoordinator { + private let provider: any MonitoringProvider + private let clock: any MonitoringClock + private var task: Task? + private var generation = 0 + private var mode = MonitoringMode.paused + private var diskTracker = CounterRateTracker() + private var networkTracker = CounterRateTracker() + private var hardware: HardwareSnapshot? + private var lastHardwareTime: Double? + private var processTracker = ProcessTracker() + private var lastProcessTime: Double? + private var processes: MetricSample<[ProcessSample]>? + private var previousCPU: MetricSample? + private var history = RingBuffer(capacity: 60) + private var continuation: AsyncStream.Continuation? + + public init( + provider: any MonitoringProvider = SystemMonitoringProvider(), + clock: any MonitoringClock = SystemMonitoringClock() + ) { + self.provider = provider + self.clock = clock + } + + public func snapshots() -> AsyncStream { + continuation?.finish() + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation = $0 } + } + + public func setMode(_ newMode: MonitoringMode) async { + if newMode == mode { + if newMode == .paused { await task?.value } + return + } + mode = newMode + generation += 1 + let token = generation + let oldTask = task + oldTask?.cancel() + // Join in-flight work before starting the next generation. Reentrant changes win. + await oldTask?.value + guard token == generation else { return } + task = nil + previousCPU = nil + diskTracker.reset() + networkTracker.reset() + hardware = nil + lastHardwareTime = nil + await provider.pauseHardware() + guard token == generation else { return } + processTracker.reset() + lastProcessTime = nil + guard newMode != .paused else { return } + task = Task { await self.run(token: token, interval: newMode.interval) } + } + + public func inspectProcess(_ identity: ProcessIdentity) async -> ProcessObservation { + await provider.inspectProcess(identity) + } + + public func inspectSleepRequests() async -> MetricSample { + await provider.readSleepRequests(at: clock.now()) + } + + public func stop() async { + await setMode(.paused) + continuation?.finish() + continuation = nil + } + + private func run(token: Int, interval: Double) async { + while !Task.isCancelled && generation == token { + let time = await clock.now() + let reading = await provider.readAggregate(at: time) + guard !Task.isCancelled, generation == token else { return } + if lastProcessTime == nil + || time.monotonic - (lastProcessTime ?? 0) >= (interval == 5 ? 5 : 2) + { + let processReading = await provider.readProcesses(at: time) + guard !Task.isCancelled, generation == token else { return } + processes = MetricSample( + value: processReading.availability == .available + ? processTracker.update(processReading, at: time) : nil, + availability: processReading.availability, unit: .collection, time: time, + source: "proc_listpids; proc_pidinfo", reason: processReading.reason) + if processReading.availability != .available { processTracker.reset() } + lastProcessTime = time.monotonic + } + if lastHardwareTime == nil + || time.monotonic - (lastHardwareTime ?? 0) >= (interval == 5 ? 5 : 2) + { + let raw = await provider.readHardware(at: time) + guard !Task.isCancelled, generation == token else { return } + hardware = HardwareSnapshot( + disks: diskTracker.update(raw.disks), interfaces: networkTracker.update(raw.interfaces), + reading: raw) + lastHardwareTime = time.monotonic + } + let hardwareSnapshot = + hardware + ?? HardwareSnapshot( + disks: MetricSample( + availability: .loading, unit: .bytesPerSecond, time: time, source: "IOKit"), + interfaces: MetricSample( + availability: .loading, unit: .bytesPerSecond, time: time, source: "getifaddrs"), + reading: .unsupported(at: time)) + var cpuValue: Double? + var state = reading.cpu.availability + if let current = reading.cpu.value, state == .available { + if let previous = previousCPU, let before = previous.value, + reading.cpu.time.monotonic > previous.time.monotonic + { + cpuValue = RateCalculator.cpu(previous: before, current: current) + } + state = cpuValue == nil ? .loading : .available + previousCPU = reading.cpu + } else { + previousCPU = nil + } + let cpu = MetricSample( + value: cpuValue, availability: state, unit: .percent, time: reading.cpu.time, + source: reading.cpu.source, reason: reading.cpu.reason) + history.append( + TrendPoint( + time: time, generation: token, ioSampled: lastHardwareTime == time.monotonic, + cpuPercent: cpuValue, memoryPercent: reading.memory.value?.occupiedPercent, + networkInput: hardwareSnapshot.aggregate(network: true, output: false).value, + networkOutput: hardwareSnapshot.aggregate(network: true, output: true).value, + diskInput: hardwareSnapshot.aggregate(network: false, output: false).value, + diskOutput: hardwareSnapshot.aggregate(network: false, output: true).value, + physicalSensors: hardwareSnapshot.reading.physicalSensors.time.monotonic == time.monotonic + ? hardwareSnapshot.reading.physicalSensors.value?.readings : nil)) + continuation?.yield( + MonitoringSnapshot( + time: time, cpu: cpu, memory: reading.memory, + history: history.elements.filter { time.monotonic - $0.time.monotonic <= 60 }, + processes: processes + ?? MetricSample( + availability: .loading, unit: .collection, time: time, source: "proc_listpids"), + hardware: hardwareSnapshot)) + do { try await clock.sleep(seconds: interval) } catch { return } + } + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/MonitoringPreferences.swift b/apps/toolbox/Sources/ToolboxMonitoring/MonitoringPreferences.swift new file mode 100644 index 0000000..516cfa0 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/MonitoringPreferences.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum MenuBarIndicator: String, Sendable, CaseIterable { + case cpu, memory, network +} + +public enum SamplingPolicy { + public static func mode( + appActive: Bool, mainWindowVisible: Bool, sleeping: Bool, menuBarEnabled: Bool + ) -> MonitoringMode { + if sleeping { return .paused } + if appActive && mainWindowVisible { return .active } + return menuBarEnabled ? .menuBar : .paused + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/NetworkChanges.swift b/apps/toolbox/Sources/ToolboxMonitoring/NetworkChanges.swift new file mode 100644 index 0000000..eff9273 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/NetworkChanges.swift @@ -0,0 +1,87 @@ +import Foundation + +public struct NetworkChange: Identifiable, Sendable { + public let id = UUID() + public let observedAt: Date + public let kind: String + public let socket: NetworkSocket + public let previousState: String? +} + +public struct NetworkChangeTracker: Sendable { + private struct Key: Hashable, Sendable { + let identity: ProcessIdentity + let transport: String + let endpoint: String + } + private var previous: [Key: NetworkSocket]? + private var observedAt: Date? + public private(set) var events: [NetworkChange] = [] + public private(set) var omittedEvents = 0 + public private(set) var unverifiedSockets = 0 + public private(set) var baselineEstablished = false + public init() {} + public mutating func invalidate() { + previous = nil + observedAt = nil + baselineEstablished = false + } + + public mutating func update(_ inspection: NetworkInspection) { + unverifiedSockets = inspection.sockets.filter { $0.identity == nil }.count + guard inspection.omitted == 0 else { + invalidate() + return + } + var next: [Key: NetworkSocket] = [:] + for socket in inspection.sockets { + guard let identity = socket.identity else { continue } + let key = Key(identity: identity, transport: socket.transport, endpoint: socket.endpoint) + // Multiple descriptors for one endpoint are coalesced; conflicting states are unknown. + if let other = next[key], other.state != socket.state { + next[key] = NetworkSocket( + id: socket.id, pid: socket.pid, processName: socket.processName, + transport: socket.transport, endpoint: socket.endpoint, state: "Unknown", + identity: identity) + } else { + next[key] = socket + } + } + if let previous, let observedAt, inspection.observedAt > observedAt, + inspection.observedAt.timeIntervalSince(observedAt) <= 600 + { + var changes: [NetworkChange] = [] + for (key, socket) in next { + if let old = previous[key] { + if old.state != socket.state { + changes.append( + NetworkChange( + observedAt: inspection.observedAt, kind: "State changed", + socket: socket, previousState: old.state)) + } + } else { + changes.append( + NetworkChange( + observedAt: inspection.observedAt, kind: "Newly observed", + socket: socket, previousState: nil)) + } + } + for (key, socket) in previous where next[key] == nil { + changes.append( + NetworkChange( + observedAt: inspection.observedAt, kind: "No longer observed", + socket: socket, previousState: nil)) + } + changes.sort { + ($0.socket.pid, $0.socket.endpoint, $0.kind) < ($1.socket.pid, $1.socket.endpoint, $1.kind) + } + let combined = changes + events + omittedEvents += max(0, combined.count - 200) + events = Array(combined.prefix(200)) + } + previous = next + observedAt = inspection.observedAt + baselineEstablished = true + } + public mutating func clear() { self = NetworkChangeTracker() } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/NetworkInspection.swift b/apps/toolbox/Sources/ToolboxMonitoring/NetworkInspection.swift new file mode 100644 index 0000000..e3ac1c9 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/NetworkInspection.swift @@ -0,0 +1,100 @@ +import Darwin +import Foundation +import ToolboxCore + +public struct NetworkSocket: Identifiable, Sendable { + public let id: String + public let pid: Int32 + public let processName: String + public let transport: String + public let endpoint: String + public let state: String + public let identity: ProcessIdentity? +} + +public struct NetworkInspection: Sendable { + public let observedAt: Date + public let sockets: [NetworkSocket] + public let omitted: Int + + public static func read() throws -> NetworkInspection { + let access = SystemProcessAccess() + var pids = [Int32](repeating: 0, count: 4096) + let bytes = pids.withUnsafeMutableBytes { + proc_listpids(UInt32(PROC_ALL_PIDS), 0, $0.baseAddress, Int32($0.count)) + } + var before: [Int32: ProcessIdentity] = [:] + if bytes > 0 { + for pid in pids.prefix(min(Int(bytes) / MemoryLayout.size, pids.count)) where pid > 0 { + try Task.checkCancellation() + before[pid] = try? access.metadata(pid: pid).identity + } + } + let text = String(decoding: try SystemInspectionRead.networkSockets.read(), as: UTF8.self) + let parsed = parse(text) + var verified: [Int32: ProcessIdentity] = [:] + for pid in Set(parsed.sockets.map(\.pid)) { + if let expected = before[pid], (try? access.metadata(pid: pid).identity) == expected { + verified[pid] = expected + } + } + return NetworkInspection( + observedAt: Date(), + sockets: parsed.sockets.map { + NetworkSocket( + id: $0.id, pid: $0.pid, processName: $0.processName, + transport: $0.transport, endpoint: $0.endpoint, state: $0.state, + identity: verified[$0.pid]) + }, omitted: parsed.omitted) + } + + /// lsof field output preserves spaces and IPv6 addresses; a socket starts at each f field. + public static func parse(_ text: String) -> NetworkInspection { + var sockets: [NetworkSocket] = [] + var pid: Int32 = 0 + var name = "" + var fd: String? + var transport = "" + var endpoint = "" + var state = "" + var omitted = 0 + func flush() { + guard let fd, pid > 0, !endpoint.isEmpty, transport == "TCP" || transport == "UDP" else { + return + } + guard sockets.count < 1024 else { + omitted += 1 + return + } + sockets.append( + .init( + id: "\(pid):\(fd):\(sockets.count)", pid: pid, + processName: name, transport: transport, endpoint: endpoint, + state: state.isEmpty ? "Unknown" : state, identity: nil)) + } + for line in text.split(separator: "\n") { + guard let field = line.first else { continue } + let value = String(line.dropFirst().prefix(2048)) + switch field { + case "p": + flush() + fd = nil + pid = Int32(value) ?? 0 + name = "" + case "c": name = value + case "f": + flush() + fd = value + transport = "" + endpoint = "" + state = "" + case "P": transport = value + case "n": endpoint = value + case "T": if value.hasPrefix("ST=") { state = String(value.dropFirst(3)) } + default: break + } + } + flush() + return NetworkInspection(observedAt: Date(), sockets: sockets, omitted: omitted) + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/NetworkOverviewProvider.swift b/apps/toolbox/Sources/ToolboxMonitoring/NetworkOverviewProvider.swift new file mode 100644 index 0000000..2887556 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/NetworkOverviewProvider.swift @@ -0,0 +1,158 @@ +import CoreWLAN +import Darwin +import Foundation +import SystemConfiguration + +public enum NetworkOverviewProvider { + public static func read() throws -> NetworkOverview { + try Task.checkCancellation() + let store = SCDynamicStoreCreate(nil, "Orven Network Observation" as CFString, nil, nil) + func dictionary(_ key: String) -> [String: Any] { + guard let store else { return [:] } + return SCDynamicStoreCopyValue(store, key as CFString) as? [String: Any] ?? [:] + } + let ipv4 = dictionary("State:/Network/Global/IPv4") + let ipv6 = dictionary("State:/Network/Global/IPv6") + let dns = dictionary("State:/Network/Global/DNS")["ServerAddresses"] as? [String] ?? [] + let configured = SCNetworkInterfaceCopyAll() as? [SCNetworkInterface] ?? [] + var kinds: [String: String] = [:] + for interface in configured { + guard let name = SCNetworkInterfaceGetBSDName(interface) as String? else { continue } + let type = SCNetworkInterfaceGetInterfaceType(interface) as String? + kinds[name] = + type == (kSCNetworkInterfaceTypeIEEE80211 as String) + ? "Wi-Fi" : type == (kSCNetworkInterfaceTypeEthernet as String) ? "Ethernet" : "Other" + } + var linked: UnsafeMutablePointer? + guard getifaddrs(&linked) == 0 else { throw CocoaError(.fileReadUnknown) } + defer { freeifaddrs(linked) } + var entries: [String: (flags: UInt32, addresses: [String], data: if_data?)] = [:] + var cursor = linked + while let node = cursor { + defer { cursor = node.pointee.ifa_next } + let value = node.pointee + let name = String(cString: value.ifa_name) + var entry = entries[name] ?? (value.ifa_flags, [], nil) + guard let address = value.ifa_addr else { continue } + let family = Int32(address.pointee.sa_family) + if family == AF_INET || family == AF_INET6 { + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + if getnameinfo( + address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), nil, 0, + NI_NUMERICHOST) == 0 + { + entry.addresses.append( + String( + decoding: host.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) }, as: UTF8.self + )) + } + } else if family == AF_LINK, let data = value.ifa_data { + entry.data = data.assumingMemoryBound(to: if_data.self).pointee + } + entries[name] = entry + } + let byteCounters = SystemNetworkCounters.read() + let mediaDeadline = ProcessInfo.processInfo.systemUptime + 6 + let names = entries.keys.sorted() + var adapters: [NetworkAdapter] = [] + var wifi: [WiFiReading] = [] + for name in names.prefix(32) { + try Task.checkCancellation() + guard let value = entries[name] else { continue } + let kind = kinds[name] ?? "Other" + var media: String? + var carrier: String? + if kind == "Ethernet", ProcessInfo.processInfo.systemUptime < mediaDeadline, + let result = try? NetworkToolsProcess.read(.media(name)), result.status == 0 + { + carrier = result.text.split(separator: "\n").first(where: { + $0.trimmingCharacters(in: .whitespaces).hasPrefix("status:") + }).map { + String($0.trimmingCharacters(in: .whitespaces).dropFirst(7)).trimmingCharacters( + in: .whitespaces) + } + media = result.text.split(separator: "\n").first(where: { + $0.trimmingCharacters(in: .whitespaces).hasPrefix("media:") + }).map { + String($0.trimmingCharacters(in: .whitespaces).dropFirst(6)).trimmingCharacters( + in: .whitespaces) + } + } + adapters.append( + .init( + name: name, kind: kind, + active: value.flags & UInt32(IFF_UP) != 0 && value.flags & UInt32(IFF_RUNNING) != 0, + addresses: Array(Set(value.addresses)).sorted(), mtu: Int(value.data?.ifi_mtu ?? 0), + received: byteCounters?[if_nametoindex(name)]?.input, + sent: byteCounters?[if_nametoindex(name)]?.output, + inputPackets: value.data.map { UInt64($0.ifi_ipackets) }, + outputPackets: value.data.map { UInt64($0.ifi_opackets) }, + inputErrors: value.data.map { UInt64($0.ifi_ierrors) }, + outputErrors: value.data.map { UInt64($0.ifi_oerrors) }, + drops: value.data.map { UInt64($0.ifi_iqdrops) }, media: media, carrier: carrier)) + if kind == "Wi-Fi", let interface = CWWiFiClient.shared().interface(withName: name) { + let channel = interface.wlanChannel() + let rssi = interface.rssiValue() + let noise = interface.noiseMeasurement() + let rate = interface.transmitRate() + wifi.append( + .init( + interface: name, ssid: interface.ssid(), bssid: interface.bssid(), + rssi: rssi < 0 ? rssi : nil, noise: noise < 0 ? noise : nil, + channel: channel.map { Int($0.channelNumber) }, band: channel.map { band($0) }, + widthMHz: channel.flatMap { width($0) }, + transmitMbps: rate > 0 && rate.isFinite ? rate : nil)) + } + } + try Task.checkCancellation() + let primary = ipv4["PrimaryInterface"] as? String ?? ipv6["PrimaryInterface"] as? String + var gateway6 = ipv6["Router"] as? String + if let gateway = gateway6, gateway.lowercased().hasPrefix("fe80:"), !gateway.contains("%"), + let interface = ipv6["PrimaryInterface"] as? String + { + gateway6 = gateway + "%" + interface + } + return NetworkOverview( + observedAt: Date(), adapters: adapters, + primary: primary, + gateway4: ipv4["Router"] as? String, gateway6: gateway6, + dns: Array(dns.prefix(32)), wifi: wifi, omittedAdapters: max(0, names.count - 32)) + } + public static func scan() throws -> NearbyWiFiScan { + try Task.checkCancellation() + guard let interface = CWWiFiClient.shared().interface() else { + throw CocoaError(.featureUnsupported) + } + // CoreWLAN owns scan duration. Cancellation suppresses publication after the OS call returns. + let networks = try interface.scanForNetworks(withSSID: nil) + try Task.checkCancellation() + let sorted = networks.sorted { $0.rssiValue > $1.rssiValue } + return NearbyWiFiScan( + observedAt: Date(), + networks: sorted.prefix(128).enumerated().map { index, network in + NearbyWiFi( + id: String(index), ssid: network.ssid, bssid: network.bssid, + channel: network.wlanChannel.map { Int($0.channelNumber) }, + band: network.wlanChannel.map { band($0) }, + rssi: network.rssiValue < 0 ? network.rssiValue : nil, + noise: network.noiseMeasurement < 0 ? network.noiseMeasurement : nil) + }, omitted: max(0, sorted.count - 128)) + } + private static func band(_ channel: CWChannel) -> String { + switch channel.channelBand { + case .band2GHz: "2.4 GHz" + case .band5GHz: "5 GHz" + case .band6GHz: "6 GHz" + default: "Unknown" + } + } + private static func width(_ channel: CWChannel) -> Int? { + switch channel.channelWidth { + case .width20MHz: 20 + case .width40MHz: 40 + case .width80MHz: 80 + case .width160MHz: 160 + default: nil + } + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/NetworkToolsModels.swift b/apps/toolbox/Sources/ToolboxMonitoring/NetworkToolsModels.swift new file mode 100644 index 0000000..981b145 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/NetworkToolsModels.swift @@ -0,0 +1,242 @@ +import Darwin +import Foundation + +public struct NetworkAdapter: Identifiable, Sendable { + public var id: String { name } + public let name: String + public let kind: String + public let active: Bool + public let addresses: [String] + public let mtu: Int + public let received: UInt64? + public let sent: UInt64? + public let inputPackets: UInt64? + public let outputPackets: UInt64? + public let inputErrors: UInt64? + public let outputErrors: UInt64? + public let drops: UInt64? + public let media: String? + public let carrier: String? +} + +public struct WiFiReading: Sendable { + public let interface: String + public let ssid: String? + public let bssid: String? + public let rssi: Int? + public let noise: Int? + public let channel: Int? + public let band: String? + public let widthMHz: Int? + public let transmitMbps: Double? + public var snr: Int? { + guard let rssi, let noise else { return nil } + return rssi - noise + } +} + +public struct NetworkOverview: Sendable { + public let observedAt: Date + public let adapters: [NetworkAdapter] + public let primary: String? + public let gateway4: String? + public let gateway6: String? + public let dns: [String] + public let wifi: [WiFiReading] + public let omittedAdapters: Int +} + +public struct NearbyWiFi: Identifiable, Sendable { + public let id: String + public let ssid: String? + public let bssid: String? + public let channel: Int? + public let band: String? + public let rssi: Int? + public let noise: Int? +} + +public struct NearbyWiFiScan: Sendable { + public let observedAt: Date + public let networks: [NearbyWiFi] + public let omitted: Int +} + +public enum NetworkTarget { + public static func isNumericIP(_ host: String) -> Bool { + var v4 = in_addr() + var v6 = in6_addr() + let unscoped = String(host.split(separator: "%", omittingEmptySubsequences: false).first ?? "") + return inet_pton(AF_INET, host, &v4) == 1 || inet_pton(AF_INET6, unscoped, &v6) == 1 + } + /// One host argument, not a URL, executable option, credential or shell expression. + public static func validate(_ raw: String) -> String? { + let host = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, host.utf8.count <= 253, !host.hasPrefix("-"), + host.unicodeScalars.allSatisfy({ + CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:%_" + ).contains($0) + }), !host.contains("..") + else { return nil } + // Reject malformed port/IPv6 mixtures; a colon-bearing target must be numeric IPv6. + if host.contains(":") { + let numeric = host.split(separator: "%", omittingEmptySubsequences: false) + var address = in6_addr() + guard numeric.count <= 2, inet_pton(AF_INET6, String(numeric[0]), &address) == 1, + numeric.count == 1 || !numeric[1].isEmpty, numeric[0].contains(":"), + numeric[0].allSatisfy({ $0.isHexDigit || $0 == ":" }) + else { return nil } + } else { + guard + host.split(separator: ".", omittingEmptySubsequences: false).allSatisfy({ + !$0.isEmpty && !$0.hasPrefix("-") && !$0.hasSuffix("-") && $0.count <= 63 + }) + else { return nil } + } + return host + } +} + +public struct LatencySample: Identifiable, Sendable { + public var id: Int { sequence } + public let sequence: Int + public let milliseconds: Double? +} + +public struct LatencyReport: Sendable { + public let target: String + public let observedAt: Date + public let samples: [LatencySample] + public let transmitted: Int? + public let received: Int? + public let exitStatus: Int32 + public var lossPercent: Double? { + guard let transmitted, let received, transmitted > 0, received <= transmitted else { + return nil + } + return Double(transmitted - received) * 100 / Double(transmitted) + } + public var meanMilliseconds: Double? { + let values = samples.compactMap(\.milliseconds) + return values.isEmpty ? nil : values.reduce(0, +) / Double(values.count) + } + /// Mean absolute difference of successive received samples; a timeout breaks adjacency. + public var jitterMilliseconds: Double? { + let deltas = zip(samples, samples.dropFirst()).compactMap { a, b -> Double? in + guard b.sequence == a.sequence + 1, let av = a.milliseconds, let bv = b.milliseconds else { + return nil + } + return abs(bv - av) + } + return deltas.isEmpty ? nil : deltas.reduce(0, +) / Double(deltas.count) + } + + public static func parse(_ text: String, target: String, exitStatus: Int32) -> Self { + var receivedValues: [Int: Double] = [:] + var transmitted: Int? + var received: Int? + for line in text.split(separator: "\n") { + if let sequenceRange = line.range(of: "icmp_seq="), + let sequence = Int(line[sequenceRange.upperBound...].prefix(while: { $0.isNumber })), + sequence >= 0, sequence < 20, + let timeRange = line.range(of: "time="), + let value = Double( + line[timeRange.upperBound...].prefix(while: { $0.isNumber || $0 == "." })), + value.isFinite, value >= 0 + { + if receivedValues[sequence] == nil { receivedValues[sequence] = value } + } + if line.contains("packets transmitted"), line.contains("packets received") { + let fields = line.split(separator: ",") + transmitted = fields.first.flatMap { + Int($0.trimmingCharacters(in: .whitespaces).split(separator: " ").first ?? "") + } + received = fields.dropFirst().first.flatMap { + Int($0.trimmingCharacters(in: .whitespaces).split(separator: " ").first ?? "") + } + } + } + let count = min(20, max(0, transmitted ?? ((receivedValues.keys.max() ?? -1) + 1))) + return Self( + target: target, observedAt: Date(), + samples: (0..= 0, + value.observedAt.timeIntervalSince(previousAt) <= 600, value.omittedAdapters == 0 + else { return } + for key in fields.keys.sorted() { + if let old = previous[key], let new = fields[key], old != new { + events.append(.init(observedAt: value.observedAt, field: key, before: old, after: new)) + } + } + if events.count > 200 { + omitted += events.count - 200 + events.removeFirst(events.count - 200) + } + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/NetworkToolsProcess.swift b/apps/toolbox/Sources/ToolboxMonitoring/NetworkToolsProcess.swift new file mode 100644 index 0000000..a9a9a48 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/NetworkToolsProcess.swift @@ -0,0 +1,154 @@ +import Darwin +import Foundation + +/// Only bounded network reads/probes. Arguments are passed directly, never through a shell. +enum NetworkToolsProcess { + enum Operation { + case media(String) + case dns(String) + case ping(String, Int) + case tcp(String, Int) + } + struct Result { + let text: String + let status: Int32 + } + static func read(_ operation: Operation) throws -> Result { + let process = Process() + let pipe = Pipe() + let duration: Double + switch operation { + case .media(let name): + guard name.utf8.count <= 32, name.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) }) + else { throw CocoaError(.validationMissingMandatoryProperty) } + process.executableURL = URL(fileURLWithPath: "/sbin/ifconfig") + process.arguments = [name] + duration = 3 + case .dns(let host): + guard NetworkTarget.validate(host) == host else { + throw CocoaError(.validationMissingMandatoryProperty) + } + process.executableURL = URL(fileURLWithPath: "/usr/bin/dscacheutil") + process.arguments = ["-q", "host", "-a", "name", host] + duration = 8 + case .ping(let host, let count): + guard NetworkTarget.validate(host) == host, (1...20).contains(count) else { + throw CocoaError(.validationMissingMandatoryProperty) + } + process.executableURL = URL( + fileURLWithPath: host.contains(":") ? "/sbin/ping6" : "/sbin/ping") + process.arguments = + host.contains(":") + ? ["-n", "-c", String(count), "-i", "1", host] + : ["-n", "-c", String(count), "-i", "1", "-W", "1000", host] + duration = Double(count) + 8 + case .tcp(let host, let port): + guard NetworkTarget.validate(host) == host, (1...65535).contains(port) else { + throw CocoaError(.validationMissingMandatoryProperty) + } + process.executableURL = URL(fileURLWithPath: "/usr/bin/nc") + process.arguments = ["-n", "-G", "3", "-w", "3", "-z", host, String(port)] + // Numeric-only nc would fail DNS names. Resolve through nc for this explicit TCP probe. + if !host.contains(":") && host.contains(where: { $0.isLetter }) { + process.arguments?.removeFirst() + } + duration = 8 + } + process.environment = ["LC_ALL": "C", "LANG": "C", "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"] + process.standardOutput = pipe + process.standardError = pipe + defer { + if process.isRunning { + process.terminate() + let end = ProcessInfo.processInfo.systemUptime + 0.25 + while process.isRunning && ProcessInfo.processInfo.systemUptime < end { + Thread.sleep(forTimeInterval: 0.01) + } + if process.isRunning { _ = kill(process.processIdentifier, SIGKILL) } + } + try? pipe.fileHandleForReading.close() + try? pipe.fileHandleForWriting.close() + } + try Task.checkCancellation() + try process.run() + try pipe.fileHandleForWriting.close() + let descriptor = pipe.fileHandleForReading.fileDescriptor + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + throw CocoaError(.fileReadUnknown) + } + let deadline = ProcessInfo.processInfo.systemUptime + duration + var bytes = Data() + var buffer = [UInt8](repeating: 0, count: 16384) + var ended = false + while ProcessInfo.processInfo.systemUptime < deadline { + try Task.checkCancellation() + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, $0.count) + } + if count > 0 { + guard bytes.count + count <= 512 * 1024 else { throw CocoaError(.fileReadTooLarge) } + bytes.append(contentsOf: buffer.prefix(count)) + } else if count == 0 { + ended = true + } else if errno != EAGAIN && errno != EINTR { + throw CocoaError(.fileReadUnknown) + } + if !process.isRunning && ended { + return Result( + text: String(decoding: bytes, as: UTF8.self), status: process.terminationStatus) + } + Thread.sleep(forTimeInterval: 0.02) + } + throw CocoaError(.fileReadUnknown) + } +} + +public enum NetworkDiagnostics { + public static func latency(target: String, count: Int = 20) throws -> LatencyReport { + let result = try NetworkToolsProcess.read(.ping(target, count)) + return LatencyReport.parse(result.text, target: target, exitStatus: result.status) + } + public static func connection(target: String, gateway: String?) throws -> [ConnectionCheck] { + guard NetworkTarget.validate(target) == target else { + throw CocoaError(.validationMissingMandatoryProperty) + } + var results: [ConnectionCheck] = [] + let operations: [(String, String, NetworkToolsProcess.Operation?)] = [ + ("Gateway ICMP", gateway ?? "Unavailable", gateway.map { .ping($0, 3) }), + ("DNS resolution", target, NetworkTarget.isNumericIP(target) ? nil : .dns(target)), + ("TCP connection", target + ":443", .tcp(target, 443)), + ] + for (label, destination, operation) in operations { + try Task.checkCancellation() + let start = ProcessInfo.processInfo.systemUptime + guard let operation else { + results.append( + .init( + id: label, target: destination, succeeded: nil, durationMilliseconds: 0, + detail: label == "DNS resolution" ? "Not applicable to a numeric IP" : "Unavailable")) + continue + } + do { + let result = try NetworkToolsProcess.read(operation) + let success = + label == "DNS resolution" + ? result.status == 0 + && (result.text.contains("ip_address:") || result.text.contains("ipv6_address:")) + : result.status == 0 + results.append( + .init( + id: label, target: destination, succeeded: success, + durationMilliseconds: (ProcessInfo.processInfo.systemUptime - start) * 1000, + detail: success ? "Succeeded" : "Failed or blocked")) + } catch is CancellationError { throw CancellationError() } catch { + results.append( + .init( + id: label, target: destination, succeeded: false, + durationMilliseconds: (ProcessInfo.processInfo.systemUptime - start) * 1000, + detail: "Timed out or unavailable")) + } + } + return results + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/PhysicalSensors.swift b/apps/toolbox/Sources/ToolboxMonitoring/PhysicalSensors.swift new file mode 100644 index 0000000..087b654 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/PhysicalSensors.swift @@ -0,0 +1,219 @@ +import Darwin +import Foundation +import IOKit + +public enum PhysicalSensorUnit: String, Sendable, Codable { + case celsius = "°C" + case rpm = "RPM" + case watts = "W" +} + +public struct PhysicalSensorReading: Sendable, Codable, Identifiable { + public var id: String { key } + public let key: String + public let group: String + public let unit: PhysicalSensorUnit + public let value: Double? + public let availability: MetricAvailability + public let dataType: String? +} + +public struct PhysicalSensorSnapshot: Sendable, Codable { + public let readings: [PhysicalSensorReading] + public let reportedFanCount: Int? + public let mappingFamily: String + public let coverageLimited: Bool +} + +// AppleSMC's protocol is undocumented. Only fixed mapped keys are read; no key writes, +// arbitrary registry values, enumeration, fan controls or privileged helper are exposed. +public enum PhysicalSensors { + public static func read(at time: SampleTime) -> MetricSample { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSMC")) + guard service != 0 else { return unavailable(.unsupported, time, "AppleSMC service absent") } + defer { IOObjectRelease(service) } + var connection: io_connect_t = 0 + let status = IOServiceOpen(service, mach_task_self_, 0, &connection) + guard status == KERN_SUCCESS else { + return unavailable( + status == kIOReturnNotPrivileged || status == kIOReturnNotPermitted + ? .permissionRequired : .error, time, "AppleSMC open status \(status)") + } + defer { IOServiceClose(connection) } + let transport = SMCReadTransport(connection: connection) + let mapping = mapping(for: processorName()) + let countValue = transport.read("FNum") + let rawCount = countValue.flatMap { SMCScalar.decode(type: $0.type, bytes: $0.bytes) } + let fanCount = rawCount.flatMap { $0 >= 0 && $0 <= 255 && $0.rounded() == $0 ? Int($0) : nil } + var definitions = mapping.keys + for index in 0.. MetricSample + { + MetricSample( + availability: state, unit: .collection, time: time, source: "AppleSMC/read-only", + reason: reason) + } + + static func mapping(for brand: String) -> ( + family: String, keys: [(String, String, PhysicalSensorUnit)] + ) { + let cpu: [String] + let gpu: [String] + let family: String + let chip = brand.split(separator: " ").prefix(2).joined(separator: " ") + if chip == "Apple M3" { + family = "Apple M3" + // Cross-checked iSMC M3 CPU e/p and GPU maps. Conflicting Tf fabric keys are + // deliberately excluded from CPU; no physical-core numbering is inferred. + cpu = [ + "Te05", "Te0H", "Te0P", "Te0S", "Te0U", "Te0L", "Tp05", "Tp0D", "Tp0L", "Tp0b", "Tp0h", + "Tp0n", "Tp1F", "Tp1R", + ] + gpu = ["Tg05", "Tg0D", "Tg0L", "Tg01", "Tg0V", "Tg0v", "Tg13", "Tg1B"] + } else if chip == "Apple M2" { + family = "Apple M2" + cpu = [ + "Tp1h", "Tp1t", "Tp1p", "Tp1l", "Tp01", "Tp05", "Tp09", "Tp0D", "Tp0H", "Tp0L", "Tp0P", + "Tp0T", + ] + gpu = ["Tg0f", "Tg0j"] + } else if chip == "Apple M1" { + family = "Apple M1" + cpu = ["Tp09", "Tp0T", "Tp01", "Tp05", "Tp0D", "Tp0H", "Tp0L", "Tp0P", "Tp0X", "Tp0b"] + gpu = ["Tg05", "Tg0D", "Tg0L", "Tg0T"] + } else if chip == "Apple M4" { + family = "Apple M4" + cpu = [ + "Te05", "Te0S", "Te09", "Te0H", "Tp01", "Tp05", "Tp09", "Tp0D", "Tp0H", "Tp0L", "Tp0P", + "Tp0T", + ] + gpu = ["Tg0G", "Tg0H", "Tg1U", "Tg1k", "Tg0K", "Tg0L", "Tg0d", "Tg0e", "Tg0j", "Tg0k"] + } else if brand.contains("Intel") { + family = "Intel" + cpu = ["TC0D", "TC0P", "TCAD"] + gpu = ["TG0D", "TG0P", "TCGC"] + } else { + return ("Unknown", []) + } + // Group association follows published community maps. These are not physical core IDs. + return ( + family, cpu.map { ($0, "CPU sensors", .celsius) } + gpu.map { ($0, "GPU sensors", .celsius) } + ) + } + + private static func processorName() -> String { + var size = 0 + guard sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) == 0, size > 0, size <= 4096 + else { return "" } + var bytes = [UInt8](repeating: 0, count: size) + guard sysctlbyname("machdep.cpu.brand_string", &bytes, &size, nil, 0) == 0 else { return "" } + return String(decoding: bytes.prefix(size).prefix(while: { $0 != 0 }), as: UTF8.self) + } +} + +// Packet offsets describe the 80-byte AppleSMC ABI, independent of Swift struct padding. +private struct SMCReadTransport { + let connection: io_connect_t + private enum Command: UInt8 { + case metadata = 9 + case read = 5 + } + func read(_ key: String) -> (type: String, bytes: [UInt8])? { + guard key.utf8.count == 4, key.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }), + let info = request(key, .metadata, size: 0) + else { return nil } + let size = word(info, 28) + guard size > 0, size <= 32, let output = request(key, .read, size: size) else { return nil } + let type = word(info, 32) + guard + let name = String( + bytes: (0..<4).map { UInt8(truncatingIfNeeded: type >> (8 * (3 - $0))) }, encoding: .ascii) + else { return nil } + return (name, Array(output[48..<48 + Int(size)])) + } + private func request(_ key: String, _ command: Command, size: UInt32) -> [UInt8]? { + var input = [UInt8](repeating: 0, count: 80) + var output = input + let code = key.utf8.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + for index in 0..<4 { + input[index] = UInt8(truncatingIfNeeded: code >> (8 * index)) + input[28 + index] = UInt8(truncatingIfNeeded: size >> (8 * index)) + } + input[42] = command.rawValue + var outputSize = 80 + let result = input.withUnsafeBytes { a in + output.withUnsafeMutableBytes { b in + IOConnectCallStructMethod( + connection, 2, a.baseAddress, input.count, b.baseAddress, &outputSize) + } + } + guard result == KERN_SUCCESS, outputSize == 80, output[40] == 0 else { return nil } + return output + } + private func word(_ bytes: [UInt8], _ offset: Int) -> UInt32 { + (0..<4).reduce(0) { $0 | UInt32(bytes[offset + $1]) << (8 * $1) } + } +} + +internal enum SMCScalar { + static func supports(type: String, unit: PhysicalSensorUnit) -> Bool { + switch unit { + case .celsius: return ["flt ", "sp78"].contains(type) + case .rpm: return ["flt ", "fpe2", "ui16"].contains(type) + case .watts: return type == "flt " + } + } + static func decode(type: String, bytes: [UInt8]) -> Double? { + switch (type, bytes.count) { + case ("flt ", 4): + let bits = (0..<4).reduce(UInt32(0)) { $0 | UInt32(bytes[$1]) << (8 * $1) } + return Double(Float(bitPattern: bits)) + case ("sp78", 2): + return Double(Int16(bitPattern: UInt16(bytes[0]) << 8 | UInt16(bytes[1]))) / 256 + case ("fpe2", 2): return Double(UInt16(bytes[0]) << 8 | UInt16(bytes[1])) / 4 + case ("ui8 ", 1): return Double(bytes[0]) + case ("ui16", 2): return Double(UInt16(bytes[0]) << 8 | UInt16(bytes[1])) + default: return nil + } + } + static func validated(_ value: Double, unit: PhysicalSensorUnit) -> Double? { + guard value.isFinite else { return nil } + switch unit { + case .celsius: return value > 0 && value <= 150 ? value : nil + case .rpm: return value >= 0 && value <= 50_000 ? value : nil + case .watts: return value >= 0 && value <= 5_000 ? value : nil + } + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/ProcessMonitoring.swift b/apps/toolbox/Sources/ToolboxMonitoring/ProcessMonitoring.swift new file mode 100644 index 0000000..55d9690 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/ProcessMonitoring.swift @@ -0,0 +1,255 @@ +import Darwin +import Foundation + +public struct ProcessIdentity: Sendable, Hashable { + public let pid: Int32 + public let startSeconds: UInt64 + public let startMicroseconds: UInt64 + + public init(pid: Int32, startSeconds: UInt64, startMicroseconds: UInt64) { + self.pid = pid + self.startSeconds = startSeconds + self.startMicroseconds = startMicroseconds + } + public var startDate: Date { + Date(timeIntervalSince1970: Double(startSeconds) + Double(startMicroseconds) / 1_000_000) + } +} + +public struct ProcessMetadata: Sendable { + public let identity: ProcessIdentity + public let name: String + public let parentPID: Int32 + public init(identity: ProcessIdentity, name: String, parentPID: Int32) { + self.identity = identity + self.name = name + self.parentPID = parentPID + } +} + +public struct ProcessCounters: Sendable { + public let cpuTicks: UInt64 + public let residentBytes: UInt64 + public let threadCount: Int32 + public init(cpuTicks: UInt64, residentBytes: UInt64, threadCount: Int32) { + self.cpuTicks = cpuTicks + self.residentBytes = residentBytes + self.threadCount = threadCount + } +} + +public enum ProcessReadError: Error, Sendable { + case permissionRequired, exited, identityChanged, unavailable + public var availability: MetricAvailability { + switch self { + case .permissionRequired: .permissionRequired + case .exited, .identityChanged: .temporarilyUnavailable + case .unavailable: .error + } + } +} + +public protocol ProcessAccess: Sendable { + func metadata(pid: Int32) throws -> ProcessMetadata + func counters(pid: Int32) throws -> ProcessCounters +} + +public struct SystemProcessAccess: ProcessAccess { + public init() {} + private func failure() -> ProcessReadError { + switch errno { + case EPERM, EACCES: .permissionRequired + case ESRCH, ENOENT: .exited + default: .unavailable + } + } + public func metadata(pid: Int32) throws -> ProcessMetadata { + var bsd = proc_bsdinfo() + let size = Int32(MemoryLayout.size) + guard proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsd, size) == size else { throw failure() } + guard bsd.pbi_start_tvsec > 0, bsd.pbi_pid == UInt32(pid) else { + throw ProcessReadError.unavailable + } + let name = withUnsafeBytes(of: bsd.pbi_name) { + String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) + } + let command = withUnsafeBytes(of: bsd.pbi_comm) { + String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) + } + return ProcessMetadata( + identity: ProcessIdentity( + pid: pid, startSeconds: bsd.pbi_start_tvsec, startMicroseconds: bsd.pbi_start_tvusec), + name: name.isEmpty ? command : name, parentPID: Int32(bsd.pbi_ppid)) + } + public func counters(pid: Int32) throws -> ProcessCounters { + var task = proc_taskinfo() + let size = Int32(MemoryLayout.size) + guard proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &task, size) == size else { throw failure() } + let sum = task.pti_total_user.addingReportingOverflow(task.pti_total_system) + guard !sum.overflow else { throw ProcessReadError.unavailable } + return ProcessCounters( + cpuTicks: sum.partialValue, residentBytes: task.pti_resident_size, + threadCount: task.pti_threadnum) + } +} + +public struct ProcessObservation: Sendable { + public let pid: Int32 + public let metadata: ProcessMetadata? + public let counters: ProcessCounters? + public let availability: MetricAvailability + public let reason: String? +} + +public struct ProcessInspector: Sendable { + private let access: any ProcessAccess + public init(access: any ProcessAccess = SystemProcessAccess()) { self.access = access } + public func inspect(pid: Int32, expected: ProcessIdentity? = nil) -> ProcessObservation { + var metadata: ProcessMetadata? + do { + let before = try access.metadata(pid: pid) + if let expected, expected != before.identity { throw ProcessReadError.identityChanged } + metadata = before + let counters = try access.counters(pid: pid) + let after = try access.metadata(pid: pid) + guard before.identity == after.identity else { throw ProcessReadError.identityChanged } + return ProcessObservation( + pid: pid, metadata: after, counters: counters, availability: .available, reason: nil) + } catch { + let error = error as? ProcessReadError ?? .unavailable + return ProcessObservation( + pid: pid, metadata: metadata, counters: nil, availability: error.availability, + reason: String(describing: error)) + } + } +} + +public enum ProcessRowID: Sendable, Hashable { + case verified(ProcessIdentity) + case restricted(Int32) +} + +public struct ProcessSample: Sendable, Identifiable { + public var id: ProcessRowID { identity.map { .verified($0) } ?? .restricted(pid) } + public let pid: Int32 + public let identity: ProcessIdentity? + public let name: String + public let parentPID: Int32? + public let cpu: MetricSample + public let memory: MetricSample + public let threads: Int32? + public let cpuHistory: [ProcessTrendPoint] +} + +public struct ProcessTrendPoint: Sendable, Identifiable { + public var id: Double { time.monotonic } + public let time: SampleTime + public let cpu: Double? +} + +public struct ProcessReading: Sendable { + public let observations: [ProcessObservation] + public let availability: MetricAvailability + public let reason: String? + public let timebaseNumerator: UInt32 + public let timebaseDenominator: UInt32 + public init( + observations: [ProcessObservation], availability: MetricAvailability, reason: String? = nil, + timebaseNumerator: UInt32 = 1, timebaseDenominator: UInt32 = 1 + ) { + self.observations = observations + self.availability = availability + self.reason = reason + self.timebaseNumerator = timebaseNumerator + self.timebaseDenominator = timebaseDenominator + } +} + +public struct ProcessTracker: Sendable { + private var previous: [ProcessIdentity: (ticks: UInt64, time: Double)] = [:] + private var trends: [ProcessIdentity: RingBuffer] = [:] + public init() {} + public mutating func reset() { + previous.removeAll() + trends.removeAll() + } + + public mutating func update(_ reading: ProcessReading, at time: SampleTime) -> [ProcessSample] { + var next: [ProcessIdentity: (ticks: UInt64, time: Double)] = [:] + var samples: [ProcessSample] = reading.observations.map { item in + let identity = item.metadata?.identity + var cpu: Double? + if let identity, let counters = item.counters { + if let before = previous[identity] { + cpu = RateCalculator.processCPU( + previous: before.ticks, current: counters.cpuTicks, + elapsed: time.monotonic - before.time, numerator: reading.timebaseNumerator, + denominator: reading.timebaseDenominator) + } + next[identity] = (counters.cpuTicks, time.monotonic) + } + return ProcessSample( + pid: item.pid, identity: identity, name: item.metadata?.name ?? "PID \(item.pid)", + parentPID: item.metadata?.parentPID, + cpu: MetricSample( + value: cpu, + availability: item.availability == .available + ? (cpu == nil ? .loading : .available) : item.availability, + unit: .percent, time: time, source: "proc_pidinfo/PROC_PIDTASKINFO; mach_timebase_info", + reason: item.reason), + memory: MetricSample( + value: item.counters?.residentBytes, availability: item.availability, + unit: .bytes, time: time, source: "proc_pidinfo/PROC_PIDTASKINFO", reason: item.reason), + threads: item.counters?.threadCount, cpuHistory: []) + } + previous = next + // Only the top 100 verified consumers own short-term trends; exits retire identities. + let tracked = Set( + samples.sorted { ($0.cpu.value ?? -1) > ($1.cpu.value ?? -1) }.prefix(100).compactMap( + \.identity)) + trends = trends.filter { tracked.contains($0.key) } + for sample in samples { + guard let identity = sample.identity, tracked.contains(identity) else { continue } + var ring = trends[identity] ?? RingBuffer(capacity: 30) + ring.append(ProcessTrendPoint(time: time, cpu: sample.cpu.value)) + trends[identity] = ring + } + samples = samples.map { sample in + ProcessSample( + pid: sample.pid, identity: sample.identity, name: sample.name, + parentPID: sample.parentPID, cpu: sample.cpu, memory: sample.memory, + threads: sample.threads, + cpuHistory: sample.identity.flatMap { + trends[$0]?.elements.filter { time.monotonic - $0.time.monotonic <= 60 } + } ?? []) + } + return samples + } +} + +public enum ProcessOrdering: String, CaseIterable, Sendable { + case cpu, memory, name, pid + public func sorted(_ processes: [ProcessSample], query: String = "") -> [ProcessSample] { + processes.filter { + query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) + || String($0.pid).contains(query) + } + .sorted { left, right in + switch self { + case .cpu: + if left.cpu.value != right.cpu.value { + return (left.cpu.value ?? -1) > (right.cpu.value ?? -1) + } + case .memory: + if left.memory.value != right.memory.value { + return (left.memory.value ?? 0) > (right.memory.value ?? 0) + } + case .name: + let order = left.name.localizedStandardCompare(right.name) + if order != .orderedSame { return order == .orderedAscending } + case .pid: break + } + return left.pid < right.pid + } + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/ProcessPriority.swift b/apps/toolbox/Sources/ToolboxMonitoring/ProcessPriority.swift new file mode 100644 index 0000000..e2f5c04 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/ProcessPriority.swift @@ -0,0 +1,35 @@ +import Darwin +import Foundation + +public enum ProcessPriority { + /// Interactive, same-user, one-way reduction only. No signals, throttling loop or elevated helper. + public static func lower(_ identity: ProcessIdentity) throws -> Int32 { + let access = SystemProcessAccess() + guard identity.pid > 1, identity.pid != getpid(), identity.pid != getppid(), + try access.metadata(pid: identity.pid).identity == identity + else { throw ProcessReadError.identityChanged } + var info = proc_bsdinfo() + let size = Int32(MemoryLayout.size) + guard proc_pidinfo(identity.pid, PROC_PIDTBSDINFO, 0, &info, size) == size, + info.pbi_uid == getuid(), info.pbi_ruid == getuid() + else { throw ProcessReadError.permissionRequired } + errno = 0 + let previous = getpriority(PRIO_PROCESS, UInt32(identity.pid)) + guard errno == 0 else { throw ProcessReadError.unavailable } + guard try access.metadata(pid: identity.pid).identity == identity else { + throw ProcessReadError.identityChanged + } + // Increasing nice lowers priority; never raise an already lower-priority process. + let desired = max(previous, 10) + guard setpriority(PRIO_PROCESS, UInt32(identity.pid), desired) == 0 else { + throw ProcessReadError.permissionRequired + } + guard try access.metadata(pid: identity.pid).identity == identity else { + throw ProcessReadError.identityChanged + } + errno = 0 + let observed = getpriority(PRIO_PROCESS, UInt32(identity.pid)) + guard errno == 0, observed >= desired else { throw ProcessReadError.unavailable } + return observed + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/SamplingMath.swift b/apps/toolbox/Sources/ToolboxMonitoring/SamplingMath.swift new file mode 100644 index 0000000..6465f33 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/SamplingMath.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct RingBuffer: Sendable { + private var slots: [Element?] + private var next = 0 + public private(set) var count = 0 + public var capacity: Int { slots.count } + + public init(capacity: Int) { + precondition(capacity > 0) + slots = Array(repeating: nil, count: capacity) + } + + public mutating func append(_ element: Element) { + slots[next] = element + next = (next + 1) % capacity + count = min(count + 1, capacity) + } + + public var elements: [Element] { + let start = count == capacity ? next : 0 + return (0.. Double? { + guard elapsed.isFinite, elapsed > 0, current >= previous else { return nil } + let result = Double(current - previous) / elapsed + return result.isFinite ? result : nil + } + + public static func cpu(previous: CPUTicks, current: CPUTicks) -> Double? { + let before = [previous.user, previous.system, previous.nice, previous.idle] + let after = [current.user, current.system, current.nice, current.idle] + guard zip(before, after).allSatisfy({ $1 >= $0 }) else { return nil } + let delta = zip(before, after).map { Double($1 - $0) } + let total = delta.reduce(0, +) + guard total > 0 else { return nil } + return (delta[0] + delta[1] + delta[2]) / total * 100 + } + + public static func processCPU( + previous: UInt64, current: UInt64, elapsed: Double, numerator: UInt32, denominator: UInt32 + ) -> Double? { + guard denominator > 0, numerator > 0, + let ticksPerSecond = rate(previous: previous, current: current, elapsed: elapsed) + else { return nil } + let result = ticksPerSecond * Double(numerator) / Double(denominator) / 1_000_000_000 * 100 + return result.isFinite ? result : nil + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/SensorWindow.swift b/apps/toolbox/Sources/ToolboxMonitoring/SensorWindow.swift new file mode 100644 index 0000000..89c23cc --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/SensorWindow.swift @@ -0,0 +1,87 @@ +import Foundation + +public enum WindowSensor: String, Sendable, Codable, CaseIterable { + case cpu, memory, diskRead, diskWrite, networkReceive, networkSend + public var title: String { + switch self { + case .cpu: "System CPU • all cores" + case .memory: "Occupied memory • includes caches" + case .diskRead: "Disk read" + case .diskWrite: "Disk write" + case .networkReceive: "Network receive" + case .networkSend: "Network send" + } + } + public var isPercent: Bool { self == .cpu || self == .memory } + func value(_ point: TrendPoint) -> Double? { + switch self { + case .cpu: point.cpuPercent + case .memory: point.memoryPercent + case .diskRead: point.ioSampled ? point.diskInput : nil + case .diskWrite: point.ioSampled ? point.diskOutput : nil + case .networkReceive: point.ioSampled ? point.networkInput : nil + case .networkSend: point.ioSampled ? point.networkOutput : nil + } + } +} + +public struct SensorExtrema: Sendable, Codable, Identifiable { + public var id: String { sensor.rawValue } + public let sensor: WindowSensor + public let minimum: Double? + public let maximum: Double? + public let validSamples: Int +} + +public enum SensorWindow { + // Reuse the bounded engine history; gaps are absent values, not zeroes. + // Hardware values reused between slow reads never count as new I/O observations. + public static func extrema(_ history: [TrendPoint], since: Double? = nil) -> [SensorExtrema] { + let latest = history.last + let points = history.suffix(60).filter { + guard let latest else { return false } + return $0.generation == latest.generation && $0.time.monotonic <= latest.time.monotonic + && $0.time.monotonic >= latest.time.monotonic - 60 + && (since == nil || $0.time.monotonic > since!) + } + return WindowSensor.allCases.map { sensor in + let values = points.compactMap { sensor.value($0) }.filter { + $0.isFinite && $0 >= 0 && (!sensor.isPercent || $0 <= 100) + } + return SensorExtrema( + sensor: sensor, minimum: values.min(), maximum: values.max(), validSamples: values.count) + } + } +} + +public struct PhysicalSensorExtrema: Sendable, Codable { + public let minimum: Double? + public let maximum: Double? + public let validSamples: Int +} + +extension SensorWindow { + public static func physicalExtrema( + _ history: [TrendPoint], sensor: PhysicalSensorReading, + since: Double? = nil + ) -> PhysicalSensorExtrema { + guard let latest = history.last else { + return PhysicalSensorExtrema(minimum: nil, maximum: nil, validSamples: 0) + } + let values = history.suffix(60).filter { point in + point.generation == latest.generation && point.time.monotonic <= latest.time.monotonic + && point.time.monotonic >= latest.time.monotonic - 60 + && (since.map { cutoff in point.time.monotonic > cutoff } ?? true) + }.compactMap { point -> Double? in + guard + let reading = point.physicalSensors?.first(where: { + $0.key == sensor.key && $0.unit == sensor.unit && $0.group == sensor.group + }), + reading.availability == .available, let value = reading.value + else { return nil } + return SMCScalar.validated(value, unit: sensor.unit) + } + return PhysicalSensorExtrema( + minimum: values.min(), maximum: values.max(), validSamples: values.count) + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/SleepInspection.swift b/apps/toolbox/Sources/ToolboxMonitoring/SleepInspection.swift new file mode 100644 index 0000000..90a4eb0 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/SleepInspection.swift @@ -0,0 +1,83 @@ +import Foundation +import IOKit.pwr_mgt + +public struct SleepRequest: Sendable, Identifiable { + public let pid: Int32 + public let index: Int + public let type: String + public let name: String + public let owner: ProcessMetadata? + public var id: String { "\(pid):\(index)" } +} + +public struct SleepInspection: Sendable { + public let requests: [SleepRequest] + public let omittedCount: Int +} + +struct SleepAssertionParser { + static func parse( + _ assertions: [NSNumber: Any], before: [Int32: ProcessMetadata], access: any ProcessAccess + ) -> SleepInspection { + var requests: [SleepRequest] = [] + var omitted = 0 + for key in assertions.keys.sorted(by: { $0.int64Value < $1.int64Value }) { + guard let pid = Int32(exactly: key.int64Value), pid > 0, key.doubleValue == Double(pid), + let rows = assertions[key] as? [[String: Any]] + else { + omitted += 1 + continue + } + let initial = before[pid] + let current = try? access.metadata(pid: pid) + let owner = initial?.identity == current?.identity ? current : nil + for (index, row) in rows.enumerated() { + guard let level = row[kIOPMAssertionLevelKey] as? NSNumber else { + omitted += 1 + continue + } + if level.doubleValue == Double(kIOPMAssertionLevelOff) { continue } + guard level.doubleValue == Double(kIOPMAssertionLevelOn) else { + omitted += 1 + continue + } + guard let type = row[kIOPMAssertionTypeKey] as? String, !type.isEmpty, + requests.count < 512 + else { + omitted += 1 + continue + } + requests.append( + SleepRequest( + pid: pid, index: index, type: String(type.prefix(256)), + name: String((row[kIOPMAssertionNameKey] as? String ?? "").prefix(512)), owner: owner)) + } + } + return SleepInspection(requests: requests, omittedCount: omitted) + } +} + +extension SystemMonitoringProvider { + public func readSleepRequests(at time: SampleTime) async -> MetricSample { + // Capture identities before reading PID-keyed assertions; a later PID reuse cannot inherit ownership. + let processes = await readProcesses(at: time) + let before = Dictionary( + processes.observations.compactMap { $0.metadata }.map { ($0.identity.pid, $0) }, + uniquingKeysWith: { first, _ in first }) + var raw: Unmanaged? + let status = IOPMCopyAssertionsByProcess(&raw) + let dictionary = raw?.takeRetainedValue() + guard status == kIOReturnSuccess, let dictionary, + let assertions = dictionary as? [NSNumber: Any] + else { + return MetricSample( + availability: status == kIOReturnNotPrivileged ? .permissionRequired : .error, + unit: .collection, time: time, source: "IOPMCopyAssertionsByProcess", + reason: "Power assertion inspection failed (\(status))") + } + return MetricSample( + value: SleepAssertionParser.parse(assertions, before: before, access: SystemProcessAccess()), + availability: .available, unit: .collection, time: time, source: "IOPMCopyAssertionsByProcess" + ) + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/SystemHardwareProvider.swift b/apps/toolbox/Sources/ToolboxMonitoring/SystemHardwareProvider.swift new file mode 100644 index 0000000..de222f8 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/SystemHardwareProvider.swift @@ -0,0 +1,170 @@ +import Darwin +import Foundation +import IOKit +import IOKit.ps + +extension SystemMonitoringProvider { + func readDiskCounters(at time: SampleTime) -> MetricSample<[IOCounter]> { + var iterator: io_iterator_t = 0 + let result = IOServiceGetMatchingServices( + kIOMainPortDefault, IOServiceMatching("IOBlockStorageDriver"), &iterator) + guard result == KERN_SUCCESS else { + return MetricSample( + availability: .error, unit: .bytes, time: time, source: "IOBlockStorageDriver", + reason: "IOKit status \(result)") + } + defer { IOObjectRelease(iterator) } + var counters: [IOCounter] = [] + var missing = 0 + while true { + let device = IOIteratorNext(iterator) + if device == 0 { break } + defer { IOObjectRelease(device) } + var identity: UInt64 = 0 + guard IORegistryEntryGetRegistryEntryID(device, &identity) == KERN_SUCCESS, + let stats = IORegistryEntryCreateCFProperty( + device, "Statistics" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() + as? [String: Any], + let read = stats["Bytes (Read)"] as? NSNumber, + let write = stats["Bytes (Write)"] as? NSNumber, read.doubleValue >= 0, + write.doubleValue >= 0 + else { + missing += 1 + continue + } + counters.append( + IOCounter( + id: String(identity), name: "Device \(identity)", inputBytes: read.uint64Value, + outputBytes: write.uint64Value)) + } + return MetricSample( + value: counters.isEmpty ? nil : counters.sorted { $0.id < $1.id }, + availability: counters.isEmpty ? .unsupported : .available, unit: .bytes, time: time, + source: "IORegistry/IOBlockStorageDriver Statistics", + reason: missing > 0 + ? "\(missing) storage devices lack readable byte counters; coverage is partial" : nil) + } + + func readNetworkCounters(at time: SampleTime) -> MetricSample<[IOCounter]> { + guard let bytes = SystemNetworkCounters.read() else { + return MetricSample( + availability: .temporarilyUnavailable, unit: .bytes, time: time, + source: "sysctl/NET_RT_IFLIST2/if_data64", reason: "64-bit interface counters unavailable") + } + var interfaces: UnsafeMutablePointer? + guard getifaddrs(&interfaces) == 0 else { + return MetricSample( + availability: .error, unit: .bytes, time: time, source: "getifaddrs", + reason: "Interface enumeration failed") + } + defer { freeifaddrs(interfaces) } + var counters: [IOCounter] = [] + var cursor = interfaces + while let entry = cursor { + defer { cursor = entry.pointee.ifa_next } + guard entry.pointee.ifa_addr?.pointee.sa_family == UInt8(AF_LINK), + let namePointer = entry.pointee.ifa_name + else { continue } + let name = String(cString: namePointer) + let index = if_nametoindex(namePointer) + guard let counter = bytes[index] else { continue } + let isLoopback = (entry.pointee.ifa_flags & UInt32(IFF_LOOPBACK)) != 0 + let isUp = (entry.pointee.ifa_flags & UInt32(IFF_UP)) != 0 + // en* is the macOS physical Ethernet/Wi-Fi naming policy; tunnels/bridges stay separate. + let aggregate = name.hasPrefix("en") && !isLoopback && isUp + counters.append( + IOCounter( + id: "\(index):\(name)", name: name, + inputBytes: counter.input, outputBytes: counter.output, + includeInAggregate: aggregate)) + } + return MetricSample( + value: counters.sorted { $0.name < $1.name }, availability: .available, unit: .bytes, + time: time, source: "getifaddrs identity; sysctl/NET_RT_IFLIST2/if_data64") + } + + func readCapacity(at time: SampleTime) -> MetricSample { + do { + let values = try URL(fileURLWithPath: "/").resourceValues(forKeys: [ + .volumeTotalCapacityKey, .volumeAvailableCapacityKey, + ]) + guard let total = values.volumeTotalCapacity, let free = values.volumeAvailableCapacity, + total > 0, free >= 0, free <= total + else { + return MetricSample( + availability: .temporarilyUnavailable, unit: .bytes, time: time, + source: "URLResourceValues/startup volume") + } + return MetricSample( + value: DiskCapacity(totalBytes: UInt64(total), availableBytes: UInt64(free)), + availability: .available, unit: .bytes, time: time, + source: "URLResourceValues/startup volume") + } catch { + return MetricSample( + availability: .error, unit: .bytes, time: time, source: "URLResourceValues/startup volume", + reason: "Volume capacity read failed") + } + } + + func readBattery(at time: SampleTime) -> MetricSample { + guard let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let sources = IOPSCopyPowerSourcesList(info)?.takeRetainedValue() as? [CFTypeRef] + else { + return MetricSample( + availability: .error, unit: .percent, time: time, source: "IOPowerSources") + } + let batteries = sources.compactMap { source -> [String: Any]? in + guard + let description = IOPSGetPowerSourceDescription(info, source)?.takeUnretainedValue() + as? [String: Any], + description[kIOPSTransportTypeKey] as? String == kIOPSInternalType + else { return nil } + return description + } + guard !batteries.isEmpty else { + return MetricSample( + availability: .unsupported, unit: .percent, time: time, source: "IOPowerSources") + } + guard batteries.count == 1, let description = batteries.first, + let current = description[kIOPSCurrentCapacityKey] as? NSNumber, + let maximum = description[kIOPSMaxCapacityKey] as? NSNumber, + let charging = description[kIOPSIsChargingKey] as? Bool, + let state = description[kIOPSPowerSourceStateKey] as? String, + state == kIOPSBatteryPowerValue || state == kIOPSACPowerValue, + let battery = BatteryInfo( + current: current.doubleValue, maximum: maximum.doubleValue, + charging: charging, onBattery: state == kIOPSBatteryPowerValue) + else { + return MetricSample( + availability: .temporarilyUnavailable, unit: .percent, time: time, source: "IOPowerSources", + reason: "Battery fields are incomplete") + } + return MetricSample( + value: battery, availability: .available, unit: .percent, time: time, + source: "IOPowerSources/internal battery") + } + + func readThermal(at time: SampleTime) -> MetricSample { + let thermal: ThermalLevel + switch ProcessInfo.processInfo.thermalState { + case .nominal: thermal = .nominal + case .fair: thermal = .fair + case .serious: thermal = .serious + case .critical: thermal = .critical + @unknown default: thermal = .unknown + } + return MetricSample( + value: thermal, availability: thermal == .unknown ? .temporarilyUnavailable : .available, + unit: .state, time: time, source: "ProcessInfo.thermalState") + } + + func readSwap(at time: SampleTime) -> MetricSample { + var usage = xsw_usage() + var size = MemoryLayout.size + let result = sysctlbyname("vm.swapusage", &usage, &size, nil, 0) + return MetricSample( + value: result == 0 ? usage.xsu_used : nil, + availability: result == 0 ? .available : .error, unit: .bytes, time: time, + source: "sysctl/vm.swapusage") + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/SystemMonitoringProvider.swift b/apps/toolbox/Sources/ToolboxMonitoring/SystemMonitoringProvider.swift new file mode 100644 index 0000000..1341b44 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/SystemMonitoringProvider.swift @@ -0,0 +1,171 @@ +import Darwin +import Dispatch +import Foundation + +public protocol MonitoringProvider: Sendable { + func readAggregate(at time: SampleTime) async -> AggregateReading + func readProcesses(at time: SampleTime) async -> ProcessReading + func inspectProcess(_ identity: ProcessIdentity) async -> ProcessObservation + func readHardware(at time: SampleTime) async -> HardwareReading + func pauseHardware() async + func readSleepRequests(at time: SampleTime) async -> MetricSample +} + +extension MonitoringProvider { + public func readSleepRequests(at time: SampleTime) async -> MetricSample { + MetricSample( + availability: .unsupported, unit: .collection, time: time, source: "IOPM assertions") + } + public func readHardware(at time: SampleTime) async -> HardwareReading { .unsupported(at: time) } + public func pauseHardware() async {} + public func inspectProcess(_ identity: ProcessIdentity) async -> ProcessObservation { + ProcessObservation( + pid: identity.pid, metadata: nil, counters: nil, availability: .unsupported, reason: nil) + } + public func readProcesses(at time: SampleTime) async -> ProcessReading { + ProcessReading(observations: [], availability: .unsupported) + } +} + +public actor SystemMonitoringProvider: MonitoringProvider { + private var cachedHardware: HardwareReading? + private var pressureSource: (any DispatchSourceMemoryPressure)? + private var pressure: MemoryPressureLevel? + private var pressureTime: SampleTime? + private var pressureGeneration = 0 + public init() {} + + public func pauseHardware() async { + pressureGeneration += 1 + pressureSource?.setEventHandler {} + pressureSource?.cancel() + pressureSource = nil + pressure = nil + pressureTime = nil + cachedHardware = nil + } + + private func ensurePressureSource() { + guard pressureSource == nil else { return } + pressureGeneration += 1 + let token = pressureGeneration + let source = DispatchSource.makeMemoryPressureSource(eventMask: .all) + source.setEventHandler { [weak self] in + let events = source.data + let level: MemoryPressureLevel = + events.contains(.critical) ? .critical : (events.contains(.warning) ? .warning : .normal) + guard let provider = self else { return } + Task { await provider.recordPressure(level, token: token) } + } + pressureSource = source + source.resume() + } + + private func recordPressure(_ level: MemoryPressureLevel, token: Int) { + if token == pressureGeneration { + pressure = level + pressureTime = SampleTime(wall: Date(), monotonic: ProcessInfo.processInfo.systemUptime) + } + } + + public func readHardware(at time: SampleTime) async -> HardwareReading { + ensurePressureSource() + let old = cachedHardware + let refreshSlow = old == nil || time.monotonic - (old?.capacity.time.monotonic ?? 0) >= 10 + let reading = HardwareReading( + disks: readDiskCounters(at: time), interfaces: readNetworkCounters(at: time), + capacity: refreshSlow ? readCapacity(at: time) : old!.capacity, + battery: refreshSlow ? readBattery(at: time) : old!.battery, + thermal: readThermal(at: time), swap: readSwap(at: time), + pressure: MetricSample( + value: pressure, availability: pressure == nil ? .temporarilyUnavailable : .available, + unit: .state, time: pressureTime ?? time, source: "DispatchSourceMemoryPressure", + reason: pressure == nil ? "No OS memory-pressure event observed" : nil), + physicalSensors: refreshSlow ? PhysicalSensors.read(at: time) : old!.physicalSensors) + cachedHardware = reading + return reading + } + + public func inspectProcess(_ identity: ProcessIdentity) async -> ProcessObservation { + ProcessInspector().inspect(pid: identity.pid, expected: identity) + } + + public func readProcesses(at time: SampleTime) async -> ProcessReading { + let requested = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0) + guard requested > 0 else { + return ProcessReading( + observations: [], availability: .error, reason: "Process enumeration failed") + } + // Bound enumeration, with headroom for processes created between the two calls. + let capacity = min(Int(requested) / MemoryLayout.size + 1024, 65536) + var pids = [Int32](repeating: 0, count: capacity) + let read = pids.withUnsafeMutableBytes { + proc_listpids(UInt32(PROC_ALL_PIDS), 0, $0.baseAddress, Int32($0.count)) + } + guard read > 0 else { + return ProcessReading( + observations: [], availability: .error, reason: "Process enumeration failed") + } + let truncated = Int(read) >= capacity * MemoryLayout.size + let inspector = ProcessInspector() + var observations: [ProcessObservation] = [] + for pid in Set(pids.prefix(Int(read) / MemoryLayout.size)).sorted() where pid > 0 { + if Task.isCancelled { break } + observations.append(inspector.inspect(pid: pid)) + } + var timebase = mach_timebase_info_data_t() + mach_timebase_info(&timebase) + return ProcessReading( + observations: observations, availability: .available, + reason: truncated ? "Process list reached its 65,536-entry bound" : nil, + timebaseNumerator: timebase.numer, timebaseDenominator: timebase.denom) + } + + public func readAggregate(at time: SampleTime) async -> AggregateReading { + let host = mach_host_self() + defer { mach_port_deallocate(mach_task_self_, host) } + var ticks = host_cpu_load_info() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size) + let cpuStatus = withUnsafeMutablePointer(to: &ticks) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(host, HOST_CPU_LOAD_INFO, $0, &count) + } + } + let cpu = MetricSample( + value: cpuStatus == KERN_SUCCESS + ? CPUTicks( + user: UInt64(ticks.cpu_ticks.0), system: UInt64(ticks.cpu_ticks.1), + nice: UInt64(ticks.cpu_ticks.3), idle: UInt64(ticks.cpu_ticks.2)) : nil, + availability: cpuStatus == KERN_SUCCESS ? .available : .error, unit: .ticks, + time: time, source: "host_statistics/HOST_CPU_LOAD_INFO", + reason: cpuStatus == KERN_SUCCESS ? nil : "Mach status \(cpuStatus)") + + var vm = vm_statistics64() + var vmCount = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size) + let vmStatus = withUnsafeMutablePointer(to: &vm) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(vmCount)) { + host_statistics64(host, HOST_VM_INFO64, $0, &vmCount) + } + } + var pageSize: vm_size_t = 0 + let pageStatus = host_page_size(host, &pageSize) + let totalBytes = ProcessInfo.processInfo.physicalMemory + let freeBytes = UInt64(vm.free_count) * UInt64(pageSize) + let memoryAvailable = + vmStatus == KERN_SUCCESS && pageStatus == KERN_SUCCESS && pageSize > 0 + && totalBytes > 0 && freeBytes <= totalBytes + let memory = MetricSample( + value: memoryAvailable + ? MemoryInfo( + totalBytes: totalBytes, + freeBytes: freeBytes, + wiredBytes: UInt64(vm.wire_count) * UInt64(pageSize), + compressedBytes: UInt64(vm.compressor_page_count) * UInt64(pageSize)) : nil, + availability: memoryAvailable ? .available : .error, unit: .bytes, + time: time, source: "host_statistics64/HOST_VM_INFO64; host_page_size", + reason: memoryAvailable ? nil : "Mach status \(vmStatus)/\(pageStatus)") + return AggregateReading(cpu: cpu, memory: memory) + } +} diff --git a/apps/toolbox/Sources/ToolboxMonitoring/SystemNetworkCounters.swift b/apps/toolbox/Sources/ToolboxMonitoring/SystemNetworkCounters.swift new file mode 100644 index 0000000..3e25da4 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxMonitoring/SystemNetworkCounters.swift @@ -0,0 +1,49 @@ +import Darwin +import Foundation + +struct NetworkByteCounters: Sendable { + let input: UInt64 + let output: UInt64 +} + +enum SystemNetworkCounters { + static func read() -> [UInt32: NetworkByteCounters]? { + var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, AF_LINK, NET_RT_IFLIST2, 0] + var size = 0 + guard sysctl(&mib, UInt32(mib.count), nil, &size, nil, 0) == 0, + size > 0, size <= 16 * 1024 * 1024 + else { return nil } + var bytes = Data(count: size) + let result = bytes.withUnsafeMutableBytes { + sysctl(&mib, UInt32(mib.count), $0.baseAddress, &size, nil, 0) + } + guard result == 0, size <= bytes.count else { return nil } + bytes.count = size + return decode(bytes) + } + + static func decode(_ bytes: Data) -> [UInt32: NetworkByteCounters]? { + bytes.withUnsafeBytes { raw in + var offset = 0 + var counters: [UInt32: NetworkByteCounters] = [:] + while offset < raw.count { + guard raw.count - offset >= 4 else { return nil } + let length = Int(raw.loadUnaligned(fromByteOffset: offset, as: UInt16.self)) + guard length >= 4, length <= raw.count - offset else { return nil } + let type = raw.load(fromByteOffset: offset + 3, as: UInt8.self) + if type == UInt8(RTM_IFINFO2) { + guard length >= MemoryLayout.size else { return nil } + let header = raw.loadUnaligned(fromByteOffset: offset, as: if_msghdr2.self) + let index = UInt32(header.ifm_index) + guard header.ifm_version == UInt8(RTM_VERSION), index > 0, + counters[index] == nil + else { return nil } + counters[index] = NetworkByteCounters( + input: header.ifm_data.ifi_ibytes, output: header.ifm_data.ifi_obytes) + } + offset += length + } + return counters + } + } +} diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Applications/ApplicationManager.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Applications/ApplicationManager.swift index 7d995a6..4e172b0 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Features/Applications/ApplicationManager.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Applications/ApplicationManager.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import Foundation import ToolboxCore @@ -28,6 +29,20 @@ struct InstalledApplication: Identifiable, Sendable { let bundleIdentifier: String? let bytes: Int64 let leftovers: [ApplicationArtifact] + let backgroundEvidenceAvailable: Bool + + init( + url: URL, name: String, bundleIdentifier: String?, bytes: Int64, + leftovers: [ApplicationArtifact], backgroundEvidenceAvailable: Bool = false + ) { + self.url = url + self.name = name + self.bundleIdentifier = bundleIdentifier + self.bytes = bytes + self.leftovers = leftovers + self.backgroundEvidenceAvailable = backgroundEvidenceAvailable + } + var id: String { url.path } var totalBytes: Int64 { bytes + leftovers.reduce(0) { $0 + $1.entry.bytes } } } @@ -64,7 +79,8 @@ struct ApplicationScanner: Sendable { bytes: (try? service.size(of: url)) ?? 0, leftovers: findLeftovers( name: name, bundleID: bundleID, home: home, service: service, - backgroundItems: backgroundItems) + backgroundItems: backgroundItems ?? ""), + backgroundEvidenceAvailable: backgroundItems != nil )) } } @@ -230,26 +246,13 @@ struct ApplicationScanner: Sendable { return ApplicationArtifact( entry: StorageEntry(url: url, bytes: size, modifiedAt: nil), kind: .packageReceipt, confidence: .dangerous, - evidence: "Receipt hệ thống chỉ dùng làm bằng chứng; Toolbox không tự xóa") + evidence: "Receipt hệ thống chỉ dùng làm bằng chứng; Orven không tự xóa") } } - private func backgroundItemsDump() -> String { - let executable = URL(fileURLWithPath: "/usr/bin/sfltool") - guard FileManager.default.isExecutableFile(atPath: executable.path) else { return "" } - let process = Process() - let pipe = Pipe() - process.executableURL = executable - process.arguments = ["dumpbtm"] - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - do { - try process.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0, data.count < 8_000_000 else { return "" } - return String(decoding: data, as: UTF8.self) - } catch { return "" } + private func backgroundItemsDump() -> String? { + guard let data = try? SystemInspectionRead.backgroundItems.read() else { return nil } + return String(decoding: data, as: UTF8.self) } private func findBackgroundItemEvidence( @@ -267,7 +270,7 @@ struct ApplicationScanner: Sendable { url: database, bytes: (try? service.size(of: database)) ?? 0, modifiedAt: nil), kind: .loginItem, confidence: .dangerous, evidence: - "sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Toolbox không xóa database hệ thống" + "sfltool phát hiện login/background item; chỉ hiển thị bằng chứng, Orven không xóa database hệ thống" ) ] } @@ -292,6 +295,7 @@ struct ApplicationScanner: Sendable { final class ApplicationViewModel: ObservableObject { @Published var applications: [InstalledApplication] = [] @Published var isWorking = false + @Published private(set) var scannedAt: Date? @Published var status = "Quét ứng dụng để tìm dữ liệu còn sót" @Published var errorMessage: String? private let scanner = ApplicationScanner() @@ -305,6 +309,7 @@ final class ApplicationViewModel: ObservableObject { let scanner = self.scanner Task { applications = await Task.detached { scanner.scan() }.value + scannedAt = Date() ScanActivityRegistry.shared.end() isWorking = false status = "Tìm thấy \(applications.count) ứng dụng" diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Cleaning/ScheduledScan.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Cleaning/ScheduledScan.swift index b77f75e..58afa4d 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Features/Cleaning/ScheduledScan.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Cleaning/ScheduledScan.swift @@ -91,7 +91,7 @@ public struct ScheduledScanService: Sendable { guard appURL.pathExtension == "app" else { throw NSError( domain: Self.label, code: 1, - userInfo: [NSLocalizedDescriptionKey: "Chỉ có thể lập lịch từ bản Toolbox.app đã đóng gói."] + userInfo: [NSLocalizedDescriptionKey: "Chỉ có thể lập lịch từ bản Orven.app đã đóng gói."] ) } let granted = try await notifications.requestAuthorization() @@ -145,11 +145,11 @@ public struct ScheduledScanService: Sendable { let bytes = results.reduce(Int64(0)) { $0 + $1.bytes } let issues = results.compactMap(\.issue).count let content = UNMutableNotificationContent() - content.title = "Toolbox đã quét xong" + content.title = "Orven đã quét xong" content.body = issues == 0 - ? "Có \(ByteCount.string(bytes)) dữ liệu an toàn để bạn xem xét. Toolbox chưa xóa gì." - : "Có \(ByteCount.string(bytes)) để xem xét và \(issues) vị trí không thể đọc. Toolbox chưa xóa gì." + ? "Có \(ByteCount.string(bytes)) dữ liệu an toàn để bạn xem xét. Orven chưa xóa gì." + : "Có \(ByteCount.string(bytes)) để xem xét và \(issues) vị trí không thể đọc. Orven chưa xóa gì." content.sound = .default try? await UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) @@ -172,7 +172,7 @@ final class ScheduledScanViewModel: ObservableObject { @Published var intervalHours = UserDefaults.standard.integer( forKey: "toolbox.scheduledScanIntervalHours") @Published var isWorking = false - @Published var status = "Lịch quét chỉ thông báo; Toolbox không tự xóa dữ liệu." + @Published var status = "Lịch quét chỉ thông báo; Orven không tự xóa dữ liệu." @Published var errorMessage: String? private let service = ScheduledScanService() diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Projects/ProjectModels.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Projects/ProjectModels.swift index 1be9ae8..b973cc6 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Features/Projects/ProjectModels.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Projects/ProjectModels.swift @@ -38,6 +38,7 @@ struct ProjectScanReport: Codable, Hashable, Sendable { let artifacts: [ProjectArtifact] let issues: [ProjectScanIssue] let cancelled: Bool + var inventory: [ProjectInventory]? = nil var reclaimableBytes: Int64 { artifacts.filter { $0.safety == .safe }.reduce(0) { $0 + $1.bytes } @@ -49,3 +50,10 @@ struct ProjectRule: Sendable { let anyMarkers: Set let artifactPaths: Set } + +struct ProjectInventory: Codable, Hashable, Identifiable, Sendable { + let root: URL + let ecosystems: [ProjectEcosystem] + let markers: [String] + var id: String { root.path } +} diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Projects/ProjectScanner.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Projects/ProjectScanner.swift index 82abd24..fcdeb41 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Features/Projects/ProjectScanner.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Projects/ProjectScanner.swift @@ -31,6 +31,7 @@ struct ProjectScanner: Sendable { var artifactsByPath: [String: ProjectArtifact] = [:] var issues: [ProjectScanIssue] = [] var scannedProjects = Set() + var inventory: [String: ProjectInventory] = [:] var cancelled = false for root in normalizedRoots { @@ -44,7 +45,8 @@ struct ProjectScanner: Sendable { } scanDirectory( root, selectedRoot: root, depth: 0, manager: manager, - scannedProjects: &scannedProjects, artifactsByPath: &artifactsByPath, issues: &issues, + scannedProjects: &scannedProjects, inventory: &inventory, artifactsByPath: &artifactsByPath, + issues: &issues, cancelled: &cancelled) } @@ -56,12 +58,14 @@ struct ProjectScanner: Sendable { } return ProjectScanReport( roots: normalizedRoots, scannedProjectCount: scannedProjects.count, artifacts: artifacts, - issues: issues.sorted { $0.path < $1.path }, cancelled: cancelled) + issues: issues.sorted { $0.path < $1.path }, cancelled: cancelled, + inventory: inventory.values.sorted { $0.root.path < $1.root.path }) } private func scanDirectory( _ directory: URL, selectedRoot: URL, depth: Int, manager: FileManager, - scannedProjects: inout Set, artifactsByPath: inout [String: ProjectArtifact], + scannedProjects: inout Set, inventory: inout [String: ProjectInventory], + artifactsByPath: inout [String: ProjectArtifact], issues: inout [ProjectScanIssue], cancelled: inout Bool ) { guard !Task.isCancelled else { @@ -80,10 +84,21 @@ struct ProjectScanner: Sendable { return } - let childNames = Set(children.map(\.lastPathComponent)) + let childNames = Set( + children.compactMap { child -> String? in + guard + let values = try? child.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + values.isRegularFile == true, values.isSymbolicLink != true + else { return nil } + return child.lastPathComponent + }) let matchedRules = Self.rules.filter { !$0.anyMarkers.isDisjoint(with: childNames) } if !matchedRules.isEmpty { scannedProjects.insert(directory.standardizedFileURL.path) + inventory[directory.standardizedFileURL.path] = ProjectInventory( + root: directory, + ecosystems: matchedRules.map(\.ecosystem), + markers: Set(matchedRules.flatMap(\.anyMarkers)).intersection(childNames).sorted()) collectArtifacts( for: matchedRules, projectRoot: directory, selectedRoot: selectedRoot, manager: manager, artifactsByPath: &artifactsByPath, issues: &issues) @@ -111,7 +126,8 @@ struct ProjectScanner: Sendable { else { continue } scanDirectory( child, selectedRoot: selectedRoot, depth: depth + 1, manager: manager, - scannedProjects: &scannedProjects, artifactsByPath: &artifactsByPath, issues: &issues, + scannedProjects: &scannedProjects, inventory: &inventory, artifactsByPath: &artifactsByPath, + issues: &issues, cancelled: &cancelled) } } diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Projects/WorkspaceUsage.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Projects/WorkspaceUsage.swift new file mode 100644 index 0000000..346457a --- /dev/null +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Projects/WorkspaceUsage.swift @@ -0,0 +1,47 @@ +import Foundation + +struct WorkspaceUsage: Sendable { + let artifactBytes: Int64? + let otherBytes: Int64? + + init(snapshot: StorageSnapshot, artifacts: [ProjectArtifact]) { + guard let folders = snapshot.comparisonFolders else { + artifactBytes = nil + otherBytes = nil + return + } + func canonical(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + } + let root = canonical(snapshot.rootURL.path) + var paths = Set() + for artifact in artifacts where artifact.safety == .safe { + guard canonical(artifact.artifactURL.deletingLastPathComponent().path) == root else { + artifactBytes = nil + otherBytes = nil + return + } + // Scanner aliases must use the analyzer's root spelling for dictionary lookup. + paths.insert( + snapshot.rootURL.appendingPathComponent(artifact.artifactURL.lastPathComponent).path) + } + let matched = paths.compactMap { folders[$0] } + var sum: Int64 = 0 + for bytes in matched { + let addition = sum.addingReportingOverflow(bytes) + guard bytes >= 0, !addition.overflow else { + artifactBytes = nil + otherBytes = nil + return + } + sum = addition.partialValue + } + guard sum <= snapshot.scannedBytes else { + artifactBytes = nil + otherBytes = nil + return + } + artifactBytes = sum + otherBytes = snapshot.scannedBytes - sum + } +} diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Storage/AnalyzerViewModel.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Storage/AnalyzerViewModel.swift index e6b4631..bd29a98 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Features/Storage/AnalyzerViewModel.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Storage/AnalyzerViewModel.swift @@ -9,7 +9,8 @@ final class AnalyzerViewModel: ObservableObject { @Published var isScanning = false @Published var status = "Chọn Quét dung lượng để bắt đầu" @Published var errorMessage: String? - @Published var folderDeltas: [StorageDelta] = [] + @Published var comparison: StorageComparison? + var folderDeltas: [StorageDelta] { comparison?.deltas ?? [] } private var scanTask: Task? private let analyzer = StorageAnalyzer() @@ -25,11 +26,13 @@ final class AnalyzerViewModel: ObservableObject { if panel.runModal() == .OK, let url = panel.url { rootURL = url snapshot = nil + comparison = nil status = "Sẵn sàng quét \(url.path)" } } func scan() { + guard !isScanning else { return } scanTask?.cancel() isScanning = true errorMessage = nil @@ -39,11 +42,17 @@ final class AnalyzerViewModel: ObservableObject { let root = rootURL scanTask = Task { do { - let result = try await Task.detached(priority: .userInitiated) { + let worker = Task.detached(priority: .userInitiated) { try analyzer.scan(rootURL: root) - }.value + } + let result = try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + try Task.checkCancellation() snapshot = result - folderDeltas = trendStore.compareAndSave(result) + comparison = try trendStore.compareAndSave(result) status = "Đã quét \(result.fileCount.formatted()) tệp • \(ByteCount.string(result.scannedBytes))" } catch is CancellationError { diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageAnalyzer.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageAnalyzer.swift index 5a674ba..bf8cfeb 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageAnalyzer.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageAnalyzer.swift @@ -71,6 +71,7 @@ struct StorageAnalyzer: Sendable { { let manager = FileManager() let root = rootURL.resolvingSymlinksInPath().standardizedFileURL + let initialIdentity = storageRootIdentity(root) let keys: Set = [ .isRegularFileKey, .isSymbolicLinkKey, .fileAllocatedSizeKey, .totalFileAllocatedSizeKey, .fileSizeKey, .contentModificationDateKey, @@ -103,10 +104,15 @@ struct StorageAnalyzer: Sendable { for case let fileURL as URL in enumerator { if Task.isCancelled { throw CancellationError() } - guard let values = try? fileURL.resourceValues(forKeys: keys), - values.isRegularFile == true, - values.isSymbolicLink != true - else { continue } + guard let values = try? fileURL.resourceValues(forKeys: keys) else { + inaccessibleCount += 1 + continue + } + if values.isSymbolicLink == true { + enumerator.skipDescendants() + continue + } + guard values.isRegularFile == true else { continue } let normalizedFileURL = fileURL.path.hasPrefix(rootPath) ? fileURL.standardizedFileURL @@ -168,7 +174,13 @@ struct StorageAnalyzer: Sendable { largeFiles: Array(largeFiles.sorted { $0.bytes > $1.bytes }.prefix(200)), categories: categories, developerData: developerData, - completedAt: Date() + completedAt: Date(), + comparisonFolders: folderSizes.count <= 5000 + ? Dictionary( + uniqueKeysWithValues: folderSizes.map { + (root.appendingPathComponent($0.key).path, $0.value) + }) : nil, + rootIdentity: initialIdentity == storageRootIdentity(root) ? initialIdentity : nil ) } diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageModels.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageModels.swift index de210b2..00cb35e 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageModels.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageModels.swift @@ -59,4 +59,13 @@ struct StorageSnapshot: Sendable { let categories: [CategoryUsage] let developerData: [DeveloperUsage] let completedAt: Date + var comparisonFolders: [String: Int64]? = nil + var rootIdentity: String? = nil +} + +func storageRootIdentity(_ root: URL) -> String? { + let attributes = try? FileManager.default.attributesOfItem(atPath: root.path) + let device = (attributes?[.systemNumber] as? NSNumber)?.stringValue + let inode = (attributes?[.systemFileNumber] as? NSNumber)?.stringValue + return device.flatMap { device in inode.map { device + ":" + $0 } } } diff --git a/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageTrend.swift b/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageTrend.swift index 11dc4cb..280f3a7 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageTrend.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Features/Storage/StorageTrend.swift @@ -6,6 +6,11 @@ struct StorageCheckpoint: Codable, Sendable { let date: Date let totalBytes: Int64 let folders: [String: Int64] + // Optional fields preserve decoding of existing checkpoints; old records establish no comparison. + var schemaVersion: Int? = nil + var rootIdentity: String? = nil + var inaccessibleCount: Int? = nil + var foldersComplete: Bool? = nil } struct StorageDelta: Identifiable, Sendable { @@ -14,29 +19,75 @@ struct StorageDelta: Identifiable, Sendable { var id: String { path } } +struct StorageComparison: Sendable { + let previousDate: Date? + let deltas: [StorageDelta] + let status: String + + static func compare(previous: StorageCheckpoint?, current: StorageCheckpoint) -> StorageComparison + { + guard let previous else { + return .init( + previousDate: nil, deltas: [], + status: "Baseline saved. Scan the same location again to compare growth.") + } + guard previous.schemaVersion == 2, current.schemaVersion == 2, + previous.rootPath == current.rootPath, let identity = current.rootIdentity, + previous.rootIdentity == identity, + previous.inaccessibleCount == 0, current.inaccessibleCount == 0, + previous.foldersComplete == true, current.foldersComplete == true, + previous.date < current.date, + previous.folders.values.allSatisfy({ $0 >= 0 }), + current.folders.values.allSatisfy({ $0 >= 0 }) + else { + return .init( + previousDate: previous.date, deltas: [], + status: + "Scans are not comparable: scope, identity, coverage or checkpoint format differs. A new baseline was saved." + ) + } + let deltas = Set(previous.folders.keys).union(current.folders.keys).compactMap { + path -> StorageDelta? in + let delta = (current.folders[path] ?? 0) - (previous.folders[path] ?? 0) + return delta == 0 ? nil : .init(path: path, bytes: delta) + }.sorted { abs($0.bytes) == abs($1.bytes) ? $0.path < $1.path : abs($0.bytes) > abs($1.bytes) } + return .init( + previousDate: previous.date, deltas: deltas, + status: + "Comparable observed folder changes. Decreases do not prove APFS space was freed; changes do not identify their cause." + ) + } +} + struct StorageTrendStore: Sendable { private var url: URL { AppMetadata.applicationSupportDirectory().appendingPathComponent("storage-checkpoints.json") } - func compareAndSave(_ snapshot: StorageSnapshot) -> [StorageDelta] { - let old = load().last { $0.rootPath == snapshot.rootURL.path } - let folders = Dictionary( - uniqueKeysWithValues: snapshot.topFolders.map { ($0.url.path, $0.bytes) }) - var all = load() - all.append( - .init( - rootPath: snapshot.rootURL.path, date: Date(), totalBytes: snapshot.scannedBytes, - folders: folders)) + func compareAndSave(_ snapshot: StorageSnapshot) throws -> StorageComparison { + var all = try load() + let old = all.last { $0.rootPath == snapshot.rootURL.path } + let current = StorageCheckpoint( + rootPath: snapshot.rootURL.path, date: snapshot.completedAt, + totalBytes: snapshot.scannedBytes, + folders: snapshot.comparisonFolders ?? [:], + schemaVersion: 2, rootIdentity: snapshot.rootIdentity, + inaccessibleCount: snapshot.inaccessibleCount, + foldersComplete: snapshot.comparisonFolders != nil) + all.append(current) all = Array(all.suffix(50)) - try? FileManager.default.createDirectory( + try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try? JSONEncoder().encode(all).write(to: url, options: .atomic) - guard let old else { return [] } - return folders.map { StorageDelta(path: $0.key, bytes: $0.value - (old.folders[$0.key] ?? 0)) } - .filter { $0.bytes != 0 }.sorted { abs($0.bytes) > abs($1.bytes) } + try JSONEncoder().encode(all).write(to: url, options: .atomic) + return StorageComparison.compare(previous: old, current: current) } - private func load() -> [StorageCheckpoint] { - guard let data = try? Data(contentsOf: url) else { return [] } - return (try? JSONDecoder().decode([StorageCheckpoint].self, from: data)) ?? [] + private func load() throws -> [StorageCheckpoint] { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard ((attributes[.size] as? NSNumber)?.int64Value ?? Int64.max) <= 32_000_000 else { + throw CocoaError(.fileReadTooLarge) + } + let data = try Data(contentsOf: url) + guard data.count <= 32_000_000 else { throw CocoaError(.fileReadTooLarge) } + return try JSONDecoder().decode([StorageCheckpoint].self, from: data) } } diff --git a/apps/toolbox/Sources/ToolboxStorage/Views/AppDossierView.swift b/apps/toolbox/Sources/ToolboxStorage/Views/AppDossierView.swift new file mode 100644 index 0000000..0cade80 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxStorage/Views/AppDossierView.swift @@ -0,0 +1,90 @@ +import AppKit +import SwiftUI +import ToolboxCore + +/// Explicit inspection of the selected scan result; no additional folder scan or mutation. +struct AppDossierView: View { + let app: InstalledApplication + let observedAt: Date? + var activity: AnyView? = nil + @Environment(\.dismiss) private var dismiss + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + private var locale: Locale { + AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(L10n.text("App Dossier")).font(.title2.bold()) + Spacer() + Button(L10n.text("Close")) { dismiss() }.keyboardShortcut(.cancelAction) + } + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(app.name).font(.title.bold()) + field("Bundle ID", app.bundleIdentifier ?? L10n.text("Unknown")) + field("Application path", app.url.path) + if let info = Bundle(url: app.url)?.infoDictionary { + field("Version", info["CFBundleShortVersionString"] as? String ?? L10n.text("Unknown")) + field( + "Minimum macOS", info["LSMinimumSystemVersion"] as? String ?? L10n.text("Unknown")) + } + if let activity { activity } + Divider() + field("Application bundle size", ByteCount.string(app.bytes)) + field("Related data observed", ByteCount.string(app.totalBytes - app.bytes)) + if let observedAt { + field( + "Last application scan", + observedAt.formatted( + .dateTime.year().month().day().hour().minute().locale(locale))) + } + Text( + L10n.text( + "Sizes are from the last scan. Related data is attributed by scan evidence, not proof of exclusive ownership. Unreadable locations may be missing." + ) + ) + .font(.callout).foregroundStyle(.secondary) + if !app.backgroundEvidenceAvailable { + Text( + L10n.text( + "Background-item evidence unavailable. The macOS helper did not complete; scan coverage is partial." + ) + ) + .font(.callout).foregroundStyle(.secondary) + } + ForEach(app.leftovers) { item in + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(L10n.text(item.kind.rawValue)).fontWeight(.semibold) + Spacer() + Text(ByteCount.string(item.entry.bytes)).monospacedDigit() + } + Text(item.entry.url.path).font(.caption).textSelection(.enabled) + Text(L10n.text(item.evidence)).font(.caption).foregroundStyle(.secondary) + Label(L10n.text(item.confidence.title), systemImage: item.confidence.symbol) + .font(.caption) + }.padding(12).background(OrvenTheme.canvas, in: RoundedRectangle(cornerRadius: 8)) + } + if app.leftovers.isEmpty { + Text(L10n.text("No related data found within scan coverage.")) + .foregroundStyle(.secondary) + } + }.frame(maxWidth: .infinity, alignment: .leading) + } + Button(L10n.text("Show in Finder")) { + NSWorkspace.shared.activateFileViewerSelecting([app.url]) + } + }.padding(24).frame(width: 600, height: 560).background(OrvenTheme.surface) + .environment(\.locale, locale) + } + + private func field(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.text(title)).font(.caption).foregroundStyle(.secondary) + Text(value).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/apps/toolbox/Sources/ToolboxStorage/Views/ApplicationsView.swift b/apps/toolbox/Sources/ToolboxStorage/Views/ApplicationsView.swift index 3c077bc..1e7f555 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Views/ApplicationsView.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Views/ApplicationsView.swift @@ -2,52 +2,82 @@ import SwiftUI import ToolboxCore struct ApplicationsView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue @ObservedObject var model: ApplicationViewModel + var applicationActivity: ((URL) -> AnyView)? = nil @State private var selected: InstalledApplication? @State private var includeLeftovers = true + @State private var inspected: InstalledApplication? + @State private var focusedAppID: String? + @State private var searchText = "" + @State private var sortsByName = false + + private var displayedApplications: [InstalledApplication] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return model.applications.filter { app in + query.isEmpty || app.name.localizedCaseInsensitiveContains(query) + || (app.bundleIdentifier?.localizedCaseInsensitiveContains(query) ?? false) + }.sorted { lhs, rhs in + if sortsByName { return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } + return lhs.totalBytes == rhs.totalBytes ? lhs.id < rhs.id : lhs.totalBytes > rhs.totalBytes + } + } var body: some View { VStack(spacing: 0) { PageHeader( title: "Gỡ ứng dụng", subtitle: "Ứng dụng và dữ liệu còn sót", symbol: "app.badge", value: model.applications.count.formatted(), valueLabel: "ứng dụng") Divider() + HStack { + TextField(L10n.text("Search apps or Bundle ID"), text: $searchText) + .textFieldStyle(.roundedBorder) + Toggle(L10n.text("Sort by name"), isOn: $sortsByName).toggleStyle(.checkbox) + }.padding(.horizontal, 18).padding(.vertical, 10) if model.applications.isEmpty { EmptyStateView( title: "Chưa quét ứng dụng", symbol: "app.badge", detail: "App sẽ tìm bundle trong Applications và đối chiếu Bundle ID với dữ liệu trong Library." ) + } else if displayedApplications.isEmpty { + EmptyStateView( + title: "No matching applications", symbol: "magnifyingglass", + detail: "Clear filters or change the search query.") } else { - List(model.applications) { app in - DisclosureGroup { - ForEach(app.leftovers) { item in + List(displayedApplications, selection: $focusedAppID) { app in + HStack(alignment: .top, spacing: 12) { + DisclosureGroup { + ForEach(app.leftovers) { item in + HStack { + Image(systemName: item.confidence.symbol).foregroundStyle( + item.confidence == .safe ? .green : item.confidence == .review ? .orange : .red) + VStack(alignment: .leading, spacing: 2) { + Text(item.entry.url.path).lineLimit(1) + Text("\(L10n.text(item.kind.rawValue)) • \(L10n.text(item.evidence))") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text(ByteCount.string(item.entry.bytes)) + } + } + } label: { HStack { - Image(systemName: item.confidence.symbol).foregroundStyle( - item.confidence == .safe ? .green : item.confidence == .review ? .orange : .red) - VStack(alignment: .leading, spacing: 2) { - Text(item.entry.url.path).lineLimit(1) - Text("\(L10n.text(item.kind.rawValue)) • \(L10n.text(item.evidence))") - .font(.caption).foregroundStyle(.secondary) + Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path)).resizable().frame( + width: 34, height: 34) + VStack(alignment: .leading) { + Text(app.name).fontWeight(.medium) + Text(app.bundleIdentifier ?? L10n.text("Không có Bundle ID")).font(.caption) + .foregroundStyle( + .secondary) } Spacer() - Text(ByteCount.string(item.entry.bytes)) - } - } - } label: { - HStack { - Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path)).resizable().frame( - width: 34, height: 34) - VStack(alignment: .leading) { - Text(app.name).fontWeight(.medium) - Text(app.bundleIdentifier ?? L10n.text("Không có Bundle ID")).font(.caption) - .foregroundStyle( - .secondary) + Text(ByteCount.string(app.totalBytes)) + } - Spacer() - Text(ByteCount.string(app.totalBytes)) - Button("Gỡ…") { selected = app } } - }.padding(.vertical, 4) + Button("Gỡ…") { selected = app }.buttonStyle(.bordered) + }.padding(.vertical, 4).tag(app.id) }.listStyle(.inset) } Divider() @@ -55,8 +85,16 @@ struct ApplicationsView: View { if model.isWorking { ProgressView().controlSize(.small) } Text(L10n.text(model.status)) Spacer() + Button(L10n.text("App Dossier")) { + inspected = displayedApplications.first { $0.id == focusedAppID } + }.disabled(!displayedApplications.contains { $0.id == focusedAppID }) Button("Quét ứng dụng") { model.scan() }.disabled(model.isWorking) }.padding(18) + }.environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + ) + .sheet(item: $inspected) { app in + AppDossierView(app: app, observedAt: model.scannedAt, activity: applicationActivity?(app.url)) }.alert( "Gỡ \(selected?.name ?? "ứng dụng")?", isPresented: Binding(get: { selected != nil }, set: { if !$0 { selected = nil } }) diff --git a/apps/toolbox/Sources/ToolboxStorage/Views/ContentView.swift b/apps/toolbox/Sources/ToolboxStorage/Views/ContentView.swift index 223bcca..6835dcc 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Views/ContentView.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Views/ContentView.swift @@ -34,6 +34,7 @@ public struct StorageModuleSummary: Sendable, Equatable { public struct StorageModuleView: View { private let destination: StorageDestination private let focusPath: String? + private let applicationActivity: ((URL) -> AnyView)? @State private var workspaceSection: StorageWorkspaceSection = .cleanup @StateObject private var cleaner = CleanerViewModel() @StateObject private var analyzer = AnalyzerViewModel() @@ -43,9 +44,13 @@ public struct StorageModuleView: View { @StateObject private var applications = ApplicationViewModel() @StateObject private var history = HistoryViewModel() - public init(destination: StorageDestination, focusPath: String? = nil) { + public init( + destination: StorageDestination, focusPath: String? = nil, + applicationActivity: ((URL) -> AnyView)? = nil + ) { self.destination = destination self.focusPath = focusPath + self.applicationActivity = applicationActivity } public var body: some View { @@ -95,7 +100,7 @@ public struct StorageModuleView: View { case .projects: ProjectsView() case .applications: - ApplicationsView(model: applications) + ApplicationsView(model: applications, applicationActivity: applicationActivity) case .recovery: HistoryView(model: history) } diff --git a/apps/toolbox/Sources/ToolboxStorage/Views/DeveloperStorageView.swift b/apps/toolbox/Sources/ToolboxStorage/Views/DeveloperStorageView.swift index 858534d..6e86421 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Views/DeveloperStorageView.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Views/DeveloperStorageView.swift @@ -99,7 +99,7 @@ struct DeveloperStorageView: View { } Divider() HStack { - Text("Toolbox chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ.") + Text("Orven chỉ dọn sau khi xác nhận và ưu tiên command chính thức của từng công cụ.") .font(.callout).foregroundStyle(.secondary) Spacer() Button("Quét thư mục người dùng") { @@ -137,7 +137,7 @@ struct DeveloperStorageView: View { } } message: { Text( - "Toolbox sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." + "Orven sẽ chạy đúng command được hiển thị từ allowlist. Command này không chuyển dữ liệu vào Trash và không thể Undo." ) } .alert("Developer Cleanup thất bại", isPresented: $showsCleanupError) { diff --git a/apps/toolbox/Sources/ToolboxStorage/Views/DeveloperWorkspaceView.swift b/apps/toolbox/Sources/ToolboxStorage/Views/DeveloperWorkspaceView.swift new file mode 100644 index 0000000..8715142 --- /dev/null +++ b/apps/toolbox/Sources/ToolboxStorage/Views/DeveloperWorkspaceView.swift @@ -0,0 +1,106 @@ +import AppKit +import SwiftUI +import ToolboxCore + +struct DeveloperWorkspaceView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue + let report: ProjectScanReport + @State private var expanded = false + @StateObject private var analyzer = AnalyzerViewModel() + var body: some View { + DisclosureGroup(L10n.text("Developer Workspace"), isExpanded: $expanded) { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text( + L10n.text( + "Recognized projects within selected roots. Manifest names are evidence of an ecosystem, not proof that its runtime is installed. Source size and runtime versions are not measured." + ) + ) + .font(.caption).foregroundStyle(.secondary) + Text( + String( + format: L10n.text("%d scan issues; results may be partial."), report.issues.count) + ) + .font(.caption) + ForEach(Array((report.inventory ?? []).prefix(100))) { project in + let artifacts = report.artifacts.filter { $0.projectRoot == project.root } + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(project.root.lastPathComponent).font(.headline) + Spacer() + Text(ByteCount.string(artifacts.reduce(0) { $0 + $1.bytes })).monospacedDigit() + Button(L10n.text("Measure workspace")) { + analyzer.rootURL = project.root + analyzer.snapshot = nil + analyzer.comparison = nil + analyzer.scan() + }.disabled(analyzer.isScanning) + Button(L10n.text("Show in Finder")) { + NSWorkspace.shared.activateFileViewerSelecting([project.root]) + } + } + Text(project.root.path).font(.caption).textSelection(.enabled) + Text(project.ecosystems.map(\.rawValue).joined(separator: " · ")) + Text(project.markers.joined(separator: " · ")).font(.caption.monospaced()) + Text( + String( + format: L10n.text( + "%d recognized artifacts; size is observed artifact data only."), + artifacts.count) + ) + .font(.caption).foregroundStyle(.secondary) + } + if analyzer.rootURL == project.root { + if analyzer.isScanning { + HStack { + ProgressView().controlSize(.small) + Button(L10n.text("Stop")) { analyzer.cancel() } + } + } + if let snapshot = analyzer.snapshot { + let usage = WorkspaceUsage(snapshot: snapshot, artifacts: artifacts) + Text( + L10n.text("Observed workspace size") + ": " + + ByteCount.string(snapshot.scannedBytes)) + Text( + L10n.text("Other observed project data") + ": " + + (usage.otherBytes.map(ByteCount.string) ?? L10n.text("Unavailable")) + ) + .font(.caption) + Text(snapshot.completedAt, style: .time).font(.caption) + Text( + String( + format: L10n.text("%d scan issues; results may be partial."), + snapshot.inaccessibleCount) + ) + .font(.caption) + if let comparison = analyzer.comparison { + Text(L10n.text(comparison.status)).font(.caption) + ForEach(comparison.deltas.prefix(5)) { delta in + Text( + URL(fileURLWithPath: delta.path).lastPathComponent + " · " + + (delta.bytes > 0 ? "+" : "−") + ByteCount.string(abs(delta.bytes)) + ) + .font(.caption.monospaced()) + } + } + } + if let error = analyzer.errorMessage { Text(L10n.text(error)).font(.caption) } + } + Divider() + } + if (report.inventory ?? []).isEmpty { + Text(L10n.text("No recognized project manifests within scan coverage.")) + } + if (report.inventory?.count ?? 0) > 100 { + Text( + L10n.text( + "Only the first 100 projects are displayed. Artifact filters remain available below." + )) + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: 220) + }.padding(.horizontal, 18).padding(.vertical, 10) + } +} diff --git a/apps/toolbox/Sources/ToolboxStorage/Views/ProjectsView.swift b/apps/toolbox/Sources/ToolboxStorage/Views/ProjectsView.swift index c83d048..96a1478 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Views/ProjectsView.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Views/ProjectsView.swift @@ -12,10 +12,13 @@ private enum ProjectSafetyFilter: String, CaseIterable, Identifiable { } struct ProjectsView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue @StateObject private var model = ProjectViewModel() @State private var ecosystem: ProjectEcosystem? @State private var safetyFilter = ProjectSafetyFilter.all @State private var searchText = "" + @State private var sortsBySize = true @State private var showsCleanupConfirmation = false private var filteredArtifacts: [ProjectArtifact] { @@ -32,6 +35,10 @@ struct ProjectsView: View { let matchesSearch = query.isEmpty || artifact.artifactURL.path.localizedCaseInsensitiveContains(query) return matchesEcosystem && matchesSafety && matchesSearch + }.sorted { lhs, rhs in + if sortsBySize && lhs.bytes != rhs.bytes { return lhs.bytes > rhs.bytes } + return lhs.artifactURL.path.localizedStandardCompare(rhs.artifactURL.path) + == .orderedAscending } } @@ -54,6 +61,9 @@ struct ProjectsView: View { Divider() bottomBar } + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale + ) .alert("Chuyển artifact đã chọn vào Trash?", isPresented: $showsCleanupConfirmation) { Button("Hủy", role: .cancel) {} Button("Chuyển vào Trash", role: .destructive) { model.cleanSelected() } @@ -63,7 +73,7 @@ struct ProjectsView: View { ) } .alert( - "Toolbox", + AppMetadata.displayName, isPresented: Binding( get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } }) @@ -79,7 +89,7 @@ struct ProjectsView: View { EmptyStateView( title: "Chưa chọn project root", symbol: "folder.badge.plus", detail: - "Toolbox chỉ quét thư mục bạn chọn và chỉ đề xuất artifact có marker hệ sinh thái rõ ràng." + "Orven chỉ quét thư mục bạn chọn và chỉ đề xuất artifact có marker hệ sinh thái rõ ràng." ) Button("Chọn project…") { chooseProjects() } .buttonStyle(.borderedProminent) @@ -91,6 +101,7 @@ struct ProjectsView: View { private var projectContent: some View { VStack(spacing: 0) { rootsBar + if let report = model.report { DeveloperWorkspaceView(report: report) } filters Divider() if filteredArtifacts.isEmpty { @@ -98,7 +109,7 @@ struct ProjectsView: View { title: model.report == nil ? "Sẵn sàng quét" : "Không có artifact phù hợp", symbol: model.report == nil ? "magnifyingglass" : "checkmark.shield", detail: model.report == nil - ? "Toolbox sẽ nhận diện output có thể tạo lại từ marker dự án." + ? "Orven sẽ nhận diện output có thể tạo lại từ marker dự án." : "Không có source, manifest, lockfile hoặc thư mục chưa biết nào được đề xuất.") } else { List(filteredArtifacts) { artifact in @@ -122,7 +133,8 @@ struct ProjectsView: View { Image(systemName: "xmark.circle.fill") } .buttonStyle(.plain) - .accessibilityLabel("Bỏ project \(root.lastPathComponent)") + .accessibilityLabel( + String(format: L10n.text("Remove project %@"), root.lastPathComponent)) } .font(.caption) .padding(.horizontal, 10) @@ -136,24 +148,26 @@ struct ProjectsView: View { } private var filters: some View { - HStack { - Picker("Hệ sinh thái", selection: $ecosystem) { - Text("Tất cả hệ sinh thái").tag(Optional.none) - ForEach(ProjectEcosystem.allCases) { value in - Text(value.rawValue).tag(Optional(value)) + VStack(alignment: .leading, spacing: 10) { + HStack { + Picker("Hệ sinh thái", selection: $ecosystem) { + Text("Tất cả hệ sinh thái").tag(Optional.none) + ForEach(ProjectEcosystem.allCases) { value in + Text(value.rawValue).tag(Optional(value)) + } } - } - .frame(maxWidth: 190) - Picker("Mức an toàn", selection: $safetyFilter) { - ForEach(ProjectSafetyFilter.allCases) { value in - Text(value.rawValue).tag(value) + .frame(maxWidth: 190) + Picker("Mức an toàn", selection: $safetyFilter) { + ForEach(ProjectSafetyFilter.allCases) { value in + Text(L10n.text(value.rawValue)).tag(value) + } } + .frame(maxWidth: 180) + Toggle(L10n.text("Largest first"), isOn: $sortsBySize).toggleStyle(.checkbox) + Spacer() } - .frame(maxWidth: 180) TextField("Tìm đường dẫn", text: $searchText) .textFieldStyle(.roundedBorder) - .frame(maxWidth: 260) - Spacer() } .padding(.horizontal, 18) .padding(.bottom, 10) @@ -178,7 +192,7 @@ struct ProjectsView: View { Image(systemName: safetySymbol(artifact.safety)) .foregroundStyle(safetyColor(artifact.safety)) - .accessibilityLabel(safetyFilterLabel(artifact.safety)) + .accessibilityLabel(L10n.text(safetyFilterLabel(artifact.safety))) VStack(alignment: .leading, spacing: 4) { HStack { Text(artifact.artifactURL.lastPathComponent).fontWeight(.semibold) @@ -195,7 +209,13 @@ struct ProjectsView: View { .truncationMode(.middle) .textSelection(.enabled) if let reason = artifact.reasons.first { - Text(L10n.text(reason)).font(.caption2).foregroundStyle(.secondary) + let description = + artifact.safety == .safe + ? String( + format: L10n.text("%@ was identified from %@ markers and can be regenerated."), + artifact.artifactURL.lastPathComponent, artifact.ecosystem.rawValue) + : L10n.text(reason) + Text(description).font(.caption2).foregroundStyle(.secondary) } } Spacer() @@ -205,9 +225,18 @@ struct ProjectsView: View { .padding(.vertical, 6) } + private var statusText: String { + if let report = model.report, model.status.hasPrefix("Tìm thấy ") { + return String( + format: L10n.text("Found %d artifacts in %d projects."), + report.artifacts.count, report.scannedProjectCount) + } + return L10n.text(model.status) + } + private var bottomBar: some View { HStack { - Text(L10n.text(model.status)).foregroundStyle(.secondary).lineLimit(1) + Text(statusText).foregroundStyle(.secondary).lineLimit(1) Spacer() Button("Thêm project…") { chooseProjects() } if model.isWorking { @@ -227,7 +256,7 @@ struct ProjectsView: View { private func chooseProjects() { let panel = NSOpenPanel() - panel.title = "Chọn project root" + panel.title = L10n.text("Chọn project root") panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = true diff --git a/apps/toolbox/Sources/ToolboxStorage/Views/Shared/SharedViews.swift b/apps/toolbox/Sources/ToolboxStorage/Views/Shared/SharedViews.swift index 932a03a..b334c93 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Views/Shared/SharedViews.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Views/Shared/SharedViews.swift @@ -28,6 +28,8 @@ struct TreemapView: View { } struct PageHeader: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue let title: String let subtitle: String let symbol: String @@ -36,17 +38,14 @@ struct PageHeader: View { var body: some View { HStack(spacing: 16) { - ZStack { - RoundedRectangle(cornerRadius: 14) - .fill( - LinearGradient( - colors: [.blue, .cyan], startPoint: .topLeading, endPoint: .bottomTrailing)) - Image(systemName: symbol).font(.system(size: 28, weight: .semibold)).foregroundStyle(.white) - } - .frame(width: 60, height: 60) + Image(systemName: symbol).font(.system(size: 27, weight: .medium)) + .foregroundStyle(OrvenTheme.accent) + .frame(width: 44, height: 44).accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { - Text(L10n.text(title)).font(.system(size: 27, weight: .bold)) - Text(L10n.text(subtitle)).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) + Text(L10n.text(title)).font(.system(size: 30, weight: .bold)).foregroundStyle( + OrvenTheme.ink) + Text(L10n.text(subtitle)).foregroundStyle(.secondary).fixedSize( + horizontal: false, vertical: true) } Spacer() VStack(alignment: .trailing, spacing: 3) { @@ -55,6 +54,9 @@ struct PageHeader: View { } } .padding(24) + .background(OrvenTheme.surface) + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) } } @@ -74,11 +76,13 @@ struct MetricCard: View { } .padding(14) .frame(maxWidth: .infinity) - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .background(OrvenTheme.surface, in: RoundedRectangle(cornerRadius: 10)) } } struct EmptyStateView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue let title: String let symbol: String let detail: String @@ -94,5 +98,7 @@ struct EmptyStateView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(30) + .environment( + \.locale, AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale) } } diff --git a/apps/toolbox/Sources/ToolboxStorage/Views/StorageAnalyzerView.swift b/apps/toolbox/Sources/ToolboxStorage/Views/StorageAnalyzerView.swift index 0d4f4ec..e944b9b 100644 --- a/apps/toolbox/Sources/ToolboxStorage/Views/StorageAnalyzerView.swift +++ b/apps/toolbox/Sources/ToolboxStorage/Views/StorageAnalyzerView.swift @@ -2,6 +2,8 @@ import SwiftUI import ToolboxCore struct StorageAnalyzerView: View { + @AppStorage(AppLanguage.storageKey) private var languageCode = AppLanguage.defaultLanguage + .rawValue @ObservedObject var model: AnalyzerViewModel @State private var showsError = false @@ -21,7 +23,7 @@ struct StorageAnalyzerView: View { summary(snapshot) TreemapView(entries: snapshot.topFolders) .frame(height: 210) - if !model.folderDeltas.isEmpty { + if model.comparison != nil { deltaSection } categorySection(snapshot) @@ -62,12 +64,24 @@ struct StorageAnalyzerView: View { private var deltaSection: some View { VStack(alignment: .leading, spacing: 10) { - Text("Thay đổi từ lần quét trước").font(.title2).fontWeight(.semibold) + Text(L10n.text("Storage Growth Detective")).font(.title2).fontWeight(.semibold) + if let comparison = model.comparison { + Text(L10n.text(comparison.status)).font(.callout).foregroundStyle(.secondary) + if let date = comparison.previousDate { + Text( + date.formatted( + .dateTime.year().month().day().hour().minute().locale( + AppLanguage(rawValue: languageCode)?.locale ?? AppLanguage.defaultLanguage.locale)) + ) + .font(.caption) + } + if comparison.deltas.isEmpty { Text(L10n.text("No comparable folder deltas to display.")) } + } ForEach(model.folderDeltas.prefix(10)) { delta in HStack { Image(systemName: delta.bytes > 0 ? "arrow.up.right" : "arrow.down.right") .foregroundStyle(delta.bytes > 0 ? .orange : .green) - Text(URL(fileURLWithPath: delta.path).lastPathComponent) + Text(delta.path).lineLimit(1).help(delta.path) Spacer() Text((delta.bytes > 0 ? "+" : "−") + ByteCount.string(abs(delta.bytes))).monospacedDigit() } diff --git a/apps/toolbox/Tests/Distribution/dmg_first_launch_contract.sh b/apps/toolbox/Tests/Distribution/dmg_first_launch_contract.sh index 138c883..af4c271 100644 --- a/apps/toolbox/Tests/Distribution/dmg_first_launch_contract.sh +++ b/apps/toolbox/Tests/Distribution/dmg_first_launch_contract.sh @@ -17,8 +17,8 @@ trap cleanup_mount EXIT hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" -quiet mounted=1 -guide="$mount_dir/Open Toolbox - First Launch.html" -test -d "$mount_dir/Toolbox.app" +guide="$mount_dir/Open Orven - First Launch.html" +test -d "$mount_dir/Orven.app" test -L "$mount_dir/Applications" test "$(readlink "$mount_dir/Applications")" = "/Applications" test -f "$guide" diff --git a/apps/toolbox/Tests/Distribution/release_contract.sh b/apps/toolbox/Tests/Distribution/release_contract.sh index 680249c..c3f3337 100755 --- a/apps/toolbox/Tests/Distribution/release_contract.sh +++ b/apps/toolbox/Tests/Distribution/release_contract.sh @@ -1,14 +1,19 @@ #!/bin/bash set -euo pipefail -app="${1:?Toolbox.app path required}" +app="${1:?Orven.app path required}" test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" = "com.thang.toolbox" -test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = "2.0.0" +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = "3.0.0" architectures="$(lipo -archs "$app/Contents/MacOS/Toolbox")" grep -qw arm64 <<<"$architectures" grep -qw x86_64 <<<"$architectures" test -f "$app/Contents/Resources/en.lproj/Localizable.strings" test -f "$app/Contents/Resources/vi.lproj/Localizable.strings" +for language in ja zh-Hans; do + test -f "$app/Contents/Resources/$language.lproj/Localizable.strings" +done +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$app/Contents/Info.plist")" = "Orven" +test -f "$app/Contents/Resources/OrvenLogo.png" codesign --verify --deep --strict "$app" -echo "PASS: Toolbox release contract ($architectures)" +echo "PASS: Orven release contract ($architectures)" diff --git a/apps/toolbox/Tests/SmokeMonitoring/main.swift b/apps/toolbox/Tests/SmokeMonitoring/main.swift new file mode 100644 index 0000000..10d9c5a --- /dev/null +++ b/apps/toolbox/Tests/SmokeMonitoring/main.swift @@ -0,0 +1,308 @@ +import Darwin +import Foundation +import ToolboxCore +import ToolboxMonitoring + +private struct ProfileCheckpoint: Encodable { + let durationSeconds: Double + let averageOneCoreCPUPercent: Double + let residentBytes: UInt64 +} + +private struct ProfileReport: Encodable { + let mode: String + let durationSeconds: Double + let cpuSeconds: Double + let averageOneCoreCPUPercent: Double + let baselineResidentBytes: UInt64 + let peakResidentBytes: UInt64 + let incrementalResidentBytes: UInt64 + let samples: Int + let checkpoints: [ProfileCheckpoint] + let maximumHistoryCount: Int + let maximumProcessCount: Int + let cpuAvailability: String + let diskAvailability: String + let batteryAvailability: String + let physicalSensorAvailability: String + let physicalSensorObservations: Int + let physicalSensorFailedObservations: Int + let maximumPhysicalSensorCount: Int + let os: String + let architecture: String +} + +private func resources() -> (cpu: Double, rss: UInt64) { + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + let cpu = + Double(usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) + + Double(usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1_000_000 + var task = proc_taskinfo() + let bytes = Int32(MemoryLayout.size) + let result = proc_pidinfo(getpid(), PROC_PIDTASKINFO, 0, &task, bytes) + return (cpu, result == bytes ? task.pti_resident_size : 0) +} + +@main +struct SmokeMonitoring { + static func main() async { + let arguments = Array(CommandLine.arguments.dropFirst()) + if arguments.first == "--nearby-wifi" { + do { + let scan = try NetworkOverviewProvider.scan() + let output: [String: Any] = [ + "observedAt": ISO8601DateFormatter().string(from: scan.observedAt), + "networks": scan.networks.count, "omitted": scan.omitted, + "identityCoverage": scan.networks.filter { $0.ssid != nil }.count, + "channelCoverage": scan.networks.filter { $0.channel != nil }.count, + "RSSICoverage": scan.networks.filter { $0.rssi != nil }.count, + "scope": "explicit CoreWLAN scan; identities not included", + ] + print( + String( + decoding: try JSONSerialization.data( + withJSONObject: output, options: [.prettyPrinted, .sortedKeys]), as: UTF8.self)) + } catch { + let error = error as NSError + print("CoreWLAN scan unavailable: \(error.domain) code \(error.code)") + exit(1) + } + return + } + if arguments.first == "--network-diagnostics", arguments.count == 2 { + do { + let target = arguments[1] + guard let validated = NetworkTarget.validate(target), validated == target else { exit(64) } + let checks = try NetworkDiagnostics.connection(target: target, gateway: nil) + let latency = try NetworkDiagnostics.latency(target: target) + let output: [String: Any] = [ + "target": target, "observedAt": ISO8601DateFormatter().string(from: Date()), + "checks": checks.map { + ["stage": $0.id, "status": $0.detail, "milliseconds": $0.durationMilliseconds] + as [String: Any] + }, + "ICMPExitStatus": latency.exitStatus, "transmitted": latency.transmitted ?? -1, + "received": latency.received ?? -1, "lossPercent": latency.lossPercent ?? -1, + "meanMilliseconds": latency.meanMilliseconds ?? -1, + "jitterMilliseconds": latency.jitterMilliseconds ?? -1, + "sampleCount": latency.samples.count, + "scope": "explicit destination; no gateway probe in CLI; -1 denotes missing", + ] + print( + String( + decoding: try JSONSerialization.data( + withJSONObject: output, options: [.prettyPrinted, .sortedKeys]), as: UTF8.self)) + } catch { + fputs("Network diagnostic failed or timed out\n", stderr) + exit(1) + } + return + } + if arguments.first == "--network-tools" { + do { + let value = try NetworkOverviewProvider.read() + let loopback = try NetworkDiagnostics.latency(target: "127.0.0.1", count: 1) + let output: [String: Any] = [ + "observedAt": ISO8601DateFormatter().string(from: value.observedAt), + "adapters": value.adapters.count, "omittedAdapters": value.omittedAdapters, + "wifiInterfaces": value.wifi.count, + "ethernetInterfaces": value.adapters.filter { $0.kind == "Ethernet" }.count, + "globalDNSCount": value.dns.count, + "hasIPv4Gateway": value.gateway4 != nil, "hasIPv6Gateway": value.gateway6 != nil, + "wifiRSSICoverage": value.wifi.filter { $0.rssi != nil }.count, + "wifiNoiseCoverage": value.wifi.filter { $0.noise != nil }.count, + "wifiChannelCoverage": value.wifi.filter { $0.channel != nil }.count, + "wifiLinkRateCoverage": value.wifi.filter { $0.transmitMbps != nil }.count, + "wifiIdentityCoverage": value.wifi.filter { $0.ssid != nil }.count, + "loopbackExitStatus": loopback.exitStatus, + "loopbackReceived": loopback.received ?? 0, + "scope": + "read-only observation and one local ICMP probe; no identities or addresses in report", + ] + print( + String( + decoding: try JSONSerialization.data( + withJSONObject: output, options: [.prettyPrinted, .sortedKeys]), as: UTF8.self)) + if value.adapters.isEmpty || loopback.exitStatus != 0 { exit(1) } + } catch { + fputs("Network tool smoke failed\n", stderr) + exit(1) + } + return + } + if arguments.first == "--physical-sensors" { + let time = SampleTime(wall: Date(), monotonic: ProcessInfo.processInfo.systemUptime) + let provider = SystemMonitoringProvider() + let reading = await provider.readHardware(at: time) + await provider.pauseHardware() + let sample = reading.physicalSensors + struct Report: Encodable { + let availability: MetricAvailability + let observedAt: Date + let sensors: PhysicalSensorSnapshot? + let elapsedSeconds: Double + let reason: String? + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + do { + let bytes = try encoder.encode( + Report( + availability: sample.availability, observedAt: sample.time.wall, + sensors: sample.value, + elapsedSeconds: ProcessInfo.processInfo.systemUptime - time.monotonic, + reason: sample.reason)) + print(String(decoding: bytes, as: UTF8.self)) + } catch { + fputs("FAIL: physical sensor encoding\n", stderr) + exit(1) + } + return + } + if arguments.first == "--system-inspection" { + do { + let sockets = try NetworkInspection.read() + let dataVolume = try APFSSpaceInspection.read(volume: .data) + let systemVolume = try APFSSpaceInspection.read(volume: .system) + let report: [String: Any] = [ + "status": "PASS", "observedAt": ISO8601DateFormatter().string(from: Date()), + "networkSockets": sockets.sockets.count, + "networkVerifiedIdentities": sockets.sockets.filter { $0.identity != nil }.count, + "networkOmitted": sockets.omitted, + "dataSnapshots": dataVolume.snapshots.count, + "systemSnapshots": systemVolume.snapshots.count, + "containerVolumeCount": dataVolume.volumes.count, + "containerRemainderBytes": dataVolume.containerRemainder.map { $0 as Any } ?? NSNull(), + "containerCapacityBytes": dataVolume.containerTotal.map { $0 as Any } ?? NSNull(), + "scope": + "Actual bounded read-only helpers; aggregate evidence excludes socket endpoints and volume names", + "os": ProcessInfo.processInfo.operatingSystemVersionString, "architecture": architecture, + ] + let bytes = try JSONSerialization.data( + withJSONObject: report, options: [.sortedKeys, .prettyPrinted]) + print(String(decoding: bytes, as: UTF8.self)) + return + } catch { + fputs("FAIL: bounded system inspection: \(error)\n", stderr) + exit(1) + } + } + if arguments.first == "--sleep-inspection" { + let engine = MonitoringCoordinator() + let sample = await engine.inspectSleepRequests() + guard sample.availability == .available, let inspection = sample.value, + inspection.requests.count <= 512 + else { + fputs("FAIL: public power assertion inspection\n", stderr) + exit(1) + } + let report: [String: Any] = [ + "availability": sample.availability.rawValue, + "requests": inspection.requests.count, + "verifiedOwners": inspection.requests.filter { $0.owner != nil }.count, + "omitted": inspection.omittedCount, + "os": ProcessInfo.processInfo.operatingSystemVersionString, + "architecture": architecture, + ] + guard let data = try? JSONSerialization.data(withJSONObject: report, options: [.sortedKeys]) + else { exit(1) } + print(String(decoding: data, as: UTF8.self)) + return + } + let profiling = arguments.first == "--profile" + let seconds = profiling ? (arguments.dropFirst().first.flatMap(Double.init) ?? 300) : 4 + guard seconds.isFinite, seconds >= 3, seconds <= 86_400 else { + fputs("Duration must be between 3 and 86,400 seconds\n", stderr) + exit(2) + } + let menuMode = arguments.contains("--menu-bar") + let engine = MonitoringCoordinator() + let start = ProcessInfo.processInfo.systemUptime + let baseline = resources() + var peak = baseline.rss + var historyCount = 0 + var processCount = 0 + var sampleCount = 0 + var physicalObservations = 0 + var physicalFailures = 0 + var maximumPhysicalCount = 0 + var lastPhysicalTime: Double? + var checkpoints: [ProfileCheckpoint] = [] + var nextCheckpoint = 300.0 + var last: MonitoringSnapshot? + let stream = await engine.snapshots() + await engine.setMode(menuMode ? .menuBar : .active) + for await sample in stream { + last = sample + sampleCount += 1 + let physical = sample.hardware.reading.physicalSensors + if lastPhysicalTime != physical.time.monotonic { + lastPhysicalTime = physical.time.monotonic + physicalObservations += 1 + if physical.availability != .available { physicalFailures += 1 } + maximumPhysicalCount = max(maximumPhysicalCount, physical.value?.readings.count ?? 0) + } + peak = max(peak, resources().rss) + historyCount = max(historyCount, sample.history.count) + processCount = max(processCount, sample.processes.value?.count ?? 0) + let elapsed = ProcessInfo.processInfo.systemUptime - start + if elapsed >= nextCheckpoint { + let measured = resources() + checkpoints.append( + ProfileCheckpoint( + durationSeconds: elapsed, + averageOneCoreCPUPercent: (measured.cpu - baseline.cpu) / elapsed * 100, + residentBytes: measured.rss)) + nextCheckpoint += 300 + } + if elapsed >= seconds { break } + } + await engine.stop() + let end = resources() + let elapsed = ProcessInfo.processInfo.systemUptime - start + guard let last, last.cpu.availability == .available, last.memory.value?.totalBytes ?? 0 > 0, + last.processes.availability == .available, historyCount <= 60 + else { + fputs("FAIL: real monitoring, warm-up or capacity contract\n", stderr) + exit(1) + } + if profiling { + let report = ProfileReport( + mode: menuMode ? "menuBar" : "active", durationSeconds: elapsed, + cpuSeconds: end.cpu - baseline.cpu, + averageOneCoreCPUPercent: (end.cpu - baseline.cpu) / elapsed * 100, + baselineResidentBytes: baseline.rss, peakResidentBytes: peak, + incrementalResidentBytes: peak - min(peak, baseline.rss), samples: sampleCount, + checkpoints: checkpoints, + maximumHistoryCount: historyCount, maximumProcessCount: processCount, + cpuAvailability: last.cpu.availability.rawValue, + diskAvailability: last.hardware.disks.availability.rawValue, + batteryAvailability: last.hardware.reading.battery.availability.rawValue, + physicalSensorAvailability: last.hardware.reading.physicalSensors.availability.rawValue, + physicalSensorObservations: physicalObservations, + physicalSensorFailedObservations: physicalFailures, + maximumPhysicalSensorCount: maximumPhysicalCount, + os: ProcessInfo.processInfo.operatingSystemVersionString, + architecture: architecture) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + do { print(String(decoding: try encoder.encode(report), as: UTF8.self)) } catch { + fputs("Report encoding failed\n", stderr) + exit(1) + } + } else { + print( + "PASS: live CPU/memory/process/hardware sampling, bounded history and clean cancellation") + } + } + + private static var architecture: String { + #if arch(arm64) + "arm64" + #else + "x86_64" + #endif + } +} diff --git a/apps/toolbox/Tests/ToolboxAppTests/DiagnosticInsightsTests.swift b/apps/toolbox/Tests/ToolboxAppTests/DiagnosticInsightsTests.swift new file mode 100644 index 0000000..4b7a69c --- /dev/null +++ b/apps/toolbox/Tests/ToolboxAppTests/DiagnosticInsightsTests.swift @@ -0,0 +1,82 @@ +import Foundation +import XCTest + +@testable import Toolbox +@testable import ToolboxMonitoring + +final class DiagnosticInsightsTests: XCTestCase { + private func snapshot(gap: Bool = false, generationChange: Bool = false) -> MonitoringSnapshot { + let time = SampleTime(wall: Date(), monotonic: 100) + let hardware = HardwareReading.unsupported(at: time) + var history: [TrendPoint] = [] + for index in 0...30 { + let shift: Double = gap && index == 4 ? 5 : 0 + let timestamp = SampleTime(wall: Date(), monotonic: Double(index + 70) - shift) + let generation: Int = generationChange && index == 3 ? 1 : 2 + history.append( + TrendPoint( + time: timestamp, generation: generation, ioSampled: false, + cpuPercent: 90, memoryPercent: 100, networkInput: nil, networkOutput: nil, + diskInput: nil, diskOutput: nil)) + } + return MonitoringSnapshot( + time: time, + cpu: .init( + value: 90, availability: .available, unit: .percent, time: time, source: "fixture"), + memory: .init( + value: .init(totalBytes: 100, freeBytes: 0, wiredBytes: 0, compressedBytes: 0), + availability: .available, unit: .bytes, time: time, source: "fixture"), + history: history, + processes: .init( + value: [], availability: .available, unit: .collection, time: time, source: "fixture"), + hardware: .init( + disks: .init( + availability: .unsupported, unit: .bytesPerSecond, time: time, source: "fixture"), + interfaces: .init( + availability: .unsupported, unit: .bytesPerSecond, time: time, source: "fixture"), + reading: hardware)) + } + func testSustainedCPURequiresContinuousFreshActiveGeneration() { + XCTAssertEqual( + DiagnosticInsights.evaluate(snapshot(), now: 100, active: true).map(\.id), ["cpu"]) + for value in [snapshot(gap: true), snapshot(generationChange: true)] { + XCTAssertTrue(DiagnosticInsights.evaluate(value, now: 100, active: true).isEmpty) + } + XCTAssertTrue(DiagnosticInsights.evaluate(snapshot(), now: 107, active: true).isEmpty) + XCTAssertTrue(DiagnosticInsights.evaluate(snapshot(), now: 100, active: false).isEmpty) + XCTAssertTrue(DiagnosticInsights.evaluate(snapshot(), now: 99, active: true).isEmpty) + // 100% occupied RAM must not manufacture pressure when pressure is unsupported. + XCTAssertFalse( + DiagnosticInsights.evaluate(snapshot(), now: 100, active: true).contains { $0.id == "memory" } + ) + } + func testExportExcludesProcessNamesByDefaultAndPreservesUnavailableState() throws { + let time = SampleTime(wall: Date(), monotonic: ProcessInfo.processInfo.systemUptime) + let original = snapshot() + let privateName = "/Users/private-person/confidential-project" + let process = ProcessSample( + pid: 42, identity: nil, name: privateName, parentPID: nil, + cpu: .init(availability: .permissionRequired, unit: .percent, time: time, source: "fixture"), + memory: .init(availability: .permissionRequired, unit: .bytes, time: time, source: "fixture"), + threads: nil, cpuHistory: []) + let value = MonitoringSnapshot( + time: time, cpu: original.cpu, memory: original.memory, + history: original.history, + processes: .init( + value: [process], availability: .available, + unit: .collection, time: time, source: "fixture"), hardware: original.hardware) + let data = try LocalDiagnosticsReport.encode(value, active: false, includeProcessNames: false) + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains(privateName)) + let report = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let rows = try XCTUnwrap(report["processes"] as? [[String: Any]]) + XCTAssertEqual(rows.first?["cpuAvailability"] as? String, "permissionRequired") + XCTAssertNil(rows.first?["cpuOneCorePercent"]) + XCTAssertEqual(report["processesAvailability"] as? String, "stale") + XCTAssertTrue( + String( + decoding: try LocalDiagnosticsReport.encode( + value, active: true, + includeProcessNames: true), as: UTF8.self + ).contains("confidential-project")) + } +} diff --git a/apps/toolbox/Tests/ToolboxAppTests/MonitoringPreferencesTests.swift b/apps/toolbox/Tests/ToolboxAppTests/MonitoringPreferencesTests.swift new file mode 100644 index 0000000..5384137 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxAppTests/MonitoringPreferencesTests.swift @@ -0,0 +1,51 @@ +import Foundation +import ToolboxMonitoring +import XCTest + +@testable import Toolbox + +@MainActor +final class MonitoringPreferencesTests: XCTestCase { + func testPausedDisplayIsStaleAndBackgroundCadencePreservesFreshness() { + let time = SampleTime(wall: Date(), monotonic: 100) + let sample = MetricSample( + value: 25.0, availability: .available, unit: .percent, time: time, source: "fixture") + XCTAssertEqual( + MonitoringDisplayState(paused: true).availability(of: sample, at: 101, maximumAge: 3), .stale) + let background = MonitoringDisplayState(minimumMaximumAge: 15) + XCTAssertEqual(background.availability(of: sample, at: 105, maximumAge: 3), .available) + XCTAssertEqual(background.availability(of: sample, at: 116, maximumAge: 3), .stale) + let denied = MetricSample( + availability: .permissionRequired, unit: .percent, time: time, source: "fixture") + XCTAssertEqual( + MonitoringDisplayState(paused: true).availability(of: denied, at: 101, maximumAge: 3), + .permissionRequired) + } + + func testMenuBarDefaultsOffPersistsAndUnknownIndicatorMigratesSafely() async { + let suite = "ToolboxMonitoringPreferences." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let first = MonitoringViewModel(defaults: defaults) + XCTAssertFalse(first.menuBarEnabled) + XCTAssertEqual(first.menuBarIndicator, .cpu) + first.menuBarEnabled = true + first.menuBarIndicator = .network + await first.stop() + let second = MonitoringViewModel(defaults: defaults) + XCTAssertTrue(second.menuBarEnabled) + XCTAssertEqual(second.menuBarIndicator, .network) + for _ in 0..<50 { + second.menuBarEnabled = false + second.menuBarEnabled = true + } + second.menuBarEnabled = false + await second.stop() + defaults.set("future-value", forKey: "toolbox.monitoring.menuBar.indicator") + let migrated = MonitoringViewModel(defaults: defaults) + XCTAssertFalse(migrated.menuBarEnabled) + XCTAssertEqual(migrated.menuBarIndicator, .cpu) + XCTAssertNil(defaults.object(forKey: "toolbox.onboarding.completed.v2")) + await migrated.stop() + } +} diff --git a/apps/toolbox/Tests/ToolboxAppTests/SmartAlertsTests.swift b/apps/toolbox/Tests/ToolboxAppTests/SmartAlertsTests.swift new file mode 100644 index 0000000..7ae2cd0 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxAppTests/SmartAlertsTests.swift @@ -0,0 +1,48 @@ +import Foundation +import XCTest + +@testable import Toolbox +@testable import ToolboxMonitoring + +final class SmartAlertsTests: XCTestCase { + func testAlertRequiresObservedRecoveryHysteresisAndCooldown() { + var gate = AlertGate() + XCTAssertEqual(gate.evaluate(findings: ["cpu"], recoveries: [], now: 0), ["cpu"]) + XCTAssertTrue(gate.evaluate(findings: ["cpu"], recoveries: [], now: 1).isEmpty) + for now in stride(from: 10.0, through: 40.0, by: 10) { + _ = gate.evaluate(findings: [], recoveries: ["cpu"], now: now) + } + XCTAssertTrue(gate.evaluate(findings: ["cpu"], recoveries: [], now: 41).isEmpty) + XCTAssertEqual(gate.evaluate(findings: ["cpu"], recoveries: [], now: 901), ["cpu"]) + } + func testUnknownEvidenceAndPauseCannotEstablishContinuousRecoveryAndSnoozeSuppresses() { + var gate = AlertGate() + _ = gate.evaluate(findings: ["thermal"], recoveries: [], now: 0) + _ = gate.evaluate(findings: [], recoveries: ["thermal"], now: 1) + _ = gate.evaluate(findings: [], recoveries: ["thermal"], now: 1000) + XCTAssertTrue(gate.evaluate(findings: ["thermal"], recoveries: [], now: 1001).isEmpty) + gate.snoozedUntil = Date(timeIntervalSince1970: 2000) + XCTAssertTrue( + gate.evaluate( + findings: ["memory"], recoveries: [], now: 1500, wall: Date(timeIntervalSince1970: 1500) + ).isEmpty) + XCTAssertEqual( + gate.evaluate( + findings: ["memory"], recoveries: [], now: 1501, wall: Date(timeIntervalSince1970: 2000)), + ["memory"]) + } + func testCSVPreservesMissingStatusAndEscapesQuotesWithoutLocalizedNumbers() { + let date = Date(timeIntervalSince1970: 0) + let record = HealthRecord( + observedAt: date, uptime: 0, segment: UUID(), + values: [ + HealthValue( + id: "smc:Te05", title: "CPU", unit: "°C", value: nil, availability: .unsupported, + source: "SMC,\"mapped\"", observedAt: date) + ]) + let csv = HealthSessionExport.csv([record]) + XCTAssertTrue(csv.contains("\"°C\",\"\",\"unsupported\",\"SMC,\"\"mapped\"\"\"")) + XCTAssertTrue(csv.hasSuffix("\n")) + XCTAssertTrue(csv.contains("1970-01-01T00:00:00Z")) + } +} diff --git a/apps/toolbox/Tests/ToolboxAppTests/ToolboxAppTests.swift b/apps/toolbox/Tests/ToolboxAppTests/ToolboxAppTests.swift index 4c14459..c2daa7d 100644 --- a/apps/toolbox/Tests/ToolboxAppTests/ToolboxAppTests.swift +++ b/apps/toolbox/Tests/ToolboxAppTests/ToolboxAppTests.swift @@ -1,11 +1,61 @@ +import Combine +import ToolboxMonitoring import XCTest @testable import Toolbox final class ToolboxAppTests: XCTestCase { - func testToolboxNavigationHasExactlySixStableSections() { - XCTAssertEqual( - ToolboxSection.allCases.map(\.rawValue), - ["home", "storage", "projects", "applications", "changes", "recovery"]) + func testLegacyNavigationRawValuesRemainRestorable() { + for legacy in ["home", "storage", "projects", "applications", "changes", "recovery"] { + XCTAssertNotNil(ToolboxSection(rawValue: legacy)) + } } + + @MainActor + func testRepeatedNavigationDoesNotRepublishUnchangedSelection() { + let suite = "ToolboxRouteIdempotence." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set("home", forKey: "toolbox.selectedSection") + let coordinator = ToolboxCoordinator(defaults: defaults) + var publications = 0 + let subscription = coordinator.objectWillChange.sink { publications += 1 } + for _ in 0..<20 { coordinator.open(.section(.home)) } + XCTAssertEqual(publications, 0) + coordinator.open(.section(.processes)) + XCTAssertEqual(publications, 1) + withExtendedLifetime(subscription) {} + } + + @MainActor + func testMonitoringRouteRestoresWithoutSelectingCleanup() { + let key = "ToolboxNavigationTests." + UUID().uuidString + let defaults = UserDefaults(suiteName: key)! + defer { defaults.removePersistentDomain(forName: key) } + defaults.set("processes", forKey: "toolbox.selectedSection") + let coordinator = ToolboxCoordinator(defaults: defaults) + XCTAssertEqual(coordinator.selectedSection, .processes) + XCTAssertNil(coordinator.storageFocusPath) + coordinator.open(.section(.storage)) + XCTAssertEqual(defaults.string(forKey: "toolbox.selectedSection"), "storage") + XCTAssertNil(coordinator.storageFocusPath) + } + @MainActor + func testProcessInspectionRouteRetainsStartIdentityAndLeavesStorageUnselected() { + let suite = "OrvenInspectionRoute." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let coordinator = ToolboxCoordinator(defaults: defaults) + let original = ProcessIdentity(pid: 41, startSeconds: 100, startMicroseconds: 2) + let replacement = ProcessIdentity(pid: 41, startSeconds: 200, startMicroseconds: 3) + coordinator.open(.inspectProcess(original)) + XCTAssertEqual(coordinator.selectedSection, .processes) + XCTAssertEqual(coordinator.processFocus, original) + XCTAssertNil(coordinator.storageFocusPath) + coordinator.open(.inspectProcess(replacement)) + XCTAssertEqual(coordinator.processFocus, replacement) + XCTAssertEqual(defaults.string(forKey: "toolbox.selectedSection"), "processes") + XCTAssertNil(coordinator.storageFocusPath) + } + } diff --git a/apps/toolbox/Tests/ToolboxCoreTests/APFSSpaceInspectionTests.swift b/apps/toolbox/Tests/ToolboxCoreTests/APFSSpaceInspectionTests.swift new file mode 100644 index 0000000..adf3cc9 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxCoreTests/APFSSpaceInspectionTests.swift @@ -0,0 +1,62 @@ +import Foundation +import XCTest + +@testable import ToolboxCore + +final class APFSSpaceInspectionTests: XCTestCase { + private func plist(_ value: [String: Any]) throws -> Data { + try PropertyListSerialization.data(fromPropertyList: value, format: .xml, options: 0) + } + func testUnknownAndInvalidBytesStayUnknownWhileSnapshotFlagsRemainTyped() throws { + let result = try APFSSpaceInspection.parse( + info: plist(["APFSContainerSize": 100, "APFSContainerFree": 101, "CapacityInUse": true]), + snapshots: plist([ + "Snapshots": [ + ["SnapshotName": "com.apple.TimeMachine.fixture", "Purgeable": true], + ["Name": "com.apple.os.update.fixture", "Purgeable": 1], + ] + ])) + XCTAssertEqual(result.containerTotal, 100) + XCTAssertNil(result.containerFree) + XCTAssertNil(result.volumeUsed) + XCTAssertEqual(result.snapshots.map(\.purgeable), [true, nil]) + XCTAssertThrowsError(try APFSSpaceInspection.parse(info: plist([:]), snapshots: plist([:]))) + let empty = try APFSSpaceInspection.parse(info: plist([:]), snapshots: plist(["Snapshots": []])) + XCTAssertTrue(empty.snapshots.isEmpty) + XCTAssertNil(empty.containerTotal) + } + func testContainerAccountingUsesMatchingContainerAndRejectsIncompleteOrNegativeRemainder() throws + { + let info = try plist(["APFSContainerReference": "disk3"]) + let snapshots = try plist(["Snapshots": []]) + func containers(_ used: Any) throws -> Data { + try plist([ + "Containers": [ + [ + "ContainerReference": "other", "CapacityCeiling": 999999, "CapacityFree": 1, + "Volumes": [], + ], + [ + "ContainerReference": "disk3", "CapacityCeiling": 1000, "CapacityFree": 100, + "Volumes": [ + ["Name": "Data", "CapacityInUse": 700], ["Name": "System", "CapacityInUse": used], + ], + ], + ] + ]) + } + let result = try APFSSpaceInspection.parse( + info: info, snapshots: snapshots, containers: containers(150)) + XCTAssertEqual(result.containerTotal, 1000) + XCTAssertEqual(result.containerRemainder, 50) + XCTAssertEqual(result.volumes.map(\.name), ["Data", "System"]) + for invalid in [201 as Any, true as Any, "unknown" as Any] { + XCTAssertNil( + try APFSSpaceInspection.parse( + info: info, snapshots: snapshots, + containers: containers(invalid) + ).containerRemainder) + } + } + +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/HardwareInspectorTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/HardwareInspectorTests.swift new file mode 100644 index 0000000..0397751 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/HardwareInspectorTests.swift @@ -0,0 +1,54 @@ +import Foundation +import XCTest + +@testable import ToolboxMonitoring + +final class HardwareInspectorTests: XCTestCase { + private func point( + _ time: Double, generation: Int = 2, cpu: Double?, io: Bool = true, disk: Double? = nil + ) -> TrendPoint { + TrendPoint( + time: SampleTime(wall: Date(timeIntervalSince1970: time), monotonic: time), + generation: generation, ioSampled: io, cpuPercent: cpu, memoryPercent: nil, + networkInput: nil, networkOutput: nil, diskInput: disk, diskOutput: nil) + } + + func testRangeExcludesOldGenerationMissingInvalidAndRepeatedIO() { + let points = [ + point(1, generation: 1, cpu: 99, disk: 999), + point(2, cpu: nil), point(3, cpu: .nan), point(4, cpu: 101), + point(5, cpu: 20, disk: 40), point(6, cpu: 0, io: false, disk: 40), + ] + let range = SensorWindow.extrema(points) + XCTAssertEqual(range[0].minimum, 0) + XCTAssertEqual(range[0].maximum, 20) + XCTAssertEqual(range[0].validSamples, 2) + XCTAssertEqual(range[2].validSamples, 1) + XCTAssertEqual(range[2].minimum, 40) + XCTAssertNil(range[1].minimum) + XCTAssertEqual(SensorWindow.extrema(points, since: 6)[0].validSamples, 0) + } + + func testRangeHonorsTimeWindowAndResetBoundary() { + let points = [point(1, cpu: 99), point(90, cpu: 80), point(100, cpu: 10)] + XCTAssertEqual(SensorWindow.extrema(points)[0].maximum, 80) + XCTAssertEqual(SensorWindow.extrema(points, since: 90)[0].maximum, 10) + } + + func testLiveProfileAndReportContainOnlyBoundedAllowlistedHardwareFields() throws { + let profile = HardwareProfile.read() + XCTAssertFalse(profile.fields.isEmpty) + XCTAssertEqual(Set(profile.fields.map(\.id)).count, profile.fields.count) + XCTAssertLessThanOrEqual(profile.fields.count, 93) + let data = try HardwareReport.encode(profile, snapshot: nil, active: false, since: nil) + let report = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(report["samplingActive"] as? Bool, false) + let fields = try XCTUnwrap((report["profile"] as? [String: Any])?["fields"] as? [[String: Any]]) + for field in fields { + let id = try XCTUnwrap(field["id"] as? String) + XCTAssertFalse(id.localizedCaseInsensitiveContains("serial")) + XCTAssertFalse(id.localizedCaseInsensitiveContains("hostname")) + XCTAssertFalse(id.contains("path")) + } + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/HardwareTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/HardwareTests.swift new file mode 100644 index 0000000..fc28e1f --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/HardwareTests.swift @@ -0,0 +1,97 @@ +import Foundation +import XCTest + +@testable import ToolboxMonitoring + +final class HardwareTests: XCTestCase { + private func sample(_ counters: [IOCounter], time: Double) -> MetricSample<[IOCounter]> { + MetricSample( + value: counters, availability: .available, unit: .bytes, + time: SampleTime(wall: Date(), monotonic: time), source: "fixture") + } + + func testDeviceRatesWarmUpAndRetireMissingResetOrRecreatedInterfaces() { + var tracker = CounterRateTracker() + func counter(_ id: String, _ bytes: UInt64) -> IOCounter { + IOCounter(id: id, name: id, inputBytes: bytes, outputBytes: bytes * 2) + } + XCTAssertNil(tracker.update(sample([counter("en0", 100)], time: 1)).value?.first?.input.value) + let valid = tracker.update(sample([counter("en0", 300)], time: 3)).value?.first + XCTAssertEqual(valid?.input.value, 100) + XCTAssertEqual(valid?.output.value, 200) + XCTAssertNil(tracker.update(sample([counter("en0", 1)], time: 4)).value?.first?.input.value) + _ = tracker.update(sample([], time: 5)) + XCTAssertNil(tracker.update(sample([counter("en0", 500)], time: 6)).value?.first?.input.value) + let changed = tracker.update(sample([counter("new-index:en0", 1000)], time: 7)) + XCTAssertNil(changed.value?.first?.input.value) + XCTAssertNil( + tracker.update(sample([counter("new-index:en0", 1100)], time: 7)).value?.first?.input.value) + XCTAssertEqual( + tracker.update(sample([counter("same", 0), counter("same", 0)], time: 8)).availability, .error + ) + } + + func testPhysicalAggregateExcludesLoopbackAndTunnelAndDoesNotInventZero() { + var tracker = CounterRateTracker() + let first = [ + IOCounter(id: "en0", name: "en0", inputBytes: 100, outputBytes: 100), + IOCounter( + id: "utun0", name: "utun0", inputBytes: 100, outputBytes: 100, includeInAggregate: false), + ] + let time = SampleTime(wall: Date(), monotonic: 1) + let raw = HardwareReading.unsupported(at: time) + let warmingRates = tracker.update(sample(first, time: 1)) + let warm = HardwareSnapshot(disks: warmingRates, interfaces: warmingRates, reading: raw) + XCTAssertNil(warm.aggregate(network: true, output: false).value) + let second = [ + IOCounter(id: "en0", name: "en0", inputBytes: 300, outputBytes: 100), + IOCounter( + id: "utun0", name: "utun0", inputBytes: 5000, outputBytes: 9000, includeInAggregate: false), + ] + let rates = tracker.update(sample(second, time: 3)) + let snapshot = HardwareSnapshot(disks: rates, interfaces: rates, reading: raw) + XCTAssertEqual(snapshot.aggregate(network: true, output: false).value, 100) + XCTAssertEqual(snapshot.aggregate(network: true, output: true).value, 0) + } + + func testBatteryRequiresCompleteValidRatio() { + XCTAssertNil(BatteryInfo(current: 0, maximum: 0, charging: false, onBattery: false)) + XCTAssertNil(BatteryInfo(current: -1, maximum: 100, charging: false, onBattery: true)) + XCTAssertNil(BatteryInfo(current: 101, maximum: 100, charging: true, onBattery: false)) + XCTAssertEqual( + BatteryInfo(current: 45, maximum: 90, charging: true, onBattery: false)?.percent, 50) + } + + func testUnavailableCounterReadBreaksRateContinuity() { + var tracker = CounterRateTracker() + let counter = IOCounter(id: "disk", name: "disk", inputBytes: 10, outputBytes: 20) + _ = tracker.update(sample([counter], time: 1)) + let failed = MetricSample<[IOCounter]>( + availability: .error, unit: .bytes, time: SampleTime(wall: Date(), monotonic: 2), + source: "fixture") + XCTAssertEqual(tracker.update(failed).availability, .error) + XCTAssertNil(tracker.update(sample([counter], time: 3)).value?.first?.input.value) + } +} + +@MainActor +final class HardwareDeviceTests: XCTestCase { + func testPublicProvidersReadRealDeviceWithoutPrivilege() async { + let provider = SystemMonitoringProvider() + let time = SampleTime(wall: Date(), monotonic: ProcessInfo.processInfo.systemUptime) + let aggregate = await provider.readAggregate(at: time) + XCTAssertEqual(aggregate.cpu.availability, .available) + XCTAssertEqual(aggregate.memory.value?.totalBytes, ProcessInfo.processInfo.physicalMemory) + let hardware = await provider.readHardware(at: time) + XCTAssertGreaterThan(hardware.capacity.value?.totalBytes ?? 0, 0) + XCTAssertEqual(hardware.interfaces.availability, .available) + XCTAssertEqual(hardware.thermal.availability, .available) + if let battery = hardware.battery.value { XCTAssertTrue((0...100).contains(battery.percent)) } + let process = await provider.readProcesses(at: time) + XCTAssertTrue( + process.observations.contains { + $0.pid == ProcessInfo.processInfo.processIdentifier && $0.availability == .available + }) + await provider.pauseHardware() + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/HealthHistoryTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/HealthHistoryTests.swift new file mode 100644 index 0000000..7b93ec4 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/HealthHistoryTests.swift @@ -0,0 +1,136 @@ +import Foundation +import XCTest + +@testable import ToolboxMonitoring + +final class HealthHistoryTests: XCTestCase { + private func record(_ index: Int, value: Double = 20, segment: UUID) -> HealthRecord { + let date = Date(timeIntervalSince1970: 1_800_000_000 + Double(index) * 60) + return HealthRecord( + observedAt: date, uptime: Double(index) * 60, segment: segment, + values: [ + HealthValue( + id: "cpu", title: "System CPU", unit: "%", value: value, + availability: .available, source: "host_statistics", observedAt: date) + ]) + } + func testStoreRoundTripCadenceRetentionAndBaselineComparison() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent("history.json") + let store = HealthHistoryStore(url: url) + let segment = UUID() + for i in 0..<30 { _ = try await store.append(record(i, segment: segment), retentionHours: 24) } + let baseDate = record(29, segment: segment).observedAt + _ = try await store.captureBaseline(now: baseDate, retentionHours: 24) + for i in 30..<40 { + _ = try await store.append(record(i, value: 50, segment: segment), retentionHours: 24) + } + let reloaded = try await HealthHistoryStore(url: url).read( + now: record(39, segment: segment).observedAt, retentionHours: 24) + XCTAssertEqual(reloaded.records.count, 40) + let comparison = try XCTUnwrap(HealthAnalysis.comparisons(reloaded).first) + XCTAssertEqual(comparison.baselineMedian, 20) + XCTAssertEqual(comparison.currentMedian, 50) + XCTAssertEqual(comparison.baselineCount, 30) + XCTAssertEqual(comparison.currentCount, 10) + let duplicate = try await store.append( + record(39, value: 99, segment: segment), retentionHours: 24) + XCTAssertEqual(duplicate.records.count, 40) + let fastRestart = HealthRecord( + observedAt: record(39, segment: segment).observedAt.addingTimeInterval(1), + uptime: 1, segment: UUID(), values: []) + let unchanged = try await store.append(fastRestart, retentionHours: 24) + XCTAssertEqual( + unchanged.records.count, 40, "Restarting must not bypass the persisted one-minute cadence") + let pruned = try await store.setRetention(now: baseDate.addingTimeInterval(90_000), hours: 24) + XCTAssertTrue(pruned.records.isEmpty) + XCTAssertNil(pruned.baseline) + } + func testCorruptAndUnknownSchemaPreservedWithoutOverwrite() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent("history.json") + for bytes in [ + Data("broken".utf8), Data("{\"schemaVersion\":99,\"records\":[]}".utf8), + Data(repeating: 0, count: 24 * 1024 * 1024 + 1), + ] { + try bytes.write(to: url) + let store = HealthHistoryStore(url: url) + do { + _ = try await store.append(record(1, segment: UUID()), retentionHours: 168) + XCTFail("Corrupt data must block writes") + } catch {} + XCTAssertEqual(try Data(contentsOf: url), bytes) + } + } + func testBaselineCannotCountCachedOrMissingObservationsAsIndependentSamples() { + let segment = UUID() + let date = Date(timeIntervalSince1970: 1_800_000_000) + let cached = HealthValue( + id: "smc:Te05", title: "CPU sensors", unit: "°C", value: 42, + availability: .available, source: "AppleSMC", observedAt: date) + var archive = HealthArchive() + archive.baseline = HealthBaseline( + capturedAt: date, + records: (0..<30).map { + HealthRecord( + observedAt: date.addingTimeInterval(Double($0)), uptime: Double($0), segment: segment, + values: [cached]) + }) + archive.records = (1...15).map { + HealthRecord( + observedAt: date.addingTimeInterval(Double($0) * 60), uptime: Double($0) * 60, + segment: segment, + values: [ + HealthValue( + id: "smc:Te05", title: "CPU sensors", unit: "°C", + value: nil, availability: .temporarilyUnavailable, source: "AppleSMC", + observedAt: date.addingTimeInterval(Double($0) * 60)) + ]) + } + XCTAssertTrue(HealthAnalysis.comparisons(archive).isEmpty) + } + func testSerializedByteBudgetEvictsOlderRecordsAndReportsTheLoss() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent("history.json") + let segment = UUID() + let date = Date(timeIntervalSince1970: 1_800_000_000) + let fields = (0..<36).map { + HealthValue( + id: "metric-\($0)", title: String(repeating: "t", count: 90), unit: "%", + value: 20, availability: .available, source: String(repeating: "s", count: 140), + observedAt: date) + } + func point(_ i: Int) -> HealthRecord { + HealthRecord( + observedAt: date.addingTimeInterval(Double(i) * 60), uptime: 1, + segment: segment, values: fields) + } + let encoder = JSONEncoder() + let limit = 24 * 1024 * 1024 + let count = min(10_000, limit / (try encoder.encode(point(0))).count) + var archive = HealthArchive() + archive.records = (0.. limit { + archive.records.removeLast() + data = try encoder.encode(archive) + } + try data.write(to: url) + let last = try XCTUnwrap(archive.records.last) + let store = HealthHistoryStore(url: url) + let next = HealthRecord( + observedAt: last.observedAt.addingTimeInterval(60), uptime: 1, + segment: UUID(), values: fields) + let saved = try await store.append(next, retentionHours: 168) + XCTAssertGreaterThan(saved.omittedRecords, 0) + XCTAssertLessThan(saved.records.count, archive.records.count + 1) + XCTAssertEqual(saved.records.last?.id, next.id) + XCTAssertLessThanOrEqual(try Data(contentsOf: url).count, limit) + } + +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkChangesTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkChangesTests.swift new file mode 100644 index 0000000..6928e64 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkChangesTests.swift @@ -0,0 +1,63 @@ +import Foundation +import XCTest + +@testable import ToolboxMonitoring + +final class NetworkChangesTests: XCTestCase { + private func socket( + id: String = "descriptor", start: UInt64 = 10, state: String = "ESTABLISHED", + verified: Bool = true + ) -> NetworkSocket { + NetworkSocket( + id: id, pid: 42, processName: "App", transport: "TCP", endpoint: "[::1]:8080->[::1]:4000", + state: state, + identity: verified ? ProcessIdentity(pid: 42, startSeconds: start, startMicroseconds: 0) : nil + ) + } + private func reading(_ seconds: Double, _ sockets: [NetworkSocket], omitted: Int = 0) + -> NetworkInspection + { + NetworkInspection( + observedAt: Date(timeIntervalSince1970: seconds), sockets: sockets, omitted: omitted) + } + func testDescriptorOrderDoesNotCreateChangesButStateAndPIDReuseDo() { + var tracker = NetworkChangeTracker() + tracker.update(reading(100, [socket()])) + tracker.update(reading(101, [socket(id: "different"), socket(id: "duplicate")])) + XCTAssertTrue(tracker.events.isEmpty) + tracker.update(reading(102, [socket(state: "CLOSE_WAIT")])) + XCTAssertEqual(tracker.events.map(\.kind), ["State changed"]) + XCTAssertEqual(tracker.events.first?.previousState, "ESTABLISHED") + tracker.update(reading(103, [socket(start: 11)])) + XCTAssertEqual( + Set(tracker.events.prefix(2).map(\.kind)), ["Newly observed", "No longer observed"]) + } + func testPartialFailureUnverifiedAndLongGapResetCoverage() { + var tracker = NetworkChangeTracker() + tracker.update(reading(100, [socket(verified: false)])) + XCTAssertEqual(tracker.unverifiedSockets, 1) + tracker.update(reading(101, [socket()], omitted: 2)) + XCTAssertFalse(tracker.baselineEstablished) + tracker.update(reading(102, [socket()])) + XCTAssertTrue(tracker.events.isEmpty) + tracker.invalidate() + tracker.update(reading(103, [])) + XCTAssertTrue(tracker.events.isEmpty) + tracker.update(reading(1000, [socket()])) + XCTAssertTrue(tracker.events.isEmpty) + tracker.update(reading(1001, [])) + XCTAssertEqual(tracker.events.map(\.kind), ["No longer observed"]) + tracker.clear() + XCTAssertTrue(tracker.events.isEmpty) + XCTAssertFalse(tracker.baselineEstablished) + } + func testChangeRetentionIsBoundedAndReportsOmissions() { + var tracker = NetworkChangeTracker() + tracker.update(reading(100, [])) + for i in 1...205 { + tracker.update(reading(100 + Double(i), i.isMultiple(of: 2) ? [] : [socket()])) + } + XCTAssertEqual(tracker.events.count, 200) + XCTAssertEqual(tracker.omittedEvents, 5) + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkCounterTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkCounterTests.swift new file mode 100644 index 0000000..43a5c57 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkCounterTests.swift @@ -0,0 +1,31 @@ +import Darwin +import Foundation +import XCTest + +@testable import ToolboxMonitoring + +final class NetworkCounterTests: XCTestCase { + func testCounterRetainsBytesBeyond32BitRangeAndRejectsAmbiguousIdentity() { + var header = if_msghdr2() + header.ifm_msglen = UInt16(MemoryLayout.size) + header.ifm_version = UInt8(RTM_VERSION) + header.ifm_type = UInt8(RTM_IFINFO2) + header.ifm_index = 7 + header.ifm_data.ifi_ibytes = 5_000_000_000 + header.ifm_data.ifi_obytes = 9_000_000_000 + let data = withUnsafeBytes(of: header) { Data($0) } + let decoded = SystemNetworkCounters.decode(data) + XCTAssertEqual(decoded?[7]?.input, 5_000_000_000) + XCTAssertEqual(decoded?[7]?.output, 9_000_000_000) + XCTAssertNil(SystemNetworkCounters.decode(data + data)) + header.ifm_version = 0 + XCTAssertNil(SystemNetworkCounters.decode(withUnsafeBytes(of: header) { Data($0) })) + } + + func testMalformedRouteMessagesCannotProducePartialOrOutOfBoundsCounters() { + XCTAssertNil(SystemNetworkCounters.decode(Data([0, 0, 0, 0]))) + XCTAssertNil(SystemNetworkCounters.decode(Data([10, 0, 0, UInt8(RTM_IFINFO2)]))) + XCTAssertNil(SystemNetworkCounters.decode(Data([4, 0, 0, UInt8(RTM_IFINFO2)]))) + XCTAssertNil(SystemNetworkCounters.decode(Data([4, 0, 0, 0, 1]))) + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkInspectionTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkInspectionTests.swift new file mode 100644 index 0000000..751e295 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkInspectionTests.swift @@ -0,0 +1,63 @@ +import Foundation +import XCTest + +@testable import ToolboxMonitoring + +final class NetworkInspectionTests: XCTestCase { + func testSocketBoundariesPreserveIPv6AndNeverInheritState() { + let result = NetworkInspection.parse( + """ + p42 + cApp With Spaces + f8 + tIPv6 + PTCP + n[::1]:8080->[::1]:49152 + TST=ESTABLISHED + f9 + PUDP + n*:5353 + pbad + f1 + PTCP + n*:22 + p43 + cOther + f1 + PTCP + n*:80 + TST=LISTEN + """) + XCTAssertEqual(result.sockets.map(\.pid), [42, 42, 43]) + XCTAssertEqual(result.sockets.map(\.state), ["ESTABLISHED", "Unknown", "LISTEN"]) + XCTAssertEqual(result.sockets.first?.endpoint, "[::1]:8080->[::1]:49152") + XCTAssertEqual(result.sockets.first?.processName, "App With Spaces") + XCTAssertTrue(result.sockets.allSatisfy { $0.identity == nil }) + } + func testCapReportsOmissionsInsteadOfClaimingCompleteCoverage() { + let body = (0..<1030).map { "f\($0)\nPTCP\nn*:8080\n" }.joined() + let result = NetworkInspection.parse("p42\ncApp\n" + body) + XCTAssertEqual(result.sockets.count, 1024) + XCTAssertEqual(result.omitted, 6) + XCTAssertEqual(Set(result.sockets.map(\.id)).count, 1024) + } + func testPriorityReductionOnOwnedChildRejectsMismatchedIdentity() throws { + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sleep") + child.arguments = ["30"] + try child.run() + defer { + if child.isRunning { child.terminate() } + child.waitUntilExit() + } + let identity = try SystemProcessAccess().metadata(pid: child.processIdentifier).identity + let wrong = ProcessIdentity( + pid: identity.pid, startSeconds: identity.startSeconds + 1, + startMicroseconds: identity.startMicroseconds) + XCTAssertThrowsError(try ProcessPriority.lower(wrong)) + XCTAssertGreaterThanOrEqual(try ProcessPriority.lower(identity), 10) + XCTAssertGreaterThanOrEqual(try ProcessPriority.lower(identity), 10) + XCTAssertThrowsError( + try ProcessPriority.lower(.init(pid: 1, startSeconds: 1, startMicroseconds: 0))) + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkToolsTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkToolsTests.swift new file mode 100644 index 0000000..e4f571a --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/NetworkToolsTests.swift @@ -0,0 +1,120 @@ +import XCTest + +@testable import ToolboxMonitoring + +final class NetworkToolsTests: XCTestCase { + func testTargetsRemainSingleValidatedHostArguments() { + for value in ["example.com", "192.168.1.1", "::1", "fe80::1%en0"] { + XCTAssertEqual(NetworkTarget.validate(value), value) + } + for value in [ + "", "-c", "https://example.com", "a;id", "$(id)", "a b", "user@host", "host:443", "::::", + "::1%", "a..b", String(repeating: "a", count: 64) + ".com", + ] { + XCTAssertNil(NetworkTarget.validate(value), value) + } + } + func testLossAndJitterKeepMissingRepliesAndDuplicateSequencesHonest() { + let report = LatencyReport.parse( + """ + 64 bytes: icmp_seq=0 ttl=64 time=10.000 ms + 64 bytes: icmp_seq=0 ttl=64 time=99.000 ms (DUP!) + 64 bytes: icmp_seq=1 ttl=64 time=14.000 ms + Request timeout for icmp_seq 2 + 64 bytes: icmp_seq=3 ttl=64 time=30.000 ms + 4 packets transmitted, 3 packets received, 25.0% packet loss + """, target: "fixture", exitStatus: 0) + XCTAssertEqual(report.lossPercent, 25) + XCTAssertEqual(report.meanMilliseconds, 18) + XCTAssertEqual(report.jitterMilliseconds, 4) + XCTAssertEqual(report.samples.count, 4) + XCTAssertNil(report.samples[2].milliseconds) + let failure = LatencyReport.parse( + "ping: cannot resolve fixture", target: "fixture", exitStatus: 68) + XCTAssertNil(failure.lossPercent) + XCTAssertNil(failure.meanMilliseconds) + XCTAssertNil(failure.jitterMilliseconds) + XCTAssertTrue(failure.samples.isEmpty) + } + func testOverviewChangesIgnoreOrderingRedactionAndPartialCoverage() { + func snapshot(_ time: Double, dns: [String], ssid: String? = "Network A", omitted: Int = 0) + -> NetworkOverview + { + NetworkOverview( + observedAt: Date(timeIntervalSince1970: time), adapters: [], primary: "en0", + gateway4: "192.0.2.1", gateway6: nil, dns: dns, + wifi: [ + .init( + interface: "en0", ssid: ssid, bssid: nil, rssi: nil, noise: nil, channel: nil, + band: nil, widthMHz: nil, transmitMbps: nil) + ], omittedAdapters: omitted) + } + var changes = NetworkOverviewChanges() + changes.consume(snapshot(1, dns: ["192.0.2.2", "192.0.2.3"])) + changes.consume(snapshot(2, dns: ["192.0.2.3", "192.0.2.2"], ssid: nil)) + XCTAssertTrue(changes.events.isEmpty) + changes.consume(snapshot(3, dns: ["192.0.2.4"])) + XCTAssertEqual(changes.events.map(\.field), ["DNS servers"]) + changes.consume(snapshot(4, dns: ["192.0.2.5"], omitted: 1)) + changes.consume(snapshot(5, dns: ["192.0.2.6"])) + XCTAssertEqual(changes.events.count, 1) + changes.consume(snapshot(1000, dns: ["192.0.2.7"])) + XCTAssertEqual(changes.events.count, 1) + for i in 1...205 { changes.consume(snapshot(1000 + Double(i), dns: ["192.0.2.\(i)"])) } + XCTAssertEqual(changes.events.count, 200) + XCTAssertEqual(changes.omitted, 6) + changes.clear() + XCTAssertTrue(changes.events.isEmpty) + XCTAssertEqual(changes.omitted, 0) + } + func testCarrierChangesAreIndependentOfInterfaceFlags() { + func snapshot(_ time: Double, carrier: String?) -> NetworkOverview { + let adapter = NetworkAdapter( + name: "en1", kind: "Ethernet", active: true, addresses: [], mtu: 1500, received: nil, + sent: nil, inputPackets: nil, outputPackets: nil, inputErrors: nil, outputErrors: nil, + drops: nil, media: nil, carrier: carrier) + return NetworkOverview( + observedAt: Date(timeIntervalSince1970: time), adapters: [adapter], primary: nil, + gateway4: nil, gateway6: nil, dns: [], wifi: [], omittedAdapters: 0) + } + var changes = NetworkOverviewChanges() + changes.consume(snapshot(1, carrier: "inactive")) + changes.consume(snapshot(2, carrier: "active")) + XCTAssertEqual(changes.events.count, 1) + XCTAssertEqual(changes.events.first?.field, "en1 • Carrier state") + XCTAssertEqual(changes.events.first?.before, "No link detected") + XCTAssertEqual(changes.events.first?.after, "Link detected") + changes.consume(snapshot(3, carrier: nil)) + changes.consume(snapshot(4, carrier: "inactive")) + XCTAssertEqual(changes.events.count, 1) + } + func testNumericDestinationSkipsDNSWithoutClaimingResolverHealth() async throws { + let checks = try await Task.detached { + try NetworkDiagnostics.connection(target: "127.0.0.1", gateway: nil) + }.value + XCTAssertEqual(checks.count, 3) + XCTAssertNil(checks[0].succeeded) + XCTAssertNil(checks[1].succeeded) + XCTAssertEqual(checks[1].detail, "Not applicable to a numeric IP") + XCTAssertEqual(checks[1].durationMilliseconds, 0) + XCTAssertNotNil(checks[2].succeeded) + } + func testRealLoopbackProbeAndCancellationAtOwnedHelperBoundary() async throws { + let report = try await Task.detached { + try NetworkDiagnostics.latency(target: "127.0.0.1", count: 1) + }.value + XCTAssertEqual(report.exitStatus, 0) + XCTAssertEqual(report.transmitted, 1) + XCTAssertEqual(report.received, 1) + XCTAssertEqual(report.lossPercent, 0) + let start = ProcessInfo.processInfo.systemUptime + let worker = Task.detached { try NetworkDiagnostics.latency(target: "127.0.0.1", count: 20) } + try await Task.sleep(for: .milliseconds(150)) + worker.cancel() + do { + _ = try await worker.value + XCTFail("Cancelled probe must not publish completion") + } catch is CancellationError {} + XCTAssertLessThan(ProcessInfo.processInfo.systemUptime - start, 3) + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/PhysicalSensorTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/PhysicalSensorTests.swift new file mode 100644 index 0000000..0879a5d --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/PhysicalSensorTests.swift @@ -0,0 +1,110 @@ +import Foundation +import XCTest + +@testable import ToolboxMonitoring + +final class PhysicalSensorTests: XCTestCase { + func testSMCIndependentBinaryFixturesAndUnitAdmission() throws { + let cases: [(String, [UInt8], Double)] = [ + ("flt ", [0, 0, 72, 66], 50), + ("sp78", [42, 128], 42.5), ("sp78", [255, 128], -0.5), + ("fpe2", [39, 16], 2500), ("ui8 ", [2], 2), ("ui16", [9, 196], 2500), + ] + for (type, bytes, expected) in cases { + XCTAssertEqual( + try XCTUnwrap(SMCScalar.decode(type: type, bytes: bytes)), expected, accuracy: 0.0001) + } + XCTAssertNil(SMCScalar.decode(type: "flt ", bytes: [0, 0])) + XCTAssertNil(SMCScalar.decode(type: "????", bytes: [0, 0, 0, 0])) + XCTAssertFalse(SMCScalar.supports(type: "ui8 ", unit: .celsius)) + XCTAssertFalse(SMCScalar.supports(type: "sp78", unit: .watts)) + XCTAssertNil(SMCScalar.validated(.nan, unit: .celsius)) + XCTAssertNil(SMCScalar.validated(0, unit: .celsius)) + XCTAssertNil(SMCScalar.validated(151, unit: .celsius)) + XCTAssertNil(SMCScalar.validated(-1, unit: .rpm)) + XCTAssertEqual(SMCScalar.validated(0, unit: .rpm), 0) + XCTAssertEqual(SMCScalar.validated(0, unit: .watts), 0) + } + + func testM3DoesNotClassifyKnownGPUFabricKeyAsCPU() { + let mapping = PhysicalSensors.mapping(for: "Apple M3 Pro") + XCTAssertFalse(mapping.keys.contains { $0.0 == "Tf04" && $0.1 == "CPU sensors" }) + } + + func testUnknownAppleGenerationDoesNotBorrowAnOlderMapping() { + XCTAssertTrue(PhysicalSensors.mapping(for: "Apple M30 Pro").keys.isEmpty) + XCTAssertTrue(PhysicalSensors.mapping(for: "Unknown CPU").keys.isEmpty) + XCTAssertEqual(PhysicalSensors.mapping(for: "Apple M3 Pro").family, "Apple M3") + } + + func testSensorWindowRejectsMissingReusedWrongUnitAndOldGenerationValues() { + func reading( + _ value: Double?, unit: PhysicalSensorUnit = .rpm, state: MetricAvailability = .available + ) -> PhysicalSensorReading { + PhysicalSensorReading( + key: "F0Ac", group: "Fans", unit: unit, value: value, availability: state, dataType: "flt ") + } + func point(_ time: Double, generation: Int = 2, sensors: [PhysicalSensorReading]?) -> TrendPoint + { + TrendPoint( + time: SampleTime(wall: Date(timeIntervalSince1970: time), monotonic: time), + generation: generation, + ioSampled: true, cpuPercent: nil, memoryPercent: nil, networkInput: nil, networkOutput: nil, + diskInput: nil, diskOutput: nil, physicalSensors: sensors) + } + let points = [ + point(1, generation: 1, sensors: [reading(8000)]), + point(2, sensors: [reading(2500)]), point(3, sensors: nil), + point(4, sensors: [reading(99, unit: .celsius)]), + point(5, sensors: [reading(nil, state: .temporarilyUnavailable)]), + point(6, sensors: [reading(0)]), + ] + let range = SensorWindow.physicalExtrema(points, sensor: reading(0)) + XCTAssertEqual(range.minimum, 0) + XCTAssertEqual(range.maximum, 2500) + XCTAssertEqual(range.validSamples, 2) + XCTAssertEqual( + SensorWindow.physicalExtrema(points, sensor: reading(0), since: 6).validSamples, 0) + XCTAssertEqual( + SensorWindow.physicalExtrema(points + [point(100, sensors: nil)], sensor: reading(0)) + .validSamples, 0) + } + + func testUnavailableSensorsSurviveHardwareExportWithoutBecomingZero() throws { + let time = SampleTime(wall: Date(timeIntervalSince1970: 100), monotonic: 100) + let missing = PhysicalSensorReading( + key: "TC0D", group: "CPU sensors", unit: .celsius, value: nil, + availability: .temporarilyUnavailable, dataType: nil) + let hardware = HardwareReading( + disks: .init(availability: .unsupported, unit: .bytes, time: time, source: "test"), + interfaces: .init(availability: .unsupported, unit: .bytes, time: time, source: "test"), + capacity: .init(availability: .unsupported, unit: .bytes, time: time, source: "test"), + battery: .init(availability: .unsupported, unit: .percent, time: time, source: "test"), + thermal: .init(availability: .unsupported, unit: .state, time: time, source: "test"), + swap: .init(availability: .unsupported, unit: .bytes, time: time, source: "test"), + pressure: .init(availability: .unsupported, unit: .state, time: time, source: "test"), + physicalSensors: .init( + value: PhysicalSensorSnapshot( + readings: [missing], reportedFanCount: nil, + mappingFamily: "Intel", coverageLimited: true), availability: .available, + unit: .collection, time: time, source: "AppleSMC")) + let snapshot = MonitoringSnapshot( + time: time, cpu: .init(availability: .loading, unit: .percent, time: time, source: "test"), + memory: .init(availability: .loading, unit: .bytes, time: time, source: "test"), history: [], + processes: .init(availability: .loading, unit: .collection, time: time, source: "test"), + hardware: HardwareSnapshot( + disks: .init(availability: .unsupported, unit: .bytesPerSecond, time: time, source: "test"), + interfaces: .init( + availability: .unsupported, unit: .bytesPerSecond, time: time, source: "test"), + reading: hardware)) + let data = try HardwareReport.encode( + HardwareProfile(observedAt: time.wall, fields: []), snapshot: snapshot, active: false, + since: nil) + let report = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let readings = try XCTUnwrap( + (report["physicalSensors"] as? [String: Any])?["readings"] as? [[String: Any]]) + XCTAssertNil(readings[0]["value"]) + XCTAssertEqual(readings[0]["availability"] as? String, "temporarilyUnavailable") + XCTAssertEqual(report["samplingActive"] as? Bool, false) + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/PolicyTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/PolicyTests.swift new file mode 100644 index 0000000..7934ad9 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/PolicyTests.swift @@ -0,0 +1,29 @@ +import XCTest + +@testable import ToolboxMonitoring + +final class PolicyTests: XCTestCase { + func testInactiveAndClosedWindowsRequireOptInSamplingAndSleepAlwaysPauses() { + for visible in [true, false] { + for menu in [true, false] { + XCTAssertEqual( + SamplingPolicy.mode( + appActive: false, mainWindowVisible: visible, sleeping: false, menuBarEnabled: menu), + menu ? .menuBar : .paused) + XCTAssertEqual( + SamplingPolicy.mode( + appActive: true, mainWindowVisible: visible, sleeping: true, menuBarEnabled: menu), + .paused) + } + } + XCTAssertEqual( + SamplingPolicy.mode( + appActive: true, mainWindowVisible: false, sleeping: false, menuBarEnabled: true), .menuBar) + XCTAssertEqual( + SamplingPolicy.mode( + appActive: true, mainWindowVisible: false, sleeping: false, menuBarEnabled: false), .paused) + XCTAssertEqual( + SamplingPolicy.mode( + appActive: true, mainWindowVisible: true, sleeping: false, menuBarEnabled: false), .active) + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/ProcessTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/ProcessTests.swift new file mode 100644 index 0000000..84cea2d --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/ProcessTests.swift @@ -0,0 +1,114 @@ +import Foundation +import XCTest +import os + +@testable import ToolboxMonitoring + +final class ProcessTests: XCTestCase { + private let identity = ProcessIdentity(pid: 123, startSeconds: 1, startMicroseconds: 0) + + func testPIDReuseAndCounterResetNeverProduceCPUFromOldProcess() { + var tracker = ProcessTracker() + func reading(start: UInt64, ticks: UInt64, state: MetricAvailability = .available) + -> ProcessReading + { + ProcessReading( + observations: [ + ProcessObservation( + pid: 123, + metadata: ProcessMetadata( + identity: ProcessIdentity(pid: 123, startSeconds: start, startMicroseconds: 0), + name: "worker", parentPID: 1), + counters: state == .available + ? ProcessCounters(cpuTicks: ticks, residentBytes: 42, threadCount: 1) : nil, + availability: state, reason: nil) + ], availability: .available) + } + func time(_ value: Double) -> SampleTime { + SampleTime(wall: Date(timeIntervalSince1970: value), monotonic: value) + } + XCTAssertNil(tracker.update(reading(start: 1, ticks: 10), at: time(1)).first?.cpu.value) + XCTAssertEqual( + tracker.update(reading(start: 1, ticks: 1_000_000_010), at: time(2)).first?.cpu.value, 100) + XCTAssertNil( + tracker.update(reading(start: 2, ticks: 2_000_000_010), at: time(3)).first?.cpu.value) + XCTAssertNil(tracker.update(reading(start: 2, ticks: 1), at: time(4)).first?.cpu.value) + XCTAssertNil( + tracker.update(reading(start: 2, ticks: 1, state: .permissionRequired), at: time(5)).first? + .memory.value) + XCTAssertNil( + tracker.update(reading(start: 2, ticks: 3_000_000_010), at: time(6)).first?.cpu.value) + } + + func testInspectorRevalidatesIdentityAfterReadingAndPreservesPermissionState() { + let metadata = ProcessMetadata(identity: identity, name: "worker", parentPID: 1) + let changed = ProcessMetadata( + identity: ProcessIdentity(pid: 123, startSeconds: 2, startMicroseconds: 0), name: "other", + parentPID: 1) + let reused = ProcessInspector(access: FixtureAccess(metadata: [metadata, changed])) + let result = reused.inspect(pid: 123) + XCTAssertEqual(result.availability, .temporarilyUnavailable) + XCTAssertNil(result.counters) + for error in [ProcessReadError.exited, .permissionRequired] { + let failed = ProcessInspector(access: FixtureAccess(metadata: [metadata], failure: error)) + .inspect(pid: 123) + XCTAssertEqual(failed.availability, error.availability) + XCTAssertNil(failed.counters) + } + let wrong = ProcessInspector(access: FixtureAccess(metadata: [changed])).inspect( + pid: 123, expected: identity) + XCTAssertNil(wrong.counters) + XCTAssertEqual(wrong.reason, "identityChanged") + } + + func testLargeProcessSetHasBoundedTrendsAndStableFilteringSorting() { + var tracker = ProcessTracker() + let observations = (1...10_000).map { pid in + ProcessObservation( + pid: Int32(pid), + metadata: ProcessMetadata( + identity: ProcessIdentity(pid: Int32(pid), startSeconds: 1, startMicroseconds: 0), + name: "worker-\(pid)", parentPID: 1), + counters: ProcessCounters(cpuTicks: 10, residentBytes: UInt64(pid), threadCount: 1), + availability: .available, reason: nil) + } + let samples = tracker.update( + ProcessReading(observations: observations, availability: .available), + at: SampleTime(wall: Date(), monotonic: 1)) + XCTAssertEqual(samples.count, 10_000) + XCTAssertEqual(samples.filter { !$0.cpuHistory.isEmpty }.count, 100) + XCTAssertEqual(ProcessOrdering.memory.sorted(samples).first?.pid, 10_000) + XCTAssertEqual(ProcessOrdering.pid.sorted(samples, query: "worker-9999").map(\.pid), [9999]) + _ = tracker.update( + ProcessReading(observations: [], availability: .available), + at: SampleTime(wall: Date(), monotonic: 2)) + let restarted = tracker.update( + ProcessReading(observations: [observations[0]], availability: .available), + at: SampleTime(wall: Date(), monotonic: 3)) + XCTAssertEqual(restarted.first?.cpuHistory.count, 1) + } +} + +private final class FixtureAccess: ProcessAccess { + private struct State { + var metadata: [ProcessMetadata] + var index = 0 + } + private let state: OSAllocatedUnfairLock + private let failure: ProcessReadError? + init(metadata: [ProcessMetadata], failure: ProcessReadError? = nil) { + state = OSAllocatedUnfairLock(initialState: State(metadata: metadata)) + self.failure = failure + } + func metadata(pid: Int32) -> ProcessMetadata { + state.withLock { state in + let value = state.metadata[min(state.index, state.metadata.count - 1)] + state.index += 1 + return value + } + } + func counters(pid: Int32) throws -> ProcessCounters { + if let failure { throw failure } + return ProcessCounters(cpuTicks: 10, residentBytes: 100, threadCount: 1) + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/SamplingTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/SamplingTests.swift new file mode 100644 index 0000000..5fca1a1 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/SamplingTests.swift @@ -0,0 +1,218 @@ +import Foundation +import XCTest + +@testable import ToolboxMonitoring + +final class SamplingTests: XCTestCase { + func testInvalidPhysicalMemoryCannotBecomeZeroUtilization() { + XCTAssertEqual( + MemoryInfo(totalBytes: 100, freeBytes: 25, wiredBytes: 10, compressedBytes: 5) + .occupiedPercent, 75) + XCTAssertNil( + MemoryInfo(totalBytes: 0, freeBytes: 0, wiredBytes: 0, compressedBytes: 0).occupiedPercent) + XCTAssertNil( + MemoryInfo(totalBytes: 100, freeBytes: 101, wiredBytes: 0, compressedBytes: 0).occupiedPercent + ) + } + + func testRatesRejectInvalidPairsAndKeepTrueZero() { + for elapsed in [0, -1, .nan, .infinity] { + XCTAssertNil(RateCalculator.rate(previous: 1, current: 2, elapsed: elapsed)) + } + XCTAssertNil(RateCalculator.rate(previous: 100, current: 1, elapsed: 1)) + XCTAssertEqual(RateCalculator.rate(previous: 100, current: 100, elapsed: 2), 0) + XCTAssertEqual(RateCalculator.rate(previous: 100, current: 400, elapsed: 2), 150) + XCTAssertEqual( + RateCalculator.processCPU( + previous: 0, current: 24_000_000, elapsed: 1, numerator: 125, denominator: 3), 100) + XCTAssertNil( + RateCalculator.processCPU(previous: 1, current: 0, elapsed: 1, numerator: 1, denominator: 1)) + } + func testSystemCPUNormalizationAndWrap() { + let before = CPUTicks(user: 10, system: 20, nice: 30, idle: 40) + XCTAssertEqual( + RateCalculator.cpu( + previous: before, current: CPUTicks(user: 20, system: 30, nice: 40, idle: 110)), 30) + XCTAssertNil(RateCalculator.cpu(previous: before, current: before)) + XCTAssertNil( + RateCalculator.cpu( + previous: before, current: CPUTicks(user: 0, system: 30, nice: 40, idle: 110))) + } + func testRingOverwritesOldestWithoutReordering() { + var ring = RingBuffer(capacity: 3) + for value in 0..<100_000 { ring.append(value) } + XCTAssertEqual(ring.count, 3) + XCTAssertEqual(ring.elements, [99_997, 99_998, 99_999]) + } + func testAvailabilityDoesNotInventZeroAndUsesMonotonicFreshness() { + let time = SampleTime(wall: Date(timeIntervalSince1970: 0), monotonic: 10) + let sample = MetricSample( + value: 0.0, availability: .available, unit: .percent, time: time, source: "fixture") + XCTAssertEqual(sample.freshness(at: 12, maximumAge: 3), .available) + XCTAssertEqual(sample.freshness(at: 14, maximumAge: 3), .stale) + XCTAssertEqual(sample.freshness(at: 9, maximumAge: 3), .stale) + XCTAssertEqual(sample.value, 0) + let missing = MetricSample( + availability: .available, unit: .percent, time: time, source: "fixture") + XCTAssertNil(missing.value) + XCTAssertEqual(missing.availability, .temporarilyUnavailable) + let failed = MetricSample( + value: 5.0, availability: .error, unit: .percent, time: time, source: "fixture") + XCTAssertNil(failed.value) + } +} + +@MainActor +final class MonitoringLifecycleTests: XCTestCase { + func testWarmUpPauseResumeAndBoundedPublication() async { + let clock = ManualMonitoringClock() + let engine = MonitoringCoordinator(provider: FixtureAggregateProvider(), clock: clock) + var iterator = await engine.snapshots().makeAsyncIterator() + await engine.setMode(.active) + let first = await iterator.next() + XCTAssertEqual(first?.cpu.availability, .loading) + await clock.advance(to: 1) + let second = await iterator.next() + XCTAssertEqual(second?.cpu.value, 50) + for second in 2...65 { + await clock.advance(to: Double(second)) + _ = await iterator.next() + } + await clock.advance(to: 66) + let bounded = await iterator.next() + XCTAssertEqual(bounded?.history.count, 60) + XCTAssertEqual(bounded?.history.first?.time.monotonic, 7) + await engine.setMode(.paused) + await engine.setMode(.active) + let resumed = await iterator.next() + XCTAssertEqual(resumed?.cpu.availability, .loading) + await engine.stop() + let finished = await iterator.next() + XCTAssertNil(finished) + } + func testCancellationDiscardsLateProviderCompletionBeforeResume() async { + let clock = ManualMonitoringClock() + let provider = GatedAggregateProvider() + let engine = MonitoringCoordinator(provider: provider, clock: clock) + var iterator = await engine.snapshots().makeAsyncIterator() + await engine.setMode(.active) + await provider.waitUntilEntered() + let pause = Task { await engine.setMode(.paused) } + await provider.waitUntilCancelled() + await provider.release() + await pause.value + await engine.setMode(.active) + let fresh = await iterator.next() + XCTAssertEqual(fresh?.cpu.availability, .loading) + XCTAssertEqual(fresh?.history.count, 1) + await engine.stop() + } + + func testRepeatedDemandHasOneSampler() async { + let clock = ManualMonitoringClock() + let provider = FixtureAggregateProvider() + let engine = MonitoringCoordinator(provider: provider, clock: clock) + var iterator = await engine.snapshots().makeAsyncIterator() + await engine.setMode(.active) + _ = await iterator.next() + for _ in 0..<50 { await engine.setMode(.active) } + let count = await provider.reads + XCTAssertEqual(count, 1) + await clock.advance(to: 1) + _ = await iterator.next() + let nextCount = await provider.reads + XCTAssertEqual(nextCount, 2) + await engine.stop() + } +} + +private actor ManualMonitoringClock: MonitoringClock { + private var time = 0.0 + private var sleeper: CheckedContinuation? + private var observers: [CheckedContinuation] = [] + func now() -> SampleTime { SampleTime(wall: Date(timeIntervalSince1970: time), monotonic: time) } + func sleep(seconds: Double) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + sleeper = continuation + for observer in observers { observer.resume() } + observers.removeAll() + } + } onCancel: { + Task { await self.cancelSleep() } + } + } + func advance(to next: Double) async { + if sleeper == nil { await withCheckedContinuation { observers.append($0) } } + time = next + sleeper?.resume() + sleeper = nil + } + private func cancelSleep() { + sleeper?.resume(throwing: CancellationError()) + sleeper = nil + } +} +private actor FixtureAggregateProvider: MonitoringProvider { + private(set) var reads = 0 + func readAggregate(at time: SampleTime) -> AggregateReading { + reads += 1 + let tick = UInt64(time.monotonic * 10) + return AggregateReading( + cpu: MetricSample( + value: CPUTicks(user: tick, system: 0, nice: 0, idle: tick), availability: .available, + unit: .ticks, time: time, source: "fixture"), + memory: MetricSample( + value: MemoryInfo(totalBytes: 100, freeBytes: 25, wiredBytes: 10, compressedBytes: 5), + availability: .available, unit: .bytes, time: time, source: "fixture")) + } +} + +private actor GatedAggregateProvider: MonitoringProvider { + private var entered = false + private var cancelled = false + private var gate: CheckedContinuation? + private var entryWaiter: CheckedContinuation? + private var cancelWaiter: CheckedContinuation? + func readAggregate(at time: SampleTime) async -> AggregateReading { + if !entered { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + gate = continuation + entered = true + entryWaiter?.resume() + entryWaiter = nil + } + } onCancel: { + Task { await self.markCancelled() } + } + } + return AggregateReading( + cpu: MetricSample( + value: CPUTicks(user: 1, system: 1, nice: 0, idle: 2), availability: .available, + unit: .ticks, time: time, source: "gated fixture"), + memory: MetricSample( + value: MemoryInfo(totalBytes: 100, freeBytes: 50, wiredBytes: 1, compressedBytes: 0), + availability: .available, unit: .bytes, time: time, source: "gated fixture")) + } + func waitUntilEntered() async { + if !entered { await withCheckedContinuation { entryWaiter = $0 } } + } + func waitUntilCancelled() async { + if !cancelled { await withCheckedContinuation { cancelWaiter = $0 } } + } + func markCancelled() { + cancelled = true + cancelWaiter?.resume() + cancelWaiter = nil + } + func release() { + gate?.resume() + gate = nil + } +} diff --git a/apps/toolbox/Tests/ToolboxMonitoringTests/SleepInspectionTests.swift b/apps/toolbox/Tests/ToolboxMonitoringTests/SleepInspectionTests.swift new file mode 100644 index 0000000..09d8aa0 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxMonitoringTests/SleepInspectionTests.swift @@ -0,0 +1,65 @@ +import Foundation +import IOKit.pwr_mgt +import XCTest + +@testable import ToolboxMonitoring + +final class SleepInspectionTests: XCTestCase { + private struct Access: ProcessAccess { + let process: ProcessMetadata + func metadata(pid: Int32) throws -> ProcessMetadata { + guard pid == process.identity.pid else { throw ProcessReadError.exited } + return process + } + func counters(pid: Int32) throws -> ProcessCounters { throw ProcessReadError.unavailable } + } + + private func metadata(start: UInt64) -> ProcessMetadata { + ProcessMetadata( + identity: ProcessIdentity(pid: 42, startSeconds: start, startMicroseconds: 0), + name: "Example", parentPID: 1) + } + + private var active: [String: Any] { + [ + kIOPMAssertionLevelKey: NSNumber(value: kIOPMAssertionLevelOn), + kIOPMAssertionTypeKey: "PreventUserIdleSystemSleep", + kIOPMAssertionNameKey: "Work in progress", + ] + } + + func testOwnershipRequiresSameStartIdentityAroundAssertionRead() { + let initial = metadata(start: 100) + for (start, verified) in [(UInt64(100), true), (UInt64(200), false)] { + let report = SleepAssertionParser.parse( + [42: [active]], before: [42: initial], access: Access(process: metadata(start: start))) + XCTAssertEqual(report.requests.count, 1) + XCTAssertEqual(report.requests.first?.owner != nil, verified) + XCTAssertEqual(report.requests.first?.type, "PreventUserIdleSystemSleep") + } + let unknown = SleepAssertionParser.parse( + [42: [active]], before: [:], access: Access(process: initial)) + XCTAssertNil(unknown.requests.first?.owner) + } + + func testInactiveMalformedAndBoundedReportsRemainDistinct() { + var inactive = active + inactive[kIOPMAssertionLevelKey] = NSNumber(value: kIOPMAssertionLevelOff) + var malformed = active + malformed[kIOPMAssertionLevelKey] = NSNumber(value: 1.5) + let access = Access(process: metadata(start: 100)) + let report = SleepAssertionParser.parse( + [42: [inactive, malformed, active], NSNumber(value: 42.5): [active]], before: [:], + access: access) + XCTAssertEqual(report.requests.count, 1) + XCTAssertEqual(report.omittedCount, 2) + let bounded = SleepAssertionParser.parse( + [42: Array(repeating: active, count: 520)], before: [:], access: access) + XCTAssertEqual(bounded.requests.count, 512) + XCTAssertEqual(bounded.omittedCount, 8) + XCTAssertEqual(Set(bounded.requests.map(\.id)).count, 512) + let empty = SleepAssertionParser.parse([42: [inactive]], before: [:], access: access) + XCTAssertTrue(empty.requests.isEmpty) + XCTAssertEqual(empty.omittedCount, 0) + } +} diff --git a/apps/toolbox/Tests/ToolboxStorageTests/StorageGrowthTests.swift b/apps/toolbox/Tests/ToolboxStorageTests/StorageGrowthTests.swift new file mode 100644 index 0000000..1b69865 --- /dev/null +++ b/apps/toolbox/Tests/ToolboxStorageTests/StorageGrowthTests.swift @@ -0,0 +1,96 @@ +import Foundation +import XCTest + +@testable import ToolboxStorage + +final class StorageGrowthTests: XCTestCase { + private func checkpoint( + _ date: Date, folders: [String: Int64], identity: String = "1:2", unreadable: Int = 0 + ) -> StorageCheckpoint { + .init( + rootPath: "/fixture", date: date, totalBytes: 100, folders: folders, + schemaVersion: 2, rootIdentity: identity, inaccessibleCount: unreadable, foldersComplete: true + ) + } + func testGrowthAccountsForRemovedItemsAndRejectsIncompatibleCoverage() { + let start = Date(timeIntervalSince1970: 100) + let end = Date(timeIntervalSince1970: 200) + let old = checkpoint(start, folders: ["/fixture/a": 10, "/fixture/gone": 50]) + let next = checkpoint(end, folders: ["/fixture/a": 30, "/fixture/new": 5]) + let result = StorageComparison.compare(previous: old, current: next) + XCTAssertEqual(result.deltas.map(\.path), ["/fixture/gone", "/fixture/a", "/fixture/new"]) + XCTAssertEqual(result.deltas.map(\.bytes), [-50, 20, 5]) + for incompatible in [ + checkpoint(end, folders: [:], identity: "1:3"), + checkpoint(end, folders: [:], unreadable: 1), checkpoint(start, folders: [:]), + ] { + XCTAssertTrue(StorageComparison.compare(previous: old, current: incompatible).deltas.isEmpty) + } + let legacy = StorageCheckpoint( + rootPath: "/fixture", date: start, totalBytes: 100, folders: old.folders) + XCTAssertTrue(StorageComparison.compare(previous: legacy, current: next).deltas.isEmpty) + } + func testComparisonRetainsFoldersOutsideTopThirtyAndInventoryIncludesSourceOnlyProject() + async throws + { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + for index in 0..<35 { + let folder = root.appendingPathComponent("folder-\(index)") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data([1]).write(to: folder.appendingPathComponent("file")) + } + try Data("{}".utf8).write(to: root.appendingPathComponent("package.json")) + let scan = try StorageAnalyzer().scan(rootURL: root) + XCTAssertEqual(scan.topFolders.count, 30) + XCTAssertEqual(scan.comparisonFolders?.count, 36) + XCTAssertNotNil(scan.rootIdentity) + let projects = await ProjectScanner().scan(roots: [root]) + XCTAssertTrue(projects.artifacts.isEmpty) + XCTAssertEqual(projects.inventory?.first?.ecosystems, [.node]) + XCTAssertEqual(projects.inventory?.first?.markers, ["package.json"]) + } + func testDirectoryNamedLikeManifestDoesNotAuthorizeArtifactRecognition() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("package.json"), withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("node_modules"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let report = await ProjectScanner().scan(roots: [root]) + XCTAssertTrue(report.artifacts.isEmpty) + XCTAssertTrue((report.inventory ?? []).isEmpty) + } + + func testWorkspacePartitionMatchesFilesystemAliases() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let artifact = root.appendingPathComponent("node_modules") + try FileManager.default.createDirectory(at: artifact, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("{}".utf8).write(to: root.appendingPathComponent("package.json")) + try Data([1]).write(to: artifact.appendingPathComponent("fixture")) + let report = await ProjectScanner().scan(roots: [root]) + let snapshot = try StorageAnalyzer().scan(rootURL: root) + let observedArtifact = try XCTUnwrap(report.artifacts.first) + let measuredPath = try XCTUnwrap( + snapshot.comparisonFolders?.keys.first { + URL(fileURLWithPath: $0).lastPathComponent == "node_modules" + }) + let aliasPath = + measuredPath.hasPrefix("/private/var/") + ? String(measuredPath.dropFirst("/private".count)) : "/private" + measuredPath + let aliased = ProjectArtifact( + projectRoot: observedArtifact.projectRoot, + artifactURL: URL(fileURLWithPath: aliasPath, isDirectory: true), + ecosystem: observedArtifact.ecosystem, bytes: observedArtifact.bytes, + modifiedAt: observedArtifact.modifiedAt, safety: observedArtifact.safety, + reasons: observedArtifact.reasons) + XCTAssertNotEqual(aliased.artifactURL.path, measuredPath) + let usage = WorkspaceUsage(snapshot: snapshot, artifacts: [aliased]) + XCTAssertGreaterThan(observedArtifact.bytes, 0) + XCTAssertEqual(usage.artifactBytes, observedArtifact.bytes) + XCTAssertEqual(usage.otherBytes, snapshot.scannedBytes - observedArtifact.bytes) + } + +} diff --git a/apps/toolbox/UX-CONTRACT.md b/apps/toolbox/UX-CONTRACT.md new file mode 100644 index 0000000..37ddf8e --- /dev/null +++ b/apps/toolbox/UX-CONTRACT.md @@ -0,0 +1,158 @@ +# Orven native interaction contract + +## English + +### Wi-Fi and LAN + +Wi-Fi/LAN uses the existing Orven theme, native controls, locale provider, MonitoringChart and rate sampler. A page-owned explicit read/probe service has one operation, cancellation and timestamped results; NWPathMonitor is a visible-page event observer. No configuration changes or persisted identities. See [network contract](../../docs/monitoring/NETWORK-TOOLS.md). + + +### Health workflows + +The user authorized local Health History/Baseline, opt-in Smart Alerts, Sensor Charts/Recording and session-only Network Connection Changes. This scoped change supersedes earlier no-persistence/no-alert wording below. Existing sampler, MonitoringChart, OrvenTheme, L10n, native controls and DiagnosticsExportView remain owners. Persistence is bounded/default-off; notification permission is explicit; endpoints stay in session memory. See [health workflow contract](../../docs/monitoring/HEALTH-WORKFLOWS.md). + + +### Physical sensors + +Physical Sensor Panel reuses the existing hardware cadence, Orven surface, native disclosure/Grid and shared export. Mapped identity, stale current, missing values and limited coverage are explicit; no fan mutation. See [physical sensor scope](../../docs/monitoring/PHYSICAL-SENSORS.md). + +### Hardware Inspector + +Hardware Inspector reuses Orven surfaces, native Grid, explicit refresh and the shared diagnostics preview/save sheet. Range reset preserves engine history; missing/current-stale fields remain explicit. See [hardware scope](../../docs/monitoring/HARDWARE-TOOLS.md). + +Coverage and privacy explanations in bounded sheets must wrap vertically, remain readable in full, and allow scrolling to related actions. App Activity Timeline explicitly fixes vertical text sizing; a clipped or ellipsized warning is not a completed narrow-window gate. Native screenshots and AX evidence remain separate from static audits. + +### Feature expansion + +New diagnostics reuse OrvenTheme, native controls and existing sampling. Explicit helper refresh owns loading/error/cancellation states. The approved CPU priority action has its own native confirmation and same-user PID/start guard; monitoring does not change process scheduling automatically. Scope and prerequisites are defined in [feature expansion](../../docs/monitoring/FEATURE-EXPANSION.md). + +Native redesign approved: shared OrvenTheme and surfaces replace the old material/gradient headers. Overview reads the existing monitoring snapshot and routes Inspect process with a full PID/start identity; no storage selection is made. Process details use a persistent inspector at wide sizes and an OS-owned modal sheet at smaller sizes. System/light/dark is app-local with toolbox.appearance defaulting to system. Table, picker, focus and scroll ownership stay native. No runtime schema or data directory changes. + +### Sources and ownership + +The approved 3.0 specification, [architecture](../../docs/ARCHITECTURE.md), [privacy](../../PRIVACY.md), [security](../../SECURITY.md), and [release readiness](../../docs/production/RELEASE-READINESS.md) define the native product boundary. The root DESIGN.md belongs to the marketing site; [native DESIGN.md](DESIGN.md) preserves the existing SwiftUI identity. English/Vietnamese/Japanese/Simplified Chinese GUI is owned by L10n and the shell locale. Every packaged locale merges the module tables into Localizable for native controls. + +| Capability | Canonical owner | Source of truth | Allowed variants | Verification | +| --- | --- | --- | --- | --- | +| Table Selection | SwiftUI Table with ProcessRowID | DESIGN.md; approved 3.0 spec; metric contracts | One read-only row; verified PID/start identity, no cleanup selection. | ProcessTests; native keyboard check | +| Select/Listbox | Native SwiftUI Picker | DESIGN.md; approved 3.0 spec; metric contracts | OS popup geometry and keyboard behavior are accepted. | native keyboard and popup check | +| Form | SwiftUI Form / SettingsView | DESIGN.md; approved 3.0 spec; metric contracts | Stable preferences; menu bar default off, one sampler, no privilege requests. | MonitoringPreferencesTests; Settings GUI | +| Scrollbar | Native macOS ScrollView/Table | DESIGN.md; approved 3.0 spec; metric contracts | System scrollbar preferences remain unchanged; table owns its scroll. | native window scroll check | +| Search | ProcessesView local TextField | DESIGN.md; approved 3.0 spec; metric contracts | Clear is immediate and returns focus; no remote request or query persistence. | ProcessTests; clear/no-results GUI | +| App Dossier and discovery | ApplicationsView / AppDossierView / ApplicationScanner | Market enhancements; DESIGN.md | Explicit inspection; scan-time size/evidence; app/Bundle ID search; no extra scan or cleanup | Universal/unit/locale checks; native UI pending unlock | +| Sleep inspection | SleepInspectorView / MonitoringCoordinator | DESIGN.md; approved feature exploration | Explicit refresh; timestamped snapshot; unknown ownership; no mutation or polling. | live provider smoke; PID reuse/parser fixtures; Light/Dark, EN/VI, explicit refresh and verified navigation observed; full VoiceOver unverified | +| Charts | MonitoringChart | DESIGN.md; approved 3.0 spec; metric contracts | Labeled axes, missing-sample gaps, VoiceOver numeric summary and previous/next buttons. | gap and numeric summary GUI; keyboard samples | +| Navigation | ToolboxCoordinator / ToolboxShellView | DESIGN.md; approved 3.0 spec; metric contracts | Existing raw values persist; cross-feature actions navigate without selecting mutations. | ToolboxAppTests; deep-link GUI | +| Feedback | MetricReadout / MetricSample | DESIGN.md; approved 3.0 spec; metric contracts | Availability, unit and timestamp remain visible; missing data never becomes zero. | SamplingTests; live/stale GUI | + +### Workflow and lifecycle + +One app-owned runtime owns routing and monitoring across the main, fallback and menu bar surfaces. Window markers identify visible main windows; inactive/closed windows pause polling unless menu bar is explicitly enabled. Enabled background sampling uses 5 s cadence; sleep pauses, wake warms up and Quit joins/cancels work. Closing a window leaves the existing Dock app available to reopen. Scheduled-scan invocation bypasses monitoring. + +Process details revalidate identity before and after counters; Applications navigation additionally checks live bundle mapping and launch date. Parent PID is advisory; a verified ancestry tree is deferred because parent start identity is not supplied in that field. Read-only views have loading, stale, error, permission, unsupported and no-results states. Serious alerts, process kill and automatic cleanup are excluded. Automatic alerts remain unavailable until separately authorized diagnostic rules exist. + +Existing foreground confirmation, canonical validation, Trash/recovery and legacy import remain with their feature owners. No monitoring data is persisted. Search/selection are transient to avoid storing process names; section and menu preferences persist. Accessibility, locale, light/dark, small supported window and actual lifecycle checks must be recorded separately from compilation. + +## Tiếng Việt + +### Wi-Fi and LAN + +Wi-Fi/LAN dùng theme/control/locale/chart/sampler cũ; owner riêng cho read/probe theo yêu cầu, một operation/cancel/timestamp. NWPathMonitor quan sát event khi trang mở. Không đổi cấu hình hoặc lưu identity. Xem [network contract](../../docs/monitoring/NETWORK-TOOLS.md). + + +### Health workflows + +Người dùng cho phép Health History/Baseline trên máy, Smart Alerts opt-in, Sensor Charts/Recording và Network Connection Changes chỉ trong phiên. Scope này thay thế các hạn chế persistence/alert cũ bên dưới. Sampler, MonitoringChart, OrvenTheme, L10n, native controls và DiagnosticsExportView hiện có tiếp tục sở hữu chức năng. Persistence có giới hạn và mặc định tắt; quyền notification qua nút riêng; endpoint chỉ giữ trong RAM phiên. Xem [health workflow contract](../../docs/monitoring/HEALTH-WORKFLOWS.md). + +### Physical sensors + +Physical Sensor Panel dùng cadence phần cứng cũ, surface Orven, disclosure/Grid native và export chung. Ghi rõ mapped identity, current stale, missing/limited coverage; không đổi quạt. Xem [scope sensor](../../docs/monitoring/PHYSICAL-SENSORS.md). + +### Hardware Inspector + +Hardware Inspector dùng lại surface Orven, Grid native, refresh theo yêu cầu và sheet preview/save diagnostics chung. Reset range giữ history engine; field thiếu/current-stale được ghi rõ. Xem [scope phần cứng](../../docs/monitoring/HARDWARE-TOOLS.md). + +Giải thích coverage/privacy trong sheet có kích thước giới hạn phải wrap theo chiều dọc, đọc được đầy đủ và cuộn tới action liên quan. App Activity Timeline cố định vertical text sizing; cảnh báo bị cắt hoặc rút gọn không đạt gate cửa sổ nhỏ. Screenshot/AX native được ghi riêng với static audit. + +### Feature expansion + +Diagnostics mới dùng lại OrvenTheme, control native và sampling cũ. Refresh helper theo yêu cầu có loading/error/cancellation. CPU priority đã được cho phép có xác nhận native riêng và guard cùng user/PID/start; monitoring không tự đổi scheduling. Scope/prerequisite ở [feature expansion](../../docs/monitoring/FEATURE-EXPANSION.md). + +Native redesign approved: shared OrvenTheme and surfaces replace the old material/gradient headers. Overview reads the existing monitoring snapshot and routes Inspect process with a full PID/start identity; no storage selection is made. Process details use a persistent inspector at wide sizes and an OS-owned modal sheet at smaller sizes. System/light/dark is app-local with toolbox.appearance defaulting to system. Table, picker, focus and scroll ownership stay native. No runtime schema or data directory changes. + +### Nguồn và ownership + +Đặc tả 3.0 đã chấp thuận, [architecture](../../docs/ARCHITECTURE.md), [privacy](../../PRIVACY.md), [security](../../SECURITY.md), [release readiness](../../docs/production/RELEASE-READINESS.md) quy định native boundary. DESIGN.md ở root thuộc marketing; [DESIGN.md native](DESIGN.md) giữ identity SwiftUI hiện có. L10n/shell locale sở hữu GUI English/Vietnamese/Japanese/Simplified Chinese. Mỗi locale được đóng gói merge table module vào Localizable cho native control. + +| Capability | Canonical owner | Source of truth | Allowed variants | Verification | +| --- | --- | --- | --- | --- | +| Table Selection | SwiftUI Table / ProcessRowID | DESIGN.md; approved 3.0 spec; metric contracts | Một row read-only, PID/start identity verify, không chọn cleanup. | ProcessTests; native keyboard check | +| Select/Listbox | SwiftUI Picker native | DESIGN.md; approved 3.0 spec; metric contracts | Chấp nhận geometry popup/keyboard OS. | native keyboard and popup check | +| Form | SwiftUI Form / SettingsView | DESIGN.md; approved 3.0 spec; metric contracts | Preference ổn định, menu bar default-off, một sampler, không xin privilege. | MonitoringPreferencesTests; Settings GUI | +| Scrollbar | ScrollView/Table native macOS | DESIGN.md; approved 3.0 spec; metric contracts | Không đổi scrollbar preference system; table sở hữu scroll. | native window scroll check | +| Search | TextField local ở ProcessesView | DESIGN.md; approved 3.0 spec; metric contracts | Clear ngay/khôi phục focus, không request remote/persist query. | ProcessTests; clear/no-results GUI | +| App Dossier and discovery | ApplicationsView / AppDossierView / ApplicationScanner | Market enhancements; DESIGN.md | Explicit inspection; scan-time size/evidence; app/Bundle ID search; no extra scan or cleanup | Universal/unit/locale checks; native UI pending unlock | +| Sleep inspection | SleepInspectorView / MonitoringCoordinator | DESIGN.md; approved feature exploration | Explicit refresh; timestamped snapshot; unknown ownership; no mutation or polling. | live provider smoke; PID reuse/parser fixtures; Light/Dark, EN/VI, explicit refresh and verified navigation observed; full VoiceOver unverified | +| Charts | MonitoringChart | DESIGN.md; approved 3.0 spec; metric contracts | Axis có label, gap mẫu thiếu, numeric summary VoiceOver, nút trước/sau. | gap and numeric summary GUI; keyboard samples | +| Navigation | ToolboxCoordinator / ToolboxShellView | DESIGN.md; approved 3.0 spec; metric contracts | Giữ raw value cũ, cross-feature chỉ navigation không chọn mutation. | ToolboxAppTests; deep-link GUI | +| Feedback | MetricReadout / MetricSample | DESIGN.md; approved 3.0 spec; metric contracts | Availability/unit/timestamp rõ; missing không thành zero. | SamplingTests; live/stale GUI | + +### Workflow và lifecycle + +Một runtime app sở hữu route/monitoring cho main/fallback/menu bar. Window marker nhận diện main visible/key; inactive/closed pause trừ khi menu bar opt-in. Background bật thì cadence 5 s; sleep pause, wake warm-up, Quit join/cancel. Đóng window vẫn giữ app trong Dock để mở lại. Scheduled scan bỏ qua monitoring. + +Detail revalidate identity trước/sau counter; navigation Applications còn kiểm tra bundle mapping live/launch date. Parent PID advisory; hoãn verified ancestry tree vì field không cung cấp parent start identity. View read-only có loading/stale/error/permission/unsupported/no-results. Không serious alert, kill, automatic cleanup. Alert tự động chưa có tới khi rule diagnostics được cho phép riêng. + +Foreground confirmation/canonical validation/Trash-recovery/legacy import giữ owner feature. Monitoring data chỉ lưu theo contract HealthHistoryStore opt-in có giới hạn. Search/selection tạm thời để tránh lưu tên process; section/menu preference persistent. Ghi riêng accessibility/locale/light-dark/window hẹp/lifecycle, không suy luận từ compile. + +## 日本語 + +### Wi-Fi and LAN + +Wi-Fi/LANは既存theme/control/locale/chart/samplerを再利用。Page-owned明示read/probeはsingleoperation/cancel/timestamp、NWPathMonitorは表示pageのeventobserver。設定変更/identity保存なし。[Network contract](../../docs/monitoring/NETWORK-TOOLS.md)参照。 + + +### Health workflows + +ユーザーはlocal Health History/Baseline、opt-in Smart Alerts、Sensor Charts/Recording、session-only Network Connection Changesを承認しました。このscopeでは下記の従来persistence/alert制限を置き換えます。既存sampler、MonitoringChart、OrvenTheme、L10n、native controls、DiagnosticsExportViewを引き続き使用します。Persistenceは上限付きで初期off、notification権限は明示button、endpointはsession memoryのみ。[Health workflow contract](../../docs/monitoring/HEALTH-WORKFLOWS.md)を参照。 + +### Physical sensors + +Physical Sensor Panelは既存hardware cadence、Orven surface、native disclosure/Grid、共通exportを再利用。Mapped identity/stale current/missing/limited coverageを明示し、fanを変更しません。[Scope](../../docs/monitoring/PHYSICAL-SENSORS.md)参照。 + +### Hardware Inspector + +Hardware InspectorはOrven surface、native Grid、明示refresh、共通diagnostics preview/save sheetを再利用。Range resetはengine historyを保持し、欠測/current-staleを明示します。[Hardware scope](../../docs/monitoring/HARDWARE-TOOLS.md)参照。 + +サイズ制限付きsheetのcoverage/privacy説明は縦方向に折り返し、全文を読めて関連actionへscrollできること。App Activity Timelineはvertical text sizingを明示固定。警告の切れ/省略表示はnarrow-window gate未完了です。Native screenshot/AX evidenceとstatic auditを区別します。 + +### Feature expansion + +新diagnosticsはOrvenTheme/native control/既存samplingを再利用。明示helper refreshはloading/error/cancellationを所有。承認済みCPU priority actionはnative確認とsame-user PID/start guardを持ちmonitoringは自動変更しません。Scope/prerequisiteは[feature expansion](../../docs/monitoring/FEATURE-EXPANSION.md)。 + +Native redesign approved: shared OrvenTheme and surfaces replace the old material/gradient headers. Overview reads the existing monitoring snapshot and routes Inspect process with a full PID/start identity; no storage selection is made. Process details use a persistent inspector at wide sizes and an OS-owned modal sheet at smaller sizes. System/light/dark is app-local with toolbox.appearance defaulting to system. Table, picker, focus and scroll ownership stay native. No runtime schema or data directory changes. + +### Source と ownership + +承認済み 3.0 spec、[architecture](../../docs/ARCHITECTURE.md)、[privacy](../../PRIVACY.md)、[security](../../SECURITY.md)、[release readiness](../../docs/production/RELEASE-READINESS.md) が native boundary。Root DESIGN.md は marketing、[native DESIGN.md](DESIGN.md) は既存 SwiftUI identity。English/Vietnamese/Japanese/Simplified Chinese GUIはL10n/shell localeが所有。各packaged localeはmodule tableをLocalizableへmergeしnative controlに適用。 + +| Capability | Canonical owner | Source of truth | Allowed variants | Verification | +| --- | --- | --- | --- | --- | +| Table Selection | SwiftUI Table / ProcessRowID | DESIGN.md; approved 3.0 spec; metric contracts | Read-only one row、verified PID/start identity、cleanup selection なし。 | ProcessTests; native keyboard check | +| Select/Listbox | Native SwiftUI Picker | DESIGN.md; approved 3.0 spec; metric contracts | OS popup geometry/keyboard behavior を受容。 | native keyboard and popup check | +| Form | SwiftUI Form / SettingsView | DESIGN.md; approved 3.0 spec; metric contracts | Stable preference、menu bar default-off、single sampler、privilege request なし。 | MonitoringPreferencesTests; Settings GUI | +| Scrollbar | Native macOS ScrollView/Table | DESIGN.md; approved 3.0 spec; metric contracts | System scrollbar preference 未変更、table が scroll を所有。 | native window scroll check | +| Search | ProcessesView local TextField | DESIGN.md; approved 3.0 spec; metric contracts | Immediate clear/focus restore、remote request/query persistence なし。 | ProcessTests; clear/no-results GUI | +| App Dossier and discovery | ApplicationsView / AppDossierView / ApplicationScanner | Market enhancements; DESIGN.md | Explicit inspection; scan-time size/evidence; app/Bundle ID search; no extra scan or cleanup | Universal/unit/locale checks; native UI pending unlock | +| Sleep inspection | SleepInspectorView / MonitoringCoordinator | DESIGN.md; approved feature exploration | Explicit refresh; timestamped snapshot; unknown ownership; no mutation or polling. | live provider smoke; PID reuse/parser fixtures; Light/Dark, EN/VI, explicit refresh and verified navigation observed; full VoiceOver unverified | +| Charts | MonitoringChart | DESIGN.md; approved 3.0 spec; metric contracts | Axis label、missing gap、VoiceOver numeric summary、previous/next button。 | gap and numeric summary GUI; keyboard samples | +| Navigation | ToolboxCoordinator / ToolboxShellView | DESIGN.md; approved 3.0 spec; metric contracts | Legacy raw value 保持、cross-feature は navigation のみで mutation を選択しません。 | ToolboxAppTests; deep-link GUI | +| Feedback | MetricReadout / MetricSample | DESIGN.md; approved 3.0 spec; metric contracts | Availability/unit/timestamp 表示、missing を zero にしません。 | SamplingTests; live/stale GUI | + +### Workflow と lifecycle + +App-owned runtime が main/fallback/menu bar の routing/monitoring を所有。Window marker で visible main を識別、inactive/closed は menu bar opt-in 以外 pause。Background は 5 s cadence、sleep pause、wake warm-up、Quit join/cancel。Close 後も既存 Dock app を reopen 可能。Scheduled scan は monitoring を bypass。 + +Detail は counter 前後で identity を revalidate、Applications は live bundle mapping/launch date も確認。Parent PID は advisory、parent start identity が field にないため verified ancestry tree は deferred。Read-only view は loading/stale/error/permission/unsupported/no-results。Serious alert/kill/automatic cleanup なし。別途承認した diagnostic rule まで automatic alert は unavailable。 + +Foreground confirmation/canonical validation/Trash-recovery/legacy import は feature owner を維持。Monitoring data persistence なし。Process name を保存しないため search/selection は transient、section/menu preference は persistent。Accessibility/locale/light-dark/narrow window/lifecycle は compilation と別に記録。 diff --git a/apps/toolbox/premium-ui.json b/apps/toolbox/premium-ui.json new file mode 100644 index 0000000..20aad85 --- /dev/null +++ b/apps/toolbox/premium-ui.json @@ -0,0 +1,14 @@ +{ + "profile": "product-admin", + "sourceRoots": ["Sources/Toolbox"], + "locale": "en", + "canonicalMap": "UX-CONTRACT.md", + "requiredCapabilities": ["Table Selection", "Select/Listbox", "Form", "Scrollbar"], + "ownership": {"Select/Listbox": "native"}, + "requiredCommands": ["unit", "build", "lint"], + "commands": { + "unit": "swift test", + "build": "swift build -c release", + "lint": "swift format lint --recursive --parallel Sources Tests Package.swift" + } +} diff --git a/apps/toolbox/scripts/build_app.sh b/apps/toolbox/scripts/build_app.sh index 5057cd8..9096b86 100755 --- a/apps/toolbox/scripts/build_app.sh +++ b/apps/toolbox/scripts/build_app.sh @@ -10,19 +10,22 @@ fi swift build -c release BIN_DIR="$(swift build -c release --show-bin-path)" -APP_DIR="$PROJECT_DIR/dist/Toolbox.app" +APP_DIR="$PROJECT_DIR/dist/Orven.app" rm -rf "$APP_DIR" mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" cp "$BIN_DIR/Toolbox" "$APP_DIR/Contents/MacOS/Toolbox" cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Contents/Info.plist" cp "$PROJECT_DIR/Resources/AppIcon.icns" "$APP_DIR/Contents/Resources/AppIcon.icns" +cp "$PROJECT_DIR/Resources/OrvenLogo.png" "$APP_DIR/Contents/Resources/OrvenLogo.png" for localization in "$PROJECT_DIR"/Resources/*.lproj; do [[ -d "$localization" ]] && cp -R "$localization" "$APP_DIR/Contents/Resources/" done -"$PROJECT_DIR/scripts/merge_localizations.swift" \ - "$PROJECT_DIR/Resources/en.lproj" \ - "$APP_DIR/Contents/Resources/en.lproj/Localizable.strings" +for language in en ja zh-Hans; do + "$PROJECT_DIR/scripts/merge_localizations.swift" \ + "$PROJECT_DIR/Resources/$language.lproj" \ + "$APP_DIR/Contents/Resources/$language.lproj/Localizable.strings" +done chmod +x "$APP_DIR/Contents/MacOS/Toolbox" codesign --force --deep --options runtime --timestamp=none --sign - "$APP_DIR" diff --git a/apps/toolbox/scripts/build_dmg.sh b/apps/toolbox/scripts/build_dmg.sh index aee4f73..f8e96ab 100755 --- a/apps/toolbox/scripts/build_dmg.sh +++ b/apps/toolbox/scripts/build_dmg.sh @@ -2,8 +2,8 @@ set -euo pipefail project_dir="$(cd "$(dirname "$0")/.." && pwd)" -app="$project_dir/dist/Toolbox.app" -dmg="$project_dir/dist/Toolbox-2.0.0.dmg" +app="$project_dir/dist/Orven.app" +dmg="$project_dir/dist/Orven-3.0.0.dmg" checksum="$dmg.sha256" test -d "$app" @@ -12,12 +12,12 @@ test -d "$app" staging_dir="$(mktemp -d -t toolbox-dmg)" trap 'rm -rf "$staging_dir"' EXIT -ditto "$app" "$staging_dir/Toolbox.app" -cp "$project_dir/Resources/Open Toolbox - First Launch.html" "$staging_dir/" +ditto "$app" "$staging_dir/Orven.app" +cp "$project_dir/Resources/Open Orven - First Launch.html" "$staging_dir/" ln -s /Applications "$staging_dir/Applications" rm -f "$dmg" "$checksum" hdiutil create \ - -volname "Toolbox" \ + -volname "Orven" \ -srcfolder "$staging_dir" \ -ov \ -format UDZO \ diff --git a/apps/toolbox/scripts/build_universal.sh b/apps/toolbox/scripts/build_universal.sh index b1ef37b..bc8a257 100755 --- a/apps/toolbox/scripts/build_universal.sh +++ b/apps/toolbox/scripts/build_universal.sh @@ -4,12 +4,15 @@ set -euo pipefail PROJECT_DIR="${0:A:h:h}" cd "$PROJECT_DIR" -swift build -c release --arch arm64 -swift build -c release --arch x86_64 - -ARM_BIN_DIR="$(swift build -c release --arch arm64 --show-bin-path)" -INTEL_BIN_DIR="$(swift build -c release --arch x86_64 --show-bin-path)" -APP_DIR="$PROJECT_DIR/dist/Toolbox.app" +ARM_BUILD_DIR="$PROJECT_DIR/.build/universal-arm64" +INTEL_BUILD_DIR="$PROJECT_DIR/.build/universal-x86_64" +# SwiftPM/Xcode can share Products/Release across --arch invocations. +swift build -c release --arch arm64 --scratch-path "$ARM_BUILD_DIR" +swift build -c release --arch x86_64 --scratch-path "$INTEL_BUILD_DIR" + +ARM_BIN_DIR="$(swift build -c release --arch arm64 --scratch-path "$ARM_BUILD_DIR" --show-bin-path)" +INTEL_BIN_DIR="$(swift build -c release --arch x86_64 --scratch-path "$INTEL_BUILD_DIR" --show-bin-path)" +APP_DIR="$PROJECT_DIR/dist/Orven.app" UNIVERSAL_BINARY="$(mktemp -t toolbox-universal)" trap 'rm -f "$UNIVERSAL_BINARY"' EXIT @@ -27,12 +30,15 @@ mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" cp "$UNIVERSAL_BINARY" "$APP_DIR/Contents/MacOS/Toolbox" cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Contents/Info.plist" cp "$PROJECT_DIR/Resources/AppIcon.icns" "$APP_DIR/Contents/Resources/AppIcon.icns" +cp "$PROJECT_DIR/Resources/OrvenLogo.png" "$APP_DIR/Contents/Resources/OrvenLogo.png" for localization in "$PROJECT_DIR"/Resources/*.lproj; do [[ -d "$localization" ]] && cp -R "$localization" "$APP_DIR/Contents/Resources/" done -"$PROJECT_DIR/scripts/merge_localizations.swift" \ - "$PROJECT_DIR/Resources/en.lproj" \ - "$APP_DIR/Contents/Resources/en.lproj/Localizable.strings" +for language in en ja zh-Hans; do + "$PROJECT_DIR/scripts/merge_localizations.swift" \ + "$PROJECT_DIR/Resources/$language.lproj" \ + "$APP_DIR/Contents/Resources/$language.lproj/Localizable.strings" +done chmod +x "$APP_DIR/Contents/MacOS/Toolbox" if [[ -n "${TOOLBOX_SOURCE_DATE_EPOCH:-}" ]]; then diff --git a/apps/toolbox/scripts/notarize.sh b/apps/toolbox/scripts/notarize.sh index 0c67014..9fb8820 100755 --- a/apps/toolbox/scripts/notarize.sh +++ b/apps/toolbox/scripts/notarize.sh @@ -5,9 +5,9 @@ set -euo pipefail : "${TOOLBOX_NOTARY_PROFILE:?Set notarytool keychain profile}" project_dir="$(cd "$(dirname "$0")/.." && pwd)" -app="$project_dir/dist/Toolbox.app" -archive="$project_dir/dist/Toolbox-2.0.0-app.zip" -dmg="$project_dir/dist/Toolbox-2.0.0.dmg" +app="$project_dir/dist/Orven.app" +archive="$project_dir/dist/Orven-3.0.0-app.zip" +dmg="$project_dir/dist/Orven-3.0.0.dmg" checksum="$dmg.sha256" test -d "$app" @@ -40,4 +40,4 @@ xcrun stapler validate "$dmg" (cd "$(dirname "$dmg")" && shasum -a 256 "$(basename "$dmg")" > "$(basename "$checksum")") "$project_dir/scripts/verify_release.sh" -echo "PASS: signed and notarized Toolbox 2.0.0" +echo "PASS: signed and notarized Orven 3.0.0" diff --git a/apps/toolbox/scripts/test_monitoring.sh b/apps/toolbox/scripts/test_monitoring.sh new file mode 100755 index 0000000..b9a1a79 --- /dev/null +++ b/apps/toolbox/scripts/test_monitoring.sh @@ -0,0 +1,5 @@ +#!/bin/zsh +set -euo pipefail +PROJECT_DIR="${0:A:h:h}" +cd "$PROJECT_DIR" +swift run SmokeMonitoring "$@" diff --git a/apps/toolbox/scripts/verify_release.sh b/apps/toolbox/scripts/verify_release.sh index 0e8d247..b05af72 100755 --- a/apps/toolbox/scripts/verify_release.sh +++ b/apps/toolbox/scripts/verify_release.sh @@ -2,7 +2,7 @@ set -euo pipefail project_dir="$(cd "$(dirname "$0")/.." && pwd)" -app="$project_dir/dist/Toolbox.app" +app="$project_dir/dist/Orven.app" allow_adhoc=0 if [[ "${1:-}" == "--allow-adhoc" ]]; then @@ -14,7 +14,7 @@ fi "$project_dir/Tests/Distribution/release_contract.sh" "$app" -dmg="$project_dir/dist/Toolbox-2.0.0.dmg" +dmg="$project_dir/dist/Orven-3.0.0.dmg" checksum="$dmg.sha256" if [[ -f "$dmg" || -f "$checksum" ]]; then test -f "$dmg" @@ -33,7 +33,7 @@ if [[ -f "$dmg" || -f "$checksum" ]]; then trap cleanup_mount EXIT hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" -quiet mounted=1 - "$project_dir/Tests/Distribution/release_contract.sh" "$mount_dir/Toolbox.app" + "$project_dir/Tests/Distribution/release_contract.sh" "$mount_dir/Orven.app" test -L "$mount_dir/Applications" test "$(readlink "$mount_dir/Applications")" = "/Applications" hdiutil detach "$mount_dir" -quiet @@ -52,4 +52,4 @@ test -f "$dmg" xcrun stapler validate "$dmg" spctl --assess --type execute --verbose=4 "$app" -echo "PASS: notarized Toolbox release" +echo "PASS: notarized Orven release" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9f27679..aec7c25 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,186 +1,43 @@ -# Toolbox Architecture - -[English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) +# Orven architecture ## English -### Scope and dependency direction - -Toolbox 2.0 is a SwiftPM package under `apps/toolbox`, targets macOS 13 or later, and ships one SwiftUI/AppKit GUI executable. `Package.swift` defines five products: three library modules, the Toolbox executable, and the non-shipping `SmokeCore` executable. The four application modules are: - -- `ToolboxCore` owns shared safety types, evidence and activity models, versioned-store recovery, localization, scan coordination, application metadata, and legacy-data migration. -- `ToolboxStorage` owns storage analysis, duplicate and similar-photo detection, project and application discovery, reviewed cleanup, developer-tool actions, scheduled scans, cleanup history, and recovery. -- `ToolboxChanges` owns system snapshots, bounded FSEvents observation, diff and risk attribution, Install Trace, trusted baselines, session history, and redacted Change Timeline reports. -- `Toolbox` owns the application lifecycle, one-window shell, onboarding, Settings, update checks, summaries, and routing between the feature modules. - -Dependency direction is `ToolboxStorage -> ToolboxCore`, `ToolboxChanges -> ToolboxCore`, and `Toolbox -> ToolboxCore + ToolboxStorage + ToolboxChanges`. Storage and Changes do not import one another. This keeps shared evidence and safety contracts below both feature modules and leaves cross-module coordination in the executable. - -See the [architecture and safety-boundary diagram](../docs/diagrams/toolbox-architecture.html) for the static component and trust-path view. - -### Application coordination - -The shell exposes Home, Storage, Projects, Applications, Change Timeline, and Recovery. `ToolboxCoordinator` persists the selected section and derives Home summaries from the Core ledgers. A Change Timeline action can route a standardized evidence path to Storage; the receiving view displays the path and opens Finder only on request. Routing does not select or mutate the item. - -`ScanActivityRegistry` is the process-wide coordination boundary for scans and snapshots. Scanners increment the registry while work is active, and Settings refuses to start an update request during that interval. It is an in-memory counter, not a durable job queue or cross-process lock. - -Install Trace accepts only `.dmg`, `.pkg`, and `.app` paths. It saves a before snapshot, starts FSEvents for the defined monitored roots, then hands the selected item to normal macOS opening through `NSWorkspace`. Toolbox does not mount, execute, install, or bypass Gatekeeper itself. Finishing captures the after snapshot, compares snapshot and FSEvents evidence, persists the session, and adds Core evidence/activity records. An interrupted trace may be resumed, but the UI marks the interval while Toolbox was not running as reduced coverage. - -### Durable data - -Active JSON data is stored under `~/Library/Application Support/Toolbox`: - -| Owner | Files | Contract | -| --- | --- | --- | -| `ToolboxCore` | `evidence-v1.json`, `activity-v1.json` | Schema-versioned envelopes, normalized evidence paths, deterministic ordering, and atomic writes. A decode failure moves the corrupt file to a sibling `*.corrupt--.json` file and reports the quarantine path. An unsupported schema is reported without quarantine. | -| `ToolboxStorage` | `history.json` | Normal cleanup-history writes retain the newest 500 entries, including Trash move manifests and restore state; migration merges existing and imported history without truncation, so a valid migrated store may contain more than 500. Writes are atomic. The current loader returns an empty history when the file is absent or undecodable, so this legacy-format store does not provide Core-style quarantine. | -| `ToolboxStorage` | `storage-checkpoints.json` | Up to 50 storage checkpoints used for local trend comparison; written atomically. Read/decode/write errors currently fail soft. | -| `ToolboxChanges` | `sessions.json`, `active-snapshot.json`, `active-trace-metadata.json`, `trusted-baseline.json` | Sessions, interrupted-work state, installer metadata, and the user-created baseline; written atomically. Normal Change Timeline and Install Trace writes retain the newest 100 sessions, but migration merges existing and imported sessions without truncation, so a valid migrated store may contain more than 100. Loaders currently return empty or `nil` for unreadable legacy-format data rather than quarantining it. | -| `ToolboxCore` migration | `migration-v1.json` | Verified completion report written only after imported destinations and the migration activity entry are written. | - -`UserDefaults` holds UI language, onboarding completion, selected section, scheduled-scan interval, and the last scheduled-scan time and byte estimate. These preferences are not evidence ledgers. +### Owners -Atomic replacement protects each individual JSON write; it is not a transaction across multiple stores. The Core quarantine guarantee applies only to `evidence-v1.json` and `activity-v1.json`. Operators must preserve that distinction during recovery. +The native executable remains Toolbox. ToolboxCore owns app metadata, locale/theme, filesystem identity, migration and bounded system/APFS inspection. ToolboxMonitoring owns coordinator cadence, processes/rates/hardware/SMC, health records/baselines, connection observations and bounded network tools. ToolboxStorage owns scanning/review/Trash/recovery/projects/applications. ToolboxChanges owns installer/snapshot/evidence/timeline workflows. -### macOS integrations and trust boundaries +### Demand and data -- `FileManager.trashItem` provides recoverable mutation for eligible cleanup actions. Recovery moves an item from an allowed Trash root to an allowed destination only when the Trash source exists and the destination does not; it never overwrites. -- FSEvents is a bounded, in-process observation stream. A session starts at `kFSEventStreamEventIdSinceNow`, uses file-level events with a 0.25-second latency, and retains at most 20,000 events. Snapshots remain the before/after source of comparison; FSEvents does not provide a complete historical audit log. -- `NSWorkspace` opens Finder, installers, the Full Disk Access settings pane, and external links only after a user action. Full Disk Access is optional; unreadable Mail or Safari sentinel folders produce reduced coverage rather than a permission bypass. -- `launchctl` manages `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist`; UserNotifications reports completed scan-only work. The scheduled process opens the packaged app with `--scheduled-scan`, scans safe targets, notifies, and exits without mutation. -- Developer cleanup uses fixed executable paths and exact argument allowlists. `sfltool dumpbtm` is a bounded read-only evidence query; login-item databases and package receipts are displayed as protected evidence and are not automatically removed. +One monitoring coordinator serves active main-window or optional menu-bar demand; sleep/inactivity pause sampling. UI snapshot publication is throttled while recording/history reuse original timestamps. HealthHistoryStore serializes versioned bounded writes off main actor. Network page owns one cancellable operation and visible-page path callbacks; probes use direct fixed arguments, never a shell. No extra background sampler or persistent endpoint history is added. -All scans, snapshots, evidence, history, and reports are local. The only in-process application network request is the user-initiated Settings GET to `https://api.github.com/repos/thangldw/toolbox/releases/latest`. It sends `Accept: application/vnd.github+json` and `User-Agent: Toolbox/` and no scan paths, evidence, results, or device payload. Links opened in the system browser are outside this request path. +### Compatibility and trust -### Mutation safety - -Mutation remains separate from discovery and evidence. The GUI requires the user to choose reviewed items and uses confirmation UI for destructive cleanup flows. Mutation services resolve symlinks and validate the exact existing target against explicit roots immediately before acting. Root, the home root, paths outside an approved root, unknown project folders, protected project files, referenced runtimes, dangerous application leftovers, package receipts, login-item databases, Docker volumes, and other non-approved targets fail closed or remain evidence-only. - -Eligible cleanup moves items to Trash and records the original and resulting Trash paths. Cleaning Trash itself and allowlisted developer commands are explicitly non-recoverable. Restore revalidates both paths and reports missing Trash items, destination conflicts, and filesystem failures instead of overwriting. Local evidence is advisory: a `safe`, `review`, or `protected` classification is not authorization to mutate without the corresponding feature's checks and user action. - -### Legacy migration - -Onboarding inspects legacy `Diskora/history.json`, `MacCleaner/history.json`, and Changeora `sessions.json`, `active-snapshot.json`, and `trusted-baseline.json`. Import is opt-in. The service decodes every present source before destination writes, derives domain-scoped stable IDs, merges only missing records, and copies active snapshots or baselines only when the Toolbox destination does not exist. - -Each written destination is decoded and verified, then one migration activity entry is recorded and `migration-v1.json` is written and verified last. Existing Toolbox records are retained, repeat import is idempotent, and original legacy files are never modified or deleted. A corrupt source or completion marker is reported; a failed import does not write a completion marker. Migration of the legacy scheduled LaunchAgent is a separate, explicit Settings operation described in `docs/OPERATIONS.md`. +Public app is Orven.app version3.0.0, executable Toolbox, bundleIDcom.thang.toolbox and ApplicationSupportToolbox preserved. Four locale resources share stable user-visible strings. Mutations revalidate paths/process identities at execution and require explicit review. Ad-hoc integrity is not Apple notarization. [Architecture diagram](diagrams/toolbox-architecture.html), [Health](monitoring/HEALTH-WORKFLOWS.md), [Network](monitoring/NETWORK-TOOLS.md). ## Tiếng Việt -### Phạm vi và hướng dependency - -Toolbox 2.0 là SwiftPM package trong `apps/toolbox`, target macOS 13 trở lên và ship một GUI executable SwiftUI/AppKit. `Package.swift` định nghĩa năm product: ba library module, executable Toolbox và executable `SmokeCore` không ship. Bốn application module là: - -- `ToolboxCore` sở hữu shared safety type, evidence/activity model, phục hồi versioned store, localization, điều phối scan, application metadata và migration dữ liệu legacy. -- `ToolboxStorage` sở hữu phân tích storage, phát hiện duplicate/similar photo, khám phá project/application, cleanup có review, developer-tool action, scheduled scan, cleanup history và recovery. -- `ToolboxChanges` sở hữu system snapshot, quan sát FSEvents có giới hạn, diff/risk attribution, Install Trace, trusted baseline, session history và báo cáo Change Timeline đã redact. -- `Toolbox` sở hữu application lifecycle, one-window shell, onboarding, Settings, update check, summary và routing giữa các feature module. - -Hướng dependency là `ToolboxStorage -> ToolboxCore`, `ToolboxChanges -> ToolboxCore` và `Toolbox -> ToolboxCore + ToolboxStorage + ToolboxChanges`. Storage và Changes không import nhau. Cấu trúc này đặt shared evidence/safety contract bên dưới cả hai feature module và giữ điều phối cross-module trong executable. - -Xem [sơ đồ architecture và safety boundary](../docs/diagrams/toolbox-architecture.html) để có component/trust-path view dạng static. - -### Điều phối ứng dụng - -Shell cung cấp Home, Storage, Projects, Applications, Change Timeline và Recovery. `ToolboxCoordinator` lưu section đang chọn và tạo summary ở Home từ các Core ledger. Một action trong Change Timeline có thể route standardized evidence path sang Storage; view nhận chỉ hiển thị path và mở Finder khi người dùng yêu cầu. Routing không chọn hoặc mutate item. - -`ScanActivityRegistry` là ranh giới điều phối toàn process cho scan và snapshot. Scanner tăng counter khi công việc đang chạy, và Settings từ chối bắt đầu update request trong khoảng đó. Đây là counter trong memory, không phải durable job queue hay cross-process lock. - -Install Trace chỉ nhận path `.dmg`, `.pkg` và `.app`. Nó lưu before snapshot, bắt đầu FSEvents cho các monitored root đã định nghĩa, rồi giao item đã chọn cho luồng mở bình thường của macOS qua `NSWorkspace`. Toolbox không tự mount, execute, install hoặc bypass Gatekeeper. Khi hoàn tất, hệ thống chụp after snapshot, so sánh evidence từ snapshot và FSEvents, lưu session, rồi thêm Core evidence/activity record. Có thể resume trace bị gián đoạn, nhưng UI đánh dấu thời gian Toolbox không chạy là reduced coverage. +### Owners -### Dữ liệu bền vững +Executable vẫn mang tên Toolbox. ToolboxCore quản lý metadata, locale/theme, định danh filesystem, migration và kiểm tra hệ thống/APFS có giới hạn. ToolboxMonitoring quản lý nhịp lấy mẫu, process, tốc độ, hardware/SMC, health records/baseline và công cụ mạng. ToolboxStorage quản lý quét, duyệt, Trash, khôi phục, dự án và ứng dụng. ToolboxChanges quản lý installer, snapshots, bằng chứng và timeline. -Dữ liệu JSON active được lưu dưới `~/Library/Application Support/Toolbox`: +### Demand and data -| Owner | File | Contract | -| --- | --- | --- | -| `ToolboxCore` | `evidence-v1.json`, `activity-v1.json` | Envelope có schema version, evidence path được normalize, ordering deterministic và ghi atomic. Decode failure chuyển file lỗi thành file sibling `*.corrupt--.json` rồi báo quarantine path. Schema không được support được báo lỗi nhưng không quarantine. | -| `ToolboxStorage` | `history.json` | Lần ghi cleanup history bình thường giữ 500 entry mới nhất, gồm Trash move manifest và restore state; migration merge history hiện có với history import mà không truncate, nên migrated store hợp lệ có thể có hơn 500 entry. Việc ghi là atomic. Loader hiện tại trả history rỗng nếu file không tồn tại hoặc không decode được, nên legacy-format store này không có quarantine như Core. | -| `ToolboxStorage` | `storage-checkpoints.json` | Tối đa 50 storage checkpoint dùng cho so sánh trend local; được ghi atomic. Lỗi read/decode/write hiện fail soft. | -| `ToolboxChanges` | `sessions.json`, `active-snapshot.json`, `active-trace-metadata.json`, `trusted-baseline.json` | Session, interrupted-work state, installer metadata và baseline do người dùng tạo; được ghi atomic. Lần ghi bình thường của Change Timeline và Install Trace giữ 100 session mới nhất, nhưng migration merge session hiện có với session import mà không truncate, nên migrated store hợp lệ có thể có hơn 100. Loader hiện trả rỗng hoặc `nil` cho legacy-format data không đọc được thay vì quarantine. | -| Migration của `ToolboxCore` | `migration-v1.json` | Báo cáo hoàn tất đã verify, chỉ được ghi sau khi destination import và migration activity entry đã được ghi. | +Một MonitoringCoordinator phục vụ cửa sổ chính đang hoạt động hoặc giám sát menu bar tùy chọn. Sleep hoặc inactive tạm dừng lấy mẫu. UI giới hạn nhịp cập nhật; history và recording dùng timestamp gốc. HealthHistoryStore ghi dữ liệu có phiên bản và giới hạn ngoài main actor. Trang mạng sở hữu một tác vụ có thể hủy và callback đường mạng khi trang hiện diện; probe dùng executable và arguments cố định, không qua shell. Không có sampler nền bổ sung hoặc lịch sử endpoint lưu trên disk. -`UserDefaults` giữ UI language, trạng thái hoàn tất onboarding, section đang chọn, scheduled-scan interval, thời gian và byte estimate của scheduled scan gần nhất. Các preference này không phải evidence ledger. +### Compatibility and trust -Atomic replacement bảo vệ từng lần ghi JSON riêng lẻ; nó không phải transaction qua nhiều store. Quarantine guarantee của Core chỉ áp dụng cho `evidence-v1.json` và `activity-v1.json`. Operator phải giữ đúng khác biệt này khi recovery. - -### Tích hợp macOS và trust boundary - -- `FileManager.trashItem` cung cấp mutation có thể recovery cho cleanup action đủ điều kiện. Recovery chỉ chuyển item từ Trash root được phép đến destination được phép khi source trong Trash còn tồn tại và destination chưa có; không bao giờ overwrite. -- FSEvents là observation stream có giới hạn, chạy trong process. Session bắt đầu tại `kFSEventStreamEventIdSinceNow`, dùng file-level event với latency 0,25 giây và giữ tối đa 20.000 event. Snapshot vẫn là nguồn before/after để so sánh; FSEvents không cung cấp historical audit log hoàn chỉnh. -- `NSWorkspace` chỉ mở Finder, installer, pane Full Disk Access và external link sau user action. Full Disk Access là tùy chọn; sentinel folder Mail hoặc Safari không đọc được sẽ tạo reduced coverage, không dẫn đến bypass permission. -- `launchctl` quản lý `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist`; UserNotifications báo công việc scan-only đã hoàn tất. Scheduled process mở packaged app với `--scheduled-scan`, scan safe target, gửi notification rồi thoát mà không mutation. -- Developer cleanup dùng executable path cố định và allowlist argument chính xác. `sfltool dumpbtm` là read-only evidence query có giới hạn; login-item database và package receipt được hiển thị như protected evidence và không bị tự động xóa. - -Mọi scan, snapshot, evidence, history và report đều local. Network request duy nhất trong process của ứng dụng là GET do người dùng khởi tạo trong Settings đến `https://api.github.com/repos/thangldw/toolbox/releases/latest`. Request gửi `Accept: application/vnd.github+json` và `User-Agent: Toolbox/`, không gửi scan path, evidence, result hoặc device payload. Link mở trong system browser nằm ngoài request path này. - -### An toàn mutation - -Mutation được tách khỏi discovery và evidence. GUI yêu cầu người dùng chọn item đã review và dùng confirmation UI cho destructive cleanup flow. Mutation service resolve symlink và validate exact existing target với root rõ ràng ngay trước khi hành động. Root, home root, path ngoài approved root, project folder không biết, protected project file, runtime đang được tham chiếu, dangerous application leftover, package receipt, login-item database, Docker volume và target không được phê duyệt khác sẽ fail closed hoặc chỉ còn evidence. - -Cleanup đủ điều kiện chuyển item vào Trash và ghi original path cùng Trash path kết quả. Dọn chính Trash và allowlisted developer command là thao tác non-recoverable được ghi rõ. Restore revalidate cả hai path và báo item trong Trash bị thiếu, destination conflict hoặc filesystem failure thay vì overwrite. Local evidence chỉ mang tính advisory: classification `safe`, `review` hoặc `protected` không phải authorization để mutation khi chưa qua check của feature tương ứng và user action. - -### Migration legacy - -Onboarding kiểm tra legacy `Diskora/history.json`, `MacCleaner/history.json`, cùng Changeora `sessions.json`, `active-snapshot.json` và `trusted-baseline.json`. Import là opt-in. Service decode mọi source hiện có trước khi ghi destination, tạo stable ID theo domain, chỉ merge record còn thiếu và chỉ copy active snapshot hoặc baseline khi destination của Toolbox chưa tồn tại. - -Mỗi destination đã ghi được decode và verify; sau đó một migration activity entry được ghi, còn `migration-v1.json` được ghi và verify cuối cùng. Record Toolbox hiện có được giữ lại, import lặp lại có tính idempotent, và file legacy gốc không bao giờ bị sửa hoặc xóa. Source hoặc completion marker lỗi được báo; import thất bại không ghi completion marker. Migration legacy scheduled LaunchAgent là operation rõ ràng, riêng biệt trong Settings, được mô tả tại `docs/OPERATIONS.md`. +App là Orven.app 3.0.0; executable Toolbox, bundle ID `com.thang.toolbox` và `Application Support/Toolbox` được giữ để tương thích. App có bốn ngôn ngữ. Mutation kiểm tra lại path và định danh process ngay trước khi chạy, sau khi người dùng duyệt. Chữ ký ad-hoc không phải Apple notarization. Xem [diagram](diagrams/toolbox-architecture.html), [Health](monitoring/HEALTH-WORKFLOWS.md), [Network](monitoring/NETWORK-TOOLS.md). ## 日本語 -### Scope と dependency direction - -Toolbox 2.0 は `apps/toolbox` の SwiftPM package で、macOS 13 以降を target とし、一つの SwiftUI/AppKit GUI executable を出荷します。`Package.swift` は五つの product、すなわち三 library module、Toolbox executable、出荷しない `SmokeCore` executable を定義します。四つの application module は次のとおりです。 - -- `ToolboxCore` は shared safety type、evidence/activity model、versioned-store recovery、localization、scan coordination、application metadata、legacy-data migration を所有します。 -- `ToolboxStorage` は storage analysis、duplicate/similar-photo detection、project/application discovery、review 済み cleanup、developer-tool action、scheduled scan、cleanup history、recovery を所有します。 -- `ToolboxChanges` は system snapshot、bounded FSEvents observation、diff/risk attribution、Install Trace、trusted baseline、session history、redacted Change Timeline report を所有します。 -- `Toolbox` は application lifecycle、one-window shell、onboarding、Settings、update check、summary、feature module 間 routing を所有します。 - -Dependency direction は `ToolboxStorage -> ToolboxCore`、`ToolboxChanges -> ToolboxCore`、`Toolbox -> ToolboxCore + ToolboxStorage + ToolboxChanges` です。Storage と Changes は相互に import しません。Shared evidence/safety contract を両 feature module の下に置き、cross-module coordination を executable に保持します。 - -Static な component/trust-path view は [architecture と safety-boundary diagram](../docs/diagrams/toolbox-architecture.html) を参照してください。 - -### Application coordination - -Shell は Home、Storage、Projects、Applications、Change Timeline、Recovery を提供します。`ToolboxCoordinator` は選択 section を保存し、Core ledger から Home summary を計算します。Change Timeline action は standardized evidence path を Storage へ route できますが、受信 view は path を表示し、要求時だけ Finder を開きます。Routing は item を選択せず mutation もしません。 - -`ScanActivityRegistry` は scan と snapshot の process-wide coordination boundary です。Scanner は実行中に counter を増やし、その間 Settings は update request の開始を拒否します。これは in-memory counter であり、durable job queue や cross-process lock ではありません。 - -Install Trace は `.dmg`、`.pkg`、`.app` path だけを受け入れます。Before snapshot を保存し、定義済み monitored root で FSEvents を開始した後、選択 item を `NSWorkspace` による通常の macOS open flow へ渡します。Toolbox 自体は mount、execute、install、Gatekeeper bypass を行いません。完了時に after snapshot を取得し、snapshot/FSEvents evidence を比較し、session を保存して Core evidence/activity record を追加します。中断 trace は resume できますが、Toolbox が停止していた区間は UI で reduced coverage と表示されます。 - -### Durable data - -Active JSON data は `~/Library/Application Support/Toolbox` に保存されます。 - -| Owner | File | Contract | -| --- | --- | --- | -| `ToolboxCore` | `evidence-v1.json`, `activity-v1.json` | Schema-versioned envelope、normalized evidence path、deterministic ordering、atomic write。Decode failure は破損 file を sibling の `*.corrupt--.json` へ移し、quarantine path を報告します。Unsupported schema は quarantine せず報告します。 | -| `ToolboxStorage` | `history.json` | 通常の cleanup-history write は Trash move manifest と restore state を含む最新 500 entry を保持します。Migration は既存/import history を truncate せず merge するため、有効な migrated store が 500 entry を超える場合があります。Write は atomic です。現在の loader は file 不在または decode 不可で空 history を返すため、この legacy-format store には Core と同じ quarantine はありません。 | -| `ToolboxStorage` | `storage-checkpoints.json` | Local trend 比較用の最大 50 storage checkpoint。Atomic write です。Read/decode/write error は現在 fail soft です。 | -| `ToolboxChanges` | `sessions.json`, `active-snapshot.json`, `active-trace-metadata.json`, `trusted-baseline.json` | Session、interrupted-work state、installer metadata、user-created baseline。Atomic write です。通常の Change Timeline/Install Trace write は最新 100 session を保持しますが、migration は既存/import session を truncate せず merge するため、有効な migrated store が 100 session を超える場合があります。Loader は unreadable な legacy-format data を quarantine せず、空または `nil` を返します。 | -| `ToolboxCore` migration | `migration-v1.json` | Imported destination と migration activity entry の書き込み後だけ保存される、verified completion report。 | - -`UserDefaults` は UI language、onboarding completion、selected section、scheduled-scan interval、最後の scheduled-scan time と byte estimate を保持します。これらの preference は evidence ledger ではありません。 - -Atomic replacement は個別 JSON write を保護しますが、複数 store にまたがる transaction ではありません。Core の quarantine guarantee は `evidence-v1.json` と `activity-v1.json` だけに適用されます。Recovery 時にはこの差を維持する必要があります。 - -### macOS integration と trust boundary - -- `FileManager.trashItem` は対象 cleanup action に recoverable mutation を提供します。Recovery は Trash source が存在し destination が存在しない場合だけ、allowed Trash root から allowed destination へ item を移し、overwrite しません。 -- FSEvents は bounded in-process observation stream です。Session は `kFSEventStreamEventIdSinceNow` から開始し、0.25 秒 latency の file-level event を使い、最大 20,000 event を保持します。Snapshot が before/after 比較元であり、FSEvents は完全な historical audit log ではありません。 -- `NSWorkspace` は user action 後だけ Finder、installer、Full Disk Access settings pane、external link を開きます。Full Disk Access は optional です。Mail/Safari sentinel folder を読めない場合は reduced coverage となり、permission を bypass しません。 -- `launchctl` は `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist` を管理し、UserNotifications は完了した scan-only work を通知します。Scheduled process は packaged app を `--scheduled-scan` で開き、safe target を scan し、通知して mutation せず終了します。 -- Developer cleanup は固定 executable path と exact argument allowlist を使います。`sfltool dumpbtm` は bounded read-only evidence query です。Login-item database と package receipt は protected evidence として表示され、自動削除されません。 - -Scan、snapshot、evidence、history、report はすべて local です。Application process 内の唯一の network request は、Settings で user が開始する `https://api.github.com/repos/thangldw/toolbox/releases/latest` への GET です。`Accept: application/vnd.github+json` と `User-Agent: Toolbox/` を送りますが、scan path、evidence、result、device payload は送りません。System browser で開く link はこの request path の外部です。 - -### Mutation safety +### Owners -Mutation は discovery/evidence から分離されます。GUI は review 済み item の user selection を要求し、destructive cleanup flow に confirmation UI を使います。Mutation service は action 直前に symlink を解決し、exact existing target を明示的 root に対して検証します。Root、home root、approved root 外、未知の project folder、protected project file、参照中 runtime、dangerous application leftover、package receipt、login-item database、Docker volume、その他未承認 target は fail closed または evidence-only です。 +Executable名はToolboxを保持します。ToolboxCoreはmetadata、locale/theme、filesystem identity、migration、限定されたsystem/APFS観測を担当します。ToolboxMonitoringはsampling cadence、process/rate、hardware/SMC、health records/baselinesとnetwork toolsを担当します。ToolboxStorageはscan、review、Trash、recovery、projects/applications、ToolboxChangesはinstaller、snapshots、evidence、timelineを担当します。 -対象 cleanup は item を Trash へ移し、original path と結果の Trash path を記録します。Trash 自体の cleanup と allowlisted developer command は明示的に non-recoverable です。Restore は両 path を再検証し、missing Trash item、destination conflict、filesystem failure を報告して overwrite しません。Local evidence は advisory です。`safe`、`review`、`protected` classification は、対応 feature check と user action なしの mutation authorization ではありません。 +### Demand and data -### Legacy migration +一つのMonitoringCoordinatorがactive main windowまたは任意のmenu-bar監視を処理します。Sleep/inactiveではsamplingを停止します。UI更新の頻度を制限し、history/recordingは元のtimestampを保持します。HealthHistoryStoreはmain actorの外でversionedかつboundedなwriteを行います。Network pageは一つのcancel可能operationと表示中のpath callbackを所有します。Probeは固定executable/argumentsを使い、shellを経由しません。追加のbackground samplerやdisk上のendpoint historyはありません。 -Onboarding は legacy `Diskora/history.json`、`MacCleaner/history.json`、Changeora の `sessions.json`、`active-snapshot.json`、`trusted-baseline.json` を検査します。Import は opt-in です。Service は destination write 前に存在する全 source を decode し、domain-scoped stable ID を生成し、missing record だけを merge します。Active snapshot/baseline は Toolbox destination が存在しない場合だけ copy します。 +### Compatibility and trust -書き込んだ各 destination を decode/verify し、その後一つの migration activity entry を記録し、最後に `migration-v1.json` を書いて verify します。既存 Toolbox record は保持され、repeat import は idempotent で、元の legacy file は変更・削除されません。破損 source または completion marker は報告され、failed import は completion marker を書きません。Legacy scheduled LaunchAgent の migration は別の明示的 Settings operation で、`docs/OPERATIONS.md` に記載します。 +AppはOrven.app 3.0.0です。Executable Toolbox、bundle ID `com.thang.toolbox`、`Application Support/Toolbox`を互換性のため維持し、四言語を提供します。Mutationは明示的なreview後、実行直前にpath/process identityを再確認します。Ad-hoc署名はApple notarizationではありません。[Diagram](diagrams/toolbox-architecture.html)、[Health](monitoring/HEALTH-WORKFLOWS.md)、[Network](monitoring/NETWORK-TOOLS.md)を参照してください。 diff --git a/docs/OPERATIONS-RELEASE.md b/docs/OPERATIONS-RELEASE.md index a0a935d..af473aa 100644 --- a/docs/OPERATIONS-RELEASE.md +++ b/docs/OPERATIONS-RELEASE.md @@ -1,294 +1,43 @@ -# Toolbox Release Operations - -[English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) +# Orven release operations ## English -### Release policy boundary - -Public artifacts are immutable outputs of an exact source tag. Source and executable tests take precedence over documentation; workflow definitions and exact-run results prove what automation executed; release metadata and re-downloaded bytes prove what was published. A local build is never publication, notarization, or end-user launch evidence. - -`v2.0.0` is a completed historical exception: it is a stable product-channel release that is ad-hoc signed and not Apple-notarized. It does not establish permission for another unsigned release. Every later public release defaults to Developer ID Application signing, Apple notarization, stapling, and Gatekeeper acceptance unless a new, explicit, reviewed exception changes policy. - -### Immutable `v2.0.0` record - -The public release was observed and its exact run metadata rechecked on 2026-08-26 UTC: - -| Field | Immutable evidence | -| --- | --- | -| Tag | `v2.0.0` | -| Published | `2026-08-25T12:33:04Z`; non-draft, non-prerelease, latest GitHub release | -| Source commit | `c60367d84cdf06a93fe95c65e2ebe110ab3f70bb` | -| Release run | `32847772209`; `Release`; completed `success` at the source commit | -| Pages run | `32847688077`; `Pages`; completed `success` at the source commit | -| Published assets | `Toolbox-2.0.0.dmg`, `Toolbox-2.0.0.dmg.sha256` | -| DMG SHA-256 | `ce9bdf8cfb67089c004d66302ef33ebd2d0c603a43435e66d58e447be9943dba` | -| Architecture contract | `arm64` and `x86_64` slices verified by the release job; no physical Intel launch claim | -| Apple trust | Ad-hoc signature, no Apple notarization or stapling, expected initial Gatekeeper rejection | - -Run `32847772209` validated the tagged source, format, XCTest, smoke/localization contracts, universal slices, bundle contract, and public release contracts. Its publish job ran `build_universal.sh`, `build_dmg.sh`, and `verify_release.sh --allow-adhoc`, then created the GitHub release from the DMG and checksum. The DMG contains `Toolbox.app`, an `/Applications` link, and `Open Toolbox - First Launch.html` with the safe **Open Anyway** flow. The exception does not permit disabling Gatekeeper, stripping quarantine attributes, claiming Apple approval, or distributing this build through Homebrew. - -Stable describes the product channel, not Apple trust. For this exact release, `codesign --verify` and the universal-slice check are positive structural evidence, while `spctl` rejection is the expected trust result. Do not reinterpret that rejection as Gatekeeper acceptance. - -### Local structural checks - -From `apps/toolbox`, the historical ad-hoc path is: - -```bash -./scripts/build_universal.sh -./scripts/build_dmg.sh -./scripts/verify_release.sh --allow-adhoc -``` - -This checks bundle identifier `com.thang.toolbox`, version `2.0.0`, `arm64` and `x86_64` slices, English/Vietnamese resources, structural code signature, checksum consistency, read-only DMG mount, `/Applications` link, and the embedded first-launch guide. `--allow-adhoc` explicitly skips stapler and Gatekeeper-acceptance checks. It is suitable only for reproducing structural evidence or the recorded `v2.0.0` exception; it is not the default release gate. - -Inspect the resulting evidence explicitly: - -```bash -lipo -archs dist/Toolbox.app/Contents/MacOS/Toolbox -codesign --verify --deep --strict --verbose=2 dist/Toolbox.app -codesign -dv --verbose=4 dist/Toolbox.app -spctl --assess --type execute --verbose=4 dist/Toolbox.app -(cd dist && shasum -a 256 -c Toolbox-2.0.0.dmg.sha256) -``` - -For an ad-hoc reproduction, `spctl` must reject. Record the exit status and output as expected rejection, not as a passing Gatekeeper gate. - -### Future notarized default - -The current `.github/workflows/release.yml`, application version, artifact names, and release scripts are deliberately fixed to `v2.0.0`; the workflow publishes with `--allow-adhoc` and does not call `notarize.sh`. Before a later release, update and review the version/tag/file contract and change the protected release job to use the notarized path. Do not trigger the existing workflow for a new version and assume it provides notarization. - -After format, XCTest, smoke, localization, release build, universal bundle, and public contract gates succeed on the exact candidate commit, prepare these values only in a protected release environment: - -- `TOOLBOX_CODESIGN_IDENTITY`: the exact Developer ID Application identity available in the release keychain. -- `TOOLBOX_NOTARY_PROFILE`: a `notarytool` keychain profile backed by protected App Store Connect credentials. +### Current release contract -Never commit or print certificate data, private keys, passwords, App Store Connect keys, issuer identifiers, or profile secrets. Build the universal app, then invoke the protected path: +Orven3.0.0/v3.0.0 is the current stable target. The user explicitly authorized ad-hoc signing and not-notarized disclosure. release.json and Info.plist must agree; ZIP/DMG names, first-launch guide, website and release notes must identify the same product/version. Historical Toolbox tags remain immutable on GitHub. -```bash -cd apps/toolbox -./scripts/build_universal.sh -TOOLBOX_CODESIGN_IDENTITY="Developer ID Application: …" \ -TOOLBOX_NOTARY_PROFILE="toolbox-release" \ -./scripts/notarize.sh -``` +### Validation and packaging -After the versioned filenames have been updated, `notarize.sh` signs the app with hardened runtime and a trusted timestamp, verifies the signature, submits an app archive, staples and validates the app, builds the DMG, submits and staples the DMG, regenerates the checksum after final stapling, and calls strict `verify_release.sh`. Missing protected variables stop before signing or submission. A command exit of zero is necessary evidence; retain the `notarytool` submission result, stapler validation, `codesign` output, and `spctl` output with the release record. +Complete source tests/smokes/format/locales/docs/site, universal bundle and ad-hoc structural checks. Mount the DMG read-only to verify Orven.app, Applications link and first-launch guide; verify checksum after final DMG bytes. Record known unverified native/hardware/resource limits without substituting old candidates. No signing identity or credentials are required for the authorized ad-hoc channel. -### Pre-publication gate +### Publication and update -Before creating a public release: - -1. Resolve the candidate tag to one commit and record the full SHA. Verify all required CI jobs succeeded at that SHA; if site/install contracts changed, verify the Pages run at the same SHA. -2. Build once from that exact checkout. Do not modify the bundle between final signing/notarization and checksum generation. -3. Verify bundle ID, version, universal slices, resources, signature, notarization ticket, stapling, strict Gatekeeper acceptance, DMG layout, embedded first-launch guide, and checksum. For later releases, any use of `--allow-adhoc` is a failed gate. -4. Mount the final DMG read-only, drag Toolbox to Applications on a clean macOS 13+ account, and perform the first-launch and core smoke path. Record hardware/OS actually exercised; architecture slices alone are not physical-machine launch evidence. -5. Publish only the final DMG and its checksum file from the tag. Release notes must identify trust state accurately and must not claim Homebrew, usage counts, or hardware coverage without direct evidence. - -### Publication and post-publication verification - -Publish through a protected GitHub Actions environment with least-privilege `contents: write`; validation jobs retain `contents: read`. The tag, source commit, workflow run, release notes, and two asset bytes form one release record. Do not rebuild inside an unrelated checkout or upload an artifact from an unrecorded local run. - -Immediately after publication: - -1. Query the GitHub release and exact workflow run. Confirm tag, target commit, draft/prerelease state, asset names, sizes, and workflow conclusion. -2. Re-download both assets into a clean directory and run `shasum -a 256 -c `. Compare the computed digest with the recorded digest; do not trust the local pre-upload copy alone. -3. Mount the downloaded DMG read-only. Re-run the bundle contract, confirm the `/Applications` link and first-launch guide, install by drag-and-drop, and repeat the clean-account launch smoke. -4. For notarized releases, re-run `codesign --verify --deep --strict --verbose=2`, `xcrun stapler validate` on the app and DMG, and `spctl --assess --type execute --verbose=4` on the installed app. For the historical `v2.0.0` exception only, record expected rejection and verify the documented **Open Anyway** path without weakening Gatekeeper. -5. Record exact tag/SHA, release and Pages run IDs, checksum, signature authority, architecture slices, notarization/stapling results, Gatekeeper result, tested OS/hardware, and observation time. Omit volatile download counts unless the timestamp and meaning are explicit; they are not unique-user evidence. - -Published binary assets are immutable. If any re-download, checksum, signature, notarization, or launch check fails, stop promotion, preserve the mismatched bytes and logs, make the affected release unavailable without replacing its assets, explain impact, and issue a new patch version from a reviewed source commit. If signing credentials may be compromised, revoke/rotate them before rebuilding. Never silently replace a DMG or checksum under an existing tag. +Publish from an exact reviewed source commit/tag and immutable assets/checksum. The release workflow only supports v3.0.0 and runs checks before publication; Pages publishes the maintained site from main. Check GitHub latest release and Pages rendered content after completion. If an upload outcome is uncertain, inspect existing release assets before retrying. [Release notes](launch/orven-3.0.0.md), [readiness](production/RELEASE-READINESS.md). ## Tiếng Việt -### Ranh giới release policy - -Public artifact là output immutable của một exact source tag. Source và executable test có authority cao hơn documentation; workflow definition và exact-run result chứng minh automation đã chạy gì; release metadata cùng byte được re-download chứng minh nội dung đã publish. Local build không bao giờ là evidence cho publication, notarization hoặc end-user launch. - -`v2.0.0` là historical exception đã hoàn tất: đây là stable product-channel release ký ad-hoc và chưa Apple-notarize. Nó không tạo quyền cho unsigned release khác. Mọi public release sau mặc định phải có Developer ID Application signing, Apple notarization, stapling và Gatekeeper acceptance, trừ khi một exception mới, explicit và đã review thay đổi policy. - -### Record immutable của `v2.0.0` - -Public release đã được quan sát và exact run metadata được recheck vào 2026-08-26 UTC: - -| Field | Immutable evidence | -| --- | --- | -| Tag | `v2.0.0` | -| Published | `2026-08-25T12:33:04Z`; non-draft, non-prerelease, latest GitHub release | -| Source commit | `c60367d84cdf06a93fe95c65e2ebe110ab3f70bb` | -| Release run | `32847772209`; `Release`; completed `success` tại source commit | -| Pages run | `32847688077`; `Pages`; completed `success` tại source commit | -| Published assets | `Toolbox-2.0.0.dmg`, `Toolbox-2.0.0.dmg.sha256` | -| DMG SHA-256 | `ce9bdf8cfb67089c004d66302ef33ebd2d0c603a43435e66d58e447be9943dba` | -| Architecture contract | Slice `arm64` và `x86_64` được release job verify; không có claim physical Intel launch | -| Apple trust | Ad-hoc signature, không Apple notarization hoặc stapling, initial Gatekeeper rejection là expected | - -Run `32847772209` validate tagged source, format, XCTest, smoke/localization contract, universal slice, bundle contract và public release contract. Publish job chạy `build_universal.sh`, `build_dmg.sh` và `verify_release.sh --allow-adhoc`, rồi tạo GitHub release từ DMG và checksum. DMG chứa `Toolbox.app`, link `/Applications` và `Open Toolbox - First Launch.html` với luồng **Open Anyway** an toàn. Exception không cho phép tắt Gatekeeper, xóa quarantine attribute, claim Apple approval hoặc phân phối build này qua Homebrew. - -Stable mô tả product channel, không phải Apple trust. Với exact release này, `codesign --verify` và universal-slice check là structural evidence dương, còn `spctl` rejection là trust result dự kiến. Không diễn giải rejection đó thành Gatekeeper acceptance. - -### Structural check local - -Từ `apps/toolbox`, historical ad-hoc path là: - -```bash -./scripts/build_universal.sh -./scripts/build_dmg.sh -./scripts/verify_release.sh --allow-adhoc -``` - -Luồng này check bundle identifier `com.thang.toolbox`, version `2.0.0`, slice `arm64`/`x86_64`, resource English/Vietnamese, structural code signature, checksum consistency, read-only DMG mount, link `/Applications` và embedded first-launch guide. `--allow-adhoc` explicit bỏ qua stapler và Gatekeeper-acceptance check. Nó chỉ phù hợp để reproduce structural evidence hoặc exception `v2.0.0` đã ghi; không phải default release gate. - -Kiểm tra evidence kết quả một cách explicit: +### Current release contract -```bash -lipo -archs dist/Toolbox.app/Contents/MacOS/Toolbox -codesign --verify --deep --strict --verbose=2 dist/Toolbox.app -codesign -dv --verbose=4 dist/Toolbox.app -spctl --assess --type execute --verbose=4 dist/Toolbox.app -(cd dist && shasum -a 256 -c Toolbox-2.0.0.dmg.sha256) -``` +Orven 3.0.0/v3.0.0 là stable target hiện tại. Người dùng đã chọn ký ad-hoc và ghi rõ chưa notarized. release.json, Info.plist, tên artifact, first-launch guide, website và release notes phải cùng product/version. Giữ nguyên các tag Toolbox trước đây trên GitHub. -Với ad-hoc reproduction, `spctl` phải reject. Ghi exit status và output như expected rejection, không phải Gatekeeper gate pass. +### Validation and packaging -### Default notarized cho release tương lai +Hoàn tất tests, smokes, format, locale, docs, site, universal build và kiểm tra chữ ký ad-hoc. Mount DMG read-only để kiểm tra Orven.app, link Applications và first-launch guide; xác minh checksum của byte DMG cuối cùng. Ghi rõ gate native/hardware/resource chưa xác minh; không thay bằng kết quả candidate cũ. Kênh ad-hoc đã chọn không yêu cầu signing identity hoặc credentials. -`.github/workflows/release.yml`, application version, artifact name và release script hiện tại được cố định có chủ đích cho `v2.0.0`; workflow publish bằng `--allow-adhoc` và không gọi `notarize.sh`. Trước release sau, update và review contract version/tag/file rồi đổi protected release job sang notarized path. Không trigger workflow hiện tại cho version mới và giả định nó cung cấp notarization. +### Publication and update -Sau khi format, XCTest, smoke, localization, release build, universal bundle và public contract gate thành công trên exact candidate commit, chỉ chuẩn bị các value sau trong protected release environment: - -- `TOOLBOX_CODESIGN_IDENTITY`: exact Developer ID Application identity có trong release keychain. -- `TOOLBOX_NOTARY_PROFILE`: `notarytool` keychain profile dựa trên protected App Store Connect credential. - -Không bao giờ commit hoặc in certificate data, private key, password, App Store Connect key, issuer identifier hoặc profile secret. Build universal app rồi gọi protected path: - -```bash -cd apps/toolbox -./scripts/build_universal.sh -TOOLBOX_CODESIGN_IDENTITY="Developer ID Application: …" \ -TOOLBOX_NOTARY_PROFILE="toolbox-release" \ -./scripts/notarize.sh -``` - -Sau khi filename theo version đã được update, `notarize.sh` ký app với hardened runtime và trusted timestamp, verify signature, submit app archive, staple/validate app, build DMG, submit/staple DMG, tạo lại checksum sau final stapling, rồi gọi strict `verify_release.sh`. Thiếu protected variable sẽ dừng trước signing hoặc submission. Command exit zero là evidence cần thiết; giữ `notarytool` submission result, stapler validation, `codesign` output và `spctl` output cùng release record. - -### Gate trước publication - -Trước khi tạo public release: - -1. Resolve candidate tag thành một commit và ghi full SHA. Verify mọi CI job bắt buộc thành công tại SHA đó; nếu site/install contract đổi, verify Pages run tại cùng SHA. -2. Build một lần từ exact checkout đó. Không sửa bundle giữa final signing/notarization và checksum generation. -3. Verify bundle ID, version, universal slice, resource, signature, notarization ticket, stapling, strict Gatekeeper acceptance, DMG layout, embedded first-launch guide và checksum. Với release sau, mọi lần dùng `--allow-adhoc` là failed gate. -4. Mount final DMG read-only, kéo Toolbox vào Applications trên clean account macOS 13+ và chạy first-launch/core smoke path. Ghi hardware/OS thực tế đã test; architecture slice riêng không phải physical-machine launch evidence. -5. Chỉ publish final DMG và checksum file từ tag. Release note phải mô tả đúng trust state và không claim Homebrew, usage count hoặc hardware coverage nếu thiếu direct evidence. - -### Publication và verification sau publish - -Publish qua protected GitHub Actions environment với least-privilege `contents: write`; validation job giữ `contents: read`. Tag, source commit, workflow run, release note và byte của hai asset tạo thành một release record. Không rebuild trong checkout không liên quan hoặc upload artifact từ local run không được ghi nhận. - -Ngay sau publication: - -1. Query GitHub release và exact workflow run. Xác nhận tag, target commit, draft/prerelease state, asset name, size và workflow conclusion. -2. Re-download cả hai asset vào clean directory rồi chạy `shasum -a 256 -c `. So sánh digest tính được với digest đã ghi; không chỉ tin local pre-upload copy. -3. Mount downloaded DMG read-only. Chạy lại bundle contract, xác nhận link `/Applications` và first-launch guide, install bằng drag-and-drop rồi lặp clean-account launch smoke. -4. Với notarized release, chạy lại `codesign --verify --deep --strict --verbose=2`, `xcrun stapler validate` trên app và DMG, cùng `spctl --assess --type execute --verbose=4` trên installed app. Chỉ với historical exception `v2.0.0`, ghi expected rejection và verify path **Open Anyway** đã document mà không làm yếu Gatekeeper. -5. Ghi exact tag/SHA, release/Pages run ID, checksum, signature authority, architecture slice, notarization/stapling result, Gatekeeper result, OS/hardware đã test và observation time. Bỏ volatile download count trừ khi timestamp và ý nghĩa explicit; nó không phải unique-user evidence. - -Published binary asset là immutable. Nếu re-download, checksum, signature, notarization hoặc launch check fail, dừng promotion, giữ mismatched byte/log, làm affected release không còn available mà không replace asset, giải thích impact và phát hành patch version mới từ reviewed source commit. Nếu signing credential có thể bị compromise, revoke/rotate trước khi rebuild. Không bao giờ âm thầm replace DMG hoặc checksum dưới tag hiện có. +Phát hành từ commit/tag đã review, với artifact và checksum cố định. Workflow hỗ trợ v3.0.0 và chạy kiểm tra trước khi publish; Pages phát hành site từ main. Sau khi hoàn tất, kiểm tra GitHub latest release và nội dung Pages thực tế. Nếu chưa rõ upload thành công hay không, đọc danh sách asset trước khi retry. Xem [release notes](launch/orven-3.0.0.md) và [readiness](production/RELEASE-READINESS.md). ## 日本語 -### Release policy boundary - -Public artifact は exact source tag の immutable output です。Source/executable test は documentation より優先され、workflow definition/exact-run result は automation の実行内容を証明し、release metadata/re-downloaded bytes は公開内容を証明します。Local build は publication、notarization、end-user launch evidence ではありません。 - -`v2.0.0` は完了済み historical exception です。Stable product-channel release ですが ad-hoc 署名で Apple-notarize されていません。別の unsigned release を許可する前例にはなりません。以後の public release は、新しい明示的かつ review 済み exception で policy を変更しない限り、Developer ID Application signing、Apple notarization、stapling、Gatekeeper acceptance が default です。 - -### Immutable `v2.0.0` record - -Public release を観測し、exact run metadata を 2026-08-26 UTC に再確認しました。 - -| Field | Immutable evidence | -| --- | --- | -| Tag | `v2.0.0` | -| Published | `2026-08-25T12:33:04Z`; non-draft, non-prerelease, latest GitHub release | -| Source commit | `c60367d84cdf06a93fe95c65e2ebe110ab3f70bb` | -| Release run | `32847772209`; `Release`; source commit で completed `success` | -| Pages run | `32847688077`; `Pages`; source commit で completed `success` | -| Published assets | `Toolbox-2.0.0.dmg`, `Toolbox-2.0.0.dmg.sha256` | -| DMG SHA-256 | `ce9bdf8cfb67089c004d66302ef33ebd2d0c603a43435e66d58e447be9943dba` | -| Architecture contract | Release job が `arm64` と `x86_64` slice を verify。Physical Intel launch claim はなし | -| Apple trust | Ad-hoc signature、Apple notarization/stapling なし、initial Gatekeeper rejection が expected | - -Run `32847772209` は tagged source、format、XCTest、smoke/localization contract、universal slice、bundle contract、public release contract を validate しました。Publish job は `build_universal.sh`、`build_dmg.sh`、`verify_release.sh --allow-adhoc` を実行し、DMG/checksum から GitHub release を作成しました。DMG には `Toolbox.app`、`/Applications` link、安全な **Open Anyway** flow を含む `Open Toolbox - First Launch.html` があります。この exception は Gatekeeper disable、quarantine attribute 削除、Apple approval claim、Homebrew 配布を許可しません。 - -Stable は product channel を表し Apple trust ではありません。この exact release では `codesign --verify` と universal-slice check は positive structural evidence ですが、`spctl` rejection が expected trust result です。その rejection を Gatekeeper acceptance と解釈しません。 - -### Local structural check - -`apps/toolbox` から historical ad-hoc path を実行します。 - -```bash -./scripts/build_universal.sh -./scripts/build_dmg.sh -./scripts/verify_release.sh --allow-adhoc -``` - -Bundle identifier `com.thang.toolbox`、version `2.0.0`、`arm64`/`x86_64` slice、English/Vietnamese resource、structural code signature、checksum consistency、read-only DMG mount、`/Applications` link、embedded first-launch guide を確認します。`--allow-adhoc` は stapler/Gatekeeper-acceptance check を明示的に skip します。Structural evidence または記録済み `v2.0.0` exception の再現専用で、default release gate ではありません。 - -結果 evidence を明示的に確認します。 - -```bash -lipo -archs dist/Toolbox.app/Contents/MacOS/Toolbox -codesign --verify --deep --strict --verbose=2 dist/Toolbox.app -codesign -dv --verbose=4 dist/Toolbox.app -spctl --assess --type execute --verbose=4 dist/Toolbox.app -(cd dist && shasum -a 256 -c Toolbox-2.0.0.dmg.sha256) -``` - -Ad-hoc reproduction では `spctl` が reject する必要があります。Exit status/output を expected rejection として記録し、Gatekeeper gate pass としません。 - -### Future notarized default - -現在の `.github/workflows/release.yml`、application version、artifact name、release script は意図的に `v2.0.0` 固定です。Workflow は `--allow-adhoc` で publish し、`notarize.sh` を呼びません。後続 release 前に version/tag/file contract を update/review し、protected release job を notarized path に変更します。現在の workflow を新 version で trigger して notarization が得られると仮定しません。 - -Format、XCTest、smoke、localization、release build、universal bundle、public contract gate が exact candidate commit で成功した後、次の値を protected release environment だけで準備します。 - -- `TOOLBOX_CODESIGN_IDENTITY`: release keychain で利用可能な exact Developer ID Application identity。 -- `TOOLBOX_NOTARY_PROFILE`: protected App Store Connect credential に基づく `notarytool` keychain profile。 - -Certificate data、private key、password、App Store Connect key、issuer identifier、profile secret を commit/print しません。Universal app を build して protected path を実行します。 - -```bash -cd apps/toolbox -./scripts/build_universal.sh -TOOLBOX_CODESIGN_IDENTITY="Developer ID Application: …" \ -TOOLBOX_NOTARY_PROFILE="toolbox-release" \ -./scripts/notarize.sh -``` - -Versioned filename の update 後、`notarize.sh` は hardened runtime/trusted timestamp で app を sign し、signature verify、app archive submit、app staple/validate、DMG build、DMG submit/staple、final stapling 後の checksum 再生成、strict `verify_release.sh` を実行します。Protected variable がなければ signing/submission 前に停止します。Command exit zero は必要 evidence です。`notarytool` submission result、stapler validation、`codesign` output、`spctl` output を release record と共に保持します。 - -### Pre-publication gate - -Public release 作成前: - -1. Candidate tag を一つの commit に resolve し full SHA を記録します。Required CI job がその SHA で成功したことを verify し、site/install contract が変わった場合は同じ SHA の Pages run を verify します。 -2. Exact checkout から一度だけ build します。Final signing/notarization と checksum generation の間に bundle を変更しません。 -3. Bundle ID、version、universal slice、resource、signature、notarization ticket、stapling、strict Gatekeeper acceptance、DMG layout、embedded first-launch guide、checksum を verify します。後続 release での `--allow-adhoc` 使用は failed gate です。 -4. Final DMG を read-only mount し、clean macOS 13+ account で Toolbox を Applications へ drag して first-launch/core smoke path を実行します。実際に試した hardware/OS を記録し、architecture slice だけを physical-machine launch evidence としません。 -5. Tag から final DMG/checksum file だけを publish します。Release note は trust state を正確に示し、direct evidence のない Homebrew、usage count、hardware coverage を claim しません。 +### Current release contract -### Publication と post-publication verification +現在のstable targetはOrven 3.0.0/v3.0.0です。ユーザーはad-hoc署名と未notarizedの明示を選択しました。release.json、Info.plist、artifact名、first-launch guide、website、release notesのproduct/versionを一致させます。以前のToolbox tagsはGitHubで変更しません。 -Least-privilege `contents: write` の protected GitHub Actions environment から publish し、validation job は `contents: read` を維持します。Tag、source commit、workflow run、release note、二 asset の bytes が一つの release record です。無関係な checkout で rebuild したり、未記録 local run の artifact を upload しません。 +### Validation and packaging -Publication 直後: +Tests、smokes、format、locale、docs、site、universal buildとad-hoc検証を完了します。DMGをread-onlyでmountし、Orven.app、Applications linkとfirst-launch guideを確認して、最終DMG bytesのchecksumを検証します。未確認のnative/hardware/resource gatesを明示し、以前のcandidateの結果で代用しません。選択されたad-hoc channelにsigning identityやcredentialsは不要です。 -1. GitHub release と exact workflow run を query し、tag、target commit、draft/prerelease state、asset name/size、workflow conclusion を確認します。 -2. 両 asset を clean directory に re-download し、`shasum -a 256 -c ` を実行します。Computed digest を記録済み digest と比較し、local pre-upload copy だけを信頼しません。 -3. Downloaded DMG を read-only mount し、bundle contract を再実行し、`/Applications` link/first-launch guide を確認し、drag-and-drop install と clean-account launch smoke を繰り返します。 -4. Notarized release では installed app に `codesign --verify --deep --strict --verbose=2`、app/DMG に `xcrun stapler validate`、installed app に `spctl --assess --type execute --verbose=4` を再実行します。Historical `v2.0.0` exception だけは expected rejection を記録し、Gatekeeper を弱めず documented **Open Anyway** path を verify します。 -5. Exact tag/SHA、release/Pages run ID、checksum、signature authority、architecture slice、notarization/stapling result、Gatekeeper result、tested OS/hardware、observation time を記録します。Volatile download count は timestamp/meaning が明示的でない限り省きます。Unique-user evidence ではありません。 +### Publication and update -Published binary asset は immutable です。Re-download、checksum、signature、notarization、launch check のいずれかが失敗したら promotion を止め、mismatched bytes/log を保持し、asset を replace せず affected release を unavailable にし、impact を説明し、reviewed source commit から新しい patch version を発行します。Signing credential compromise の可能性があれば rebuild 前に revoke/rotate します。既存 tag 下の DMG/checksum を黙って replace しません。 +Review済みcommit/tagから変更しないartifact/checksumを公開します。Workflowはv3.0.0に対応し、publish前に検証します。Pagesはmainからsiteを公開します。完了後、GitHub latest releaseと実際に描画されたPagesを確認します。Upload結果が不明な場合は、retry前に既存assetを確認します。[Release notes](launch/orven-3.0.0.md)と[readiness](production/RELEASE-READINESS.md)を参照してください。 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index f66a250..7af06a0 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -1,315 +1,43 @@ -# Toolbox Operations - -[English](#english) · [Tiếng Việt](#tiếng-việt) · [日本語](#日本語) +# Orven operations ## English -Visual references: [Install Trace sequence](diagrams/install-trace-sequence.html) and [review/recovery state machine](diagrams/review-recovery-state.html). - -### Workstation and toolchain - -Toolbox is a dependency-free SwiftPM package with `swift-tools-version: 6.0` and a macOS 13 deployment target. Development, application bundling, FSEvents, LaunchAgent, signing, DMG, and Gatekeeper checks require macOS. GitHub CI uses `macos-15` and prints the selected Swift version; the repository does not promise a specific local Xcode build beyond what the manifest and tests prove. - -Start from the repository root and inspect the active developer directory before validation: - -```bash -xcode-select -p -cd apps/toolbox -swift --version -``` - -Use a full Xcode developer directory when running XCTest. Do not change application source, release scripts, or workflow policy merely to accommodate a Command Line Tools-only host. - -### Development validation - -Run the source and bundle gates from `apps/toolbox`: - -```bash -swift format lint --recursive --parallel Sources Tests Package.swift -swift test -./scripts/test_core.sh -./scripts/test_storage.sh -./scripts/test_changes.sh -./scripts/test_app.sh -./scripts/lint_localizations.swift -swift build -c release -./scripts/build_app.sh -test -x dist/Toolbox.app/Contents/MacOS/Toolbox -test -f dist/Toolbox.app/Contents/Resources/en.lproj/Localizable.strings -test -f dist/Toolbox.app/Contents/Resources/vi.lproj/Localizable.strings -plutil -lint Resources/Info.plist Resources/en.lproj/*.strings Resources/vi.lproj/*.strings -codesign --verify --deep --strict dist/Toolbox.app -``` - -`build_app.sh` assembles a local single-architecture app and applies an ad-hoc signature. A successful build and `codesign --verify` prove bundle structure and signature consistency only; they do not prove a universal binary, Developer ID identity, notarization, stapling, Gatekeeper acceptance, or launch on physical Intel hardware. - -Run documentation and workflow checks from the repository root when those contracts change: - -```bash -python3 tests/check_docs.py docs/ARCHITECTURE.md docs/OPERATIONS.md docs/OPERATIONS-RELEASE.md -bash tests/unsigned_stable_release_test.sh -actionlint .github/workflows/ci.yml .github/workflows/release.yml -git diff --check -``` - -The explicit exception for `v2.0.0` is historical. Its reproducible structural checks, immutable evidence, and the notarized default for later releases are defined in [Release operations](OPERATIONS-RELEASE.md). - -### XCTest boundary - -If `xcode-select -p` points to Command Line Tools and `swift test` fails with `no such module 'XCTest'`, report XCTest as blocked on that host. The four smoke scripts remain useful executable evidence for Core, selected Storage and Changes paths, and the update checker, but they compile curated source lists and do not replace the SwiftPM XCTest targets. - -A merge or release claim requires `swift test` under full Xcode or the exact-commit GitHub Actions XCTest job to succeed. Do not convert smoke success into an XCTest, signing, notarization, launch, or production claim. - -### Scheduled-scan lifecycle - -The user enables a schedule from the packaged Toolbox GUI and must grant Notification permission. The interval must be 1 through 168 hours. Toolbox atomically writes `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist`, asks `launchctl` to boot out any currently loaded Toolbox label on a best-effort basis, then bootstraps the new plist. If bootstrap fails, Toolbox removes the new plist, does not store the interval, and surfaces the exact `launchctl` error. - -The LaunchAgent runs `/usr/bin/open -gj --args --scheduled-scan`. The app switches to accessory mode, scans only default targets classified `safe`, posts a local summary notification, stores last-run metadata in `UserDefaults`, and exits. It never selects, trashes, or deletes scan results. - -Settings detects `~/Library/LaunchAgents/com.thang.diskora.scheduled-scan.plist` and requires confirmation before replacement. Replacement order is: install and successfully bootstrap the Toolbox job, best-effort boot out `com.thang.diskora.scheduled-scan`, then remove the legacy plist. A Toolbox bootstrap failure removes the Toolbox plist and preserves the legacy plist. The current implementation does not fail replacement when booting out the already-loaded legacy label fails; removing its plist prevents the legacy job from loading at a later login, but an operator should inspect the current `launchctl` session if both jobs appear active. - -Disable removes the Toolbox plist and schedule metadata after a best-effort bootout. If a job still runs after disable, inspect `launchctl print gui/$(id -u)/com.thang.toolbox.scheduled-scan`; do not delete unrelated LaunchAgents. - -### Update checks +### Build and verify -Toolbox performs no automatic update polling. Clicking **Check for Updates** is the only in-process trigger and is rejected while `ScanActivityRegistry` reports an active scan or snapshot. The request is: +Use full Xcode with a per-command DEVELOPER_DIR, Swift6 and macOS13deployment. Run all XCTest and smoke contracts, strict formatting and locale lint, then build_universal.sh/build_dmg.sh/verify_release.sh --allow-adhoc. Artifacts are Orven.app and Orven-3.0.0.dmg. The explicit exception for Orven3.0.0 is ad-hoc/unnotarized; no Gatekeeper bypass command is permitted. -```text -GET https://api.github.com/repos/thangldw/toolbox/releases/latest -Accept: application/vnd.github+json -User-Agent: Toolbox/ -``` +### Local data and cleanup -There is no request body and no scan path, evidence, result, or device payload. A non-2xx response is shown as an error. The returned tag is parsed as semantic `major.minor.patch`, ignoring a leading `v` and prerelease suffix for comparison; malformed tags yield no parsed latest version. Toolbox reports availability but does not download or install an update. +Keep bundle/data namespace stable. Do not reset corrupt stores automatically or silently seed history. Preserve skipped/missing/permission-reduced coverage. Scheduled scans do not delete; eligible cleanup and irreversible developer commands have distinct reviewed flows. [Installation trace](diagrams/install-trace-sequence.html) and [recovery](diagrams/review-recovery-state.html). -### Local data, backup, and recovery +### Support and limitations -Before manual data maintenance, quit Toolbox and copy `~/Library/Application Support/Toolbox` to protected local storage. If a schedule is installed, also preserve `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist`. These files may contain user paths, timestamps, application attribution, cleanup history, and Trash locations; treat the backup as private. - -For `evidence-v1.json` or `activity-v1.json`, a decode failure automatically moves the file to a sibling `*.corrupt--.json` path and reports an error. Preserve the quarantined file for diagnosis. An unsupported schema is not quarantined. `history.json`, `storage-checkpoints.json`, `sessions.json`, `active-snapshot.json`, `active-trace-metadata.json`, and `trusted-baseline.json` use older fail-soft loaders: corrupt or unreadable content may appear as empty or missing state. Do not infer that the underlying data never existed; restore a known-good local backup or move the affected file aside while Toolbox is closed before recreating state. - -Use Recovery only for cleanup history entries that retain Trash move manifests. Restore requires the recorded Trash source to exist, the original destination to be absent, and both paths to pass the allowlisted-root checks. It creates only the destination parent and never overwrites a conflict. Cleaning Trash, developer-tool commands, entries without a Trash manifest, and already-restored items are not recoverable through Toolbox. Preserve partial-failure details and resolve each conflict instead of repeating the whole mutation blindly. - -Full Disk Access is optional. Toolbox probes `~/Library/Mail` and `~/Library/Safari`: if neither sentinel exists, it reports both paths as reduced coverage; otherwise only an unreadable existing sentinel reduces coverage. Grant Full Disk Access only when the desired scan requires it, then use Settings **Check again**. Reduced coverage is an evidence limitation, not permission to bypass macOS protections. - -### Redacted export - -Change Timeline can save one selected session as Markdown or JSON through `NSSavePanel`. Export is local and atomic. The redaction is deliberately narrow: the exact home-directory prefix becomes `~`. Filenames, remaining path components, timestamps, change/risk/category fields, attribution, reasons, and event counts can remain in the report, and paths outside the home directory are not redacted. - -Review the generated file before sharing it. Move it only through a user-chosen channel; Toolbox does not upload exports. If stronger anonymization is required, remove names, non-home paths, project identifiers, timestamps, and attribution outside Toolbox before transmission. - -### Failure and escalation - -- For a scan or mutation error, preserve the displayed per-path errors and local ledgers, stop retrying destructive work, and verify the target and Trash state in Finder. -- For migration failure, preserve the original Diskora, MacCleaner, and Changeora files. Fix the unreadable source or completion marker before retrying; absence of `migration-v1.json` means completion was not recorded. -- For schedule failure, preserve both plist files and the exact `launchctl` stderr, then inspect only the two Toolbox/Diskora labels described above. -- For evidence-store quarantine or unexpected empty legacy-format state, preserve the affected and quarantined files before recovery. Do not hand-edit live JSON while Toolbox is running. -- For a suspected vulnerability or unsafe path acceptance, stop the affected workflow and follow `SECURITY.md`. For artifact, signing, notarization, checksum, or published-release issues, follow `docs/OPERATIONS-RELEASE.md`. +Report actual version, architecture, permission scope and fresh timestamps. Do not publish raw private diagnostic reports. See [user guide](USER-GUIDE.md), [readiness](production/RELEASE-READINESS.md), [privacy](../PRIVACY.md) and [release operations](OPERATIONS-RELEASE.md). ## Tiếng Việt -Tham chiếu trực quan: [sequence Install Trace](diagrams/install-trace-sequence.html) và [state machine review/recovery](diagrams/review-recovery-state.html). - -### Workstation và toolchain - -Toolbox là SwiftPM package không có dependency ngoài, dùng `swift-tools-version: 6.0` và deployment target macOS 13. Development, application bundling, FSEvents, LaunchAgent, signing, DMG và Gatekeeper check cần macOS. GitHub CI dùng `macos-15` và in Swift version đã chọn; repository không hứa một local Xcode build cụ thể ngoài những gì manifest và test chứng minh. - -Bắt đầu từ repository root và kiểm tra active developer directory trước khi validation: - -```bash -xcode-select -p -cd apps/toolbox -swift --version -``` - -Dùng full Xcode developer directory khi chạy XCTest. Không thay application source, release script hoặc workflow policy chỉ để phù hợp host chỉ có Command Line Tools. - -### Validation khi development - -Chạy source và bundle gate từ `apps/toolbox`: - -```bash -swift format lint --recursive --parallel Sources Tests Package.swift -swift test -./scripts/test_core.sh -./scripts/test_storage.sh -./scripts/test_changes.sh -./scripts/test_app.sh -./scripts/lint_localizations.swift -swift build -c release -./scripts/build_app.sh -test -x dist/Toolbox.app/Contents/MacOS/Toolbox -test -f dist/Toolbox.app/Contents/Resources/en.lproj/Localizable.strings -test -f dist/Toolbox.app/Contents/Resources/vi.lproj/Localizable.strings -plutil -lint Resources/Info.plist Resources/en.lproj/*.strings Resources/vi.lproj/*.strings -codesign --verify --deep --strict dist/Toolbox.app -``` - -`build_app.sh` assemble app single-architecture local và áp dụng ad-hoc signature. Build thành công cùng `codesign --verify` chỉ chứng minh bundle structure và signature consistency; chúng không chứng minh universal binary, Developer ID identity, notarization, stapling, Gatekeeper acceptance hoặc launch trên physical Intel hardware. - -Chạy documentation và workflow check từ repository root khi các contract đó thay đổi: - -```bash -python3 tests/check_docs.py docs/ARCHITECTURE.md docs/OPERATIONS.md docs/OPERATIONS-RELEASE.md -bash tests/unsigned_stable_release_test.sh -actionlint .github/workflows/ci.yml .github/workflows/release.yml -git diff --check -``` - -Exception explicit cho `v2.0.0` là historical. Structural check có thể reproduce, immutable evidence và default notarized cho release sau được định nghĩa trong [Release operations](OPERATIONS-RELEASE.md). - -### Ranh giới XCTest - -Nếu `xcode-select -p` trỏ đến Command Line Tools và `swift test` fail với `no such module 'XCTest'`, báo XCTest bị block trên host đó. Bốn smoke script vẫn là executable evidence hữu ích cho Core, các path Storage/Changes được chọn và update checker, nhưng chúng compile curated source list và không thay thế SwiftPM XCTest target. +### Build and verify -Claim merge hoặc release cần `swift test` dưới full Xcode hoặc job XCTest của GitHub Actions trên exact commit thành công. Không nâng smoke success thành claim về XCTest, signing, notarization, launch hoặc production. +Dùng Xcode đầy đủ, `DEVELOPER_DIR` theo từng command, Swift 6 và deployment target macOS 13. Chạy XCTest, năm smoke contracts, strict format và locale lint; sau đó chạy `build_universal.sh`, `build_dmg.sh`, `verify_release.sh --allow-adhoc` trong apps/toolbox/scripts. Artifact là Orven.app và Orven-3.0.0.dmg. Orven 3.0.0 được phép ký ad-hoc và ghi rõ chưa notarized; không dùng command bỏ qua Gatekeeper. -### Vòng đời scheduled scan +### Local data and cleanup -Người dùng bật schedule từ packaged Toolbox GUI và phải cấp Notification permission. Interval phải từ 1 đến 168 giờ. Toolbox ghi atomic `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist`, yêu cầu `launchctl` boot out Toolbox label đang load theo best effort, rồi bootstrap plist mới. Nếu bootstrap fail, Toolbox xóa plist mới, không lưu interval và hiển thị exact `launchctl` error. +Giữ bundle ID và namespace dữ liệu. Không tự reset store bị hỏng hoặc tự tạo history giả. Hiển thị mẫu thiếu, mục bỏ qua và giới hạn quyền. Scheduled scans không xóa dữ liệu. Cleanup đủ điều kiện và developer commands không thể khôi phục phải có luồng duyệt riêng. Xem [Install Trace](diagrams/install-trace-sequence.html) và [recovery](diagrams/review-recovery-state.html). -LaunchAgent chạy `/usr/bin/open -gj --args --scheduled-scan`. App chuyển sang accessory mode, chỉ scan default target được phân loại `safe`, gửi local summary notification, lưu last-run metadata trong `UserDefaults` rồi thoát. Nó không bao giờ chọn, chuyển Trash hoặc xóa scan result. +### Support and limitations -Settings phát hiện `~/Library/LaunchAgents/com.thang.diskora.scheduled-scan.plist` và yêu cầu confirmation trước khi replace. Thứ tự replacement là: install và bootstrap thành công job Toolbox, best-effort boot out `com.thang.diskora.scheduled-scan`, rồi xóa legacy plist. Nếu bootstrap Toolbox fail, Toolbox plist bị xóa và legacy plist được giữ. Implementation hiện tại không làm replacement fail khi bootout legacy label đang load thất bại; xóa plist sẽ ngăn legacy job load ở lần login sau, nhưng operator nên kiểm tra session `launchctl` hiện tại nếu cả hai job có vẻ active. - -Disable xóa Toolbox plist và schedule metadata sau best-effort bootout. Nếu job vẫn chạy sau disable, kiểm tra `launchctl print gui/$(id -u)/com.thang.toolbox.scheduled-scan`; không xóa LaunchAgent không liên quan. - -### Update check - -Toolbox không tự động poll update. Click **Check for Updates** là trigger duy nhất trong process và bị từ chối khi `ScanActivityRegistry` báo scan hoặc snapshot active. Request là: - -```text -GET https://api.github.com/repos/thangldw/toolbox/releases/latest -Accept: application/vnd.github+json -User-Agent: Toolbox/ -``` - -Không có request body và không có scan path, evidence, result hoặc device payload. Response không phải 2xx được hiển thị như error. Tag trả về được parse thành semantic `major.minor.patch`, bỏ leading `v` và prerelease suffix khi so sánh; tag sai format không tạo latest version đã parse. Toolbox chỉ báo availability, không download hoặc install update. - -### Local data, backup và recovery - -Trước khi bảo trì data thủ công, quit Toolbox và copy `~/Library/Application Support/Toolbox` vào local storage được bảo vệ. Nếu schedule đã install, cũng giữ `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist`. Các file này có thể chứa user path, timestamp, application attribution, cleanup history và Trash location; coi backup là private. - -Với `evidence-v1.json` hoặc `activity-v1.json`, decode failure tự động chuyển file thành sibling path `*.corrupt--.json` và báo error. Giữ quarantined file để diagnose. Unsupported schema không bị quarantine. `history.json`, `storage-checkpoints.json`, `sessions.json`, `active-snapshot.json`, `active-trace-metadata.json` và `trusted-baseline.json` dùng loader fail-soft cũ: nội dung corrupt hoặc unreadable có thể xuất hiện như state rỗng hoặc missing. Không suy luận rằng data gốc chưa từng tồn tại; restore local backup đã biết tốt hoặc move affected file sang chỗ khác khi Toolbox đã đóng trước khi tạo lại state. - -Chỉ dùng Recovery cho cleanup history entry còn Trash move manifest. Restore yêu cầu source đã ghi trong Trash còn tồn tại, original destination chưa tồn tại và cả hai path qua allowlisted-root check. Nó chỉ tạo destination parent và không bao giờ overwrite conflict. Dọn Trash, developer-tool command, entry không có Trash manifest và item đã restore không thể recovery qua Toolbox. Giữ chi tiết partial failure và xử lý từng conflict thay vì lặp lại toàn bộ mutation một cách mù quáng. - -Full Disk Access là tùy chọn. Toolbox probe `~/Library/Mail` và `~/Library/Safari`: nếu cả hai sentinel đều không tồn tại, app báo reduced coverage cho cả hai path; nếu có sentinel tồn tại, chỉ sentinel đó không đọc được mới giảm coverage. Chỉ cấp Full Disk Access khi scan mong muốn cần nó, rồi dùng **Check again** trong Settings. Reduced coverage là giới hạn evidence, không phải quyền bypass bảo vệ macOS. - -### Export đã redact - -Change Timeline có thể lưu một session đã chọn dưới dạng Markdown hoặc JSON qua `NSSavePanel`. Export là local và atomic. Redaction cố ý hẹp: exact home-directory prefix được thay bằng `~`. Filename, path component còn lại, timestamp, change/risk/category field, attribution, reason và event count có thể vẫn nằm trong report; path ngoài home directory không được redact. - -Review file đã tạo trước khi share. Chỉ move file qua channel do người dùng chọn; Toolbox không upload export. Nếu cần anonymization mạnh hơn, xóa name, non-home path, project identifier, timestamp và attribution bên ngoài Toolbox trước khi transmission. - -### Failure và escalation - -- Với scan hoặc mutation error, giữ per-path error hiển thị và local ledger, ngừng retry destructive work, rồi verify target và Trash state trong Finder. -- Với migration failure, giữ file Diskora, MacCleaner và Changeora gốc. Sửa source hoặc completion marker không đọc được trước khi retry; không có `migration-v1.json` nghĩa là completion chưa được ghi. -- Với schedule failure, giữ cả hai plist và exact `launchctl` stderr, rồi chỉ kiểm tra hai label Toolbox/Diskora mô tả ở trên. -- Với evidence-store quarantine hoặc legacy-format state rỗng bất ngờ, giữ affected/quarantined file trước khi recovery. Không hand-edit live JSON khi Toolbox đang chạy. -- Với suspected vulnerability hoặc unsafe path acceptance, dừng workflow bị ảnh hưởng và theo `SECURITY.md`. Với artifact, signing, notarization, checksum hoặc published-release issue, theo `docs/OPERATIONS-RELEASE.md`. +Báo version, architecture, phạm vi quyền và timestamp thực. Không công bố báo cáo chẩn đoán riêng tư chưa duyệt. Xem [guide](USER-GUIDE.md), [readiness](production/RELEASE-READINESS.md), [privacy](../PRIVACY.md), [release operations](OPERATIONS-RELEASE.md). ## 日本語 -Visual reference: [Install Trace sequence](diagrams/install-trace-sequence.html) と [review/recovery state machine](diagrams/review-recovery-state.html)。 - -### Workstation と toolchain - -Toolbox は外部 dependency のない SwiftPM package で、`swift-tools-version: 6.0` と macOS 13 deployment target を使用します。Development、application bundling、FSEvents、LaunchAgent、signing、DMG、Gatekeeper check には macOS が必要です。GitHub CI は `macos-15` を使い、選択された Swift version を表示します。Repository は manifest と test が証明する範囲を超えて特定 local Xcode build を保証しません。 - -Repository root から開始し、validation 前に active developer directory を確認します。 - -```bash -xcode-select -p -cd apps/toolbox -swift --version -``` - -XCTest 実行時は full Xcode developer directory を使います。Command Line Tools-only host に合わせるためだけに application source、release script、workflow policy を変更しません。 - -### Development validation - -`apps/toolbox` で source/bundle gate を実行します。 - -```bash -swift format lint --recursive --parallel Sources Tests Package.swift -swift test -./scripts/test_core.sh -./scripts/test_storage.sh -./scripts/test_changes.sh -./scripts/test_app.sh -./scripts/lint_localizations.swift -swift build -c release -./scripts/build_app.sh -test -x dist/Toolbox.app/Contents/MacOS/Toolbox -test -f dist/Toolbox.app/Contents/Resources/en.lproj/Localizable.strings -test -f dist/Toolbox.app/Contents/Resources/vi.lproj/Localizable.strings -plutil -lint Resources/Info.plist Resources/en.lproj/*.strings Resources/vi.lproj/*.strings -codesign --verify --deep --strict dist/Toolbox.app -``` - -`build_app.sh` は local single-architecture app を assemble し、ad-hoc signature を適用します。Build 成功と `codesign --verify` が証明するのは bundle structure と signature consistency だけです。Universal binary、Developer ID identity、notarization、stapling、Gatekeeper acceptance、physical Intel hardware での launch は証明しません。 - -Documentation/workflow contract を変更した場合は repository root で次を実行します。 - -```bash -python3 tests/check_docs.py docs/ARCHITECTURE.md docs/OPERATIONS.md docs/OPERATIONS-RELEASE.md -bash tests/unsigned_stable_release_test.sh -actionlint .github/workflows/ci.yml .github/workflows/release.yml -git diff --check -``` - -`v2.0.0` の明示的 exception は historical です。再現可能な structural check、immutable evidence、後続 release の notarized default は [Release operations](OPERATIONS-RELEASE.md) で定義します。 - -### XCTest boundary - -`xcode-select -p` が Command Line Tools を指し、`swift test` が `no such module 'XCTest'` で失敗する場合、その host では XCTest blocked と報告します。四つの smoke script は Core、選択された Storage/Changes path、update checker の有用な executable evidence ですが、curated source list を compile するため SwiftPM XCTest target の代替ではありません。 - -Merge/release claim には full Xcode 下の `swift test` または exact-commit GitHub Actions XCTest job の成功が必要です。Smoke success を XCTest、signing、notarization、launch、production claim に昇格させません。 - -### Scheduled-scan lifecycle - -User は packaged Toolbox GUI から schedule を有効化し、Notification permission を許可する必要があります。Interval は 1~168 時間です。Toolbox は `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist` を atomic write し、現在 load 済みの Toolbox label を best-effort で boot out してから、新しい plist を bootstrap します。Bootstrap failure 時は新しい plist を削除し、interval を保存せず、exact `launchctl` error を表示します。 - -LaunchAgent は `/usr/bin/open -gj --args --scheduled-scan` を実行します。App は accessory mode に切り替わり、`safe` と分類された default target だけを scan し、local summary notification を送り、last-run metadata を `UserDefaults` に保存して終了します。Scan result の選択、Trash 移動、削除は行いません。 - -Settings は `~/Library/LaunchAgents/com.thang.diskora.scheduled-scan.plist` を検出し、replacement 前に confirmation を要求します。順序は Toolbox job の install と bootstrap 成功、`com.thang.diskora.scheduled-scan` の best-effort bootout、legacy plist 削除です。Toolbox bootstrap failure では Toolbox plist を削除し legacy plist を保持します。現在の implementation は load 済み legacy label の bootout failure で replacement を失敗させません。Plist 削除により次回 login での legacy job load は防ぎますが、両 job が active に見える場合、operator は現在の `launchctl` session を調べる必要があります。 - -Disable は best-effort bootout 後に Toolbox plist と schedule metadata を削除します。Disable 後も job が動く場合は `launchctl print gui/$(id -u)/com.thang.toolbox.scheduled-scan` を調べ、無関係な LaunchAgent は削除しません。 - -### Update check - -Toolbox は automatic update polling を行いません。**Check for Updates** の click が唯一の in-process trigger で、`ScanActivityRegistry` が active scan/snapshot を示す間は拒否されます。Request は次のとおりです。 - -```text -GET https://api.github.com/repos/thangldw/toolbox/releases/latest -Accept: application/vnd.github+json -User-Agent: Toolbox/ -``` - -Request body はなく、scan path、evidence、result、device payload を送りません。Non-2xx response は error として表示します。Returned tag は leading `v` と比較時の prerelease suffix を除いて semantic `major.minor.patch` として parse されます。Malformed tag は parsed latest version を生成しません。Toolbox は availability を報告するだけで update を download/install しません。 - -### Local data、backup、recovery - -Manual data maintenance 前に Toolbox を終了し、`~/Library/Application Support/Toolbox` を保護された local storage へ copy します。Schedule を install 済みなら `~/Library/LaunchAgents/com.thang.toolbox.scheduled-scan.plist` も保存します。これらの file は user path、timestamp、application attribution、cleanup history、Trash location を含む可能性があるため、backup を private data として扱います。 - -`evidence-v1.json` または `activity-v1.json` の decode failure では、file が sibling の `*.corrupt--.json` path へ自動的に移され、error が報告されます。診断用に quarantined file を保持します。Unsupported schema は quarantine されません。`history.json`、`storage-checkpoints.json`、`sessions.json`、`active-snapshot.json`、`active-trace-metadata.json`、`trusted-baseline.json` は古い fail-soft loader を使うため、corrupt/unreadable content が empty/missing state に見えることがあります。元 data が存在しなかったと推測せず、Toolbox 終了中に known-good local backup を restore するか affected file を別の場所へ move してから state を再作成します。 - -Recovery は Trash move manifest を保持する cleanup history entry にだけ使います。Restore には記録済み Trash source の存在、original destination の不在、両 path の allowlisted-root check 通過が必要です。Destination parent だけを作成し、conflict を overwrite しません。Trash cleanup、developer-tool command、Trash manifest のない entry、already-restored item は Toolbox から recovery できません。Partial-failure detail を保持し、mutation 全体を盲目的に繰り返さず conflict ごとに解決します。 - -Full Disk Access は optional です。Toolbox は `~/Library/Mail` と `~/Library/Safari` を probe します。両 sentinel が存在しなければ両 path を reduced coverage として報告し、いずれかが存在する場合は unreadable な既存 sentinel だけが coverage を低下させます。必要な scan のためだけに Full Disk Access を許可し、Settings の **Check again** を使います。Reduced coverage は evidence limitation であり macOS protection を bypass する権限ではありません。 +### Build and verify -### Redacted export +完全なXcode、commandごとの`DEVELOPER_DIR`、Swift 6、macOS 13 deployment targetを使用します。XCTest、五つのsmoke contracts、strict format、locale lintの後、apps/toolbox/scriptsの`build_universal.sh`、`build_dmg.sh`、`verify_release.sh --allow-adhoc`を実行します。ArtifactはOrven.appとOrven-3.0.0.dmgです。Orven 3.0.0はad-hoc署名と未notarizedの明示を承認済みで、Gatekeeperを回避するcommandは使いません。 -Change Timeline は選択した一つの session を `NSSavePanel` 経由で Markdown または JSON に保存できます。Export は local かつ atomic です。Redaction は意図的に限定され、exact home-directory prefix だけを `~` に置換します。Filename、残りの path component、timestamp、change/risk/category field、attribution、reason、event count は report に残る可能性があり、home directory 外の path は redact されません。 +### Local data and cleanup -共有前に生成 file を review します。User が選択した channel だけで移動し、Toolbox は export を upload しません。より強い anonymization が必要なら、送信前に Toolbox 外で name、non-home path、project identifier、timestamp、attribution を削除します。 +Bundle IDとdata namespaceを維持します。壊れたstoreの自動resetや架空historyの生成を行いません。Missing、skipped、permissionによるcoverage制限を保持します。Scheduled scansは削除しません。対象となるcleanupと不可逆なdeveloper commandsには、それぞれ明示reviewが必要です。[Install Trace](diagrams/install-trace-sequence.html)と[recovery](diagrams/review-recovery-state.html)を参照してください。 -### Failure と escalation +### Support and limitations -- Scan/mutation error では表示された per-path error と local ledger を保持し、destructive retry を止め、Finder で target/Trash state を verify します。 -- Migration failure では元の Diskora、MacCleaner、Changeora file を保持します。Unreadable source または completion marker を直してから retry します。`migration-v1.json` がなければ completion は記録されていません。 -- Schedule failure では両 plist と exact `launchctl` stderr を保持し、上記の Toolbox/Diskora label 二つだけを調べます。 -- Evidence-store quarantine または予期しない empty legacy-format state では、recovery 前に affected/quarantined file を保持します。Toolbox 実行中に live JSON を hand-edit しません。 -- Suspected vulnerability または unsafe path acceptance では対象 workflow を停止し `SECURITY.md` に従います。Artifact、signing、notarization、checksum、published-release issue では `docs/OPERATIONS-RELEASE.md` に従います。 +実際のversion、architecture、permission scopeとtimestampを報告します。未reviewのprivate diagnostic reportsを公開しません。[Guide](USER-GUIDE.md)、[readiness](production/RELEASE-READINESS.md)、[privacy](../PRIVACY.md)、[release operations](OPERATIONS-RELEASE.md)を参照してください。 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md new file mode 100644 index 0000000..d2ade9a --- /dev/null +++ b/docs/USER-GUIDE.md @@ -0,0 +1,103 @@ +# Orven user guide + +## English + +### Install and upgrade + +Download Orven-3.0.0.dmg and its SHA-256 file from the same published GitHub release. Run `shasum -a 256 -c Orven-3.0.0.dmg.sha256`, open the DMG, read the included guide and drag Orven.app into Applications. Ad-hoc signed, not notarized: if first launch is blocked, use System Settings → Privacy & Security → Open Anyway. Do not disable Gatekeeper. The old data namespace and bundle ID remain compatible; no data migration to a new Orven folder is required. + +### Observe your Mac + +Overview and monitoring pages show observed CPU/memory, processes, disk/network rates and battery/thermal. Live requires an active main window; otherwise sampling pauses unless optional menu-bar monitoring is enabled. Hardware Inspector shows the machine profile and available mapped sensors. Missing readings and stale timestamps are meaningful; do not interpret them as zero. Sleep Inspector presents observed sleep-prevention assertions, not permission to kill a process. + +### Wi-Fi and LAN + +Choose Wi-Fi & LAN in the sidebar, select a tool and refresh explicitly. Overview/Ethernet show addresses, gateway/global DNS, media and carrier; UP/RUNNING flags are distinct from physical link detection. Wi-Fi shows signal/noise/SNR/channel/band/width and link rate when available. Identity may require Location permission. Nearby scans are explicit; channel counts/RSSI are not interference measurements. + +For Connection Diagnostics or Latency & Stability, enter one hostname or numeric IP, then run the measurement. No URL, port, command options or credentials. DNS/TCP443/gateway stages report independent outcomes; numeric IPs skip DNS. ICMP20 reports loss/received mean/jitter with missing-sequence gaps. Stop cancels the owned operation and preserves earlier results. Connection/path timelines are opt-in session observations, not packet history or exact disconnect times. + +### History, baseline and recording + +Enable local health history deliberately; it defaults off and does not enable background monitoring. Retain24h/7d within record/byte budgets. After30–60 real observations, Capture Baseline stores reference medians; at least10 newer valid observations per metric are needed for comparison. Different workloads can explain differences. + +Select15/30/60 minutes for a sensor recording, then Start. Stop preserves the session; Start replaces it, and Quit discards it. Sleep/inactivity create gaps or extend elapsed wall time. JSON/CSV export previews exactly what will be saved. Smart Alerts are opt-in; OS notification delivery depends on permission and a working OS service and remains unverified in current QA. + +### Storage, projects and applications + +Choose roots before scanning. Review allocated sizes, duplicate candidates, growth and rebuildable project artifacts. App Dossier links observations and leftovers; receipts and registrations do not authorize deletion. Eligible file cleanup requires review and Trash/recovery checks. Developer-tool cleanup commands are a separate explicit operation and may not use Trash or support Undo; review the exact command before confirming. Manual CPU priority lowering is not an automatic CPU cap. + +### Changes, recovery and reports + +Install Trace records bounded observations while you run the normal installation/update/uninstall flow. It never runs a dropped installer automatically. Compare snapshots/timeline evidence, review partial coverage, and export locally. Restore only when the original destination is unoccupied. Reports may include names, paths or endpoints; review before sharing. + +### Limits and support + +Mapped temperature labels vary by model; link rate is not Internet throughput. Hidden-space/snapshot explanations do not establish exclusive snapshot bytes. No firewall, fan control or privileged repair is included. Current foreground resources, physical Intel/macOS13/Ethernet, full VoiceOver, Japanese critical-copy review and native notification delivery remain unverified. See [release readiness](production/RELEASE-READINESS.md), [Privacy](../PRIVACY.md), [Security](../SECURITY.md) and [GitHub issues](https://github.com/thangldw/toolbox/issues). + +## Tiếng Việt + +### Cài đặt và nâng cấp + +Tải Orven-3.0.0.dmg và SHA-256 từ cùng một release GitHub đã công bố. Chạy `shasum -a 256 -c Orven-3.0.0.dmg.sha256`, mở DMG, đọc hướng dẫn và kéo Orven.app vào Applications. App ký ad-hoc, chưa notarized. Nếu lần mở đầu bị chặn, dùng System Settings → Privacy & Security → Open Anyway; không tắt Gatekeeper. Bundle ID và thư mục dữ liệu cũ được giữ tương thích, không cần chuyển sang thư mục Orven mới. + +### Quan sát Mac + +Overview và các trang giám sát hiển thị CPU, bộ nhớ, process, tốc độ disk/network, pin và thermal. Live cần cửa sổ chính đang hoạt động; nếu không, sampling tạm dừng trừ khi đã bật giám sát menu bar. Hardware Inspector hiển thị profile và cảm biến mapped khả dụng. Mẫu thiếu hoặc timestamp cũ không có nghĩa là giá trị bằng không. Sleep Inspector hiển thị các yêu cầu giữ máy thức, không phải quyền kill process. + +### Wi-Fi và LAN + +Chọn Wi-Fi & LAN trong sidebar, chọn công cụ rồi làm mới. Overview/Ethernet hiển thị địa chỉ, gateway, DNS toàn cục, media và carrier; UP/RUNNING khác với phát hiện kết nối vật lý. Wi-Fi hiển thị signal, noise, SNR, channel, band, width và link rate khi có dữ liệu. Tên mạng có thể cần quyền Location. Nearby scan chạy theo nút; số lượng mạng và RSSI không phải phép đo nhiễu. + +Trong Connection Diagnostics hoặc Latency & Stability, nhập một hostname hoặc IP, không kèm URL, port, options hay credentials. DNS, TCP 443 và gateway có kết quả độc lập; IP số bỏ qua DNS. Phiên 20 phép đo ICMP hiển thị loss, mean, jitter và khoảng trống mẫu thiếu. Stop chỉ hủy tác vụ của Orven, giữ kết quả trước đó. Timeline tùy chọn giữ quan sát trong RAM của phiên, không phải packet history hoặc thời điểm mất kết nối chính xác. + +### History, baseline và recording + +History cục bộ mặc định tắt. Bật tính năng này không bật sampling nền. Chọn giữ 24 giờ hoặc 7 ngày trong giới hạn dung lượng. Sau 30–60 quan sát thực, chọn Capture Baseline; so sánh trung vị cần ít nhất 10 quan sát mới hợp lệ cho mỗi metric. Workload khác nhau có thể giải thích chênh lệch. + +Chọn 15/30/60 phút rồi Start. Stop giữ phiên, Start thay phiên, Quit bỏ phiên khỏi RAM. Sleep/inactive tạo khoảng trống và có thể kéo dài thời gian thực. Preview JSON/CSV đúng nội dung sẽ lưu. Smart Alerts tùy chọn; thông báo OS cần quyền và service thực tế, việc gửi đến người dùng chưa được xác minh. + +### Storage, projects và applications + +Chọn thư mục gốc trước khi quét. Duyệt allocation, duplicates, growth và artifacts có thể tạo lại. App Dossier liên kết quan sát và leftovers; receipt hoặc registration không cấp quyền xóa. Cleanup đủ điều kiện cần review, Trash và kiểm tra recovery. Developer cleanup commands là thao tác riêng có xác nhận; có thể không dùng Trash hoặc Undo, nên hãy đọc đúng command trước khi chạy. Giảm CPU priority thủ công không phải tự động giới hạn CPU. + +### Changes, recovery và reports + +Install Trace ghi quan sát có giới hạn khi bạn thực hiện install/update/uninstall bình thường; không tự chạy installer được thả vào app. So sánh snapshots/timeline, kiểm tra coverage và export cục bộ. Chỉ restore khi đích gốc đang trống. Report có thể chứa tên, path và endpoint; duyệt trước khi chia sẻ. + +### Giới hạn và support + +Tên cảm biến phụ thuộc model; link rate không phải tốc độ Internet. Giải thích dung lượng ẩn/snapshots không chứng minh exclusive snapshot bytes. App không có firewall, điều khiển quạt hoặc sửa hệ thống với đặc quyền. Foreground resources, Intel/macOS 13/Ethernet vật lý, full VoiceOver, câu chữ quan trọng tiếng Nhật và thông báo native chưa được xác minh. Xem [readiness](production/RELEASE-READINESS.md), [Privacy](../PRIVACY.md), [Security](../SECURITY.md), [issues](https://github.com/thangldw/toolbox/issues). + +## 日本語 + +### インストールと更新 + +同じ公開済みGitHub releaseからOrven-3.0.0.dmgとSHA-256を取得し、`shasum -a 256 -c Orven-3.0.0.dmg.sha256`で確認します。DMG内の案内を読んでOrven.appをApplicationsへ移動します。Ad-hoc署名でApple notarizationは受けていません。初回起動が拒否された場合はSystem Settings → Privacy & Security → Open Anywayを使い、Gatekeeperを無効化しないでください。旧bundle IDとdata namespaceを維持するため、新しいOrven folderへの移行は不要です。 + +### Macを観測する + +Overviewと監視画面でCPU、memory、process、disk/network rate、battery/thermalを確認します。任意のmenu-bar監視を有効にしていない場合、Liveにはactive main windowが必要で、それ以外はsamplingを停止します。Hardware Inspectorはprofileと取得可能なmapped sensorsを表示します。Missingや古いtimestampはゼロを意味しません。Sleep Inspectorはsleepを防ぐassertionsの観測であり、process終了の許可ではありません。 + +### Wi-FiとLAN + +SidebarのWi-Fi & LANでtoolを選んでrefreshします。Overview/Ethernetはaddresses、gateway、global DNS、media、carrierを表示します。UP/RUNNINGとphysical linkの検出は別です。Wi-Fiは取得可能なsignal/noise/SNR/channel/band/width/link rateを表示します。IdentityにはLocation permissionが必要な場合があります。Nearby scanは明示操作のみで、network countやRSSIはinterference測定ではありません。 + +Connection DiagnosticsまたはLatency & Stabilityに単一のhostname/IPを入力します。URL、port、options、credentialsは含めません。DNS、TCP 443、gatewayは独立した結果で、numeric IPはDNSを省略します。20回のICMPでloss、mean、jitterとmissing gapsを確認します。StopはOrven所有のoperationのみcancelし、以前の結果を保持します。任意timelineはsession RAM内の観測で、packet historyや正確なdisconnect時刻ではありません。 + +### History、baseline、recording + +Local historyは初期状態でoffです。有効にしてもbackground samplingを開始しません。24時間または7日を容量上限内で保持します。30–60回の実観測後にCapture Baselineを実行し、median比較にはmetricごとに10個の新しいvalid observationsが必要です。Workloadの違いが差の理由となる場合があります。 + +15/30/60分を選んでStartします。Stopはsessionを保持、Startは置換、QuitはRAM内sessionを破棄します。Sleep/inactiveはgapを作り、実時間を延ばす場合があります。JSON/CSV previewは保存内容と同一です。Smart Alertsは任意設定です。OS通知にはpermissionと実serviceが必要で、通知到達は未検証です。 + +### Storage、projects、applications + +Rootを選択してscanし、allocation、duplicates、growth、再生成可能なartifactsをreviewします。App Dossierは観測とleftoversを関連づけますが、receipt/registrationは削除許可ではありません。対象cleanupにはreview、Trashとrecovery検証が必要です。Developer cleanup commandsは別の明示操作で、Trash/Undoを使えない場合があります。実行前に正確なcommandを確認します。手動のCPU priority低下は自動CPU capではありません。 + +### Changes、recovery、reports + +Install Traceは通常のinstall/update/uninstallを人が行う間に限定された観測を記録し、dropされたinstallerを自動実行しません。Snapshots/timelineとcoverageをreviewしてlocal exportします。Restoreは元のdestinationが空の場合のみです。Reportに名前、path、endpointを含む場合があるため、共有前に確認します。 + +### 制限とサポート + +Mapped sensor labelsは機種に依存し、link rateはInternet throughputではありません。Hidden space/snapshotの説明はexclusive snapshot bytesの証明ではありません。Firewall、fan control、privileged repairは含みません。Current foreground resources、実機Intel/macOS 13/Ethernet、完全なVoiceOver、重要な日本語表現review、native通知到達は未検証です。[Readiness](production/RELEASE-READINESS.md)、[Privacy](../PRIVACY.md)、[Security](../SECURITY.md)、[issues](https://github.com/thangldw/toolbox/issues)を参照してください。 diff --git a/docs/diagrams/install-trace-sequence.html b/docs/diagrams/install-trace-sequence.html index cecbe31..3210a61 100644 --- a/docs/diagrams/install-trace-sequence.html +++ b/docs/diagrams/install-trace-sequence.html @@ -157,7 +157,7 @@

English

Content
The user selects a supported installer and the GUI calls coordinator.start. The coordinator captures a before snapshot and starts bounded FSEvents; after start returns, the GUI invokes NSWorkspace.shared.open(metadata.sourceURL) for normal macOS handling. Finishing captures after evidence, compares it, and persists the session and audit records.
Exclusions
-
Toolbox does not mount, execute, install, uninstall, or bypass Gatekeeper for the selected item. The sequence does not claim that FSEvents is a complete historical audit.
+
Orven does not mount, execute, install, uninstall, or bypass Gatekeeper for the selected item. The sequence does not claim that FSEvents is a complete historical audit.
Safety boundary
The user starts and finishes the trace in the foreground. cancel() stops the journal and clears active-snapshot.json plus active-trace-metadata.json, so cancellation is not resumable. Only unexpected interruption may retain resumable metadata; resumed observation reports the interruption interval as reduced coverage.
@@ -168,7 +168,7 @@

Tiếng Việt

Nội dung
Người dùng chọn installer được hỗ trợ và GUI gọi coordinator.start. Coordinator chụp before snapshot rồi bắt đầu FSEvents có giới hạn; sau khi start return, GUI gọi NSWorkspace.shared.open(metadata.sourceURL) để macOS xử lý bình thường. Khi finish, hệ thống chụp after evidence, so sánh và lưu session cùng audit record.
Không bao gồm
-
Toolbox không mount, execute, install, uninstall hoặc bypass Gatekeeper cho item đã chọn. Sequence không claim FSEvents là historical audit đầy đủ.
+
Orven không mount, execute, install, uninstall hoặc bypass Gatekeeper cho item đã chọn. Sequence không claim FSEvents là historical audit đầy đủ.
Ranh giới an toàn
Người dùng bắt đầu và kết thúc trace trong foreground. cancel() dừng journal và xóa active-snapshot.json cùng active-trace-metadata.json, nên cancellation không thể resume. Chỉ unexpected interruption mới có thể giữ metadata để resume; observation sau resume báo khoảng interruption là reduced coverage.
@@ -179,7 +179,7 @@

日本語

内容
User が supported installer を選択し、GUI が coordinator.start を呼びます。Coordinator は before snapshot を取得して bounded FSEvents を開始し、start が return した後、GUI が NSWorkspace.shared.open(metadata.sourceURL) を呼んで通常の macOS handling に渡します。Finish 時に after evidence を取得・比較し、session と audit record を保存します。
対象外
-
Toolbox は選択 item を mount、execute、install、uninstall せず、Gatekeeper を bypass しません。この sequence は FSEvents を完全な historical audit として扱いません。
+
Orven は選択 item を mount、execute、install、uninstall せず、Gatekeeper を bypass しません。この sequence は FSEvents を完全な historical audit として扱いません。
Safety boundary
Trace の開始と終了は user の foreground action です。cancel() は journal を停止し active-snapshot.json と active-trace-metadata.json を消去するため、cancellation は resume できません。Unexpected interruption だけが resumable metadata を保持でき、resume 後の observation は interruption 区間を reduced coverage として報告します。
diff --git a/docs/diagrams/review-recovery-state.html b/docs/diagrams/review-recovery-state.html index 6d171aa..e96ab32 100644 --- a/docs/diagrams/review-recovery-state.html +++ b/docs/diagrams/review-recovery-state.html @@ -154,7 +154,7 @@

Reviewed mutation and recovery

English

Content
-
A discovered candidate remains evidence until the user reviews and confirms it. Toolbox immediately revalidates the canonical target and approved root, moves an eligible item to Trash, records both paths, and restores only when the Trash source exists and the original destination is absent.
+
A discovered candidate remains evidence until the user reviews and confirms it. Orven immediately revalidates the canonical target and approved root, moves an eligible item to Trash, records both paths, and restores only when the Trash source exists and the original destination is absent.
Exclusions
Not every cleanup is recoverable. Cleaning Trash, developer-tool commands, entries without a Trash manifest, and already-restored items do not enter the recovery-eligible state.
Safety boundary
@@ -165,7 +165,7 @@

English

Tiếng Việt

Nội dung
-
Candidate được phát hiện chỉ là evidence cho đến khi người dùng review và confirm. Toolbox revalidate ngay canonical target cùng approved root, chuyển item đủ điều kiện vào Trash, ghi cả hai path và chỉ restore khi Trash source còn tồn tại còn original destination chưa tồn tại.
+
Candidate được phát hiện chỉ là evidence cho đến khi người dùng review và confirm. Orven revalidate ngay canonical target cùng approved root, chuyển item đủ điều kiện vào Trash, ghi cả hai path và chỉ restore khi Trash source còn tồn tại còn original destination chưa tồn tại.
Không bao gồm
Không phải cleanup nào cũng recover được. Dọn Trash, developer-tool command, entry không có Trash manifest và item đã restore không đi vào trạng thái recovery eligible.
Ranh giới an toàn
@@ -176,7 +176,7 @@

Tiếng Việt

日本語

内容
-
検出 candidate は user が review・confirm するまで evidence のままです。Toolbox は canonical target と approved root を直前に再検証し、対象 item を Trash へ移動して両 path を記録します。Restore は Trash source が存在し original destination が存在しない場合だけ行います。
+
検出 candidate は user が review・confirm するまで evidence のままです。Orven は canonical target と approved root を直前に再検証し、対象 item を Trash へ移動して両 path を記録します。Restore は Trash source が存在し original destination が存在しない場合だけ行います。
対象外
すべての cleanup が recoverable ではありません。Trash cleanup、developer-tool command、Trash manifest のない entry、already-restored item は recovery eligible state に入りません。
Safety boundary
diff --git a/docs/diagrams/toolbox-architecture.html b/docs/diagrams/toolbox-architecture.html index 10f3c8c..3776417 100644 --- a/docs/diagrams/toolbox-architecture.html +++ b/docs/diagrams/toolbox-architecture.html @@ -3,7 +3,7 @@ - Toolbox architecture and safety boundaries + Orven architecture and safety boundaries