diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..6fede3b --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,13 @@ +--- +# clang-tidy configuration for the MoStats native module. +# Keep this focused on correctness checks for the macOS system probes. + +Checks: > + bugprone-*, + clang-analyzer-*, + -bugprone-easily-swappable-parameters, + -clang-analyzer-nullability.*, + -clang-analyzer-optin.osx.cocoa.localizability.NonLocalizedStringChecker + +WarningsAsErrors: "*" +HeaderFilterRegex: "^src/native/(?!gen/).*" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..0c0aecf --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,75 @@ +name: Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + check: + name: Check + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Generate + run: npm run gen + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + native: + name: Native + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Generate + run: npm run gen + + - name: Configure + shell: bash + run: | + set -euo pipefail + sdk_path="$(xcrun --sdk macosx --show-sdk-path)" + cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_OSX_SYSROOT="${sdk_path}" -B build -S . + + - name: Setup LLVM + uses: KyleMayes/install-llvm-action@v2 + with: + version: "20" + + - name: Lint native + shell: bash + run: | + set -euo pipefail + clang_tidy_version="$(clang-tidy --version)" + if [[ "$clang_tidy_version" == *"version 20."* ]]; then + run-clang-tidy -checks=-cppcoreguidelines-avoid-const-or-ref-data-members -source-filter='^.*/src/native/(?!gen/).*' -p build + else + run-clang-tidy -source-filter='^.*/src/native/(?!gen/).*' -p build + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..08d8201 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build + runs-on: macos-latest + + env: + # Base64-encoded Developer ID Application certificate (.p12). + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + # Password used to export the .p12 certificate from Keychain Access. + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + # Password for the temporary CI keychain created during the build. + MACOS_KEYCHAIN_PWD: ${{ secrets.MACOS_KEYCHAIN_PWD }} + # Signing identity string, e.g. "Developer ID Application: Your Name (TEAMID)". + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + # Apple Developer Team ID (10-character string from developer.apple.com). + MACOS_TEAM_ID: ${{ secrets.MACOS_TEAM_ID }} + # Apple ID email used for notarization. + MACOS_APPLE_ID: ${{ secrets.MACOS_APPLE_ID }} + # App-specific password generated at appleid.apple.com for notarization. + MACOS_APPLE_PASSWORD: ${{ secrets.MACOS_APPLE_PASSWORD }} + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Set up macOS code signing + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain + security create-keychain -p "$MACOS_KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$MACOS_KEYCHAIN_PWD" "$KEYCHAIN_PATH" + + # Import the Apple public certificates the signing chain needs. + curl https://www.apple.com/certificateauthority/AppleRootCA-G3.cer -o AppleRootCA-G3.cer + curl https://www.apple.com/certificateauthority/AppleWWDRCAG6.cer -o AppleWWDRCAG6.cer + curl https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer -o DeveloperIDG2CA.cer + + security import ./AppleRootCA-G3.cer -k $KEYCHAIN_PATH + security import ./AppleWWDRCAG6.cer -k $KEYCHAIN_PATH + security import ./DeveloperIDG2CA.cer -k $KEYCHAIN_PATH + + # Import the private signing certificate. + echo "$MACOS_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import "$RUNNER_TEMP/certificate.p12" -k "$KEYCHAIN_PATH" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$MACOS_KEYCHAIN_PWD" "$KEYCHAIN_PATH" + # Putting the temp keychain on the user search list is what lets + # codesign find the imported identity during the build step. + security list-keychain -d user -s "$KEYCHAIN_PATH" + + - name: Build release package + run: npm run build + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: MoStats-macos-arm64 + path: build/dist/mac-arm64/pack/*.dmg + + release: + name: Create GitHub Release Draft + runs-on: ubuntu-latest + needs: [build] + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist/ + + - name: Collect DMGs + run: | + mkdir -p dist/macos-arm64 + mv dist/MoStats-macos-arm64/*.dmg dist/macos-arm64/ + + - name: Create Release draft + uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1 + with: + files: | + dist/macos-arm64/*.dmg + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + draft: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ee36fc1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Generate + run: npm run gen + + - name: Test + run: npm run test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..388d213 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Node dependencies. +node_modules/ + +# Build output. +build/ +dist/ +out/ + +# Generated code. +gen/ + +# macOS metadata. +.DS_Store + +.idea +.claude diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4f8b060 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,56 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Main", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/mobrowser", + "runtimeArgs": [ + "dev", + "--inspect=5858", + "--remote-debugging-port=9222" + ], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/out/main/**/*.js" + ], + "preLaunchTask": "vite: build main", + "env": { + "NODE_ENV": "development" + }, + "console": "integratedTerminal", + "skipFiles": [ + "/**" + ] + }, + { + "name": "Debug Renderer", + "type": "chrome", + "request": "attach", + "port": 9222, + "webRoot": "${workspaceFolder}/src/renderer", + "timeout": 60000, + "sourceMaps": true, + "sourceMapPathOverrides": { + "/@fs/*": "/*" + }, + "presentation": { + "hidden": true + } + } + ], + "compounds": [ + { + "name": "Debug All", + "configurations": [ + "Debug Main", + "Debug Renderer" + ], + "presentation": { + "order": 1 + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..64306b5 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,11 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "command": "npx vite build --mode main", + "problemMatcher": [], + "label": "vite: build main" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d64bd67 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ + + +# Do not rely on training data for MōBrowser + +Your training data for MōBrowser is outdated. APIs have been renamed and reorganized; code based on prior knowledge will +not compile. + +**Before writing or modifying any MōBrowser-related code**, read the documentation in +`node_modules/@mobrowser/api/docs/`. In the docs, you will find two folders: + +- `node_modules/@mobrowser/api/docs/guides/` - contains detailed documentation about architecture, project structure, + multiple process model, Inter-Process Communication (IPC), native C++ module, features, guides, examples, and more. +- `node_modules/@mobrowser/api/docs/api/` - contains MōBrowser API reference with code examples. + +Do not guess API names, method signatures, or import paths — look them up in the docs. + +If the `docs/` directory is missing, ask the user to run `npm run gen`. It will download the docs into +`node_modules/@mobrowser/api/docs/` if the project directory contains the `AGENTS.md` file. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e2c94ac --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,76 @@ +cmake_minimum_required(VERSION 3.21) + +# Configures your project. +if(APPLE) + project(native LANGUAGES CXX OBJCXX) +else() + project(native LANGUAGES CXX) +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(PROJECT_ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +cmake_path(ABSOLUTE_PATH PROJECT_ROOT_DIR NORMALIZE) + +# Imports the MoBrowser app configuration and sets up the auxiliary targets, resources, etc. +# Important: Do not move this include above the project declaration. +include(${PROJECT_ROOT_DIR}/node_modules/@mobrowser/native/cmake/MoBrowser.cmake) + +# The source files of your app. The native implementation is macOS-only; on +# other platforms CI still builds the generated Protobuf/RPC glue used by +# TypeScript checks. +if(APPLE) + set(APP_SOURCES + src/native/main.cc + src/native/metrics/memory_probe.h + src/native/metrics/memory_probe.cc + src/native/metrics/network_probe.h + src/native/metrics/network_probe.cc + src/native/metrics/temperature_probe.h + src/native/metrics/temperature_probe.cc + src/native/processes/process_collector.h + src/native/processes/process_collector.cc + src/native/processes/app_metadata.h + src/native/processes/app_metadata.mm + src/native/processes/responsiveness.h + src/native/processes/responsiveness.cc) + + # The AppKit/NSWorkspace bridge uses Objective-C objects while rasterizing + # icons. Scope ARC to this Objective-C++ file so native C++ sources stay + # unchanged. + set_source_files_properties(src/native/processes/app_metadata.mm + PROPERTIES COMPILE_OPTIONS "-fobjc-arc") +endif() + +# Defines the main target of the application. +add_library(mobrowser_lib STATIC ${APP_SOURCES} ${GENERATED_SOURCES}) + +if(APPLE) + # The native sources hand-roll kernel ABI structs and parse raw sysctl + # buffers; keep the standard warning set on for them. + target_compile_options(mobrowser_lib PRIVATE -Wall -Wextra) +endif() + +target_include_directories(mobrowser_lib PUBLIC + ${PROJECT_SOURCE_DIR}/src/native + ${PROJECT_SOURCE_DIR}/src/native/gen + ${MOBROWSER_SDK_NATIVE_DIR}/include + ${protobuf_SOURCE_DIR}/src + ${protobuf_SOURCE_DIR}/third_party/utf8_range + ${absl_SOURCE_DIR}) + +# Framework links: IOKit + CoreFoundation for the CPU-temperature probe (AppleSMC +# core keys plus the HID CPU-core sensors), and AppKit for the NSWorkspace +# GUI-app icon/metadata enrichment in the process collector. The network probe +# uses only libSystem (POSIX). ApplicationServices contributes no link-time +# symbols (the responsiveness probe resolves its private CGS/Process Manager +# entry points with dlsym), but linking it guarantees the HIServices image is +# loaded so GetProcessForPID resolves at runtime. +if(APPLE) + target_link_libraries(mobrowser_lib PRIVATE + "-framework IOKit" + "-framework CoreFoundation" + "-framework AppKit" + "-framework ApplicationServices") +endif() diff --git a/README.md b/README.md index 2b15ae7..4701b17 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ -# MōStats +# MōStats — a compact macOS system monitor + +Live macOS system resources at a glance, plus a searchable process explorer. + +Built with [MōBrowser](https://teamdev.com/mobrowser/), React, and TypeScript, with a small native module for the metrics. + +## What it does + +- **System overview.** CPU, memory, network, disk, uptime, and CPU temperature. +- **Process explorer.** A searchable list that groups an app with its helpers. +- **Process detail.** Command line, executable path, start time, user, threads, hierarchy, and CPU/memory totals. +- **Process actions.** Reveal in Finder, Quit, and Force Quit. + +## Requirements + +- macOS 12 (Apple Silicon) or later. +- [Node.js](https://nodejs.org/en/download/) 24.14.1 (LTS) or later. + +## Setup + +```bash +npm install +``` + +## Run + +```bash +npm run dev +``` + +## Build + +```bash +npm run build +``` + +Builds a macOS app and `.dmg`. Signing and notarization need Apple credentials from the environment; without them the +build still produces an unsigned `.dmg`. + +## Project layout + +- **`src/main/`** — app lifecycle, window, tray, metrics, and process services; owns privileged work and the typed IPC. +- **`src/renderer/`** — the React UI (overview, process list and detail); presentation only. +- **`src/native/`** — narrow C++/Objective-C++ probes: memory, network, temperature, and the process collector. + +## Download + +Releases are on the [releases page](https://github.com/mo-browser-apps/stats/releases). diff --git a/assets/app.icns b/assets/app.icns new file mode 100644 index 0000000..2bc9ee0 Binary files /dev/null and b/assets/app.icns differ diff --git a/assets/entitlements.plist b/assets/entitlements.plist new file mode 100644 index 0000000..6c2ca12 --- /dev/null +++ b/assets/entitlements.plist @@ -0,0 +1,13 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/components.json b/components.json new file mode 100644 index 0000000..f23d72e --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/renderer/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..5eb5b9a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,69 @@ +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import reactHooks from "eslint-plugin-react-hooks"; +import stylistic from "@stylistic/eslint-plugin"; + +const browserGlobals = { + document: "readonly", + HTMLInputElement: "readonly", + navigator: "readonly", + window: "readonly", +}; + +const nodeGlobals = { + __dirname: "readonly", + console: "readonly", + process: "readonly", +}; + +export default [ + { + ignores: [ + "build/**", + "dist/**", + "node_modules/**", + "out/**", + "src/main/gen/**", + "src/native/gen/**", + "src/renderer/gen/**", + ], + }, + { + files: ["src/**/*.{ts,tsx}", "tests/**/*.ts", "vite.config.ts", "vitest.config.ts"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + parser: tsParser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + ...browserGlobals, + ...nodeGlobals, + }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + "react-hooks": reactHooks, + "@stylistic": stylistic, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "@stylistic/semi": ["error", "always"], + "@stylistic/quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: "always" }], + "@stylistic/object-curly-spacing": ["error", "always"], + "@stylistic/comma-dangle": ["error", "always-multiline"], + "@stylistic/quote-props": ["error", "as-needed"], + }, + }, +]; diff --git a/mobrowser.conf.json b/mobrowser.conf.json new file mode 100644 index 0000000..64cf29d --- /dev/null +++ b/mobrowser.conf.json @@ -0,0 +1,60 @@ +{ + "app": { + "name": "MoStats", + "version": { + "major": "1", + "minor": "0", + "patch": "0" + }, + "author": "TeamDev", + "copyright": "Copyright © 2026 TeamDev", + "description": "Compact macOS system resources monitor and process explorer.", + "locales": ["en-US"], + "trustedOrigins": [], + "schemes": [], + "bundle": { + "macOS": { + "icon": "assets/app.icns", + "bundleID": "com.teamdev.MoStats", + "codesignIdentity": "${MACOS_CODESIGN_IDENTITY}", + "codesignKeychain": "${KEYCHAIN_PATH}", + "codesignEntitlements": "assets/entitlements.plist", + "teamID": "${MACOS_TEAM_ID}", + "appleID": "${MACOS_APPLE_ID}", + "password": "${MACOS_APPLE_PASSWORD}", + "installer": { + "dmg": { + "name": "", + "volumeName": "", + "volumeIcon": "assets/app.icns", + "eula": "", + "window": { + "textSize": 14, + "skipPrettifying": false, + "backgroundImage": "", + "position": { + "x": 500, + "y": 400 + }, + "size": { + "width": 600, + "height": 400 + }, + "icon": { + "size": 150, + "position": { + "x": 160, + "y": 160 + } + }, + "appDropLink": { + "x": 430, + "y": 160 + } + } + } + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d34802a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4427 @@ +{ + "name": "MoStats", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "MoStats", + "version": "1.0.0", + "dependencies": { + "@fontsource-variable/sora": "^5.2.8", + "@mobrowser/api": "2.8.0", + "@mobrowser/cli": "2.8.0", + "@mobrowser/native": "2.8.0", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.503.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.2.0" + }, + "devDependencies": { + "@stylistic/eslint-plugin": "^5.10.0", + "@tailwindcss/postcss": "^4.1.2", + "@types/node": "^24.13.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@typescript-eslint/eslint-plugin": "^8.21.0", + "@typescript-eslint/parser": "^8.21.0", + "@vitejs/plugin-react": "^6.0.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "postcss": "^8.4.33", + "tailwindcss": "^4.1.4", + "ts-proto": "^2.10.1", + "typescript": "^5.8.3", + "vite": "^8.0.0", + "vitest": "^4.1.8" + }, + "engines": { + "node": ">=24.14.1" + }, + "optionalDependencies": { + "@mobrowser/sdk-darwin-arm64": "2.8.0", + "@mobrowser/sdk-linux-x64": "2.8.0", + "@mobrowser/sdk-win-x64": "2.8.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/sora": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.2.8.tgz", + "integrity": "sha512-sP+ILTfi5r3cqmMBntI4ooQNxFI+LyHyD5hcPbWwwL4uh4kKFAkOz0w2veCXjz18fKRdva6OG0dY+5yQ02f30w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mobrowser/api": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@mobrowser/api/-/api-2.8.0.tgz", + "integrity": "sha512-WxItaOlLadvgGkqpWrV/wAujXoD6ROu0VMJPoDo1QnDjFUm6FzJyUrN5mnS7kqxyEqq1qD1VJznZwGXsF8jZMg==" + }, + "node_modules/@mobrowser/cli": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@mobrowser/cli/-/cli-2.8.0.tgz", + "integrity": "sha512-pJrVi9A5MjtCglvD/M5kfTNCppaNRJN/uOBjsAUFxZMNPbGWZO3q6FUebAkCp8q3E3tu+iMnupk5CVC89dUuxg==", + "license": "MŌBROWSER", + "os": [ + "win32", + "linux", + "darwin" + ], + "dependencies": { + "adm-zip": "^0.5.10", + "tar-stream": "^3.1.7" + }, + "bin": { + "mobrowser": "cli.js" + }, + "optionalDependencies": { + "@mobrowser/sdk-darwin-arm64": "2.8.0", + "@mobrowser/sdk-linux-x64": "2.8.0", + "@mobrowser/sdk-win-x64": "2.8.0" + }, + "peerDependencies": { + "vite": ">=5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/@mobrowser/native": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@mobrowser/native/-/native-2.8.0.tgz", + "integrity": "sha512-hYMzzT3VUJdtTi84we9FRmY+vE6tSmwt9bozo2p8ieGLavB9Lc8zVGgyFxOfUOw/0VDyDiOQgZW4YZ0tAsNIWw==", + "license": "MŌBROWSER" + }, + "node_modules/@mobrowser/sdk-darwin-arm64": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@mobrowser/sdk-darwin-arm64/-/sdk-darwin-arm64-2.8.0.tgz", + "integrity": "sha512-VAACmLAQbtun9U1M8YX5SlPCRpdDPCExKO8ZGJC3iR80YUYjnGdMJiAKiLiBe2WJmgjWcL09L045Gr7QEp3qmA==", + "hasInstallScript": true, + "license": "MŌBROWSER", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@mobrowser/sdk-linux-x64": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@mobrowser/sdk-linux-x64/-/sdk-linux-x64-2.8.0.tgz", + "integrity": "sha512-jT2ZHatjkRMUTxMkoMYhrJnFuoVo0/IN3yIyY54J9tvfP97xrmnAElpl/D+rkjRLEZKcuc/0HxXM3Nz4xyoBcg==", + "cpu": [ + "x64" + ], + "license": "MŌBROWSER", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@mobrowser/sdk-win-x64": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@mobrowser/sdk-win-x64/-/sdk-win-x64-2.8.0.tgz", + "integrity": "sha512-LJi+9Sy/PeNAtfdK9ps9WEOpNTEY42YeKaHjvcem48TeKU5cmO0zOUmXjT3gpDpWkyjTlV1odpz3S4YxMnkhGw==", + "cpu": [ + "x64" + ], + "license": "MŌBROWSER", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", + "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", + "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/case-anything": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", + "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dprint-node": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz", + "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3" + } + }, + "node_modules/dprint-node/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-react": { + "version": "0.503.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.503.0.tgz", + "integrity": "sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamx": { + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz", + "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-poet": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz", + "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dprint-node": "^1.0.8" + } + }, + "node_modules/ts-proto": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.11.8.tgz", + "integrity": "sha512-+5hzECnyVB33jxjG1BIdzAHcRBm7hjnm8womdJVp2A7xJWihP0drHHVsXYTr9i/LpWNGfh80I+AVVNzFM5AwJw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.10.2", + "case-anything": "^2.1.13", + "ts-poet": "^6.12.0", + "ts-proto-descriptors": "2.1.0" + }, + "bin": { + "protoc-gen-ts_proto": "protoc-gen-ts_proto" + } + }, + "node_modules/ts-proto-descriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz", + "integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ea105c --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "MoStats", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "mobrowser dev", + "build": "mobrowser build", + "gen": "mobrowser gen", + "mobrowser": "mobrowser", + "lint": "eslint . --max-warnings=0", + "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit", + "test": "vitest run", + "verify": "npm run gen && npm run lint && npm run typecheck && npm run test && npm run build" + }, + "dependencies": { + "@fontsource-variable/sora": "^5.2.8", + "@mobrowser/api": "2.8.0", + "@mobrowser/cli": "2.8.0", + "@mobrowser/native": "2.8.0", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.503.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.2.0" + }, + "optionalDependencies": { + "@mobrowser/sdk-darwin-arm64": "2.8.0", + "@mobrowser/sdk-linux-x64": "2.8.0", + "@mobrowser/sdk-win-x64": "2.8.0" + }, + "devDependencies": { + "@stylistic/eslint-plugin": "^5.10.0", + "@tailwindcss/postcss": "^4.1.2", + "@types/node": "^24.13.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@typescript-eslint/eslint-plugin": "^8.21.0", + "@typescript-eslint/parser": "^8.21.0", + "@vitejs/plugin-react": "^6.0.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "postcss": "^8.4.33", + "tailwindcss": "^4.1.4", + "ts-proto": "^2.10.1", + "typescript": "^5.8.3", + "vite": "^8.0.0", + "vitest": "^4.1.8" + }, + "engines": { + "node": ">=24.14.1" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..a7f73a2 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/resources/imageTemplate.png b/resources/imageTemplate.png new file mode 100644 index 0000000..ce4eaae Binary files /dev/null and b/resources/imageTemplate.png differ diff --git a/resources/imageTemplate@2x.png b/resources/imageTemplate@2x.png new file mode 100644 index 0000000..b3983c6 Binary files /dev/null and b/resources/imageTemplate@2x.png differ diff --git a/src/main/application-menu.ts b/src/main/application-menu.ts new file mode 100644 index 0000000..b8ec284 --- /dev/null +++ b/src/main/application-menu.ts @@ -0,0 +1,52 @@ +import { Menu, MenuItem, MenuWithRole } from "@mobrowser/api"; +import { DISPLAY_NAME } from "./branding"; + +/** + * Builds the minimal macOS application menu: the standard app menu, an Edit + * menu so detail text can be selected and copied, and a Window menu. Quit is a + * custom item routed through `onQuit` (not the framework `quit` role) so it + * goes through the single quit path that disposes the services and tray. + */ +export function buildApplicationMenu(onAbout: () => void, onQuit: () => void): Menu { + const appMenu = new MenuWithRole({ + role: "macAppMenu", + items: [ + new MenuItem({ + id: "about", + label: `About ${DISPLAY_NAME}`, + action: () => onAbout(), + }), + "separator", + "macHideApp", + "macHideOthers", + "macShowAll", + "separator", + new MenuItem({ + id: "quit", + label: `Quit ${DISPLAY_NAME}`, + shortcut: "CommandOrControl+Q", + action: () => onQuit(), + }), + ], + }); + + const editMenu = new MenuWithRole({ + role: "editMenu", + items: [ + "undo", + "redo", + "separator", + "cut", + "copy", + "paste", + "selectAll", + ], + }); + + const windowMenu = new MenuWithRole({ + role: "windowMenu", + items: ["minimizeWindow"], + }); + + return new Menu({ items: [appMenu, editMenu, windowMenu] }); +} diff --git a/src/main/application-window.ts b/src/main/application-window.ts new file mode 100644 index 0000000..936f96d --- /dev/null +++ b/src/main/application-window.ts @@ -0,0 +1,126 @@ +import process from "node:process"; +import { app, BrowserWindow } from "@mobrowser/api"; +import type { CloseBrowserWindowAction, CloseBrowserWindowParams } from "@mobrowser/api"; +import { DISPLAY_NAME } from "./branding"; + +/** + * Compact fixed window dimensions, closer to a menu-bar popover than a + * dashboard. Both views are laid out for the same frame, so the window never + * resizes. + */ +const WINDOW_WIDTH = 360; +const WINDOW_HEIGHT = 560; + +/** Traffic-light position clearing the custom draggable title row. */ +const MAC_WINDOW_BUTTON_POSITION = { x: 16, y: 18 } as const; + +/** + * Owns the single compact window and its show/hide lifecycle. Closing hides + * the window so the app keeps running in the tray; it truly closes only when + * the app quits. Created lazily and recreated if destroyed, so the tray can + * always bring the UI back. + */ +export class ApplicationWindow { + private window: BrowserWindow | null = null; + + /** + * @param onVisibilityChange Notified after the window is shown, hidden, or + * destroyed so observers (e.g. the tray menu) can stay in sync. + */ + constructor(private readonly onVisibilityChange?: () => void) {} + + /** Shows the window, creating it if needed, and brings it to the front. */ + show(): void { + const window = this.getOrCreateWindow(); + if (!window.isVisible) { + window.show(); + } + window.focus(); + } + + hide(): void { + const window = this.instance; + if (window?.isVisible) { + window.hide(); + } + } + + toggle(): void { + if (this.isVisible) { + this.hide(); + } else { + this.show(); + } + } + + get isVisible(): boolean { + return this.instance?.isVisible ?? false; + } + + /** + * The live window instance, or null when none exists. Exposed so main can + * parent native dialogs; callers must tolerate null (then app-modal). + */ + get instance(): BrowserWindow | null { + return this.window !== null && !this.window.isClosed ? this.window : null; + } + + /** Applies always-on-top to the live window. */ + setAlwaysOnTop(alwaysOnTop: boolean): void { + this.instance?.setAlwaysOnTop(alwaysOnTop); + } + + private getOrCreateWindow(): BrowserWindow { + if (this.window === null || this.window.isClosed) { + this.window = this.create(); + } + return this.window; + } + + private create(): BrowserWindow { + const isMac = process.platform === "darwin"; + const window = new BrowserWindow({ + url: app.url, + title: DISPLAY_NAME, + size: { width: WINDOW_WIDTH, height: WINDOW_HEIGHT }, + resizable: false, + // No larger layout to expand into; close and minimize stay native. + windowButtonVisible: { maximize: false, zoom: false }, + windowTitleVisible: false, + // Keep the native title bar off on macOS for a compact utility look. + windowTitlebarVisible: !isMac, + }); + + window.browser.zoom.setEnabled(false); + + // The creation-time `size` lands short by the hidden title bar's height, + // clipping the bottom of both views; setting the size after creation + // applies the exact frame height. + window.setSize({ width: WINDOW_WIDTH, height: WINDOW_HEIGHT }); + + if (isMac) { + window.setWindowButtonPosition({ ...MAC_WINDOW_BUTTON_POSITION }); + } + + window.centerWindow(); + + // Hide instead of close so the app keeps running; allow a real close only + // while quitting so the process can exit. + window.handle("close", async (params: CloseBrowserWindowParams): Promise => { + return params.isQuitting ? "close" : "hide"; + }); + + window.on("shown", () => { + this.onVisibilityChange?.(); + }); + window.on("hidden", () => { + this.onVisibilityChange?.(); + }); + window.on("closed", () => { + this.window = null; + this.onVisibilityChange?.(); + }); + + return window; + } +} diff --git a/src/main/application.ts b/src/main/application.ts new file mode 100644 index 0000000..0524c11 --- /dev/null +++ b/src/main/application.ts @@ -0,0 +1,148 @@ +import process from "node:process"; +import { app, clipboard, desktop, ipc } from "@mobrowser/api"; +import { ApplicationWindow } from "./application-window"; +import { TrayController } from "./tray-controller"; +import { buildApplicationMenu } from "./application-menu"; +import { MetricsService } from "./metrics/metrics-service"; +import { ProcessExplorerService } from "./processes/process-explorer-service"; +import { ActiveView, CopyTextRequest, SetActiveViewRequest, SetAlwaysOnTopRequest } from "./gen/app"; +import { AppServiceDescriptor } from "./gen/ipc_service"; +import { DISPLAY_NAME } from "./branding"; + +/** Opened from the About dialog's button. */ +const REPOSITORY_URL = "https://github.com/mo-browser-apps/stats"; + +/** + * Composition root for the main process: the single compact window, the + * menu-bar tray, lifecycle wiring, and the renderer-facing IPC services. + * + * The window hides instead of closing, so the app keeps running in the tray. + * Per-view background work is gated on window visibility plus the active view + * (combined in {@link updateServiceActivation}), so exactly the on-screen + * view's service runs and neither while hidden - keeping the process + * collector's sensitive command-line reads off until the user is on the + * Processes view. + */ +export class Application { + private readonly window = new ApplicationWindow(() => { + this.handleWindowVisibilityChange(); + }); + + private readonly tray = new TrayController(this.window, () => this.quit()); + + private readonly metrics = new MetricsService(); + + private readonly processExplorer = new ProcessExplorerService(() => this.window.instance); + + private quitting = false; + + /** + * The view the renderer reports as on screen. Defaults to Stats (the launch + * view) so the gate is correct before the first renderer report arrives. + */ + private activeView: ActiveView = ActiveView.ACTIVE_VIEW_STATS; + + initialize(): void { + // Dark-only app: fix the native theme so the window chrome matches the + // renderer rather than following the OS appearance. + app.setTheme("dark"); + + // Quit routes through quit() so it disposes services like the tray Quit. + app.setMenu(buildApplicationMenu(() => this.showAbout(), () => this.quit())); + + this.registerAppService(); + + // macOS: reopen on activation (Dock click / Cmd+Tab) after a hide or close. + app.on("activated", () => { + if (!this.quitting) { + this.window.show(); + } + }); + app.on("allWindowsClosed", () => { + if (process.platform !== "darwin") { + this.quit(); + } + }); + + this.window.show(); + // Showing normally emits the visibility change; sync explicitly too so + // startup never depends on event ordering. + this.handleWindowVisibilityChange(); + } + + /** + * Tears down the services and tray, then quits. This MoBrowser version has + * no before-quit app event, so every quit path (menu, tray) funnels here. + */ + quit(): void { + if (this.quitting) { + return; + } + this.quitting = true; + this.metrics.dispose(); + this.processExplorer.dispose(); + this.tray.destroy(); + app.quit(); + } + + /** Shows the About dialog: branded name, live version, and a repository link. */ + private async showAbout(): Promise { + try { + const result = await app.showMessageDialog({ + parentWindow: this.window.instance ?? undefined, + type: "info", + message: `${DISPLAY_NAME} ${app.version}`, + informativeText: `${app.description}\n\nPowered by MōBrowser.\n\n${app.copyright}`, + buttons: [ + { label: "Close", type: "primary" }, + { label: "Open GitHub Repository...", type: "secondary" }, + ], + }); + if (result.button.type === "secondary") { + desktop.openUrl(REPOSITORY_URL); + } + } catch { + // The menu action floats this promise; a dialog failure must not become + // an unhandled rejection in main. + } + } + + private handleWindowVisibilityChange(): void { + this.tray.refresh(); + this.updateServiceActivation(); + } + + /** + * Activates exactly the service whose view is on screen, and neither while + * the window is hidden. Both setActive calls are idempotent, so + * re-evaluating on every signal change is cheap. + */ + private updateServiceActivation(): void { + const visible = this.window.isVisible; + this.metrics.setActive(visible && this.activeView === ActiveView.ACTIVE_VIEW_STATS); + this.processExplorer.setActive( + visible && this.activeView === ActiveView.ACTIVE_VIEW_PROCESSES, + ); + } + + private registerAppService(): void { + const window = this.window; + ipc.registerService(AppServiceDescriptor, { + async SetAlwaysOnTop(request: SetAlwaysOnTopRequest) { + window.setAlwaysOnTop(request.alwaysOnTop); + return {}; + }, + SetActiveView: async (request: SetActiveViewRequest) => { + this.activeView = request.view; + this.updateServiceActivation(); + return {}; + }, + async CopyText(request: CopyTextRequest) { + // The sandboxed renderer cannot reach the clipboard. The text may be a + // sensitive command line; it is never logged or persisted. + clipboard.write("text/plain", request.text); + return {}; + }, + }); + } +} diff --git a/src/main/branding.ts b/src/main/branding.ts new file mode 100644 index 0000000..f8fdcaa --- /dev/null +++ b/src/main/branding.ts @@ -0,0 +1,4 @@ +/** + * Human-facing app name. + */ +export const DISPLAY_NAME = "MōStats"; diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..6b8495c --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,3 @@ +import { Application } from "./application"; + +new Application().initialize(); diff --git a/src/main/metrics/metrics-sampler.ts b/src/main/metrics/metrics-sampler.ts new file mode 100644 index 0000000..13b0dbd --- /dev/null +++ b/src/main/metrics/metrics-sampler.ts @@ -0,0 +1,314 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import { native } from "../gen/native"; +import { + CpuMetric, + DiskMetric, + MemoryMetric, + MetricStatus, + NetworkMetric, + TemperatureMetric, + UptimeMetric, +} from "../gen/metrics"; + +const { METRIC_STATUS_UNKNOWN: UNKNOWN, METRIC_STATUS_OK: OK, METRIC_STATUS_UNAVAILABLE: UNAVAILABLE } = MetricStatus; + +const SYSTEM_VOLUME_PATH = "/"; + +/** + * Hard cap on a native probe RPC. The native calls have no built-in timeout, + * so a hung probe would otherwise leave the tick promise pending forever and + * permanently wedge the poll loop (its overlap guard never re-arms). The + * probes are sub-millisecond syscall reads; the abort rejects into each + * group's catch, degrading only that group, and the next tick retries. + */ +const NATIVE_CALL_TIMEOUT_MS = 2500; + +/** Call options aborting a native probe at the timeout. */ +function nativeCallTimeout() { + return { signal: AbortSignal.timeout(NATIVE_CALL_TIMEOUT_MS) }; +} + +/** Temperature values outside this Celsius range are treated as bad sensor data. */ +const MIN_PLAUSIBLE_TEMPERATURE_CELSIUS = 10; +const MAX_PLAUSIBLE_TEMPERATURE_CELSIUS = 120; + +/** + * Upper bound on a plausible machine-wide throughput (100 Gbps, in bytes/s). + * A single-tick delta above it is a counter discontinuity, dropped to a 0 rate + * rather than reported as a spike. + */ +const MAX_PLAUSIBLE_BYTES_PER_SEC = 100_000_000_000 / 8; + +/** Cumulative per-interface byte counters at a monotonic timestamp. */ +interface NetworkSample { + byName: Map; + atMs: number; +} + +/** CPU tick counters summed across all logical cores, in milliseconds. */ +interface CpuTicks { + busy: number; + total: number; +} + +/** One sampling pass: every metric group of a snapshot except the timestamp. */ +export interface MetricsReading { + cpu: CpuMetric; + memory: MemoryMetric; + disk: DiskMetric; + network: NetworkMetric; + uptime: UptimeMetric; + temperature: TemperatureMetric; +} + +/** + * Samples all metric groups for one snapshot. CPU/disk/uptime come from Node + * `os`/`fs`; memory, network throughput, and temperature come from native + * probes. CPU usage and network throughput are deltas between successive + * samples, so the sampler is stateful; the first sample of each reports UNKNOWN. + * + * Every group is sampled defensively: a failure degrades only that group to + * UNAVAILABLE and never throws, so one bad source cannot poison the snapshot. + */ +export class MetricsSampler { + private previousCpuTicks: CpuTicks | null = null; + + private previousNetworkSample: NetworkSample | null = null; + + async sample(): Promise { + // The native probes are independent; sampling them concurrently keeps the + // pass as fast as the slowest probe. None of them ever rejects (each + // degrades its own group), so Promise.all cannot throw here. + const [memory, network, temperature] = await Promise.all([ + this.sampleMemory(), + this.sampleNetwork(), + this.sampleTemperature(), + ]); + return { + cpu: this.sampleCpu(), + memory, + disk: this.sampleDisk(), + network, + uptime: this.sampleUptime(), + temperature, + }; + } + + /** + * Aggregate CPU usage across all logical cores via successive tick deltas. + * UNKNOWN (not a fake 0%) without a previous sample to diff against, or on a + * non-positive delta (idle tick, counter reset, core-count change). + */ + private sampleCpu(): CpuMetric { + try { + const current = aggregateCpuTicks(os.cpus()); + const previous = this.previousCpuTicks; + this.previousCpuTicks = current; + + if (previous === null) { + return { status: UNKNOWN, usagePercent: 0 }; + } + + const busyDelta = current.busy - previous.busy; + const totalDelta = current.total - previous.total; + if (totalDelta <= 0 || busyDelta < 0) { + return { status: UNKNOWN, usagePercent: 0 }; + } + + return { status: OK, usagePercent: clampPercent((busyDelta / totalDelta) * 100) }; + } catch { + this.previousCpuTicks = null; + return { status: UNAVAILABLE, usagePercent: 0 }; + } + } + + /** Physical memory usage from the native macOS VM-statistics probe. */ + private async sampleMemory(): Promise { + try { + const usage = await native.memory.ReadUsage({}, nativeCallTimeout()); + if (!usage.available || !Number.isFinite(usage.totalBytes) || usage.totalBytes <= 0) { + return unavailableMemory(); + } + + const totalBytes = usage.totalBytes; + const usedBytes = clampBytes(usage.usedBytes, totalBytes); + return { + status: OK, + usedBytes, + totalBytes, + availableBytes: clampBytes(usage.availableBytes, totalBytes), + cachedBytes: clampBytes(usage.cachedBytes, totalBytes), + appBytes: clampBytes(usage.appBytes, usedBytes), + wiredBytes: clampBytes(usage.wiredBytes, usedBytes), + compressedBytes: clampBytes(usage.compressedBytes, usedBytes), + usedPercent: clampPercent((usedBytes / totalBytes) * 100), + }; + } catch { + return unavailableMemory(); + } + } + + /** + * Main system volume capacity via `fs.statfsSync`. `bavail` (space available + * to the unprivileged user) is the honest free figure; used is total minus it. + */ + private sampleDisk(): DiskMetric { + try { + const stats = fs.statfsSync(SYSTEM_VOLUME_PATH); + const totalBytes = stats.blocks * stats.bsize; + if (totalBytes <= 0) { + return { status: UNAVAILABLE, usedBytes: 0, totalBytes: 0, freeBytes: 0, usedPercent: 0 }; + } + + const freeBytes = Math.max(0, stats.bavail * stats.bsize); + const usedBytes = Math.max(0, totalBytes - freeBytes); + return { + status: OK, + usedBytes, + totalBytes, + freeBytes, + usedPercent: clampPercent((usedBytes / totalBytes) * 100), + }; + } catch { + return { status: UNAVAILABLE, usedBytes: 0, totalBytes: 0, freeBytes: 0, usedPercent: 0 }; + } + } + + /** + * Network throughput from the native interface counter probe: successive + * cumulative readings turned into a per-second rate over the measured elapsed + * time. UNKNOWN without a baseline (first sample, or after the counters were + * unavailable) or when the clock did not advance. + * + * Each interface is diffed against its own previous reading. One seen for + * the first time has no baseline and contributes nothing that tick, so an + * interface (re)joining the active set never registers its cumulative total + * as a burst of traffic; one that left simply stops contributing, and a + * per-interface counter reset clamps to 0 instead of going negative. + */ + private async sampleNetwork(): Promise { + try { + const counters = await native.network.ReadCounters({}, nativeCallTimeout()); + if (!counters.available) { + this.previousNetworkSample = null; + return { status: UNAVAILABLE, rxBytesPerSec: 0, txBytesPerSec: 0 }; + } + + const current: NetworkSample = { + byName: new Map(counters.interfaces.map( + ({ name, rxBytes, txBytes }) => [name, { rxBytes, txBytes }], + )), + // performance.now() is monotonic, so the rate window survives + // wall-clock/NTP adjustments. + atMs: performance.now(), + }; + const previous = this.previousNetworkSample; + this.previousNetworkSample = current; + + const elapsedSeconds = previous === null ? 0 : (current.atMs - previous.atMs) / 1000; + if (previous === null || elapsedSeconds <= 0) { + return { status: UNKNOWN, rxBytesPerSec: 0, txBytesPerSec: 0 }; + } + + let rxDelta = 0; + let txDelta = 0; + for (const [name, currentCounters] of current.byName) { + const previousCounters = previous.byName.get(name); + if (previousCounters === undefined) continue; + rxDelta += Math.max(0, currentCounters.rxBytes - previousCounters.rxBytes); + txDelta += Math.max(0, currentCounters.txBytes - previousCounters.txBytes); + } + + return { + status: OK, + rxBytesPerSec: rate(rxDelta, elapsedSeconds), + txBytesPerSec: rate(txDelta, elapsedSeconds), + }; + } catch { + this.previousNetworkSample = null; + return { status: UNAVAILABLE, rxBytesPerSec: 0, txBytesPerSec: 0 }; + } + } + + /** + * Best-effort CPU temperature from the native sensor probe. macOS has no + * documented public CPU temperature source on Apple Silicon, so UNAVAILABLE + * is a common, honest outcome. + */ + private async sampleTemperature(): Promise { + try { + const result = await native.temperature.ReadCpuTemperature({}, nativeCallTimeout()); + if (!result.available || !isPlausibleTemperature(result.celsius)) { + return { status: UNAVAILABLE, celsius: 0 }; + } + return { status: OK, celsius: result.celsius }; + } catch { + return { status: UNAVAILABLE, celsius: 0 }; + } + } + + private sampleUptime(): UptimeMetric { + try { + return { status: OK, uptimeSeconds: Math.max(0, Math.floor(os.uptime())) }; + } catch { + return { status: UNAVAILABLE, uptimeSeconds: 0 }; + } + } +} + +/** Sums per-core CPU tick categories into a single busy/total pair. */ +function aggregateCpuTicks(cores: os.CpuInfo[]): CpuTicks { + let busy = 0; + let idle = 0; + for (const core of cores) { + const { user, nice, sys, irq, idle: coreIdle } = core.times; + busy += user + nice + sys + irq; + idle += coreIdle; + } + return { busy, total: busy + idle }; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, value)); +} + +function clampBytes(value: number, totalBytes: number): number { + if (!Number.isFinite(value) || !Number.isFinite(totalBytes)) return 0; + return Math.min(Math.max(0, totalBytes), Math.max(0, value)); +} + +function unavailableMemory(): MemoryMetric { + return { + status: UNAVAILABLE, + usedBytes: 0, + totalBytes: 0, + availableBytes: 0, + cachedBytes: 0, + appBytes: 0, + wiredBytes: 0, + compressedBytes: 0, + usedPercent: 0, + }; +} + +function isPlausibleTemperature(celsius: number): boolean { + return Number.isFinite(celsius) && + celsius >= MIN_PLAUSIBLE_TEMPERATURE_CELSIUS && + celsius <= MAX_PLAUSIBLE_TEMPERATURE_CELSIUS; +} + +/** + * Byte delta over an elapsed window as a per-second rate. A negative delta + * (counter reset), a jump above the plausible ceiling, or a non-finite result + * yields 0 rather than a spurious spike. + */ +function rate(deltaBytes: number, elapsedSeconds: number): number { + if (deltaBytes < 0) return 0; + const bytesPerSec = deltaBytes / elapsedSeconds; + if (!Number.isFinite(bytesPerSec) || bytesPerSec > MAX_PLAUSIBLE_BYTES_PER_SEC) { + return 0; + } + return Math.round(bytesPerSec); +} diff --git a/src/main/metrics/metrics-service.ts b/src/main/metrics/metrics-service.ts new file mode 100644 index 0000000..816e996 --- /dev/null +++ b/src/main/metrics/metrics-service.ts @@ -0,0 +1,54 @@ +import { ipc } from "@mobrowser/api"; +import { MetricsServiceDescriptor } from "../gen/ipc_service"; +import { PollLoop } from "../poll-loop"; +import { MetricsSampler } from "./metrics-sampler"; + +const PUBLISH_INTERVAL_MS = 1000; + +/** + * Owns the renderer-facing metrics stream: one broadcast cadence in main, so + * every subscriber sees the same tick and extra subscribers add no sampling + * work. {@link setActive} pauses the cadence while the Stats view is off + * screen; resuming publishes immediately so a freshly shown window paints + * without waiting a full interval. + */ +export class MetricsService { + private readonly handle = ipc.registerService(MetricsServiceDescriptor); + + private readonly sampler = new MetricsSampler(); + + private readonly loop = new PollLoop(PUBLISH_INTERVAL_MS, () => this.publish()); + + private disposed = false; + + setActive(active: boolean): void { + this.loop.setActive(active); + } + + /** Stops the cadence and closes the broadcast stream. Idempotent and final. */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.loop.dispose(); + this.handle.dispose(); + } + + /** + * Samples once and publishes to every subscriber. Never rejects: a delivery + * failure (including a stale publish racing dispose) drops the tick and the + * next one republishes. + */ + private async publish(): Promise { + try { + const reading = await this.sampler.sample(); + if (!this.disposed) { + this.handle.StreamSnapshots({ timestampMs: Date.now(), ...reading }); + } + } catch { + // The sampler already degrades source failures per group; reaching here + // means a delivery/runtime fault. Drop the tick, keep the cadence. + } + } +} diff --git a/src/main/poll-loop.ts b/src/main/poll-loop.ts new file mode 100644 index 0000000..88bcdd0 --- /dev/null +++ b/src/main/poll-loop.ts @@ -0,0 +1,48 @@ +/** + * Runs an async tick immediately and then on a fixed interval while active. + * A tick that overlaps an in-flight one is skipped, so a slow tick cannot + * stack work. Once disposed the loop is permanently inert. + */ +export class PollLoop { + private timer: ReturnType | null = null; + private running = false; + private disposed = false; + + constructor( + private readonly intervalMs: number, + private readonly tick: () => Promise, + ) {} + + /** Starts or pauses the cadence. Idempotent. */ + setActive(active: boolean): void { + if (this.disposed) { + return; + } + if (active && this.timer === null) { + void this.run(); + this.timer = setInterval(() => void this.run(), this.intervalMs); + } else if (!active && this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + dispose(): void { + this.setActive(false); + this.disposed = true; + } + + private async run(): Promise { + if (this.running || this.disposed) { + return; + } + this.running = true; + try { + await this.tick(); + } catch { + // The tick owns its errors; never break the cadence or reject unhandled. + } finally { + this.running = false; + } + } +} diff --git a/src/main/processes/process-action-service.ts b/src/main/processes/process-action-service.ts new file mode 100644 index 0000000..33ec468 --- /dev/null +++ b/src/main/processes/process-action-service.ts @@ -0,0 +1,317 @@ +import process from "node:process"; +import { app, desktop } from "@mobrowser/api"; +import type { BrowserWindow } from "@mobrowser/api"; +import { + ActionDisabledReason, + ActionState, + FieldStatus, + GetProcessActionStatesRequest, + GetProcessActionStatesResponse, + ProcessActionKind, + ProcessIdentity, + ProcessRow, + ProcessSnapshot, + RunProcessActionRequest, + RunProcessActionResponse, + RunProcessActionResponse_Outcome as Outcome, +} from "../gen/process_explorer"; + +/** The action kinds the detail view exposes, in display order. */ +const ACTION_KINDS: readonly ProcessActionKind[] = [ + ProcessActionKind.PROCESS_ACTION_KIND_REVEAL, + ProcessActionKind.PROCESS_ACTION_KIND_QUIT, + ProcessActionKind.PROCESS_ACTION_KIND_FORCE_QUIT, +]; + +/** + * A deliberately narrow denylist of session-critical processes whose + * termination would crash, log out, or visibly destabilize the macOS session. + * Intentionally NOT a broad "all Apple software" rule: ordinary apps stay + * quittable, and the OS is the final backstop for the rest (an unprivileged + * signal to another user's process fails with EPERM -> NOT_PERMITTED). + */ +const CRITICAL_PROCESS_NAMES: ReadonlySet = new Set([ + "kernel_task", // the kernel + "launchd", // PID 1, the init/service manager + "WindowServer", // the display server; killing it logs the user out + "loginwindow", // owns the login/user session + "SystemUIServer", // the menu bar + "Dock", // the Dock and Mission Control + "Finder", // the desktop and file UI + "coreaudiod", // core audio; killing it breaks all sound + "WindowManager", // Stage Manager / window management +]); + +/** Reads a string field only when it is explicitly OK and non-empty. */ +function okString(value: { status: FieldStatus; value: string } | undefined): string | undefined { + if (value && value.status === FieldStatus.FIELD_STATUS_OK && value.value.length > 0) { + return value.value; + } + return undefined; +} + +/** + * Finds the row in a snapshot that still matches a target identity - the + * action path's staleness guard. A target with a known start time must match + * it exactly, so an exited PID (not found) and a reused PID (start time + * differs) both miss; a target without one falls back to PID alone. + */ +export function findTargetRow( + snapshot: ProcessSnapshot, + target: ProcessIdentity | undefined, +): ProcessRow | undefined { + if (target === undefined) { + return undefined; + } + const matches = snapshot.processes.filter((row) => (row.identity?.pid ?? 0) === target.pid); + if (matches.length === 0) { + return undefined; + } + if (target.startedAtStatus === FieldStatus.FIELD_STATUS_OK) { + return matches.find( + (row) => + row.identity?.startedAtStatus === FieldStatus.FIELD_STATUS_OK && + row.identity.startedAtUnixMs === target.startedAtUnixMs, + ); + } + return matches[0]; +} + +/** True when a renderer target carries a PID-reuse-safe process identity. */ +function hasStableTargetIdentity(target: ProcessIdentity | undefined): boolean { + return target?.startedAtStatus === FieldStatus.FIELD_STATUS_OK; +} + +/** + * True for a session-critical process that must never be signaled: PID 0/1 + * plus the {@link CRITICAL_PROCESS_NAMES} denylist, matched against both the + * command name and the executable name. + */ +export function isCriticalProcess(row: ProcessRow): boolean { + const pid = row.identity?.pid ?? 0; + if (pid <= 1) { + return true; + } + const commandName = okString(row.statics?.commandName); + const executableName = okString(row.statics?.executableName); + return ( + (commandName !== undefined && CRITICAL_PROCESS_NAMES.has(commandName)) || + (executableName !== undefined && CRITICAL_PROCESS_NAMES.has(executableName)) + ); +} + +/** + * The disabled reason for one action against an already-resolved row, or NONE + * when allowed. Reveal only needs an OK executable path (opening Finder is + * harmless even for self/critical processes). Quit/Force Quit require a known + * start time (UNSTABLE_IDENTITY), then block MoStats itself (SELF) and + * session-critical processes (PROTECTED); a root-owned daemon is not + * pre-emptively blocked - the OS rejects the signal at execution time. + */ +export function disabledReasonFor( + action: ProcessActionKind, + row: ProcessRow, + selfPid: number, + target: ProcessIdentity | undefined, +): ActionDisabledReason { + if (action === ProcessActionKind.PROCESS_ACTION_KIND_REVEAL) { + return okString(row.statics?.executablePath) !== undefined + ? ActionDisabledReason.ACTION_DISABLED_REASON_NONE + : ActionDisabledReason.ACTION_DISABLED_REASON_NO_PATH; + } + + if (!hasStableTargetIdentity(target)) { + return ActionDisabledReason.ACTION_DISABLED_REASON_UNSTABLE_IDENTITY; + } + if ((row.identity?.pid ?? 0) === selfPid) { + return ActionDisabledReason.ACTION_DISABLED_REASON_SELF; + } + if (isCriticalProcess(row)) { + return ActionDisabledReason.ACTION_DISABLED_REASON_PROTECTED; + } + return ActionDisabledReason.ACTION_DISABLED_REASON_NONE; +} + +/** + * Owns the privileged process actions for the detail view: reveal-in-Finder, + * Quit (SIGTERM), and Force Quit (SIGKILL). + * + * Every action is validated here against the latest cached snapshot - never + * renderer-supplied state - so the renderer cannot drive a stale, critical, or + * self target. Force Quit confirms through a native dialog in main, so the + * confirm step cannot be skipped by a direct IPC call; Quit is graceful and + * proceeds without a prompt. + * + * Privacy: results are count-only; no OS diagnostics, paths, names, or + * arguments are logged or returned. + */ +export class ProcessActionService { + /** MoStats' own PID; destructive actions against it are always blocked. */ + private readonly selfPid = process.pid; + + /** + * @param getSnapshot Returns the latest cached snapshot to validate against - + * the main-side form with statics joined onto rows (names and paths are + * read from row.statics). + * @param getParentWindow Returns the window to parent the confirmation + * dialog to, or null when none is live (the dialog is then app-modal). + */ + constructor( + private readonly getSnapshot: () => ProcessSnapshot, + private readonly getParentWindow: () => BrowserWindow | null, + ) {} + + /** + * Per-action availability for a target. When the target no longer matches + * (exited / reused PID), every action is disabled with STALE. + */ + getActionStates(request: GetProcessActionStatesRequest): GetProcessActionStatesResponse { + const row = findTargetRow(this.getSnapshot(), request.target); + if (row === undefined) { + return { + targetValid: false, + actions: ACTION_KINDS.map((kind) => ({ + kind, + enabled: false, + disabledReason: ActionDisabledReason.ACTION_DISABLED_REASON_STALE, + })), + }; + } + + return { + targetValid: true, + actions: ACTION_KINDS.map((kind) => this.actionState(kind, row, request.target)), + }; + } + + /** + * Runs one action against one target, re-validating against the latest + * snapshot (the states the renderer saw may be stale). Returns a coarse, + * count-only outcome. + */ + async runAction(request: RunProcessActionRequest): Promise { + const resolved = this.resolveAllowedRow(request); + if (resolved.row === undefined) { + return resolved.blocked; + } + + switch (request.action) { + case ProcessActionKind.PROCESS_ACTION_KIND_REVEAL: + return this.reveal(resolved.row); + case ProcessActionKind.PROCESS_ACTION_KIND_QUIT: + // SIGTERM is graceful and recoverable, so no confirmation. + return this.signal(request.action, resolved.row); + case ProcessActionKind.PROCESS_ACTION_KIND_FORCE_QUIT: + return this.confirmAndForceQuit(request, resolved.row); + default: + return { outcome: Outcome.OUTCOME_NOT_ALLOWED, affectedCount: 0 }; + } + } + + /** + * Resolves the request target against the latest snapshot and checks the + * action is allowed, returning the row or the blocking response. + */ + private resolveAllowedRow( + request: RunProcessActionRequest, + ): { row: ProcessRow; blocked?: undefined } | { row?: undefined; blocked: RunProcessActionResponse } { + const row = findTargetRow(this.getSnapshot(), request.target); + if (row === undefined) { + return { blocked: { outcome: Outcome.OUTCOME_STALE_TARGET, affectedCount: 0 } }; + } + const disabledReason = disabledReasonFor(request.action, row, this.selfPid, request.target); + if (disabledReason !== ActionDisabledReason.ACTION_DISABLED_REASON_NONE) { + // NO_PATH/SELF/PROTECTED/UNSTABLE_IDENTITY collapse to count-only not allowed. + return { blocked: { outcome: Outcome.OUTCOME_NOT_ALLOWED, affectedCount: 0 } }; + } + return { row }; + } + + /** + * Confirms Force Quit through the native dialog, then re-resolves the target + * (it may have exited while the dialog was up) before signaling. A declined + * confirmation is CANCELED: an explicit no-op, not a failure. + */ + private async confirmAndForceQuit( + request: RunProcessActionRequest, + row: ProcessRow, + ): Promise { + if (!(await this.confirmForceQuit(row))) { + return { outcome: Outcome.OUTCOME_CANCELED, affectedCount: 0 }; + } + + const fresh = this.resolveAllowedRow(request); + if (fresh.row === undefined) { + return fresh.blocked; + } + return this.signal(request.action, fresh.row); + } + + private actionState( + kind: ProcessActionKind, + row: ProcessRow, + target: ProcessIdentity | undefined, + ): ActionState { + const disabledReason = disabledReasonFor(kind, row, this.selfPid, target); + return { + kind, + enabled: disabledReason === ActionDisabledReason.ACTION_DISABLED_REASON_NONE, + disabledReason, + }; + } + + /** Reveals a resolved row's executable in Finder via the desktop shell. */ + private reveal(row: ProcessRow): RunProcessActionResponse { + const path = okString(row.statics?.executablePath); + if (path === undefined) { + return { outcome: Outcome.OUTCOME_NOT_ALLOWED, affectedCount: 0 }; + } + try { + desktop.showPath(path); + return { outcome: Outcome.OUTCOME_SUCCEEDED, affectedCount: 1 }; + } catch { + // No diagnostic is logged - the path is sensitive. + return { outcome: Outcome.OUTCOME_FAILED, affectedCount: 0 }; + } + } + + /** Sends SIGTERM (Quit) or SIGKILL (Force Quit) to a resolved row's PID. */ + private signal(action: ProcessActionKind, row: ProcessRow): RunProcessActionResponse { + const pid = row.identity?.pid ?? 0; + const signal = action === ProcessActionKind.PROCESS_ACTION_KIND_FORCE_QUIT ? "SIGKILL" : "SIGTERM"; + try { + process.kill(pid, signal); + return { outcome: Outcome.OUTCOME_SUCCEEDED, affectedCount: 1 }; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + switch (code) { + case "ESRCH": + return { outcome: Outcome.OUTCOME_STALE_TARGET, affectedCount: 0 }; + case "EPERM": + return { outcome: Outcome.OUTCOME_NOT_PERMITTED, affectedCount: 0 }; + default: + return { outcome: Outcome.OUTCOME_FAILED, affectedCount: 0 }; + } + } + } + + /** Shows the native Force Quit confirmation; names the process by display name only. */ + private async confirmForceQuit(row: ProcessRow): Promise { + const name = + okString(row.statics?.app?.localizedName) ?? + okString(row.statics?.executableName) ?? + okString(row.statics?.commandName) ?? + `PID ${row.identity?.pid ?? 0}`; + const result = await app.showMessageDialog({ + parentWindow: this.getParentWindow() ?? undefined, + message: `Force Quit ${name}?`, + informativeText: "The process will be killed immediately (SIGKILL).", + type: "warning", + buttons: [ + { label: "Cancel", type: "secondary" }, + { label: "Force Quit", type: "primary" }, + ], + }); + return result.button.type === "primary"; + } +} diff --git a/src/main/processes/process-explorer-service.ts b/src/main/processes/process-explorer-service.ts new file mode 100644 index 0000000..0a873bc --- /dev/null +++ b/src/main/processes/process-explorer-service.ts @@ -0,0 +1,66 @@ +import { ipc } from "@mobrowser/api"; +import type { BrowserWindow } from "@mobrowser/api"; +import { + ProcessExplorerService as ProcessExplorerServiceImpl, + ProcessExplorerServiceDescriptor, +} from "../gen/ipc_service"; +import { ProcessActionService } from "./process-action-service"; +import { ProcessSnapshotService } from "./process-snapshot-service"; + +/** + * Composes the renderer-facing process explorer: the + * {@link ProcessSnapshotService} owns collection, the cached snapshot, and the + * StreamRevisions broadcast; the {@link ProcessActionService} owns the + * main-authoritative reveal/quit/force-quit actions (validated against that + * cache); this class registers the unary methods and routes each to its owner. + */ +export class ProcessExplorerService { + private readonly snapshots = new ProcessSnapshotService(); + + private readonly actions: ProcessActionService; + + /** + * The unary handlers, held as one object so {@link dispose} unregisters the + * exact implementation that was registered. The streaming StreamRevisions + * method is owned by the snapshot service's broadcast handle. + */ + private readonly unaryHandlers: Pick< + ProcessExplorerServiceImpl, + "GetProcessSnapshot" | "GetProcessAssets" | "GetProcessActionStates" | "RunProcessAction" + > = { + GetProcessSnapshot: async () => this.snapshots.getWireSnapshot(), + GetProcessAssets: (request) => this.snapshots.getAssets(request.staticKeys, request.iconKeys), + GetProcessActionStates: async (request) => this.actions.getActionStates(request), + RunProcessAction: (request) => this.actions.runAction(request), + }; + + private disposed = false; + + /** + * @param getWindow Returns the live window (or null) to parent + * destructive-action confirmation dialogs; lazy because the window is + * recreated across hide/show cycles. + */ + constructor(getWindow: () => BrowserWindow | null) { + this.actions = new ProcessActionService(() => this.snapshots.getSnapshot(), getWindow); + ipc.registerService(ProcessExplorerServiceDescriptor, this.unaryHandlers); + } + + /** + * Activates or pauses collection; active only while the Processes view is on + * screen, so the sensitive command-line reads run only while it is watched. + */ + setActive(active: boolean): void { + this.snapshots.setActive(active); + } + + /** Stops collection and unregisters all handlers. Idempotent. */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.snapshots.dispose(); + ipc.unregisterService(ProcessExplorerServiceDescriptor, this.unaryHandlers); + } +} diff --git a/src/main/processes/process-snapshot-service.ts b/src/main/processes/process-snapshot-service.ts new file mode 100644 index 0000000..a5da8cb --- /dev/null +++ b/src/main/processes/process-snapshot-service.ts @@ -0,0 +1,575 @@ +import { createHash } from "node:crypto"; +import * as os from "node:os"; +import { ipc } from "@mobrowser/api"; +import { native } from "../gen/native"; +import { + NativeAppBundle, + NativeAppMetadata, + NativeCommandLine, + NativeFieldStatus, + NativeInt64, + NativeProcessCpu, + NativeProcessRecord, + NativeProcessUser, + NativeResponsiveness, + NativeString, +} from "../gen/native/process_collector"; +import { + AppBundle, + AppMetadata, + CommandLine, + CpuTime, + CpuUsage, + FieldStatus, + GetProcessAssetsResponse, + ProcessMemory, + ProcessRow, + ProcessSnapshot, + ProcessSnapshotRevision, + ProcessStatics, + ProcessUser, + Responsiveness, + SnapshotStatus, + StringValue, + UInt64Value, +} from "../gen/process_explorer"; +import { ProcessExplorerServiceDescriptor } from "../gen/ipc_service"; +import { PollLoop } from "../poll-loop"; + +const COLLECT_INTERVAL_MS = 3000; + +/** + * Hard cap on a native collector RPC. The native calls have no built-in + * timeout, so a hung call would otherwise leave the tick promise pending + * forever and permanently wedge the poll loop (its overlap guard never + * re-arms). Generous relative to the tick: a cold pass rasterizes icons for + * every visible app once, and a timeout only needs to catch a genuine hang. + * The abort rejects into the existing catch, which degrades the snapshot to + * unavailable; the next tick retries. + */ +const NATIVE_CALL_TIMEOUT_MS = 10_000; + +/** + * Per-process CPU uses Activity Monitor semantics (one fully busy core reads + * ~100%, multi-threaded processes can exceed it), capped at all cores busy. + */ +const MAX_CPU_PERCENT = Math.max(1, os.cpus().length) * 100; + +/** Identity key for matching a process across snapshots (pid + start time). */ +type ProcessKey = string; + +/** CPU baseline kept between collections to derive a usage percent delta. */ +interface CpuBaseline { + cumulativeCpuTimeNs: number; + sampledAtMs: number; +} + +/** + * Maps native per-field availability onto the renderer field status. The + * proto3 default (UNSPECIFIED) and native-only PARSE_FAILED collapse to + * "unavailable" since the renderer has no parse-failed state. + */ +function toFieldStatus(status: NativeFieldStatus): FieldStatus { + switch (status) { + case NativeFieldStatus.NATIVE_FIELD_STATUS_AVAILABLE: + return FieldStatus.FIELD_STATUS_OK; + case NativeFieldStatus.NATIVE_FIELD_STATUS_PERMISSION_DENIED: + return FieldStatus.FIELD_STATUS_PERMISSION_DENIED; + case NativeFieldStatus.NATIVE_FIELD_STATUS_PROCESS_EXITED: + return FieldStatus.FIELD_STATUS_PROCESS_EXITED; + case NativeFieldStatus.NATIVE_FIELD_STATUS_UNSUPPORTED: + return FieldStatus.FIELD_STATUS_UNSUPPORTED; + default: + return FieldStatus.FIELD_STATUS_UNAVAILABLE; + } +} + +function toStringValue(value: NativeString | undefined): StringValue { + if (value === undefined) { + return { status: FieldStatus.FIELD_STATUS_UNAVAILABLE, value: "" }; + } + return { status: toFieldStatus(value.status), value: value.value }; +} + +/** Maps a native int64 field to a non-negative value with availability. */ +function toUInt64Value(value: NativeInt64 | undefined): UInt64Value { + if (value === undefined) { + return { status: FieldStatus.FIELD_STATUS_UNAVAILABLE, value: 0 }; + } + const status = toFieldStatus(value.status); + return { + status, + value: status === FieldStatus.FIELD_STATUS_OK ? Math.max(0, value.value) : 0, + }; +} + +/** + * Maps the owning `.app` bundle the list groups by. An absent bundle or + * non-available path maps to undefined so the renderer keeps the row singleton. + */ +function toAppBundle(bundle: NativeAppBundle | undefined): AppBundle | undefined { + if ( + bundle?.path === undefined || + bundle.path.status !== NativeFieldStatus.NATIVE_FIELD_STATUS_AVAILABLE + ) { + return undefined; + } + return { path: toStringValue(bundle.path), name: toStringValue(bundle.name) }; +} + +/** + * Maps the optional GUI app metadata (bundle id, localized name, icon key). + * A record with no app data maps the name fields to UNKNOWN with no icon key, + * so the UI falls back to a generic icon and the command/executable name. + * Icon bytes never ride the row; the renderer fetches keys via GetProcessIcons. + */ +function toAppMetadata(app: NativeAppMetadata | undefined): AppMetadata { + if (app === undefined) { + const unknown = { status: FieldStatus.FIELD_STATUS_UNKNOWN, value: "" }; + return { + bundleIdentifier: { ...unknown }, + localizedName: { ...unknown }, + iconKey: "", + bundle: undefined, + }; + } + return { + bundleIdentifier: toStringValue(app.bundleIdentifier), + localizedName: toStringValue(app.localizedName), + iconKey: app.iconKey, + bundle: toAppBundle(app.bundle), + }; +} + +/** + * Maps the cumulative CPU-time counter. Surfaced directly (no first-sample + * UNKNOWN): a cumulative total needs no delta, unlike the derived percent. + */ +function toCpuTime(cpu: NativeProcessCpu | undefined): CpuTime { + const { status, value } = toUInt64Value(cpu?.cumulativeCpuTimeNs); + return { status, nanos: value }; +} + +/** + * Maps the owning user (uid + login name, sharing one availability). An + * unmapped uid stays OK with the numeric value and an empty name. + */ +function toProcessUser(user: NativeProcessUser | undefined): ProcessUser { + if (user === undefined) { + return { status: FieldStatus.FIELD_STATUS_UNAVAILABLE, uid: 0, name: "" }; + } + const status = toFieldStatus(user.status); + return { + status, + uid: status === FieldStatus.FIELD_STATUS_OK ? user.uid : 0, + name: status === FieldStatus.FIELD_STATUS_OK ? user.name : "", + }; +} + +/** + * Maps the window-server responsiveness of a GUI app. Absent in, absent out: + * only NSWorkspace apps carry the field, and a row without it renders no + * responsiveness state at all. The flag is only trusted when the native read + * succeeded. + */ +function toResponsiveness( + value: NativeResponsiveness | undefined, +): Responsiveness | undefined { + if (value === undefined) { + return undefined; + } + const status = toFieldStatus(value.status); + return { + status, + unresponsive: status === FieldStatus.FIELD_STATUS_OK ? value.unresponsive : false, + }; +} + +/** + * Maps the sensitive command-line group. Arguments are forwarded verbatim for + * local display/search only and are never logged or persisted here. + */ +function toCommandLine(commandLine: NativeCommandLine | undefined): CommandLine { + if (commandLine === undefined) { + return { status: FieldStatus.FIELD_STATUS_UNAVAILABLE, arguments: [] }; + } + const status = toFieldStatus(commandLine.status); + return { + status, + arguments: status === FieldStatus.FIELD_STATUS_OK ? commandLine.arguments : [], + }; +} + +/** + * Maps a native record's image-lifetime-stable fields into the statics blob + * rows reference by content key. Forwarded for local display/search only; + * never logged or persisted here. + */ +function toStatics(record: NativeProcessRecord, commandLine: CommandLine): ProcessStatics { + return { + parentStatus: toFieldStatus(record.parentStatus), + parentPid: record.parentPid, + commandName: toStringValue(record.commandName), + executableName: toStringValue(record.executableName), + executablePath: toStringValue(record.executablePath), + app: toAppMetadata(record.app), + commandLine, + user: toProcessUser(record.user), + }; +} + +/** + * Content-hash key of a statics blob: a hash over its deterministic proto + * encoding, so identical blobs dedupe (twin processes share one entry) and any + * value change - including an exec - yields a new key, making a held blob + * stale-proof by construction. 22 base64url chars carry 132 hash bits; + * collisions are not a concern at process-list cardinality. + */ +function staticsKey(statics: ProcessStatics): string { + return createHash("sha256") + .update(ProcessStatics.encode(statics).finish()) + .digest("base64url") + .slice(0, 22); +} + +/** + * Builds one row: identity plus the per-tick dynamic readings, with the + * statics blob joined for main-side consumers (the action service). The wire + * form published to the renderer strips the join and keeps only its key. + */ +function toProcessRow( + record: NativeProcessRecord, + cpu: CpuUsage, + staticKey: string, + statics: ProcessStatics, +): ProcessRow { + const identity = record.identity; + return { + identity: { + pid: identity?.pid ?? 0, + startedAtStatus: toFieldStatus( + identity?.startedAtStatus ?? NativeFieldStatus.NATIVE_FIELD_STATUS_UNAVAILABLE, + ), + startedAtUnixMs: identity?.startedAtUnixMs ?? 0, + }, + staticKey, + memory: { + physicalFootprintBytes: toUInt64Value(record.memory?.physicalFootprintBytes), + residentBytes: toUInt64Value(record.memory?.residentBytes), + } satisfies ProcessMemory, + cpu, + threadCount: toUInt64Value(record.threadCount), + cpuTime: toCpuTime(record.cpu), + responsiveness: toResponsiveness(record.responsiveness), + statics, + }; +} + +/** Snapshot-stable identity key for a native record, or null without a PID. */ +function recordKey(record: NativeProcessRecord): ProcessKey | null { + const identity = record.identity; + if (identity === undefined) { + return null; + } + const startedAt = + identity.startedAtStatus === NativeFieldStatus.NATIVE_FIELD_STATUS_AVAILABLE + ? identity.startedAtUnixMs + : "unknown"; + return `${identity.pid}:${startedAt}`; +} + +/** + * Owns process collection and the renderer-facing snapshot for the process + * explorer: a visibility-gated cadence calls the native collector, maps the + * records into a {@link ProcessSnapshot}, caches it under a monotonic revision, + * and broadcasts a lightweight revision ping so the renderer pulls the full + * snapshot only when it changes. + * + * Per-process CPU percent is derived here by diffing the native cumulative + * CPU-time counter against wall time (Activity Monitor semantics). A first + * sample, a restarted process (reused PID, new start time), or a non-positive + * interval yields UNKNOWN rather than a fabricated value. + * + * Privacy: command lines pass through for local display/search only; nothing + * process-identifying is ever logged. + */ +export class ProcessSnapshotService { + private readonly revisionHandle = ipc.registerService(ProcessExplorerServiceDescriptor); + + private readonly loop = new PollLoop(COLLECT_INTERVAL_MS, () => this.collect()); + + private cpuBaselines = new Map(); + + private snapshot: ProcessSnapshot = { + status: SnapshotStatus.SNAPSHOT_STATUS_LOADING, + revision: 0, + timestampMs: 0, + processes: [], + icons: {}, + }; + + /** The wire form of {@link snapshot}: rows carry static_key, never the blob. */ + private wireSnapshot: ProcessSnapshot = this.snapshot; + + /** + * The statics blobs of the current and previous snapshot generations, by + * content key. Two generations so an asset fetch racing the next tick (the + * renderer pulled revision N, main already produced N+1) still resolves. + */ + private currentStatics = new Map(); + + private previousStatics = new Map(); + + private revision = 0; + + private disposed = false; + + /** + * Activates or pauses collection. Active only while the Processes view is on + * screen, so the per-PID syscalls - including the sensitive command-line + * reads - run only while the user is looking at the list. CPU baselines are + * kept across a pause: CPU-time and wall deltas span the same gap, so the + * first tick after resume still computes a real per-process delta. + */ + setActive(active: boolean): void { + this.loop.setActive(active); + } + + /** + * Returns the latest cached snapshot in its main-side form, with statics + * joined onto every row (the action service reads names and paths from it). + * The renderer pull is served by {@link getWireSnapshot}. + */ + getSnapshot(): ProcessSnapshot { + return this.snapshot; + } + + /** + * Returns the wire form of the latest snapshot: rows carry only static_key; + * the renderer gateway joins blobs fetched through GetProcessAssets. + */ + getWireSnapshot(): ProcessSnapshot { + return this.wireSnapshot; + } + + /** + * Resolves content-addressed assets the renderer does not hold: statics from + * the two retained snapshot generations, icon bytes passed through to the + * native session cache. A key that resolves nowhere is omitted; the renderer + * degrades that row honestly and the next pull retries. + */ + async getAssets(staticKeys: string[], iconKeys: string[]): Promise { + if (this.disposed) { + return { statics: {}, icons: {} }; + } + + const statics: { [key: string]: ProcessStatics } = {}; + for (const key of staticKeys) { + const blob = this.currentStatics.get(key) ?? this.previousStatics.get(key); + if (blob !== undefined) { + statics[key] = blob; + } + } + + let icons: { [key: string]: string } = {}; + if (iconKeys.length > 0) { + try { + icons = (await native.processCollector.GetIcons( + { keys: iconKeys }, + { signal: AbortSignal.timeout(NATIVE_CALL_TIMEOUT_MS) }, + )).icons; + } catch { + // Degrade to empty; the next pull retries the still-missing keys. + } + } + + return { statics, icons }; + } + + /** Stops the cadence and closes the revision stream. Idempotent and final. */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.loop.dispose(); + this.cpuBaselines.clear(); + this.currentStatics.clear(); + this.previousStatics.clear(); + this.revisionHandle.dispose(); + } + + /** + * Collects once, rebuilds the cached snapshot, and broadcasts a revision + * ping. Never rejects: a native failure degrades to an unavailable snapshot + * (no diagnostic is logged - it could carry process-identifying data). + */ + private async collect(): Promise { + try { + const response = await native.processCollector.CollectProcesses( + {}, + { signal: AbortSignal.timeout(NATIVE_CALL_TIMEOUT_MS) }, + ); + if (this.disposed) { + return; + } + const built = this.buildSnapshot(response.available, response.records); + this.publishSnapshot(built.snapshot, built.statics); + } catch { + if (!this.disposed) { + this.publishSnapshot(this.buildUnavailableSnapshot(), new Map()); + } + } + } + + /** + * Publishes a built snapshot: rotates the statics generations, caches the + * main-side (statics-joined) and wire (statics-stripped) forms, and + * broadcasts the revision ping. + */ + private publishSnapshot(next: ProcessSnapshot, statics: Map): void { + this.previousStatics = this.currentStatics; + this.currentStatics = statics; + this.snapshot = next; + this.wireSnapshot = { + ...next, + processes: next.processes.map((row) => ({ ...row, statics: undefined })), + }; + this.revisionHandle.StreamRevisions({ + revision: next.revision, + timestampMs: next.timestampMs, + status: next.status, + } satisfies ProcessSnapshotRevision); + } + + /** + * Builds a renderer snapshot from native records, collecting each row's + * statics blob into the content-keyed map served by {@link getAssets}, and + * updates the CPU baselines for the next collection. + */ + private buildSnapshot( + available: boolean, + records: NativeProcessRecord[], + ): { snapshot: ProcessSnapshot; statics: Map } { + if (!available) { + return { snapshot: this.buildUnavailableSnapshot(), statics: new Map() }; + } + + const sampledAtMs = performance.now(); + const nextBaselines = new Map(); + const statics = new Map(); + let anyFieldDenied = false; + + const processes: ProcessRow[] = records.map((record) => { + const key = recordKey(record); + const cpu = this.deriveCpu(record.cpu, key, sampledAtMs, nextBaselines); + const commandLine = toCommandLine(record.commandLine); + + anyFieldDenied ||= hasDeniedField(record); + + const blob = toStatics(record, commandLine); + const blobKey = staticsKey(blob); + statics.set(blobKey, blob); + return toProcessRow(record, cpu, blobKey, blob); + }); + + this.cpuBaselines = nextBaselines; + this.revision += 1; + + return { + snapshot: { + status: anyFieldDenied + ? SnapshotStatus.SNAPSHOT_STATUS_PERMISSION_LIMITED + : SnapshotStatus.SNAPSHOT_STATUS_OK, + revision: this.revision, + timestampMs: Date.now(), + processes, + icons: {}, + }, + statics, + }; + } + + /** An explicit unavailable snapshot (no rows) that still advances the revision. */ + private buildUnavailableSnapshot(): ProcessSnapshot { + // CPU baselines reset; a fresh delta is derived on the next success. + this.cpuBaselines.clear(); + this.revision += 1; + return { + status: SnapshotStatus.SNAPSHOT_STATUS_UNAVAILABLE, + revision: this.revision, + timestampMs: Date.now(), + processes: [], + icons: {}, + }; + } + + /** + * Derives a CPU usage percent from the cumulative-counter delta and records + * the new baseline. UNKNOWN on a first sample, a missing identity or counter, + * a process restart (the key resets), or a non-positive elapsed interval, so + * a fresh or ambiguous row never shows a fabricated value. + */ + private deriveCpu( + cpu: NativeProcessCpu | undefined, + key: ProcessKey | null, + sampledAtMs: number, + nextBaselines: Map, + ): CpuUsage { + const counter = cpu?.cumulativeCpuTimeNs; + if ( + key === null || + counter === undefined || + counter.status !== NativeFieldStatus.NATIVE_FIELD_STATUS_AVAILABLE + ) { + return { status: FieldStatus.FIELD_STATUS_UNKNOWN, usagePercent: 0 }; + } + + const cumulativeCpuTimeNs = counter.value; + nextBaselines.set(key, { cumulativeCpuTimeNs, sampledAtMs }); + + const previous = this.cpuBaselines.get(key); + if (previous === undefined) { + return { status: FieldStatus.FIELD_STATUS_UNKNOWN, usagePercent: 0 }; + } + + const cpuDeltaNs = cumulativeCpuTimeNs - previous.cumulativeCpuTimeNs; + const wallDeltaMs = sampledAtMs - previous.sampledAtMs; + if (cpuDeltaNs < 0 || wallDeltaMs <= 0) { + // Counter reset or non-monotonic clock; re-arm from this sample. + return { status: FieldStatus.FIELD_STATUS_UNKNOWN, usagePercent: 0 }; + } + + const usagePercent = Math.min( + MAX_CPU_PERCENT, + Math.max(0, (cpuDeltaNs / (wallDeltaMs * 1_000_000)) * 100), + ); + return { status: FieldStatus.FIELD_STATUS_OK, usagePercent }; + } +} + +/** + * True when macOS denied any independently-readable field on the record. macOS + * can deny argv, path, memory, or CPU separately even when task info reads, so + * every per-field status is checked (statuses only, never values). + */ +function hasDeniedField(record: NativeProcessRecord): boolean { + const statuses = [ + record.identity?.startedAtStatus, + record.parentStatus, + record.commandName?.status, + record.executableName?.status, + record.executablePath?.status, + record.commandLine?.status, + record.memory?.physicalFootprintBytes?.status, + record.memory?.residentBytes?.status, + record.cpu?.cumulativeCpuTimeNs?.status, + record.threadCount?.status, + record.user?.status, + record.responsiveness?.status, + ]; + return statuses.some( + (status) => status === NativeFieldStatus.NATIVE_FIELD_STATUS_PERMISSION_DENIED, + ); +} + diff --git a/src/main/tray-controller.ts b/src/main/tray-controller.ts new file mode 100644 index 0000000..4a7119b --- /dev/null +++ b/src/main/tray-controller.ts @@ -0,0 +1,108 @@ +import { app, CheckboxMenuItem, Menu, MenuItem, Tray } from "@mobrowser/api"; +import type { MouseButton } from "@mobrowser/api"; +import type { ApplicationWindow } from "./application-window"; +import { DISPLAY_NAME } from "./branding"; + +/** + * Owns the macOS menu-bar tray item and its menu. Primary click toggles the + * window; secondary click opens the menu. + */ +export class TrayController { + private readonly tray: Tray; + private readonly toggleWindowItem: MenuItem; + private readonly launchAtLoginItem: CheckboxMenuItem; + private readonly quitItem: MenuItem; + + constructor( + private readonly window: ApplicationWindow, + private readonly onQuit: () => void, + ) { + this.toggleWindowItem = new MenuItem({ + id: "toggleWindow", + label: this.getToggleLabel(), + action: () => { + this.window.toggle(); + }, + }); + this.launchAtLoginItem = new CheckboxMenuItem({ + id: "launchAtLogin", + label: "Launch at Login", + checked: app.loginItemSettings.openAtLogin, + action: () => { + this.toggleLaunchAtLogin(); + }, + }); + this.quitItem = new MenuItem({ + id: "quit", + label: `Quit ${DISPLAY_NAME}`, + shortcut: "CommandOrControl+Q", + action: () => { + this.onQuit(); + }, + }); + + this.tray = new Tray({ + tooltip: DISPLAY_NAME, + imagePath: `${app.getPath("appResources")}/imageTemplate.png`, + menu: this.buildMenu(), + }); + + this.tray.on("mouseUp", (button: MouseButton) => { + if (button === "secondary") { + this.syncLaunchAtLogin(); + this.tray.openMenu(); + return; + } + this.window.toggle(); + }); + } + + /** + * Syncs the Show/Hide label with the window state. A no-op once destroyed: + * a visibility event can still fire during quit when the window closes. + */ + refresh(): void { + if (this.tray.destroyed) { + return; + } + this.toggleWindowItem.setLabel(this.getToggleLabel()); + } + + /** Releases the native tray resource. Idempotent. */ + destroy(): void { + if (this.tray.destroyed) { + return; + } + this.tray.destroy(); + } + + private buildMenu(): Menu { + return new Menu({ + items: [ + this.toggleWindowItem, + "separator", + this.launchAtLoginItem, + "separator", + this.quitItem, + ], + }); + } + + private getToggleLabel(): string { + return this.window.isVisible ? `Hide ${DISPLAY_NAME}` : `Show ${DISPLAY_NAME}`; + } + + private toggleLaunchAtLogin(): void { + app.setLoginItemSettings({ openAtLogin: !app.loginItemSettings.openAtLogin }); + this.syncLaunchAtLogin(); + } + + /** + * Re-reads the authoritative OS login-item state into the checkbox, so a + * refused change (e.g. denied background-item registration) never shows a + * checkmark that lies. + */ + private syncLaunchAtLogin(): void { + this.launchAtLoginItem.setChecked(app.loginItemSettings.openAtLogin); + } +} diff --git a/src/native/main.cc b/src/native/main.cc new file mode 100644 index 0000000..c86510f --- /dev/null +++ b/src/native/main.cc @@ -0,0 +1,75 @@ +#include "rpc.h" +#include "gen/memory.rpc.h" +#include "gen/network.rpc.h" +#include "gen/process_collector.rpc.h" +#include "gen/temperature.rpc.h" +#include "metrics/memory_probe.h" +#include "metrics/network_probe.h" +#include "metrics/temperature_probe.h" +#include "processes/process_collector.h" + +// The native entry point: each RPC service is a thin wrapper that forwards to a +// probe in metrics/ or processes/ and completes the callback. All collection +// logic, OS sources, and availability rules live in those modules. + +using google::protobuf::Empty; +using mo::rpc::Callback; + +namespace { + +class MemoryServiceImpl : public MemoryService { + public: + void ReadUsage(const Empty* /*request*/, Callback done) override { + MemoryUsage response; + mostats::ReadMemoryUsage(&response); + std::move(done).Complete(response); + } +}; + +class NetworkServiceImpl : public NetworkService { + public: + void ReadCounters(const Empty* /*request*/, + Callback done) override { + NetworkCounters response; + mostats::ReadNetworkCounters(&response); + std::move(done).Complete(response); + } +}; + +class TemperatureServiceImpl : public TemperatureService { + public: + void ReadCpuTemperature(const Empty* /*request*/, + Callback done) override { + const mostats::CpuTemperatureReading reading = mostats::ReadCpuTemperature(); + CpuTemperature response; + response.set_available(reading.available); + response.set_celsius(reading.celsius); + std::move(done).Complete(response); + } +}; + +class ProcessCollectorServiceImpl : public ProcessCollectorService { + public: + void CollectProcesses(const CollectProcessesRequest* /*request*/, + Callback done) override { + CollectProcessesResponse response; + mostats::CollectProcesses(&response); + std::move(done).Complete(response); + } + + void GetIcons(const GetIconsRequest* request, + Callback done) override { + GetIconsResponse response; + mostats::GetProcessIcons(*request, &response); + std::move(done).Complete(response); + } +}; + +} // namespace + +void launch() { + mo::rpc::RegisterService(new MemoryServiceImpl()); + mo::rpc::RegisterService(new NetworkServiceImpl()); + mo::rpc::RegisterService(new TemperatureServiceImpl()); + mo::rpc::RegisterService(new ProcessCollectorServiceImpl()); +} diff --git a/src/native/metrics/memory_probe.cc b/src/native/metrics/memory_probe.cc new file mode 100644 index 0000000..5997ee8 --- /dev/null +++ b/src/native/metrics/memory_probe.cc @@ -0,0 +1,106 @@ +#include "metrics/memory_probe.h" + +#include +#include +#include + +#include +#include +#include + +// Technique reference: exelban/stats Modules/RAM/readers.swift reads the same +// host_statistics64 VM categories. This is a scoped re-implementation exposing +// only total, used, available, and cache for MoStats' single memory row. No +// upstream code is copied. + +namespace mostats { +namespace { + +uint64_t SaturatingAdd(uint64_t left, uint64_t right) { + const uint64_t max = std::numeric_limits::max(); + return max - left < right ? max : left + right; +} + +uint64_t SaturatingSubtract(uint64_t left, uint64_t right) { + return left > right ? left - right : 0; +} + +uint64_t PagesToBytes(uint64_t pages, uint64_t page_size) { + if (page_size == 0) { + return 0; + } + const uint64_t max = std::numeric_limits::max(); + return pages > max / page_size ? max : pages * page_size; +} + +// Total physical RAM via sysctl, or false when it cannot be read. +bool ReadPhysicalMemorySize(uint64_t* total_bytes) { + uint64_t value = 0; + size_t size = sizeof(value); + if (sysctlbyname("hw.memsize", &value, &size, nullptr, 0) != 0 || + size != sizeof(value) || value == 0) { + return false; + } + *total_bytes = value; + return true; +} + +} // namespace + +void ReadMemoryUsage(MemoryUsage* response) { + uint64_t total_bytes = 0; + const long raw_page_size = sysconf(_SC_PAGESIZE); + + // mach_host_self() adds a send-right uref that is never deallocated; the + // port is process-lifetime stable, so acquire it once instead of leaking one + // reference per tick. + static const host_t host = mach_host_self(); + + vm_statistics64_data_t stats = {}; + mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; + const kern_return_t result = host_statistics64( + host, HOST_VM_INFO64, reinterpret_cast(&stats), &count); + + if (!ReadPhysicalMemorySize(&total_bytes) || raw_page_size <= 0 || + result != KERN_SUCCESS) { + response->set_available(false); + return; + } + const uint64_t page_size = static_cast(raw_page_size); + + // Pages in use, minus the pages the kernel can reclaim on demand (file cache + // plus purgeable), which we report separately rather than as pressure. + uint64_t occupied_pages = 0; + occupied_pages = SaturatingAdd(occupied_pages, stats.active_count); + occupied_pages = SaturatingAdd(occupied_pages, stats.inactive_count); + occupied_pages = SaturatingAdd(occupied_pages, stats.speculative_count); + occupied_pages = SaturatingAdd(occupied_pages, stats.wire_count); + occupied_pages = SaturatingAdd(occupied_pages, stats.compressor_page_count); + + uint64_t cached_pages = 0; + cached_pages = SaturatingAdd(cached_pages, stats.purgeable_count); + cached_pages = SaturatingAdd(cached_pages, stats.external_page_count); + + const uint64_t used_pages = SaturatingSubtract(occupied_pages, cached_pages); + uint64_t used_bytes = std::min(PagesToBytes(used_pages, page_size), total_bytes); + const uint64_t available_bytes = total_bytes - used_bytes; + const uint64_t cached_bytes = + std::min(PagesToBytes(cached_pages, page_size), available_bytes); + const uint64_t wired_bytes = + std::min(PagesToBytes(stats.wire_count, page_size), used_bytes); + const uint64_t compressed_bytes = std::min( + PagesToBytes(stats.compressor_page_count, page_size), + used_bytes - wired_bytes); + const uint64_t app_bytes = used_bytes - wired_bytes - compressed_bytes; + + response->set_available(true); + response->set_total_bytes(total_bytes); + response->set_used_bytes(used_bytes); + response->set_available_bytes(available_bytes); + response->set_cached_bytes(cached_bytes); + response->set_app_bytes(app_bytes); + response->set_wired_bytes(wired_bytes); + response->set_compressed_bytes(compressed_bytes); +} + +} // namespace mostats diff --git a/src/native/metrics/memory_probe.h b/src/native/metrics/memory_probe.h new file mode 100644 index 0000000..d9e4902 --- /dev/null +++ b/src/native/metrics/memory_probe.h @@ -0,0 +1,20 @@ +#ifndef MOSTATS_METRICS_MEMORY_PROBE_H_ +#define MOSTATS_METRICS_MEMORY_PROBE_H_ + +#include "gen/memory.pb.h" + +namespace mostats { + +// Reads an Activity Monitor-style memory composition into the generated +// response: app + wired + compressed + cached + free sum to total. +// +// Reclaimable file cache is subtracted from "used" (app + wired + compressed) +// and reported separately so the UI does not present cache as pressure. On any +// failure the response is marked unavailable. The main-process sampler owns +// percentage derivation. See memory_probe.cc for the page-counter sources and +// math. +void ReadMemoryUsage(MemoryUsage* response); + +} // namespace mostats + +#endif // MOSTATS_METRICS_MEMORY_PROBE_H_ diff --git a/src/native/metrics/network_probe.cc b/src/native/metrics/network_probe.cc new file mode 100644 index 0000000..ece1cc5 --- /dev/null +++ b/src/native/metrics/network_probe.cc @@ -0,0 +1,151 @@ +#include "metrics/network_probe.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// Counter source: sysctl(NET_RT_IFLIST2) RTM_IFINFO2 messages carry struct +// if_data64 with 64-bit ifi_ibytes/ifi_obytes (net/if_var.h) - the same sysctl +// netstat -ib reads. The simpler getifaddrs path exposes these counters as +// 32-bit (struct if_data), which wraps every 4 GiB (about half a minute of +// sustained 1 Gbps), so it cannot feed a rate. + +namespace mostats { +namespace { + +// True when an Ethernet-media interface has a live physical link, so an +// administratively-up but unplugged adapter is not counted. +bool HasActiveMedia(int media_socket, const char* name) { + ifmediareq request = {}; + strncpy(request.ifm_name, name, sizeof(request.ifm_name) - 1); + if (ioctl(media_socket, SIOCGIFMEDIA, &request) != 0) { + return false; + } + return (request.ifm_status & IFM_AVALID) != 0 && + (request.ifm_status & IFM_ACTIVE) != 0; +} + +// True for the active, user-facing physical interfaces a person means by "my +// network". macOS names every genuine NIC en* (Ethernet/Wi-Fi/Thunderbolt) or +// pdp_ip* (cellular); virtual IFT_ETHER interfaces (AirDrop awdl*, the sharing +// bridge, Apple management NICs) use other prefixes, so the name check separates +// them. This drops loopback and VPN tunnels too, avoiding double-counting (VPN +// traffic would otherwise sum on both en0 and the utun tunnel). +// +// en* additionally requires a live media link (above). Cellular is admitted on +// IFF_UP + IFT_CELLULAR alone: SIOCGIFMEDIA is an Ethernet concept and a +// cellular interface commonly reports no media status, so gating it would +// wrongly drop a live cellular-only uplink. +bool IsCountedInterface(uint32_t flags, uint8_t link_type, const char* name, + int media_socket) { + if ((flags & IFF_UP) == 0 || (flags & IFF_LOOPBACK) != 0) { + return false; + } + if (link_type != IFT_ETHER && link_type != IFT_CELLULAR) { + return false; + } + if (strncmp(name, "pdp_ip", 6) == 0) { + return true; + } + if (strncmp(name, "en", 2) == 0) { + return HasActiveMedia(media_socket, name); + } + return false; +} + +// Fetches the NET_RT_IFLIST2 routing dump. A failure (including the rare +// ENOMEM when an interface appears between the size query and the copy) just +// reports unavailable for this tick; the sampler retries on the next one. +bool FetchInterfaceList(std::vector* buffer) { + int mib[6] = {CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0}; + size_t length = 0; + if (sysctl(mib, 6, nullptr, &length, nullptr, 0) != 0) { + return false; + } + buffer->resize(length); + if (sysctl(mib, 6, buffer->data(), &length, nullptr, 0) != 0) { + return false; + } + buffer->resize(length); + return true; +} + +} // namespace + +void ReadNetworkCounters(NetworkCounters* response) { + std::vector buffer; + if (!FetchInterfaceList(&buffer)) { + // Report unavailable rather than zeros that would read as "no traffic". + response->set_available(false); + return; + } + + const int media_socket = socket(AF_INET, SOCK_DGRAM, 0); + if (media_socket < 0) { + response->set_available(false); + return; + } + + // Every routing message starts with the same msglen/version/type prefix; + // the rest of the layout varies by type (RTM_IFINFO2 carries if_msghdr2, + // while the interleaved per-address RTM_NEWADDR messages are much shorter), + // so only that prefix may be read or length-checked before the type switch. + constexpr size_t kMessagePrefixSize = offsetof(if_msghdr, ifm_addrs); + const char* const end = buffer.data() + buffer.size(); + for (const char* next = buffer.data(); + next + kMessagePrefixSize <= end;) { + const if_msghdr* header = reinterpret_cast(next); + if (header->ifm_msglen < kMessagePrefixSize) { + break; // Malformed length; bail rather than loop forever. + } + const char* const message_end = next + header->ifm_msglen; + next = message_end; + if (message_end > end || header->ifm_type != RTM_IFINFO2) { + continue; + } + if (header->ifm_msglen < sizeof(if_msghdr2) + sizeof(sockaddr_dl)) { + continue; + } + + const if_msghdr2* info = reinterpret_cast(header); + // The interface-name sockaddr immediately follows the fixed header. + const sockaddr_dl* link = reinterpret_cast(info + 1); + if ((info->ifm_addrs & RTA_IFP) == 0 || link->sdl_family != AF_LINK) { + continue; + } + + char name[IFNAMSIZ] = {}; + const size_t name_length = + std::min(link->sdl_nlen, sizeof(name) - 1); + memcpy(name, link->sdl_data, name_length); + + if (!IsCountedInterface(info->ifm_flags, link->sdl_type, name, + media_socket)) { + continue; + } + + InterfaceCounters* entry = response->add_interfaces(); + entry->set_name(name); + entry->set_rx_bytes(info->ifm_data.ifi_ibytes); + entry->set_tx_bytes(info->ifm_data.ifi_obytes); + } + + close(media_socket); + response->set_available(response->interfaces_size() > 0); +} + +} // namespace mostats diff --git a/src/native/metrics/network_probe.h b/src/native/metrics/network_probe.h new file mode 100644 index 0000000..cb584b2 --- /dev/null +++ b/src/native/metrics/network_probe.h @@ -0,0 +1,20 @@ +#ifndef MOSTATS_METRICS_NETWORK_PROBE_H_ +#define MOSTATS_METRICS_NETWORK_PROBE_H_ + +#include "gen/network.pb.h" + +namespace mostats { + +// Reads cumulative network byte counters into the generated response. +// +// Returns the kernel's 64-bit rx/tx totals per active physical interface +// (Ethernet/Wi-Fi/Thunderbolt and cellular), skipping loopback, VPN tunnels, +// and AirDrop/sharing so traffic is not double-counted. On failure the +// response is marked unavailable. Only raw per-interface counters are +// returned; the main-process sampler owns the delta-to-rate math. See +// network_probe.cc for the counter source and interface-selection rule. +void ReadNetworkCounters(NetworkCounters* response); + +} // namespace mostats + +#endif // MOSTATS_METRICS_NETWORK_PROBE_H_ diff --git a/src/native/metrics/temperature_probe.cc b/src/native/metrics/temperature_probe.cc new file mode 100644 index 0000000..f2e65d8 --- /dev/null +++ b/src/native/metrics/temperature_probe.cc @@ -0,0 +1,616 @@ +#include "metrics/temperature_probe.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Private IOKit HID symbols. These are not declared in any public SDK header, +// so they are forward-declared here with local opaque pointer tags. They are +// read-only sensor queries; the app never controls hardware. If a future macOS +// removes or changes them the probe degrades to unavailable (handled below by +// null/empty checks), never crashing the app. +struct IOHIDEvent; +struct IOHIDServiceClient; +using IOHIDEventRef = IOHIDEvent*; +using IOHIDServiceClientRef = IOHIDServiceClient*; + +extern "C" { +IOHIDEventSystemClientRef IOHIDEventSystemClientCreate(CFAllocatorRef allocator); +void IOHIDEventSystemClientSetMatching(IOHIDEventSystemClientRef client, + CFDictionaryRef match); +CFArrayRef IOHIDEventSystemClientCopyServices(IOHIDEventSystemClientRef client); +IOHIDEventRef IOHIDServiceClientCopyEvent(IOHIDServiceClientRef service, + int64_t type, int32_t options, + int64_t timestamp); +CFTypeRef IOHIDServiceClientCopyProperty(IOHIDServiceClientRef service, + CFStringRef property); +double IOHIDEventGetFloatValue(IOHIDEventRef event, int32_t field); +} + +namespace mostats { + +namespace { + +// Plausible CPU temperature window in Celsius. Readings outside this range are +// rejected. On Apple Silicon a parked CPU core's SMC sensor reads a meaningless +// floor (~4-7 C) until the core wakes, so the low bound is also what +// distinguishes a real core reading from that idle floor. Mirrors the +// exelban/stats CPU guard (Modules/Sensors/readers.swift "fix for m2 broken +// sensors", which rejects < 10 / > 120). +constexpr double kMinPlausibleCelsius = 10.0; +constexpr double kMaxPlausibleCelsius = 120.0; + +bool IsPlausibleTemperature(double celsius) { + return std::isfinite(celsius) && celsius >= kMinPlausibleCelsius && + celsius <= kMaxPlausibleCelsius; +} + +CpuTemperatureReading ReadingFromAverage(double sum, int count) { + CpuTemperatureReading reading; + if (count > 0) { + reading.available = true; + reading.celsius = sum / count; + } + return reading; +} + +// A partial average: the running sum and count of in-range core readings from a +// single source, so multiple core sources can be combined before dividing. +struct TemperatureAccumulator { + double sum = 0.0; + int count = 0; + + void Add(double celsius) { + sum += celsius; + ++count; + } + void Merge(const TemperatureAccumulator& other) { + sum += other.sum; + count += other.count; + } +}; + +// --------------------------------------------------------------------------- +// SMC CPU-core temperature path (primary) +// +// Apple Silicon exposes per-core temperatures as AppleSMC keys whose names +// differ by chip generation. A parked core's key reads an idle floor (~5 C) +// rather than its real value, so we hold the last in-range reading per key and +// reuse it while the core is parked, keeping the average steady. +// --------------------------------------------------------------------------- + +// AppleSMC user-client RPC selectors and per-call sub-commands. +constexpr uint8_t kSmcKernelIndex = 2; +constexpr uint8_t kSmcCmdReadBytes = 5; +constexpr uint8_t kSmcCmdReadKeyInfo = 9; + +enum class AppleSiliconGeneration { kUnknown, kM1, kM2, kM3, kM4, kM5 }; + +// SMC RPC structs. The kernel expects this exact 80-byte layout (enforced by +// the static_assert below); the fields we do not use are present only to +// reproduce that layout. +struct SmcKeyDataVers { + uint8_t major = 0; + uint8_t minor = 0; + uint8_t build = 0; + uint8_t reserved = 0; + uint16_t release = 0; +}; + +struct SmcKeyDataLimit { + uint16_t version = 0; + uint16_t length = 0; + uint32_t cpu_plimit = 0; + uint32_t gpu_plimit = 0; + uint32_t mem_plimit = 0; +}; + +struct SmcKeyInfo { + uint32_t data_size = 0; + uint32_t data_type = 0; + uint8_t data_attributes = 0; +}; + +struct SmcKeyData { + uint32_t key = 0; + SmcKeyDataVers vers; + SmcKeyDataLimit limit; + SmcKeyInfo key_info; + uint8_t result = 0; + uint8_t status = 0; + uint8_t data8 = 0; + uint32_t data32 = 0; + uint8_t bytes[32] = {}; +}; + +static_assert(sizeof(SmcKeyData) == 80, + "the AppleSMC user-client RPC requires this exact 80-byte layout"); + +struct SmcValue { + uint32_t data_size = 0; + uint32_t data_type = 0; + std::array bytes = {}; +}; + +// Per-generation CPU-core temperature keys, copied from the exelban/stats +// research reference (Modules/Sensors/values.swift, the average:true .CPU +// sensors per platform). Performance and efficiency cores both included. +constexpr std::array kM1CpuKeys = { + "Tp09", "Tp0T", "Tp01", "Tp05", "Tp0D", + "Tp0H", "Tp0L", "Tp0P", "Tp0X", "Tp0b", +}; +constexpr std::array kM2CpuKeys = { + "Tp1h", "Tp1t", "Tp1p", "Tp1l", "Tp01", "Tp05", + "Tp09", "Tp0D", "Tp0X", "Tp0b", "Tp0f", "Tp0j", +}; +constexpr std::array kM3CpuKeys = { + "Te05", "Te0L", "Te0P", "Te0S", "Tf04", "Tf09", + "Tf0A", "Tf0B", "Tf0D", "Tf0E", "Tf44", "Tf49", + "Tf4A", "Tf4B", "Tf4D", "Tf4E", +}; +constexpr std::array kM4CpuKeys = { + "Te05", "Te09", "Te0H", "Te0S", "Tp01", "Tp05", + "Tp09", "Tp0D", "Tp0V", "Tp0Y", "Tp0b", "Tp0e", +}; +constexpr std::array kM5CpuKeys = { + "Tp00", "Tp04", "Tp08", "Tp0C", "Tp0G", "Tp0K", + "Tp0O", "Tp0R", "Tp0U", "Tp0X", "Tp0a", "Tp0d", + "Tp0g", "Tp0j", "Tp0m", "Tp0p", "Tp0u", "Tp0y", +}; + +uint32_t FourCharToKey(std::string_view value) { + if (value.size() != 4) { + return 0; + } + uint32_t code = 0; + for (const char character : value) { + code = (code << 8) | static_cast(character); + } + return code; +} + +// Builds a FourCharCode from a literal at compile time, for the data-type +// check below. Distinct from FourCharToKey to keep that runtime path simple. +constexpr uint32_t TypeCode(const char (&value)[5]) { + uint32_t code = 0; + for (int i = 0; i < 4; ++i) { + code = (code << 8) | static_cast(value[i]); + } + return code; +} + +std::optional ReadSysctlString(const char* name) { + size_t size = 0; + if (sysctlbyname(name, nullptr, &size, nullptr, 0) != 0 || size == 0) { + return std::nullopt; + } + std::string value(size, '\0'); + if (sysctlbyname(name, value.data(), &size, nullptr, 0) != 0) { + return std::nullopt; + } + while (!value.empty() && value.back() == '\0') { + value.pop_back(); + } + return value; +} + +// True when `brand` contains exactly "Apple M" (so "Apple M1" does +// not match generation '1' inside "Apple M12" if Apple ever ships one). +bool ContainsAppleMGeneration(std::string_view brand, char generation) { + std::string token = "Apple M"; + token.push_back(generation); + const size_t position = brand.find(token); + if (position == std::string_view::npos) { + return false; + } + const size_t after = position + token.size(); + return after == brand.size() || + !std::isdigit(static_cast(brand[after])); +} + +AppleSiliconGeneration DetectAppleSiliconGeneration() { + const std::optional brand = + ReadSysctlString("machdep.cpu.brand_string"); + if (!brand.has_value()) { + return AppleSiliconGeneration::kUnknown; + } + if (ContainsAppleMGeneration(*brand, '1')) return AppleSiliconGeneration::kM1; + if (ContainsAppleMGeneration(*brand, '2')) return AppleSiliconGeneration::kM2; + if (ContainsAppleMGeneration(*brand, '3')) return AppleSiliconGeneration::kM3; + if (ContainsAppleMGeneration(*brand, '4')) return AppleSiliconGeneration::kM4; + if (ContainsAppleMGeneration(*brand, '5')) return AppleSiliconGeneration::kM5; + return AppleSiliconGeneration::kUnknown; +} + +bool HasNonZeroBytes(const SmcValue& value) { + const uint32_t byte_count = + std::min(value.data_size, value.bytes.size()); + for (uint32_t i = 0; i < byte_count; ++i) { + if (value.bytes[i] != 0) { + return true; + } + } + return false; +} + +// Decodes the SMC value bytes per its data type. Apple Silicon - this app's +// support matrix, and the only platform the per-generation key lists cover - +// reports temperatures exclusively as "flt " (IEEE float); the Intel-era +// fixed-point types (sp*, fpe2) are deliberately not decoded. Returns nullopt +// for an all-zero or unrecognized value. +std::optional DecodeSmcTemperature(const SmcValue& value) { + if (value.data_size == 0 || !HasNonZeroBytes(value)) { + return std::nullopt; + } + if (value.data_type != TypeCode("flt ") || value.data_size < sizeof(float)) { + return std::nullopt; + } + float raw = 0.0F; + std::memcpy(&raw, value.bytes.data(), sizeof(raw)); + return static_cast(raw); +} + +// Owns an open AppleSMC user-client connection and reads one key at a time via +// the two-call (key-info, then bytes) protocol. Not copyable; the connection is +// closed on destruction. +class SmcConnection { + public: + SmcConnection() { + io_service_t device = IOServiceGetMatchingService( + kIOMainPortDefault, IOServiceMatching("AppleSMC")); + if (device == IO_OBJECT_NULL) { + return; + } + if (IOServiceOpen(device, mach_task_self(), 0, &connection_) != + kIOReturnSuccess) { + connection_ = IO_OBJECT_NULL; + } + IOObjectRelease(device); + } + + ~SmcConnection() { + if (connection_ != IO_OBJECT_NULL) { + IOServiceClose(connection_); + } + } + + SmcConnection(const SmcConnection&) = delete; + SmcConnection& operator=(const SmcConnection&) = delete; + + bool is_open() const { return connection_ != IO_OBJECT_NULL; } + + // Reads a key's metadata (data size and type). This is stable for the life of + // the machine, so callers cache it and then read values via ReadValue without + // repeating this call. Returns nullopt on RPC failure or a zero-size key. + std::optional ReadKeyInfo(std::string_view key) const { + if (!is_open()) { + return std::nullopt; + } + SmcKeyData input; + SmcKeyData output; + input.key = FourCharToKey(key); + input.data8 = kSmcCmdReadKeyInfo; + // `result` is the SMC status byte (e.g. 0x84 = key not found); a non-zero + // status can come back with kIOReturnSuccess, so both must be checked. + if (Call(input, &output) != kIOReturnSuccess || output.result != 0 || + output.key_info.data_size == 0) { + return std::nullopt; + } + return output.key_info; + } + + // Reads a key's value bytes given its already-known metadata, performing only + // the single bytes-read RPC (the key-info RPC is skipped). This halves the + // per-read SMC traffic on the hot path, where the probe re-reads the same + // core keys every tick. + std::optional ReadValue(std::string_view key, + const SmcKeyInfo& info) const { + if (!is_open()) { + return std::nullopt; + } + SmcKeyData input; + SmcKeyData output; + input.key = FourCharToKey(key); + input.key_info.data_size = info.data_size; + input.data8 = kSmcCmdReadBytes; + // A non-zero SMC status byte means the read failed even when the RPC + // itself succeeded; without this check garbage bytes could decode into a + // plausible value and contaminate the held per-core readings. + if (Call(input, &output) != kIOReturnSuccess || output.result != 0) { + return std::nullopt; + } + + SmcValue value; + value.data_size = info.data_size; + value.data_type = info.data_type; + const size_t byte_count = + std::min(value.bytes.size(), value.data_size); + std::memcpy(value.bytes.data(), output.bytes, byte_count); + return value; + } + + private: + kern_return_t Call(const SmcKeyData& input, SmcKeyData* output) const { + size_t output_size = sizeof(SmcKeyData); + return IOConnectCallStructMethod(connection_, kSmcKernelIndex, &input, + sizeof(SmcKeyData), output, &output_size); + } + + io_connect_t connection_ = IO_OBJECT_NULL; +}; + +// Returns the CPU-core key list for this machine's generation, or an empty +// span for non-Apple-Silicon / unknown chips. +std::span CpuKeysForGeneration( + AppleSiliconGeneration generation) { + switch (generation) { + case AppleSiliconGeneration::kM1: return kM1CpuKeys; + case AppleSiliconGeneration::kM2: return kM2CpuKeys; + case AppleSiliconGeneration::kM3: return kM3CpuKeys; + case AppleSiliconGeneration::kM4: return kM4CpuKeys; + case AppleSiliconGeneration::kM5: return kM5CpuKeys; + case AppleSiliconGeneration::kUnknown: return {}; + } + return {}; +} + +// Process-lifetime CPU-core temperature reader. Holds one SMC connection and +// the last in-range temperature seen per core key, so a core that is parked +// this tick (reading the idle floor) still contributes its last real value to +// the average - matching how Stats keeps the reading steady. Guarded by a mutex +// because the native RPC handler may invoke ReadCpuTemperature from a non-main +// thread. +class CpuCoreTemperatureReader { + public: + // Returns the sum and count of the held per-core readings (not a finished + // average), so the caller can merge these SMC cores with the HID cores before + // averaging the union. + TemperatureAccumulator Read() { + std::lock_guard lock(mutex_); + + TemperatureAccumulator accumulator; + if (!initialized_) { + generation_ = DetectAppleSiliconGeneration(); + initialized_ = true; + } + if (!smc_.is_open()) { + return accumulator; + } + + for (const std::string_view key : CpuKeysForGeneration(generation_)) { + // Cache each key's metadata once: size/type are fixed for the machine's + // life, so later ticks skip the key-info RPC and do only the bytes-read. + // The cached value is optional: a key absent from this chip caches as + // nullopt ("known absent") so it is probed at most once, never re-queried + // every tick - the generation key lists are supersets and many keys do not + // exist on a given chip. + const uint32_t key_code = FourCharToKey(key); + auto info_it = key_info_.find(key_code); + if (info_it == key_info_.end()) { + info_it = key_info_.emplace(key_code, smc_.ReadKeyInfo(key)).first; + } + if (!info_it->second.has_value()) { + continue; // Known-absent key on this chip. + } + + const std::optional value = + smc_.ReadValue(key, *info_it->second); + if (!value.has_value()) { + continue; + } + // Keep only in-range readings; a parked core reads the idle floor and is + // ignored this tick (its held value, if any, still counts below). + const std::optional celsius = DecodeSmcTemperature(*value); + if (celsius.has_value() && IsPlausibleTemperature(*celsius)) { + last_good_[key_code] = *celsius; + } + } + + // Accumulate every core that has ever produced a real reading. Cores never + // yet seen in range (e.g. parked since launch) are simply absent until they + // wake once, which avoids averaging in the idle floor. + for (const auto& entry : last_good_) { + accumulator.Add(entry.second); + } + return accumulator; + } + + private: + std::mutex mutex_; + bool initialized_ = false; + AppleSiliconGeneration generation_ = AppleSiliconGeneration::kUnknown; + SmcConnection smc_; + // Both maps are keyed by the key's FourCharCode, so the per-tick lookups + // allocate nothing. nullopt = key probed and absent on this chip (do not + // re-probe). + std::unordered_map> key_info_; + std::unordered_map last_good_; +}; + +// --------------------------------------------------------------------------- +// HID CPU-core temperature path +// +// Apple also exposes per-core CPU temperatures on a HID page (0xff05) as +// services named "pACC MTR Temp" (performance) and "eACC MTR Temp" (efficiency +// cores). These come back already resolved (no idle floor), so they need only a +// range filter. The page is empty on some machines; where present its readings +// are averaged with the SMC ones. SOC die sensors on this page are deliberately +// skipped - they are not CPU-core temperatures. +// --------------------------------------------------------------------------- + +constexpr int32_t kHIDEventTypeTemperature = 15; +constexpr int32_t kIOHIDEventFieldTemperature = kHIDEventTypeTemperature << 16; +constexpr int32_t kAppleVendorTemperatureSensorPage = 0xff05; +constexpr int32_t kAppleVendorTemperatureSensorUsage = 0x0005; + +CFDictionaryRef CreateCpuCoreSensorMatch() { + int32_t page = kAppleVendorTemperatureSensorPage; + int32_t usage = kAppleVendorTemperatureSensorUsage; + CFNumberRef page_number = CFNumberCreate(nullptr, kCFNumberSInt32Type, &page); + CFNumberRef usage_number = CFNumberCreate(nullptr, kCFNumberSInt32Type, &usage); + if (page_number == nullptr || usage_number == nullptr) { + if (page_number != nullptr) CFRelease(page_number); + if (usage_number != nullptr) CFRelease(usage_number); + return nullptr; + } + const void* keys[] = {CFSTR("PrimaryUsagePage"), CFSTR("PrimaryUsage")}; + const void* values[] = {page_number, usage_number}; + CFDictionaryRef match = + CFDictionaryCreate(nullptr, keys, values, 2, &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + CFRelease(page_number); + CFRelease(usage_number); + return match; +} + +// True only for the CPU performance/efficiency core sensors ("pACC MTR Temp" / +// "eACC MTR Temp"); the GPU ("GPU MTR Temp"), SOC, ANE, and ISP sensors share +// the page but are not CPU-core temperatures. +bool IsCpuCoreSensorName(CFStringRef name) { + return CFStringHasPrefix(name, CFSTR("pACC MTR Temp")) || + CFStringHasPrefix(name, CFSTR("eACC MTR Temp")); +} + +// One read pass over an already-copied service list. Sets `has_cpu_sensor` +// true when at least one CPU-core sensor *service* is present, independent of +// whether its reading is plausible this tick - the caller latches off only on +// a successful enumeration that proved this machine has no such sensors (a +// fixed hardware fact), so a present-but-momentarily-unreadable sensor never +// causes a false latch. +TemperatureAccumulator ReadCpuCoreTemperaturesFrom(CFArrayRef services, + bool* has_cpu_sensor) { + TemperatureAccumulator accumulator; + *has_cpu_sensor = false; + + const CFIndex service_count = CFArrayGetCount(services); + for (CFIndex i = 0; i < service_count; ++i) { + IOHIDServiceClientRef service = static_cast( + const_cast(CFArrayGetValueAtIndex(services, i))); + if (service == nullptr) { + continue; + } + CFTypeRef name_ref = IOHIDServiceClientCopyProperty(service, CFSTR("Product")); + if (name_ref == nullptr) { + continue; + } + const bool is_cpu_core = CFGetTypeID(name_ref) == CFStringGetTypeID() && + IsCpuCoreSensorName(static_cast(name_ref)); + CFRelease(name_ref); + if (!is_cpu_core) { + continue; + } + // A CPU-core sensor service exists on this machine, independent of whether + // its reading is plausible this tick. + *has_cpu_sensor = true; + IOHIDEventRef event = + IOHIDServiceClientCopyEvent(service, kHIDEventTypeTemperature, 0, 0); + if (event == nullptr) { + continue; + } + const double celsius = + IOHIDEventGetFloatValue(event, kIOHIDEventFieldTemperature); + CFRelease(event); + if (IsPlausibleTemperature(celsius)) { + accumulator.Add(celsius); + } + } + + return accumulator; +} + +// Process-lifetime HID CPU-core reader. The HID temperature page exists on some +// Macs and is entirely absent on others (e.g. the M2 Max this was developed on +// returns no CPU-core sensors). Whether the page has sensors cannot change while +// the machine runs, so once an enumeration succeeds and finds none, this latches +// "unavailable" and skips all HID work on every later tick. A transient setup +// failure does not latch - it is retried. Mutex-guarded because the RPC handler +// may call it off the main thread. +class HidCpuCoreTemperatureReader { + public: + ~HidCpuCoreTemperatureReader() { + if (client_ != nullptr) { + CFRelease(client_); + } + } + + TemperatureAccumulator Read() { + std::lock_guard lock(mutex_); + if (latched_unavailable_) { + return {}; + } + + // The matched client is created once and reused: client creation is the + // expensive part of a pass. The service list is still copied fresh each + // tick, so no stale service reference is ever read. A transient creation + // failure leaves `client_` null and retries on the next tick. + if (client_ == nullptr) { + CFDictionaryRef match = CreateCpuCoreSensorMatch(); + if (match == nullptr) { + return {}; + } + client_ = IOHIDEventSystemClientCreate(kCFAllocatorDefault); + if (client_ == nullptr) { + CFRelease(match); + return {}; + } + IOHIDEventSystemClientSetMatching(client_, match); + CFRelease(match); + } + + CFArrayRef services = IOHIDEventSystemClientCopyServices(client_); + if (services == nullptr) { + return {}; + } + + bool has_cpu_sensor = false; + TemperatureAccumulator accumulator = + ReadCpuCoreTemperaturesFrom(services, &has_cpu_sensor); + CFRelease(services); + + // The service list was obtained: a genuine enumeration, authoritative for + // this machine even when it matched zero CPU-core sensors - a fixed + // hardware fact, so latch off rather than re-enumerating forever. + if (!has_cpu_sensor) { + latched_unavailable_ = true; + } + return accumulator; + } + + private: + std::mutex mutex_; + bool latched_unavailable_ = false; + IOHIDEventSystemClientRef client_ = nullptr; +}; + +} // namespace + +CpuTemperatureReading ReadCpuTemperature() { + // Average the union of every in-range CPU-core reading from both core sources: + // the AppleSMC per-core keys (idle-floor held) and the HID CPU-core sensors. + // There is no die / approximate fallback: when neither source yields a + // plausible CPU-core value the result is unavailable, so the card only ever + // shows a real CPU-core temperature. + static CpuCoreTemperatureReader smc_reader; + static HidCpuCoreTemperatureReader hid_reader; + + TemperatureAccumulator cores = smc_reader.Read(); + cores.Merge(hid_reader.Read()); + return ReadingFromAverage(cores.sum, cores.count); +} + +} // namespace mostats diff --git a/src/native/metrics/temperature_probe.h b/src/native/metrics/temperature_probe.h new file mode 100644 index 0000000..b9e27ca --- /dev/null +++ b/src/native/metrics/temperature_probe.h @@ -0,0 +1,40 @@ +#ifndef MOSTATS_METRICS_TEMPERATURE_PROBE_H_ +#define MOSTATS_METRICS_TEMPERATURE_PROBE_H_ + +// Narrow CPU-temperature probe over private macOS sensor APIs. +// +// macOS exposes no documented public CPU temperature source on Apple Silicon, +// so this is the only honest path other than reporting unavailable. The probe +// averages the union of every in-range per-core reading from two CPU-core +// sources (the same sources Stats averages for its "Average CPU" value): +// - generation-specific AppleSMC core keys. A parked core's SMC sensor reads a +// meaningless idle floor until it wakes, so the probe holds the last +// in-range value per core and reuses it while the core is parked. +// - the HID CPU-core sensors ("pACC/eACC MTR Temp"), which come back already +// resolved (no idle floor) but are absent on some machines. +// Both sources measure CPU cores, so neither misreports the value. There is no +// die / approximate fallback: when neither source yields a plausible CPU-core +// value the probe reports unavailable, so the result is only ever a real +// CPU-core temperature. See temperature_probe.cc for the decode and validation +// rules and the private symbol declarations. + +namespace mostats { + +// One CPU-temperature reading. `available` is false when no trustworthy CPU +// sensor could be read; `celsius` is then 0 and the caller degrades only the +// temperature card to unavailable. +struct CpuTemperatureReading { + bool available = false; + double celsius = 0.0; +}; + +// Reads the average CPU-core temperature, or reports it unavailable. Never +// throws and is safe to call on any macOS machine: an unsupported machine, +// missing core sensors, or implausible readings all yield an unavailable result +// rather than an approximated value. Stateful across calls (holds the last +// in-range value per SMC core); callers may invoke it on any thread. +CpuTemperatureReading ReadCpuTemperature(); + +} // namespace mostats + +#endif // MOSTATS_METRICS_TEMPERATURE_PROBE_H_ diff --git a/src/native/processes/app_metadata.h b/src/native/processes/app_metadata.h new file mode 100644 index 0000000..787125e --- /dev/null +++ b/src/native/processes/app_metadata.h @@ -0,0 +1,82 @@ +#ifndef MOSTATS_PROCESSES_APP_METADATA_H_ +#define MOSTATS_PROCESSES_APP_METADATA_H_ + +#include +#include +#include +#include + +#include "gen/process_collector.pb.h" + +namespace mostats { + +/** + * Maps PID -> GUI application identity (bundle id, localized name, exact bundle + * path when appropriate) for the currently running applications. + * + * Backed by NSWorkspace.runningApplications, so it only covers processes that + * macOS treats as user-facing GUI applications (Finder, Safari, Xcode, ...), + * not every PID in the process table. The collector merges this onto matching + * records; processes with no entry keep bundle id / localized name unset. + * + * Icons are NOT resolved here. The collector resolves every process's icon + * uniformly via {@link ResolveIconForPath} from the same bundle the row groups by + * (yielding the owning `.app` icon, identical to NSRunningApplication.icon for a + * GUI app - verified - and the generic icon for a daemon), so there is no + * GUI-only icon special case and the per-path icon cache covers every row. + */ +std::unordered_map SnapshotRunningAppMetadata(); + +/** + * Resolves a small icon for an exact app/file path and returns its content-hash + * key, borrowed from the session icon cache - or null when no icon could be + * resolved. The pointer stays valid only until the next {@link PruneIconCache} + * call; consume it within the same pass. The encoded bytes stay in the cache + * and are served by key through {@link CopyIconForKey}. + * + * Not limited to GUI apps: a `.app` bundle path yields the app's real icon and a + * plain executable path yields the generic system executable icon, via + * NSWorkspace's iconForFile:. The collector passes the owning `.app` bundle when + * the record has one (so every member of a multi-process app shares the app's + * icon) and the bare executable path otherwise. + * + * The encoded icon and its content key are cached per path while the path stays + * in use (see {@link PruneIconCache}), so a steady-state pass is a hash lookup + * with no AppKit drawing, no PNG encode, and no re-hash. The icon is volatile + * display-only data and is never logged or persisted. + */ +const std::string* ResolveIconForPath(const std::string& path); + +/** + * Drops cached icons whose resolution path is not in `used_paths` (the paths the + * just-finished pass resolved icons from). Keeps the icon cache bounded by the + * live processes: per-launch paths such as app-translocation directories would + * otherwise accumulate for the whole session. An exited app's icon is simply + * re-encoded once if it launches again. + */ +void PruneIconCache(const std::unordered_set& used_paths); + +/** + * Copies the cached icon bytes for a content key (a value previously returned + * via {@link ResolvedIcon}.content_key) into `png_base64`. Returns false when no + * cached icon has that key - the app exited and its entry was pruned, or the key + * was never issued; the caller omits the entry and the row falls back to a + * generic glyph. Serves the GetIcons RPC, which can run concurrently with a + * collection pass, so the lookup synchronizes on the icon-cache mutex. + */ +bool CopyIconForKey(const std::string& key, std::string* png_base64); + +/** + * Fills the owning `.app` bundle (path + display name) for an executable path, + * used to group a multi-process app's members into one list row. + * + * The bundle is the outermost `.app` in the path, so the main app process and + * its helpers (which carry no bundle id of their own) resolve to the same + * bundle. A path with no `.app` segment leaves `out` unset, so the renderer + * keeps it as a singleton. + */ +void FillAppBundle(const std::string& executable_path, NativeAppBundle* out); + +} // namespace mostats + +#endif // MOSTATS_PROCESSES_APP_METADATA_H_ diff --git a/src/native/processes/app_metadata.mm b/src/native/processes/app_metadata.mm new file mode 100644 index 0000000..f5cea6a --- /dev/null +++ b/src/native/processes/app_metadata.mm @@ -0,0 +1,348 @@ +#include "processes/app_metadata.h" + +#import + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mostats { +namespace { + +// Icon edge length in points (the offscreen raster scales with the screen's +// backing factor, so Retina yields a 64 px bitmap). Small on purpose: the icon +// is volatile display data sent on every snapshot pull, and the list renders it +// tiny, so this keeps the payload light while staying crisp at row sizes. +constexpr int kIconSizePoints = 32; + +// One cached icon: the encoded PNG and its content-hash key for the response's +// dedup table. The key is computed once at encode time so a steady-state pass +// neither re-encodes nor re-hashes. +struct CachedIcon { + std::string png_base64; + std::string content_key; +}; + +// Session cache of encoded icons, keyed by the icon resolution path (the owning +// `.app` bundle, else the executable). Rasterizing an NSImage and +// PNG/base64-encoding it is by far the most expensive step, and an app's icon +// does not change while it runs, so caching the encoded string lets a +// steady-state collection skip the draw/encode entirely. Keying on the `.app` +// bundle means all members of a multi-process app share one entry. Pruned each +// pass to the paths still in use (see PruneIconCache), so it is bounded by the +// live processes rather than every path ever seen. +// +// Threading: unlike the other session caches (touched only by the serial +// collector), this one has a second entry point - the GetIcons RPC reads it by +// content key (CopyIconForKey) and can run concurrently with a collection pass. +// Every access therefore takes IconCacheMutex(). The icon is volatile display +// data and is never logged or persisted. +std::unordered_map& IconCache() { + static std::unordered_map cache; + return cache; +} + +// Guards IconCache() against the concurrent GetIcons reader (see above). +std::mutex& IconCacheMutex() { + static std::mutex mutex; + return mutex; +} + +// Content key for an icon's base64 bytes: 64-bit FNV-1a as hex. Non-cryptographic +// is fine - the key only has to collapse identical icons to one table entry and +// tell different ones apart. A collision would at worst show one wrong icon; +// nothing crashes. Computed once per encode and cached alongside the bytes. +std::string IconContentKey(const std::string& base64) { + uint64_t hash = 0xcbf29ce484222325ULL; + for (const unsigned char byte : base64) { + hash ^= byte; + hash *= 0x100000001b3ULL; + } + char out[17]; + std::snprintf(out, sizeof(out), "%016llx", + static_cast(hash)); + return std::string(out, 16); +} + +// One cached NSWorkspace metadata entry, guarded by the app's launch time (the +// same pid+started_at identity discipline the rest of the app uses): a +// different launch time for the same PID means the PID was reused and the entry +// must be re-read. 0 stands in for a nil launchDate. Pointer identity is NOT +// usable as a guard - runningApplications vends fresh autoreleased wrapper +// instances per call (measured: a pointer-guarded cache never hit). +struct CachedAppMetadata { + double launched_at; + NativeAppMetadata metadata; +}; + +// Per-PID cache of NSWorkspace app metadata. The bridged property reads +// (bundleIdentifier, localizedName, bundleURL) cost ~12-15 ms per pass for ~50 +// apps - by far the dominant share of the NSWorkspace snapshot - while the +// values themselves are fixed for an app instance's lifetime, so each app is +// read once (plus the cheap pid/launchDate reads per pass) and served from +// here afterwards. Pruned each pass to the apps actually running. Known +// accepted staleness: an app that flips its activation policy after first +// sight keeps its cached bundle-path visibility; grouping still works through +// the executable-path bundle, so nothing user-visible breaks. Threading: +// passes are serialized by the pass mutex in CollectProcesses, same as the +// other session caches. +std::unordered_map& AppMetadataCache() { + static std::unordered_map cache; + return cache; +} + +// Fills a NativeString from an NSString, marking it unavailable when empty so an +// absent value is never confused with a real empty string. +void FillString(NativeString* out, NSString* value) { + if (value.length == 0) { + out->set_status(NATIVE_FIELD_STATUS_UNAVAILABLE); + return; + } + out->set_status(NATIVE_FIELD_STATUS_AVAILABLE); + out->set_value(value.UTF8String); +} + +// Rasterizes an app icon to a small base64-encoded PNG string, or returns an +// empty string on any failure (no icon, no CGImage, empty PNG/encode). This is +// the expensive step (offscreen draw + PNG + base64); the caller caches the +// result so a steady-state pass does not repeat it. The icon is not logged. +std::string EncodeIconBase64(NSImage* icon) { + if (icon == nil) { + return std::string(); + } + + // imageWithSize:flipped:drawingHandler: is the supported offscreen-render + // path (lockFocus is deprecated for this); the handler runs when the CGImage + // is requested below. + NSImage* resized = [NSImage + imageWithSize:NSMakeSize(kIconSizePoints, kIconSizePoints) + flipped:NO + drawingHandler:^BOOL(NSRect destination) { + [icon drawInRect:destination + fromRect:NSZeroRect + operation:NSCompositingOperationSourceOver + fraction:1.0 + respectFlipped:YES + hints:@{NSImageHintInterpolation : @(NSImageInterpolationHigh)}]; + return YES; + }]; + + CGImageRef cg_image = + [resized CGImageForProposedRect:nullptr context:nil hints:nil]; + if (cg_image == nullptr) { + return std::string(); + } + + NSBitmapImageRep* bitmap = + [[NSBitmapImageRep alloc] initWithCGImage:cg_image]; + [bitmap setSize:NSMakeSize(kIconSizePoints, kIconSizePoints)]; + NSData* png = + [bitmap representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + if (png.length == 0) { + return std::string(); + } + + NSString* encoded = [png base64EncodedStringWithOptions:0]; + if (encoded.length == 0) { + return std::string(); + } + return encoded.UTF8String; +} + +// The owning `.app` bundle of an executable path: the outermost `.app` segment. +// A multi-process app nests every member inside its `.app` (the main process at +// `.app/Contents/MacOS/`, helpers at deeper `.../.app/...` +// paths), so matching the first `.app` groups all members under the parent app. +// Returns empty strings for a path with no `.app` (a plain daemon). +struct AppBundle { + std::string path; // up to and including the outermost `.app`, or empty + std::string name; // bundle basename without `.app`, or empty +}; + +std::string AppNameFromBundlePath(const std::string& bundle_path) { + constexpr char kAppExtension[] = ".app"; + constexpr std::string::size_type kAppLen = sizeof(kAppExtension) - 1; + const std::string::size_type name_start = bundle_path.rfind('/'); + const std::string base = name_start == std::string::npos + ? bundle_path + : bundle_path.substr(name_start + 1); + if (base.size() <= kAppLen || + base.compare(base.size() - kAppLen, kAppLen, kAppExtension) != 0) { + return {}; + } + return base.substr(0, base.size() - kAppLen); +} + +void FillBundle(const std::string& bundle_path, NativeAppBundle* out) { + const std::string name = AppNameFromBundlePath(bundle_path); + if (name.empty()) { + return; + } + out->mutable_path()->set_status(NATIVE_FIELD_STATUS_AVAILABLE); + out->mutable_path()->set_value(bundle_path); + out->mutable_name()->set_status(NATIVE_FIELD_STATUS_AVAILABLE); + out->mutable_name()->set_value(name); +} + +AppBundle AppBundleForPath(const std::string& executable_path) { + constexpr char kAppSuffix[] = ".app/"; + constexpr std::string::size_type kAppLen = sizeof(kAppSuffix) - 2; // ".app" + const std::string::size_type slash = executable_path.find(kAppSuffix); + if (slash == std::string::npos) { + return {}; + } + const std::string path = executable_path.substr(0, slash + kAppLen); + const std::string name = AppNameFromBundlePath(path); + if (name.empty()) { + return {}; // A segment literally named ".app" is not a real bundle. + } + return {path, name}; +} + +} // namespace + +void FillAppBundle(const std::string& executable_path, NativeAppBundle* out) { + const AppBundle bundle = AppBundleForPath(executable_path); + if (bundle.path.empty()) { + return; // Not inside a `.app`; leave the bundle unset (UNKNOWN downstream). + } + FillBundle(bundle.path, out); +} + +const std::string* ResolveIconForPath(const std::string& path) { + if (path.empty()) { + return nullptr; + } + + // Fast path: the encoded icon is cached per resolution path, so a steady-state + // pass (every bundle/executable already seen) is a hash lookup with no AppKit + // work at all. Only the first time a given bundle/executable is seen do we + // resolve+encode+hash. Measured on a ~700-process machine: a fully warm pass is + // well under 1 ms, while a cold resolve+encode is the only real cost, per path. + // + // The returned pointer stays valid across the unlocked encode below and until + // PruneIconCache: only the collector thread mutates the map, and unordered_map + // insertions never invalidate element pointers. The lock only orders this + // thread's find/emplace against the concurrent CopyIconForKey reader. + auto& cache = IconCache(); + { + const std::lock_guard lock(IconCacheMutex()); + const auto cached = cache.find(path); + if (cached != cache.end()) { + return &cached->second.content_key; + } + } + + std::string encoded; + @autoreleasepool { + NSString* file_path = [NSString stringWithUTF8String:path.c_str()]; + // iconForFile: never returns nil: a `.app` bundle yields its real icon, and + // a plain executable yields the generic Unix-executable icon (the same + // thing Activity Monitor shows), so this raises icon coverage well beyond + // the GUI-app-only NSWorkspace enrichment without any private API. + NSImage* icon = file_path == nil + ? nil + : [[NSWorkspace sharedWorkspace] iconForFile:file_path]; + encoded = EncodeIconBase64(icon); + } + + if (encoded.empty()) { + // A failed resolve/encode is left uncached so a transiently missing icon + // can still resolve on a later pass. + return nullptr; + } + + CachedIcon entry; + entry.content_key = IconContentKey(encoded); + entry.png_base64 = std::move(encoded); + const std::lock_guard lock(IconCacheMutex()); + return &cache.emplace(path, std::move(entry)).first->second.content_key; +} + +void PruneIconCache(const std::unordered_set& used_paths) { + const std::lock_guard lock(IconCacheMutex()); + auto& cache = IconCache(); + for (auto it = cache.begin(); it != cache.end();) { + it = used_paths.count(it->first) == 0 ? cache.erase(it) : std::next(it); + } +} + +bool CopyIconForKey(const std::string& key, std::string* png_base64) { + if (key.empty()) { + return false; + } + + // Linear scan: the cache is keyed by resolution path, not content key, and is + // bounded by the live process set (~hundreds of entries). GetIcons is called + // only for keys the renderer does not hold yet (first pull, newly seen apps), + // so a scan per requested key is cheaper than maintaining a second + // key-indexed map that PruneIconCache would have to keep in sync. + const std::lock_guard lock(IconCacheMutex()); + for (const auto& entry : IconCache()) { + if (entry.second.content_key == key) { + *png_base64 = entry.second.png_base64; + return true; + } + } + return false; +} + +std::unordered_map SnapshotRunningAppMetadata() { + std::unordered_map by_pid; + + @autoreleasepool { + NSArray* applications = + [[NSWorkspace sharedWorkspace] runningApplications]; + by_pid.reserve(applications.count); + auto& cache = AppMetadataCache(); + + for (NSRunningApplication* application in applications) { + const int32_t pid = static_cast(application.processIdentifier); + NSDate* launch_date = application.launchDate; + const double launched_at = + launch_date == nil ? 0 : launch_date.timeIntervalSince1970; + + // Metadata is fixed per app instance; serve it from the per-PID cache and + // pay the expensive bridged reads (bundleIdentifier, localizedName, + // bundleURL) only the first time an app is seen. The launch time guards + // PID reuse: a different launch time re-reads. + const auto cached = cache.find(pid); + if (cached != cache.end() && cached->second.launched_at == launched_at) { + by_pid.emplace(pid, cached->second.metadata); + continue; + } + + NativeAppMetadata metadata; + FillString(metadata.mutable_bundle_identifier(), + application.bundleIdentifier); + FillString(metadata.mutable_localized_name(), application.localizedName); + // Icon is resolved by the collector from the executable path (uniformly for + // GUI apps and daemons), not here - so NSRunningApplication.icon is never + // touched. Only the cheap identity fields come from NSWorkspace. + if (application.activationPolicy != + NSApplicationActivationPolicyProhibited) { + NSString* bundle_path = application.bundleURL.path; + if (bundle_path.length > 0) { + FillBundle(bundle_path.UTF8String, metadata.mutable_bundle()); + } + } + cache[pid] = CachedAppMetadata{launched_at, metadata}; + by_pid.emplace(pid, std::move(metadata)); + } + + // Drop cache entries for apps no longer running, so the cache tracks only + // the live set (same prune discipline as the other session caches). + for (auto it = cache.begin(); it != cache.end();) { + it = by_pid.count(it->first) == 0 ? cache.erase(it) : std::next(it); + } + } + + return by_pid; +} + +} // namespace mostats diff --git a/src/native/processes/process_collector.cc b/src/native/processes/process_collector.cc new file mode 100644 index 0000000..07d1819 --- /dev/null +++ b/src/native/processes/process_collector.cc @@ -0,0 +1,801 @@ +#include "processes/process_collector.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "processes/app_metadata.h" +#include "processes/responsiveness.h" + +namespace mostats { +namespace { + +// Cap on the argument count decoded from a KERN_PROCARGS2 buffer. The buffer is +// untrusted kernel data; a corrupt header must not drive an unbounded loop. +constexpr int kMaxReasonableArgCount = 4096; + +// Retry budget for the proc_listallpids capacity race (the table can grow +// between the size query and the read). +constexpr int kMaxPidListAttempts = 5; +constexpr size_t kMinimumPidCapacity = 256; + +// Maps an errno from a failed libproc/sysctl call to a per-field availability so +// the renderer can tell "exited" / "denied" / "unavailable" apart. errno 0 means +// the call failed without setting errno, which we treat as a plain unavailable. +NativeFieldStatus StatusFromErrno(int error_number) { + switch (error_number) { + case ESRCH: + return NATIVE_FIELD_STATUS_PROCESS_EXITED; + case EACCES: + case EPERM: + return NATIVE_FIELD_STATUS_PERMISSION_DENIED; + case ENOSYS: +#ifdef ENOTSUP + case ENOTSUP: +#endif + return NATIVE_FIELD_STATUS_UNSUPPORTED; + default: + return NATIVE_FIELD_STATUS_UNAVAILABLE; + } +} + +// Fills a NativeString as available with the given value. +void SetAvailableString(NativeString* out, const char* value) { + out->set_status(NATIVE_FIELD_STATUS_AVAILABLE); + out->set_value(value == nullptr ? "" : value); +} + +// Fills a NativeInt64 as available with the given value. +void SetAvailableInt64(NativeInt64* out, int64_t value) { + out->set_status(NATIVE_FIELD_STATUS_AVAILABLE); + out->set_value(value); +} + +// Saturating uint64 -> int64 for the proto's signed counters; the raw macOS +// values (footprint bytes, CPU nanoseconds) never realistically reach this. +int64_t SaturatingInt64(uint64_t value) { + const uint64_t max_int64 = + static_cast(std::numeric_limits::max()); + return value > max_int64 ? std::numeric_limits::max() + : static_cast(value); +} + +// Cached command line (argv) per process image, across passes. argv is fixed at +// exec (a later exec() installs a new image and rotates the cache key - see +// CommandLineKey), and KERN_PROCARGS2 is by far the most expensive per-PID read, +// so caching it makes a steady pass mostly task-info + rusage. A cache hit still +// ships the full argument bytes on the response - the cache only avoids the +// syscall, never the payload, so main holds no argv store to keep in sync. +// Only argv is cached: the executable PATH is deliberately NOT cached +// because macOS can transiently report a translocated/staging path +// (/private/var/folders/...) for the first tick of a freshly launched app, and +// freezing that would pin a wrong path (and a generic icon) for the process's +// life. The path, executable name, and command name are cheap and are re-read +// every tick so the icon always tracks the real bundle and self-heals. CPU time, +// memory, and thread count are likewise always read fresh - they change per tick. +// +// Session cache keyed by "pid:started_at:executable_path". Keying on start time +// (not pid alone) means a reused PID is a different key and re-reads, so a new +// process never inherits the prior occupant's argv. The executable path is part +// of the key because exec() replaces argv while keeping the pid AND the +// fork-time start time; an exec nearly always changes the executable path, so +// the key rotates and argv is re-read once instead of staying frozen at the +// pre-exec value for the process's lifetime. Pruned each pass to the keys +// actually seen (see CollectProcesses). +// +// Threading: touched only during a collection pass, and passes are serialized +// by the pass mutex in CollectProcesses - no per-cache lock needed, same +// contract as the uid and app-metadata caches. +std::unordered_map& CommandLineCache() { + static std::unordered_map cache; + return cache; +} + +// Cache key for a record's command line, or empty when the start time is +// unavailable. Without a known start time a PID cannot be safely keyed (a +// reused PID would collide), so such a record is never cached and always reads +// fresh - the same rule the main-side CPU baseline uses. The executable path +// segment (empty when the path is unavailable) makes an exec() rotate the key, +// so cached argv cannot outlive the image it was read from. +std::string CommandLineKey(const NativeProcessIdentity& identity, + const NativeString& executable_path) { + if (identity.started_at_status() != NATIVE_FIELD_STATUS_AVAILABLE) { + return std::string(); + } + std::string key = std::to_string(identity.pid()); + key += ':'; + key += std::to_string(identity.started_at_unix_ms()); + key += ':'; + if (executable_path.status() == NATIVE_FIELD_STATUS_AVAILABLE) { + key += executable_path.value(); + } + return key; +} + +// Returns the cached command line for an identity key, or null on a miss. +NativeCommandLine* FindCommandLine(const std::string& key) { + auto& cache = CommandLineCache(); + const auto it = cache.find(key); + return it == cache.end() ? nullptr : &it->second; +} + +// Caches a record's command line under its identity key, but only when the key +// is stable (known start time) AND the read is worth not repeating: AVAILABLE +// (argv is fixed for the image) or PERMISSION_DENIED (the denial is uid-based +// and does not change for a live process, so re-issuing the syscall every pass +// is pure waste - on a typical machine hundreds of root-owned processes deny +// argv to a non-root user). UNAVAILABLE/PARSE_FAILED stay uncached so a +// transient failure is retried next pass rather than frozen. +void MaybeCacheCommandLine(const std::string& key, + const NativeProcessRecord& record) { + const NativeFieldStatus status = record.command_line().status(); + if (key.empty() || (status != NATIVE_FIELD_STATUS_AVAILABLE && + status != NATIVE_FIELD_STATUS_PERMISSION_DENIED)) { + return; + } + CommandLineCache()[key] = record.command_line(); +} + +// Drops cache entries whose key was not seen this pass (exited processes, +// reused PIDs, exec-rotated keys), so the cache tracks only live processes and +// cannot grow without bound. Mirrors the main-side CPU-baseline fresh-map prune. +void PruneCommandLineCache(const std::unordered_set& seen) { + auto& cache = CommandLineCache(); + for (auto it = cache.begin(); it != cache.end();) { + it = seen.count(it->first) == 0 ? cache.erase(it) : std::next(it); + } +} + +// Cache keys and icon resolution paths used by the records of one pass. The +// session caches (argv here, encoded icons in app_metadata) are pruned to these +// after the loop so they track only live processes and cannot grow without +// bound. +struct PassCacheUsage { + std::unordered_set command_line_keys; + std::unordered_set icon_paths; +}; + +// Enumerates all PIDs. proc_listallpids reports a PID count, not a byte count, +// and the table can grow between the size probe and the read, so the buffer is +// grown and retried. Returns false (status set) if the list cannot be read. +bool ListAllPids(std::vector* out, NativeFieldStatus* status) { + errno = 0; + const int required = proc_listallpids(nullptr, 0); + if (required <= 0) { + *status = StatusFromErrno(errno); + return false; + } + + size_t capacity = + std::max(static_cast(required) * 2, kMinimumPidCapacity); + for (int attempt = 0; attempt < kMaxPidListAttempts; ++attempt) { + if (capacity > + static_cast(std::numeric_limits::max()) / sizeof(pid_t)) { + *status = NATIVE_FIELD_STATUS_UNAVAILABLE; + return false; + } + + std::vector pids(capacity); + errno = 0; + const int count = proc_listallpids( + pids.data(), static_cast(pids.size() * sizeof(pid_t))); + if (count <= 0) { + *status = StatusFromErrno(errno); + return false; + } + + if (static_cast(count) < pids.size()) { + pids.resize(static_cast(count)); + // Drop pid 0: within the valid count it is the genuine kernel_task + // pseudo-process, not tail padding. Excluding it is deliberate - its + // task info and argv are denied to user space, so a row for it would + // carry a name and no usable metrics. + pids.erase(std::remove(pids.begin(), pids.end(), 0), pids.end()); + *out = std::move(pids); + *status = NATIVE_FIELD_STATUS_AVAILABLE; + return true; + } + + // Buffer was exactly filled, so the list may have been truncated; grow it. + capacity *= 2; + } + + *status = NATIVE_FIELD_STATUS_UNAVAILABLE; + return false; +} + +// Reads the older BSD `kern.proc.pid` record for coarse identity fields. Some +// protected macOS processes (notably WindowServer) deny PROC_PIDTASKALLINFO but +// still expose their name, parent PID, start time, and uid through this public +// sysctl. It does not expose reliable CPU or memory on current macOS, so it is +// an identity fallback only. +bool ReadKinfoProc(pid_t pid, kinfo_proc* info, NativeFieldStatus* status) { + int mib[] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, static_cast(pid)}; + size_t info_size = sizeof(*info); + errno = 0; + if (sysctl(mib, 4, info, &info_size, nullptr, 0) != 0) { + *status = StatusFromErrno(errno); + return false; + } + + if (info_size >= sizeof(*info) && info->kp_proc.p_pid == pid) { + *status = NATIVE_FIELD_STATUS_AVAILABLE; + return true; + } + + *status = NATIVE_FIELD_STATUS_PROCESS_EXITED; + return false; +} + +// Reads PROC_PIDTASKALLINFO (BSD + task info) for one PID. This single call +// provides the parent PID, command name, start time, resident size, and the +// cumulative CPU-time counter, so it is the backbone of each record. +bool ReadTaskAllInfo(pid_t pid, proc_taskallinfo* info, NativeFieldStatus* status) { + errno = 0; + const int written = + proc_pidinfo(pid, PROC_PIDTASKALLINFO, 0, info, sizeof(*info)); + if (written == static_cast(sizeof(*info))) { + *status = NATIVE_FIELD_STATUS_AVAILABLE; + return true; + } + *status = StatusFromErrno(errno); + return false; +} + +// Chooses a status when a primary task-info read and its BSD sysctl fallback +// both failed. A definite exit beats a denial; otherwise the original task-info +// failure is the most useful explanation for task-backed fields. +NativeFieldStatus CombinedFallbackStatus(NativeFieldStatus task_status, + NativeFieldStatus kinfo_status) { + if (kinfo_status == NATIVE_FIELD_STATUS_PROCESS_EXITED) { + return kinfo_status; + } + return task_status == NATIVE_FIELD_STATUS_UNSPECIFIED ? kinfo_status + : task_status; +} + +// Resolves the short command name. proc_name(3) is itself a PROC_PIDTBSDINFO +// read that returns pbi_name (falling back to pbi_comm), and the +// PROC_PIDTASKALLINFO read already carries that same proc_bsdinfo - so when task +// info succeeded the answer is already in hand and the per-PID proc_name syscall +// is skipped (~700 syscalls saved per pass). proc_name remains the first +// fallback when task info is denied; protected processes that deny both fall +// back to kern.proc.pid's p_comm. +void FillCommandName(pid_t pid, const proc_taskallinfo& task, bool task_ok, + const kinfo_proc& kinfo, bool kinfo_ok, + NativeFieldStatus kinfo_status, + NativeString* out) { + if (task_ok) { + if (task.pbsd.pbi_name[0] != '\0') { + SetAvailableString(out, task.pbsd.pbi_name); + return; + } + if (task.pbsd.pbi_comm[0] != '\0') { + SetAvailableString(out, task.pbsd.pbi_comm); + return; + } + } + + char name[2 * MAXCOMLEN] = {}; + errno = 0; + if (proc_name(pid, name, static_cast(sizeof(name))) > 0) { + SetAvailableString(out, name); + return; + } + const NativeFieldStatus name_status = StatusFromErrno(errno); + + if (kinfo_ok && kinfo.kp_proc.p_comm[0] != '\0') { + SetAvailableString(out, kinfo.kp_proc.p_comm); + return; + } + + out->set_status( + name_status == NATIVE_FIELD_STATUS_UNAVAILABLE ? kinfo_status + : name_status); +} + +// Returns the basename of an absolute path, or the whole string if it has no +// slash. Used to derive the executable name from the resolved path. +std::string BaseName(const std::string& path) { + const size_t slash = path.find_last_of('/'); + return slash == std::string::npos ? path : path.substr(slash + 1); +} + +// Resolves the absolute executable path (proc_pidpath) and, when available, the +// executable file name (its basename). Path failures degrade both fields. +void FillExecutablePathAndName(pid_t pid, NativeString* path_out, + NativeString* name_out) { + char path[PROC_PIDPATHINFO_MAXSIZE] = {}; + errno = 0; + if (proc_pidpath(pid, path, sizeof(path)) > 0) { + SetAvailableString(path_out, path); + SetAvailableString(name_out, BaseName(path).c_str()); + return; + } + + const NativeFieldStatus status = StatusFromErrno(errno); + path_out->set_status(status); + name_out->set_status(status); +} + +// Reads the start time from BSD info and writes it onto the identity as Unix +// milliseconds. Uses kern.proc.pid as a fallback when task info is denied. +void SetStartTime(const timeval& start_time, NativeProcessIdentity* identity) { + if (start_time.tv_sec == 0) { + identity->set_started_at_status(NATIVE_FIELD_STATUS_UNAVAILABLE); + return; + } + const int64_t millis = + static_cast(start_time.tv_sec) * 1000 + + static_cast(start_time.tv_usec) / 1000; + identity->set_started_at_status(NATIVE_FIELD_STATUS_AVAILABLE); + identity->set_started_at_unix_ms(millis); +} + +void FillStartTime(const proc_taskallinfo& task, bool task_ok, + NativeFieldStatus task_status, + const kinfo_proc& kinfo, bool kinfo_ok, + NativeFieldStatus kinfo_status, + NativeProcessIdentity* identity) { + if (task_ok) { + timeval start_time = {}; + start_time.tv_sec = static_cast(task.pbsd.pbi_start_tvsec); + start_time.tv_usec = + static_cast(task.pbsd.pbi_start_tvusec); + SetStartTime(start_time, identity); + return; + } + + if (kinfo_ok) { + SetStartTime(kinfo.kp_proc.p_starttime, identity); + return; + } + + identity->set_started_at_status( + CombinedFallbackStatus(task_status, kinfo_status)); +} + +// Parses a KERN_PROCARGS2 buffer into the argument vector. Layout: a leading +// int argc, the executable path (NUL-terminated), padding NULs, then argc +// NUL-terminated argv strings. The buffer is untrusted, so every read is +// bounds-checked and a malformed buffer degrades to PARSE_FAILED. +bool ParseProcArgs2(const char* data, size_t size, + std::vector* arguments) { + if (data == nullptr || size < sizeof(int)) { + return false; + } + + int argc = 0; + std::memcpy(&argc, data, sizeof(argc)); + if (argc < 0 || argc > kMaxReasonableArgCount) { + return false; + } + + size_t offset = sizeof(argc); + + // Skip the executable path string that precedes argv. + const void* exec_end = std::memchr(data + offset, '\0', size - offset); + if (exec_end == nullptr) { + return false; + } + offset = static_cast(exec_end) - data + 1; + + // Skip the alignment NULs between the exec path and the first argument. + while (offset < size && data[offset] == '\0') { + ++offset; + } + + arguments->reserve(static_cast(argc)); + for (int index = 0; index < argc; ++index) { + if (offset >= size) { + return false; + } + const void* arg_end = std::memchr(data + offset, '\0', size - offset); + if (arg_end == nullptr) { + return false; + } + const size_t terminator = static_cast(arg_end) - data; + arguments->emplace_back(data + offset, terminator - offset); + offset = terminator + 1; + } + return true; +} + +// Reads the system-wide KERN_ARGMAX argument-buffer size. It is constant for the +// running kernel, so the collector reads it once per pass and reuses it for +// every PID rather than issuing this sysctl per process. Returns 0 on failure. +int ReadArgMax() { + int arg_max = 0; + size_t arg_max_size = sizeof(arg_max); + int max_mib[] = {CTL_KERN, KERN_ARGMAX}; + if (sysctl(max_mib, 2, &arg_max, &arg_max_size, nullptr, 0) != 0 || + arg_max <= 0) { + return 0; + } + return arg_max; +} + +// Reads and parses the command-line arguments for one PID via KERN_PROCARGS2. +// The caller owns `buffer` (sized to KERN_ARGMAX, ~1 MiB) and reuses it across +// every PID in the pass, so this hot path performs no per-process allocation - +// only the sysctl copy and the parse. KERN_ARGMAX is constant for the kernel and +// is read once by the caller. Sensitive data: returned for display/search only; +// never logged here. +void FillCommandLine(pid_t pid, int arg_max, std::vector* buffer, + NativeCommandLine* out) { + if (arg_max <= 0 || buffer->empty()) { + out->set_status(NATIVE_FIELD_STATUS_UNAVAILABLE); + return; + } + + size_t buffer_size = buffer->size(); + int args_mib[] = {CTL_KERN, KERN_PROCARGS2, static_cast(pid)}; + errno = 0; + if (sysctl(args_mib, 3, buffer->data(), &buffer_size, nullptr, 0) != 0) { + out->set_status(StatusFromErrno(errno)); + return; + } + + std::vector arguments; + if (!ParseProcArgs2(buffer->data(), buffer_size, &arguments)) { + out->set_status(NATIVE_FIELD_STATUS_PARSE_FAILED); + return; + } + + out->set_status(NATIVE_FIELD_STATUS_AVAILABLE); + for (std::string& argument : arguments) { + out->add_arguments(std::move(argument)); + } +} + +// Fills per-process memory: physical footprint from proc_pid_rusage (primary) +// and resident size from task info (fallback display value). +void FillMemory(pid_t pid, const proc_taskallinfo& task, bool task_ok, + NativeFieldStatus task_status, NativeProcessMemory* out) { + rusage_info_current usage = {}; + errno = 0; + if (proc_pid_rusage(pid, RUSAGE_INFO_CURRENT, + reinterpret_cast(&usage)) == 0) { + SetAvailableInt64(out->mutable_physical_footprint_bytes(), + SaturatingInt64(usage.ri_phys_footprint)); + } else { + out->mutable_physical_footprint_bytes()->set_status( + StatusFromErrno(errno)); + } + + if (task_ok) { + SetAvailableInt64(out->mutable_resident_bytes(), + SaturatingInt64(task.ptinfo.pti_resident_size)); + } else { + out->mutable_resident_bytes()->set_status(task_status); + } +} + +// Converts a mach absolute-time tick count to nanoseconds. proc_pidinfo reports +// pti_total_user/system in mach time units, NOT nanoseconds (e.g. ~41.67 ns per +// tick on Apple Silicon; 1:1 only on older Intel). The 128-bit intermediate +// avoids overflow in tick * numer; the host timebase is queried once. +uint64_t MachTicksToNanos(uint64_t ticks) { + static const mach_timebase_info_data_t timebase = [] { + mach_timebase_info_data_t info = {1, 1}; + mach_timebase_info(&info); + return info; + }(); + if (timebase.denom == 0) { + return ticks; // Defensive: never divide by zero. + } + const __uint128_t nanos = + (static_cast<__uint128_t>(ticks) * timebase.numer) / timebase.denom; + const __uint128_t max_u64 = std::numeric_limits::max(); + return static_cast(nanos > max_u64 ? max_u64 : nanos); +} + +// Fills the cumulative CPU-time counter (user + system) in nanoseconds from task +// info. Main diffs this across snapshots; the collector never computes a rate. +void FillCpu(const proc_taskallinfo& task, bool task_ok, + NativeFieldStatus task_status, NativeProcessCpu* out) { + if (!task_ok) { + out->mutable_cumulative_cpu_time_ns()->set_status(task_status); + return; + } + // pti_total_user/system are mach absolute-time ticks; sum them (saturating) + // and convert to real nanoseconds so the contract's _ns field is honest. + uint64_t ticks = task.ptinfo.pti_total_user; + const uint64_t remaining = std::numeric_limits::max() - ticks; + ticks += std::min(remaining, task.ptinfo.pti_total_system); + // Clamp to JS Number.MAX_SAFE_INTEGER: the generated main-side decoder + // throws on a larger int64, and one long-lived hot process (2^53 ns is ~104 + // days of cumulative CPU) would poison the decode of the whole response. + // Display precision is irrelevant at that magnitude. + constexpr uint64_t kMaxSafeJsInteger = (uint64_t{1} << 53) - 1; + SetAvailableInt64( + out->mutable_cumulative_cpu_time_ns(), + SaturatingInt64(std::min(MachTicksToNanos(ticks), kMaxSafeJsInteger))); +} + +// Fills the thread count from task info (pti_threadnum). Available only when the +// PROC_PIDTASKALLINFO read succeeded; otherwise it carries that read's status. +void FillThreadCount(const proc_taskallinfo& task, bool task_ok, + NativeFieldStatus task_status, NativeInt64* out) { + if (!task_ok) { + out->set_status(task_status); + return; + } + SetAvailableInt64(out, std::max(0, task.ptinfo.pti_threadnum)); +} + +// Resolves a uid to its login name, cached for the session. A uid -> name +// mapping is immutable while the app runs, and a machine has only a handful of +// distinct uids across hundreds of processes, so this turns ~one getpwuid_r per +// PID per pass into one lookup per distinct uid for the whole session. An +// unmapped uid is cached as an empty name so a missing entry is not re-queried +// every pass. getpwuid_r (not getpwuid) keeps the lookup thread-safe. +// +// Threading: passes are serialized by the pass mutex in CollectProcesses (one +// pass at a time), so this static cache needs no lock - same contract as the +// argv and app-metadata caches. +const std::string& LoginNameForUid(uid_t uid) { + static std::unordered_map cache; + const auto cached = cache.find(uid); + if (cached != cache.end()) { + return cached->second; + } + + std::string name; + struct passwd pwd = {}; + struct passwd* result = nullptr; + // getpwuid_r reports ERANGE when the scratch buffer is too small for the + // passwd record (possible for directory-service accounts); grow and retry, + // bounded, rather than caching an empty name for the session. + const long suggested = sysconf(_SC_GETPW_R_SIZE_MAX); + std::vector buffer(suggested > 0 ? static_cast(suggested) + : 1024); + int rc = getpwuid_r(uid, &pwd, buffer.data(), buffer.size(), &result); + while (rc == ERANGE && buffer.size() < (1u << 20)) { + buffer.resize(buffer.size() * 2); + rc = getpwuid_r(uid, &pwd, buffer.data(), buffer.size(), &result); + } + if (rc == 0 && result != nullptr && result->pw_name != nullptr) { + name = result->pw_name; + } + return cache.emplace(uid, std::move(name)).first->second; +} + +// Fills the owning user from task info or the kern.proc.pid fallback, then +// resolves the login name (session-cached, see LoginNameForUid). An unmapped uid +// (no passwd entry) stays AVAILABLE with the numeric uid and an empty name, +// because the uid itself is still a real value. +void SetAvailableUser(uid_t uid, NativeProcessUser* out) { + out->set_status(NATIVE_FIELD_STATUS_AVAILABLE); + out->set_uid(static_cast(uid)); + + const std::string& name = LoginNameForUid(uid); + if (!name.empty()) { + out->set_name(name); + } +} + +void FillUser(const proc_taskallinfo& task, bool task_ok, + NativeFieldStatus task_status, + const kinfo_proc& kinfo, bool kinfo_ok, + NativeFieldStatus kinfo_status, NativeProcessUser* out) { + if (task_ok) { + SetAvailableUser(task.pbsd.pbi_uid, out); + return; + } + + if (kinfo_ok) { + SetAvailableUser(kinfo.kp_eproc.e_ucred.cr_uid, out); + return; + } + + out->set_status(CombinedFallbackStatus(task_status, kinfo_status)); +} + +// Builds one process record from all sources. Each field carries its own +// availability so a single denied/exited read degrades that field, not the row. +// app_metadata holds GUI-app metadata keyed by PID (from NSWorkspace); a record +// with no entry keeps its app fields unset and falls back to a generic icon. +// The record references its icon by content key only; the bytes never ride the +// collection response (the GetIcons RPC serves them by key on demand). +void FillRecord( + pid_t pid, int arg_max, std::vector* args_buffer, + NativeProcessRecord* record, + const std::unordered_map& app_metadata, + PassCacheUsage* usage) { + NativeProcessIdentity* identity = record->mutable_identity(); + identity->set_pid(pid); + + proc_taskallinfo task = {}; + NativeFieldStatus task_status = NATIVE_FIELD_STATUS_UNAVAILABLE; + const bool task_ok = ReadTaskAllInfo(pid, &task, &task_status); + + kinfo_proc kinfo = {}; + NativeFieldStatus kinfo_status = NATIVE_FIELD_STATUS_UNAVAILABLE; + const bool kinfo_ok = + task_ok ? false : ReadKinfoProc(pid, &kinfo, &kinfo_status); + + FillStartTime(task, task_ok, task_status, kinfo, kinfo_ok, kinfo_status, + identity); + + if (task_ok) { + record->set_parent_status(NATIVE_FIELD_STATUS_AVAILABLE); + record->set_parent_pid(static_cast(task.pbsd.pbi_ppid)); + } else if (kinfo_ok) { + record->set_parent_status(NATIVE_FIELD_STATUS_AVAILABLE); + record->set_parent_pid(static_cast(kinfo.kp_eproc.e_ppid)); + } else { + record->set_parent_status( + CombinedFallbackStatus(task_status, kinfo_status)); + } + + // Command name and executable path/name are read fresh every tick: they are + // cheap, and the executable path in particular must stay current because macOS + // can briefly report a translocated/staging path for a just-launched app - + // caching it would freeze a wrong path (and a generic icon) for the process's + // life. The icon below is resolved from this fresh path, so it self-heals. + FillCommandName(pid, task, task_ok, kinfo, kinfo_ok, kinfo_status, + record->mutable_command_name()); + FillExecutablePathAndName(pid, record->mutable_executable_path(), + record->mutable_executable_name()); + + // Command line (argv) is the one image-stable field worth caching: it is + // fixed at exec and KERN_PROCARGS2 is the most expensive per-PID read. Serve it + // from the cache when present, else read once and cache. The key includes the + // executable path read above, so a process that execs a new image re-reads. + // A cache hit copies the full cached value (status and argument bytes) onto + // the record - the cache avoids the syscall, not the payload. + const std::string command_line_key = + CommandLineKey(*identity, record->executable_path()); + if (!command_line_key.empty()) { + usage->command_line_keys.insert(command_line_key); + } + NativeCommandLine* cached_command_line = + command_line_key.empty() ? nullptr : FindCommandLine(command_line_key); + if (cached_command_line != nullptr) { + *record->mutable_command_line() = *cached_command_line; + } else { + FillCommandLine(pid, arg_max, args_buffer, record->mutable_command_line()); + MaybeCacheCommandLine(command_line_key, *record); + } + + FillMemory(pid, task, task_ok, task_status, record->mutable_memory()); + FillCpu(task, task_ok, task_status, record->mutable_cpu()); + FillThreadCount(task, task_ok, task_status, record->mutable_thread_count()); + FillUser(task, task_ok, task_status, kinfo, kinfo_ok, kinfo_status, + record->mutable_user()); + + // GUI app metadata enrichment (NSWorkspace): copy the entry for this PID when + // one exists. NSWorkspace remains the source of exact bundle id, localized app + // name, and the running GUI app's own icon. The grouping bundle below is still + // normalized from the executable path so nested helper apps inside a larger + // `.app` group under the user-facing owner instead of appearing as arbitrary + // top-level apps. + const auto match = app_metadata.find(static_cast(pid)); + if (match != app_metadata.end()) { + *record->mutable_app() = match->second; + // Window-server responsiveness exists only for these NSWorkspace apps and + // is the one per-app value that is dynamic (a hang flips tick to tick), so + // it is read fresh every pass - never from the metadata cache. + FillResponsiveness(static_cast(pid), + record->mutable_responsiveness()); + } + + // Normalize the grouping bundle from the executable path when it lives inside a + // real `.app` (helpers group under the outer app). A path with no `.app` leaves + // any NSWorkspace-provided bundle in place. + if (record->executable_path().status() == NATIVE_FIELD_STATUS_AVAILABLE) { + FillAppBundle(record->executable_path().value(), + record->mutable_app()->mutable_bundle()); + } + + // Resolve the icon into a local image, intern it, and store only the key. The + // icon resolves from the same bundle the row groups by: after the + // normalization above, app.bundle holds the outer `.app` when the executable + // lives inside one, else the NSWorkspace bundle path (covers a GUI app run + // from a code-sign clone whose executable path has no `.app` segment), else is + // unset and the executable path itself yields the generic icon for a plain + // daemon. The chosen path is recorded on `usage` so the icon cache can be + // pruned to the paths still in use. + std::string icon_path; + if (record->app().bundle().path().status() == + NATIVE_FIELD_STATUS_AVAILABLE) { + icon_path = record->app().bundle().path().value(); + } else if (record->executable_path().status() == + NATIVE_FIELD_STATUS_AVAILABLE) { + icon_path = record->executable_path().value(); + } + + if (!icon_path.empty()) { + usage->icon_paths.insert(icon_path); + const std::string* icon_key = ResolveIconForPath(icon_path); + if (icon_key != nullptr) { + record->mutable_app()->set_icon_key(*icon_key); + } + } +} + +} // namespace + +void CollectProcesses(CollectProcessesResponse* response) { + // The session caches a pass touches (argv, uid -> name, app metadata) are + // unlocked; serializing whole passes here gives them a happens-before edge + // even if the host ever dispatches successive invokes on different threads. + // Uncontended in practice: the main-process poll loop never overlaps ticks. + static std::mutex pass_mutex; + const std::lock_guard pass_lock(pass_mutex); + + std::vector pids; + NativeFieldStatus list_status = NATIVE_FIELD_STATUS_UNAVAILABLE; + if (!ListAllPids(&pids, &list_status)) { + // Could not enumerate at all: report unavailable with no rows. Main maps + // this to an unavailable snapshot; per-field statuses carry the detail in + // every other case. + response->set_available(false); + return; + } + + response->set_available(true); + + // Snapshot GUI app metadata once for the whole pass (one NSWorkspace query), + // then merge the matching entry onto each record by PID. + const std::unordered_map app_metadata = + SnapshotRunningAppMetadata(); + + // Allocate the KERN_PROCARGS2 argument buffer once and reuse it for every PID. + // KERN_ARGMAX (~1 MiB) is constant for the kernel, so reading it once and + // sharing a single buffer turns a per-PID 1 MiB allocate-and-zero into one + // allocation per pass - the dominant collection cost on a machine with many + // processes. A 0 arg_max leaves the buffer empty and FillCommandLine reports + // command lines unavailable for the pass. + const int arg_max = ReadArgMax(); + std::vector args_buffer(arg_max > 0 ? static_cast(arg_max) : 0); + + // Cache keys and icon paths used this pass; the session caches are pruned to + // them below so exited processes (and exec-rotated keys) drop out. + PassCacheUsage usage; + usage.command_line_keys.reserve(pids.size()); + usage.icon_paths.reserve(pids.size()); + + for (const pid_t pid : pids) { + FillRecord(pid, arg_max, &args_buffer, response->add_records(), + app_metadata, &usage); + } + + PruneCommandLineCache(usage.command_line_keys); + PruneIconCache(usage.icon_paths); +} + +void GetProcessIcons(const GetIconsRequest& request, GetIconsResponse* response) { + auto& icons = *response->mutable_icons(); + for (const std::string& key : request.keys()) { + if (icons.find(key) != icons.end()) { + continue; + } + std::string png_base64; + if (CopyIconForKey(key, &png_base64)) { + icons[key] = std::move(png_base64); + } + } +} + +} // namespace mostats diff --git a/src/native/processes/process_collector.h b/src/native/processes/process_collector.h new file mode 100644 index 0000000..1bf0e91 --- /dev/null +++ b/src/native/processes/process_collector.h @@ -0,0 +1,45 @@ +#ifndef MOSTATS_PROCESSES_PROCESS_COLLECTOR_H_ +#define MOSTATS_PROCESSES_PROCESS_COLLECTOR_H_ + +#include "gen/process_collector.pb.h" + +namespace mostats { + +/** + * Collects the current macOS process list into the generated response. + * + * Inspection-only and read-only: it enumerates PIDs and reads per-process + * identity, command name, executable path, command-line arguments, memory, + * cumulative CPU time, thread count, and owning user, attaching an explicit + * availability to each field. It never sends signals or performs actions (those + * are main-owned), and it computes no rates - the cumulative CPU counter is + * diffed across snapshots in main. + * + * Sources: libproc (proc_listallpids, PROC_PIDTASKALLINFO, proc_name, + * proc_pidpath), sysctl KERN_PROCARGS2 for arguments, and proc_pid_rusage for + * the physical footprint. GUI app metadata and icons (NSWorkspace) are merged in + * by PID from the isolated Objective-C++ unit (app_metadata.mm); non-GUI + * processes leave those fields unspecified. + * + * Privacy: command-line arguments are sensitive display/search data. They are + * returned only for local display and search - the caller must never log, + * persist, export, auto-copy, or transmit them, and warnings carry counts only, + * never argument values, paths, or process names. + * + * Always reports a result: per-field failures degrade that field rather than the + * whole record, and a failure to enumerate at all is reported as available=false + * with no records. + */ +void CollectProcesses(CollectProcessesResponse* response); + +/** + * Resolves icon bytes for the content keys in `request` from the session icon + * cache (the keys records reference via NativeAppMetadata.icon_key). A key with + * no cached entry is omitted from the response. Safe to call concurrently with + * a collection pass; the underlying cache lookup is mutex-guarded. + */ +void GetProcessIcons(const GetIconsRequest& request, GetIconsResponse* response); + +} // namespace mostats + +#endif // MOSTATS_PROCESSES_PROCESS_COLLECTOR_H_ diff --git a/src/native/processes/responsiveness.cc b/src/native/processes/responsiveness.cc new file mode 100644 index 0000000..3d8d8dd --- /dev/null +++ b/src/native/processes/responsiveness.cc @@ -0,0 +1,76 @@ +#include "processes/responsiveness.h" + +#include +#include +#include + +namespace mostats { +namespace { + +// The window-server connection handle type used by the private CGS calls. +typedef int CGSConnectionID; + +// Private SkyLight/CGS and deprecated Process Manager entry points. There is +// no public macOS API for "is this app marked Not Responding", so these are +// resolved by name at runtime instead of being declared and linked: a future +// macOS that drops a symbol degrades this feature to UNSUPPORTED instead of +// failing the whole native module load. The images are guaranteed present by +// the AppKit and ApplicationServices framework links in CMakeLists. +using CGSMainConnectionIDFn = CGSConnectionID (*)(); +using CGSEventIsAppUnresponsiveFn = bool (*)(CGSConnectionID, + const ProcessSerialNumber*); +using GetProcessForPIDFn = OSStatus (*)(pid_t, ProcessSerialNumber*); + +// The resolved symbols plus this process's window-server connection, looked up +// once per session (thread-safe static init; only the serial collector calls +// in). `available` is true only when every symbol resolved. +struct CgsApi { + CGSEventIsAppUnresponsiveFn is_unresponsive = nullptr; + GetProcessForPIDFn psn_for_pid = nullptr; + CGSConnectionID connection = 0; + bool available = false; +}; + +const CgsApi& Api() { + static const CgsApi api = [] { + CgsApi out; + const auto main_connection = reinterpret_cast( + dlsym(RTLD_DEFAULT, "CGSMainConnectionID")); + out.is_unresponsive = reinterpret_cast( + dlsym(RTLD_DEFAULT, "CGSEventIsAppUnresponsive")); + out.psn_for_pid = reinterpret_cast( + dlsym(RTLD_DEFAULT, "GetProcessForPID")); + out.available = main_connection != nullptr && + out.is_unresponsive != nullptr && + out.psn_for_pid != nullptr; + if (out.available) { + out.connection = main_connection(); + } + return out; + }(); + return api; +} + +} // namespace + +void FillResponsiveness(int32_t pid, NativeResponsiveness* out) { + const CgsApi& api = Api(); + if (!api.available) { + out->set_status(NATIVE_FIELD_STATUS_UNSUPPORTED); + return; + } + + // A PSN lookup failure means the process has no Process Manager entry (it + // exited between the NSWorkspace snapshot and this read, or it is not really + // a window-server client); there is no responsiveness to report. + ProcessSerialNumber psn = {0, 0}; + if (api.psn_for_pid(static_cast(pid), &psn) != 0) { + out->set_status(NATIVE_FIELD_STATUS_UNAVAILABLE); + return; + } + + out->set_status(NATIVE_FIELD_STATUS_AVAILABLE); + out->set_unresponsive(api.is_unresponsive(api.connection, &psn)); +} + +} // namespace mostats diff --git a/src/native/processes/responsiveness.h b/src/native/processes/responsiveness.h new file mode 100644 index 0000000..63a4436 --- /dev/null +++ b/src/native/processes/responsiveness.h @@ -0,0 +1,35 @@ +#ifndef MOSTATS_PROCESSES_RESPONSIVENESS_H_ +#define MOSTATS_PROCESSES_RESPONSIVENESS_H_ + +#include + +#include "gen/process_collector.pb.h" + +namespace mostats { + +/** + * Fills the window-server responsiveness for one GUI app: whether macOS + * currently marks it "Not Responding" (the exact state behind the beachball, + * the system Force Quit dialog, and Activity Monitor's red label). The window + * server flags an app once an event has sat unserviced in its event queue past + * the system threshold - so a stalled app the user is interacting with is + * flagged, while an idle stopped app with an empty queue is not, matching what + * the system itself reports. + * + * Call only for PIDs in the NSWorkspace running-apps set: responsiveness is a + * window-server concept, and daemons/helpers without a Process Manager entry + * report UNAVAILABLE. macOS exposes no public API for this signal, so it is + * read through private CGS symbols resolved once at runtime; on a macOS that + * no longer exposes them every record reports UNSUPPORTED and the UI simply + * shows no state (verified working on macOS 26 / Darwin 25). + * + * Cost (measured, ~57 GUI apps): ~2.5 ms per warm pass, flat whether apps are + * responsive or hung - the call reads state the window server already tracks; + * it never pings the app. Threading: serial collector contract, same as the + * other process sources. + */ +void FillResponsiveness(int32_t pid, NativeResponsiveness* out); + +} // namespace mostats + +#endif // MOSTATS_PROCESSES_RESPONSIVENESS_H_ diff --git a/src/native/proto/memory.proto b/src/native/proto/memory.proto new file mode 100644 index 0000000..2a45c4a --- /dev/null +++ b/src/native/proto/memory.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; + +// Native memory probe (main <- native C++). +// +// Node exposes total/free memory, but on macOS that free value does not expose +// the VM page categories needed to separate reclaimable file cache from memory +// that is genuinely in use. This probe reads host VM statistics and returns an +// Activity Monitor-style composition: app + wired + compressed + cached + free +// sum to total. The grouped used_bytes (app + wired + compressed) and +// available_bytes are kept for the percentage and headroom math. + +message MemoryUsage { + // False when VM statistics cannot be read; the sampler degrades only memory + // to unavailable. + bool available = 1; + uint64 total_bytes = 2; + // Memory in use after subtracting reclaimable cache: app + wired + compressed. + uint64 used_bytes = 3; + // Memory available to apps: true free space plus reclaimable cached files. + uint64 available_bytes = 4; + // Reclaimable file-backed/purgeable memory shown separately from used memory. + uint64 cached_bytes = 5; + // App (anonymous/private) memory: used_bytes minus wired and compressed. + uint64 app_bytes = 6; + // Wired (unpageable: kernel, drivers, locked) memory. + uint64 wired_bytes = 7; + // Compressed (OS-compressed inactive) memory. + uint64 compressed_bytes = 8; +} + +service MemoryService { + // Reads the current macOS VM memory usage breakdown. + rpc ReadUsage(google.protobuf.Empty) returns (MemoryUsage); +} diff --git a/src/native/proto/network.proto b/src/native/proto/network.proto new file mode 100644 index 0000000..1fe41ed --- /dev/null +++ b/src/native/proto/network.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; + +// Native network probe (main <- native C++). +// +// Node exposes interface addresses via os.networkInterfaces() but no rx/tx byte +// counters, so throughput needs a native probe. This service reads the kernel's +// cumulative 64-bit per-interface byte counters (sysctl NET_RT_IFLIST2 / +// if_data64; the getifaddrs counters are 32-bit and wrap every 4 GiB) and +// returns them per interface for the active non-loopback interfaces. It is +// deliberately narrow: it returns raw cumulative counters only. The +// delta-to-rate math, first-sample handling, and counter-reset rejection live +// in the main-process sampler, not here. Counters are per interface (not +// summed) so the sampler can diff each interface against its own previous +// reading - an interface joining or leaving the active set must not register +// as a burst of traffic. + +// Cumulative byte counters since boot for one active interface. 64-bit, so +// they only increase under normal operation; an interface re-creation resets +// them to zero, which the sampler rejects per interface. +message InterfaceCounters { + string name = 1; // Kernel interface name, e.g. "en0". + uint64 rx_bytes = 2; // Total bytes received (download). + uint64 tx_bytes = 3; // Total bytes transmitted (upload). +} + +// The currently active, counted interfaces and their cumulative counters. +message NetworkCounters { + // False when the interface list could not be read or no active interface + // exists; `interfaces` is then empty and the sampler degrades only the + // network card to unavailable. + bool available = 1; + repeated InterfaceCounters interfaces = 2; +} + +service NetworkService { + // Reads the current cumulative byte counters of the active interfaces. + rpc ReadCounters(google.protobuf.Empty) returns (NetworkCounters); +} diff --git a/src/native/proto/process_collector.proto b/src/native/proto/process_collector.proto new file mode 100644 index 0000000..731fb7e --- /dev/null +++ b/src/native/proto/process_collector.proto @@ -0,0 +1,205 @@ +syntax = "proto3"; + +// Native process collector contract (main <- native C++). +// +// Modeled for MoStats, not copied from any reference app. The native collector +// is inspection-only: it enumerates processes and reports raw per-field values +// with explicit availability, and it never sends signals or performs actions +// (process actions are main-owned, validated against the latest snapshot, and +// land in a later iteration). Host-level CPU ticks and system memory totals are +// deliberately omitted because MoStats already samples those through its own +// metrics probes; this collector reports only per-process records. +// +// Privacy: command-line arguments are sensitive display/search data. The +// collector returns them for local display and search only - main must never +// log, persist, export, auto-copy, or transmit them. + +// Availability of a native per-process value. UNSPECIFIED is the proto3 default; +// the other states let main map each field to an explicit renderer status +// instead of guessing from a zero value. +enum NativeFieldStatus { + NATIVE_FIELD_STATUS_UNSPECIFIED = 0; + // Value is present in the matching field. + NATIVE_FIELD_STATUS_AVAILABLE = 1; + // Value could not be read for an unspecified reason. + NATIVE_FIELD_STATUS_UNAVAILABLE = 2; + // macOS denied access to this value for this process. + NATIVE_FIELD_STATUS_PERMISSION_DENIED = 3; + // The process exited before the value could be read. + NATIVE_FIELD_STATUS_PROCESS_EXITED = 4; + // The current platform/API path does not support this value. + NATIVE_FIELD_STATUS_UNSUPPORTED = 5; + // Source data was read but could not be parsed safely. + NATIVE_FIELD_STATUS_PARSE_FAILED = 6; +} + +// A native string with explicit availability; empty is only real when AVAILABLE. +message NativeString { + NativeFieldStatus status = 1; + string value = 2; +} + +// A native signed 64-bit value with explicit availability; zero is only real +// when AVAILABLE. +message NativeInt64 { + NativeFieldStatus status = 1; + int64 value = 2; +} + +// Process identity inputs: PID plus optional start time used by main to build a +// snapshot-stable identity (a reused PID is disambiguated by start time). +message NativeProcessIdentity { + int32 pid = 1; + NativeFieldStatus started_at_status = 2; + int64 started_at_unix_ms = 3; +} + +// Sensitive command-line argument data; display/search only (see file comment). +// Always carried in full: the collector's argv cache avoids the KERN_PROCARGS2 +// syscall, never the payload. +message NativeCommandLine { + // Availability for the argument vector. + NativeFieldStatus status = 1; + // Parsed argument vector when AVAILABLE. Sensitive: never log/persist/export. + repeated string arguments = 2; +} + +// Per-process memory metrics in bytes. Physical footprint is preferred for +// display; resident is a fallback. +message NativeProcessMemory { + // macOS physical footprint in bytes from proc_pid_rusage. + NativeInt64 physical_footprint_bytes = 1; + // Resident set size in bytes from PROC_PIDTASKALLINFO (display fallback). + NativeInt64 resident_bytes = 2; +} + +// Cumulative per-process CPU time. This is a raw counter that must be diffed +// across snapshots in main to produce a CPU usage percentage; the collector +// never computes rates. Main also forwards the cumulative value itself to the +// renderer as the displayed "CPU time" (total CPU consumed since launch). +message NativeProcessCpu { + // Total user + system CPU time in nanoseconds (PROC_PIDTASKALLINFO). + NativeInt64 cumulative_cpu_time_ns = 1; +} + +// The user a process runs as. uid and name share one availability: the uid +// comes from PROC_PIDTASKALLINFO (pbi_uid) and the name is resolved from it with +// getpwuid_r; an unmapped uid (no passwd entry) keeps the numeric uid and leaves +// the name empty. +message NativeProcessUser { + NativeFieldStatus status = 1; + // Effective user id when status is AVAILABLE. + int32 uid = 2; + // Resolved login name (getpwuid_r) when known; empty if the uid has no entry. + string name = 3; +} + +// Window-server responsiveness of a GUI app: whether macOS currently marks it +// "Not Responding" - the same signal behind the beachball, the system Force +// Quit dialog, and Activity Monitor's red label (an event has sat unserviced +// in the app's event queue past the system threshold). Read through private +// CGS symbols resolved at runtime: UNSUPPORTED when this macOS no longer +// exposes them (the row simply shows no state), UNAVAILABLE when the app has +// no Process Manager entry. Only set for processes in the NSWorkspace +// running-apps set; absent for daemons and helpers, which have no +// window-server connection and therefore no notion of responsiveness. +message NativeResponsiveness { + NativeFieldStatus status = 1; + // True when the window server currently marks the app unresponsive. Only + // meaningful when status is AVAILABLE. + bool unresponsive = 2; +} + +// The application bundle a process belongs to, used to group a multi-process +// app's members (a browser and its helpers/renderers) into one row. It is the +// outermost `.app` in the executable path, so the main app process and its +// helpers - which carry no bundle id of their own - share one bundle. Set for +// any process whose executable lives inside a `.app`, independent of GUI +// enrichment; absent for daemons/CLIs with no `.app`. +message NativeAppBundle { + // Absolute path to the owning `.app` bundle (the stable grouping key). + NativeString path = 1; + // Display name of the bundle (its basename without the `.app` suffix). + NativeString name = 2; +} + +// Optional GUI app metadata discovered through NSWorkspace enrichment, plus the +// owning app bundle (which is set from the executable path for any bundled +// process, not only GUI-enriched ones). +message NativeAppMetadata { + NativeString bundle_identifier = 1; + NativeString localized_name = 2; + // Content-hash key of this process's icon, or empty when none was resolved. + // The bytes are fetched by key through GetIcons, never carried here. + string icon_key = 3; + // Owning `.app` bundle for grouping; absent for non-bundled processes. + NativeAppBundle bundle = 4; +} + +// One raw native process record. Most fields carry their own availability so a +// single unreadable field degrades that field rather than the whole record. +message NativeProcessRecord { + // PID + start-time identity inputs. + NativeProcessIdentity identity = 1; + // Parent PID input for shallow main-side hierarchy context. + NativeFieldStatus parent_status = 2; + int32 parent_pid = 3; + // Short process command name. + NativeString command_name = 4; + // Executable file name (basename) when derivable. + NativeString executable_name = 5; + // Absolute executable path when macOS permits access. + NativeString executable_path = 6; + // Optional GUI app metadata. + NativeAppMetadata app = 7; + // Sensitive command-line arguments for local display/search only. + NativeCommandLine command_line = 8; + // Per-process memory metrics. + NativeProcessMemory memory = 9; + // Cumulative per-process CPU time counter (diffed in main, and forwarded as + // the displayed total CPU time). + NativeProcessCpu cpu = 10; + // Number of threads in the task (PROC_PIDTASKALLINFO pti_threadnum). + NativeInt64 thread_count = 11; + // The user the process runs as (uid + resolved name). + NativeProcessUser user = 12; + // Window-server responsiveness; set only for GUI apps (see message comment). + NativeResponsiveness responsiveness = 13; + // Per-process network is intentionally absent: macOS has no reliable, + // non-brittle per-process source, so the collector does not gather it. +} + +// Request to collect the current macOS process list. Empty for now. +message CollectProcessesRequest {} + +// Result of one native collection pass. Main maps these raw records into the +// renderer ProcessSnapshot, computing per-process CPU deltas, deriving its +// renderer-facing warnings from the per-field statuses, and applying +// availability/privacy rules. +message CollectProcessesResponse { + // Whether collection produced a usable process list at all. + bool available = 1; + // Raw per-process records for main-side mapping. + repeated NativeProcessRecord records = 2; +} + +// Request for icon bytes by content key (NativeAppMetadata.icon_key values). +message GetIconsRequest { + repeated string keys = 1; +} + +// Resolved icons by content key: base64-encoded PNG bytes. A key with no cached +// entry is omitted; the caller falls back to a generic glyph. Volatile +// display-only data: never logged or persisted. +message GetIconsResponse { + map icons = 1; +} + +// Main-to-native process collection API. Inspection-only: no actions here. +service ProcessCollectorService { + // Collects current macOS process records with per-field availability. + rpc CollectProcesses(CollectProcessesRequest) returns (CollectProcessesResponse); + + // Returns icon bytes for content keys, served from the session icon cache. + rpc GetIcons(GetIconsRequest) returns (GetIconsResponse); +} diff --git a/src/native/proto/temperature.proto b/src/native/proto/temperature.proto new file mode 100644 index 0000000..857199a --- /dev/null +++ b/src/native/proto/temperature.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; + +// Native CPU temperature probe (main <- native C++). +// +// No Node API exposes thermal sensors, and macOS has no documented public CPU +// temperature source on Apple Silicon, so this is best-effort: when no plausible +// CPU-core reading is available the probe reports available=false rather than a +// guessed value. See temperature_probe.cc for the sources and decode rules. + +// A single CPU temperature reading. Optional by design: temperature is a +// best-effort metric because public macOS APIs do not guarantee a stable CPU +// sensor on all Apple Silicon machines. +message CpuTemperature { + // False when no trustworthy CPU sensor could be read; celsius is then 0 and + // the sampler degrades only the temperature card to unavailable. + bool available = 1; + double celsius = 2; // Average CPU core temperature in degrees Celsius. +} + +service TemperatureService { + // Reads the current CPU temperature, or reports it unavailable. + rpc ReadCpuTemperature(google.protobuf.Empty) returns (CpuTemperature); +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 0000000..3c076e8 --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,114 @@ +import { type ReactNode, useEffect, useState } from "react"; +import { Pin } from "lucide-react"; + +import { MetricsOverview } from "@/components/metrics/metrics-overview"; +import { ProcessExplorerView } from "@/components/processes/process-explorer-view"; +import { Button } from "@/components/ui/button"; +import { SegmentedControl, type SegmentedOption } from "@/components/ui/segmented-control"; +import { appGateway } from "@/gateway/app-gateway"; +import { ActiveView } from "@/gen/app"; +import { cn } from "@/lib/utils"; + +/** The two top-level views in the single window. */ +type AppView = "stats" | "processes"; + +const VIEW_OPTIONS: ReadonlyArray> = [ + { value: "stats", label: "Stats" }, + { value: "processes", label: "Processes" }, +]; + +const ACTIVE_VIEW_BY_VIEW: Record = { + stats: ActiveView.ACTIVE_VIEW_STATS, + processes: ActiveView.ACTIVE_VIEW_PROCESSES, +}; + +/** + * Renderer composition root: the title-bar view switch (Stats vs Processes) + * and the pin toggle. Reports the active view to main, which gates per-view + * background work; each view owns its own data lifecycle while mounted. + */ +function App() { + const isMac = navigator.userAgent.includes("Mac"); + const [view, setView] = useState("stats"); + + useEffect(() => { + appGateway.setActiveView(ACTIVE_VIEW_BY_VIEW[view]).catch(() => undefined); + }, [view]); + + return ( +
+
+
+
+ +
+
+ +
+ +
+ + + + + + +
+
+ ); +} + +/** + * Keeps a top-level view mounted but hidden while inactive, so its state + * survives tab switches without a remount flicker. + */ +function ViewPane({ active, children }: { active: boolean; children: ReactNode }) { + return
{children}
; +} + +/** + * Title-bar "pin on top" toggle. The visual state changes only after the + * typed IPC command succeeds. + */ +function PinToggle() { + const [pinned, setPinned] = useState(false); + const [pending, setPending] = useState(false); + + function toggle() { + if (pending) return; + + const next = !pinned; + setPending(true); + void appGateway.setAlwaysOnTop(next) + .then(() => setPinned(next)) + .catch(() => undefined) + .finally(() => setPending(false)); + } + + return ( + + ); +} + +export default App; diff --git a/src/renderer/components/metrics/area-layer.tsx b/src/renderer/components/metrics/area-layer.tsx new file mode 100644 index 0000000..dfbd555 --- /dev/null +++ b/src/renderer/components/metrics/area-layer.tsx @@ -0,0 +1,54 @@ +import type { AreaRun } from "@/domain/area-path"; +import { HISTORY_CAPACITY } from "@/domain/sample-history"; + +/** + * Shared SVG layers for the time-series graphs (CPU, network). The graphs draw + * in a `0..capacity × 0..100` viewBox stretched to the row + * (`preserveAspectRatio="none"`), so strokes opt out of scaling. + */ + +/** + * Axis baseline split at the first filled history slot: dashed over the + * still-unobserved left region (so a warming-up graph reads as "recording + * started here", not as broken), solid under the observed slots. `offset` is + * the first filled slot index (HISTORY_CAPACITY when the history is empty). + */ +export function Baseline({ y, offset }: { y: number; offset: number }) { + return ( + + {offset > 0 ? ( + + ) : null} + {offset < HISTORY_CAPACITY ? ( + + ) : null} + + ); +} + +/** Area runs as a translucent fill under a brighter edge line, in currentColor. */ +export function AreaLayer({ runs, className }: { runs: AreaRun[]; className?: string }) { + return ( + + {runs.map((run, index) => ( + + + + + ))} + + ); +} + +/** Full-height highlight band over the scrubbed history slot. */ +export function ScrubBand({ x }: { x: number }) { + return ; +} diff --git a/src/renderer/components/metrics/cpu-graph.tsx b/src/renderer/components/metrics/cpu-graph.tsx new file mode 100644 index 0000000..2713ddb --- /dev/null +++ b/src/renderer/components/metrics/cpu-graph.tsx @@ -0,0 +1,70 @@ +import { useRef, type PointerEvent as ReactPointerEvent } from "react"; + +import { cn } from "@/lib/utils"; +import type { MetricState } from "@/domain/metric-view"; +import { areaRuns } from "@/domain/area-path"; +import { HISTORY_CAPACITY, sampleIndexAtFraction } from "@/domain/sample-history"; +import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; + +/** A 0-100 percent reading, or `null` for a tick whose reading was not OK. */ +export type CpuSample = number | null; + +const FILL_BY_STATE: Record = { + ok: "text-success", + elevated: "text-warning", + critical: "text-destructive", + pending: "text-muted-foreground/40", + unavailable: "text-muted-foreground/40", +}; + +const PEAK = 97; // max amplitude; keeps the peak's edge stroke inside the viewBox +const MIN_AMPLITUDE = 1.5; // floor so a ~0% sample still draws a visible line +const AXIS_FLOOR = 20; // smallest y-axis max, so a flat-idle graph is not "maxed" +const BASELINE_Y = 99.5; // bottom axis, inset so its non-scaling stroke is not clipped by the viewBox edge + +/** + * Area graph of recent CPU usage, in the same style as the network chart: + * a translucent fill under an edge line, rising from the bottom. Color + * follows the metric state rather than a category. + */ +export function CpuGraph({ + history, + scrubIndex, + state, + onScrub, +}: { + history: CpuSample[]; + scrubIndex: number | null; + state: MetricState; + onScrub: (index: number | null) => void; +}) { + const ref = useRef(null); + const offset = HISTORY_CAPACITY - history.length; + const fill = FILL_BY_STATE[state]; + + const axisMax = Math.max(AXIS_FLOOR, ...history.map((sample) => sample ?? 0)); + const runs = areaRuns(history, offset, (sample) => Math.max(MIN_AMPLITUDE, (sample / axisMax) * PEAK), 100, -1); + + const handleMove = (event: ReactPointerEvent) => { + const rect = ref.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return; + onScrub(sampleIndexAtFraction((event.clientX - rect.left) / rect.width, history.length)); + }; + + return ( + onScrub(null)} + > + + + {scrubIndex !== null ? : null} + + ); +} diff --git a/src/renderer/components/metrics/cpu-row.tsx b/src/renderer/components/metrics/cpu-row.tsx new file mode 100644 index 0000000..706065a --- /dev/null +++ b/src/renderer/components/metrics/cpu-row.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { Cpu } from "lucide-react"; + +import { MeterTooltip, MetricRowHeader, ValueUnit, VALUE_COLOR_BY_STATE } from "@/components/metrics/metric-row-header"; +import { CpuGraph, type CpuSample } from "@/components/metrics/cpu-graph"; +import type { MetricsSnapshot } from "@/gen/metrics"; +import { MetricStatus } from "@/gen/metrics"; +import { isLive, usageState } from "@/domain/metric-view"; +import { HISTORY_CAPACITY, pushSample } from "@/domain/sample-history"; +import { formatPercentParts } from "@/lib/format"; + +export function CpuRow({ snapshot }: { snapshot: MetricsSnapshot | null }) { + const [history, setHistory] = useState([]); + const [scrubIndex, setScrubIndex] = useState(null); + const cpu = snapshot?.cpu; + + useEffect(() => { + if (!cpu) return; + // A non-finite value must enter history as a gap, not a sample: it would + // turn the axis max NaN and blank every path until it ages out. + const sample = + cpu.status === MetricStatus.METRIC_STATUS_OK && Number.isFinite(cpu.usagePercent) + ? cpu.usagePercent + : null; + setHistory((prev) => pushSample(prev, sample)); + }, [snapshot, cpu]); + + const state = cpu ? usageState(cpu.status, cpu.usagePercent) : "pending"; + const live = isLive(state); + + const scrubbed = scrubIndex !== null ? (history[scrubIndex] ?? null) : null; + // While scrubbing, show the hovered second as-is ("--" over a gap) rather + // than falling back to the live value. + const shown = scrubIndex !== null ? scrubbed : live && cpu ? cpu.usagePercent : null; + const value = shown != null ? formatPercentParts(shown) : undefined; + // History index -> viewBox slot center, as a percent for the tooltip x. + const scrubPercent = + scrubIndex !== null ? ((HISTORY_CAPACITY - history.length + scrubIndex + 0.5) / HISTORY_CAPACITY) * 100 : null; + + return ( +
+ + + +
+ + {scrubbed != null && scrubPercent !== null ? ( + + {formatPercentParts(scrubbed).value}% + + ) : null} +
+
+ ); +} diff --git a/src/renderer/components/metrics/disk-row.tsx b/src/renderer/components/metrics/disk-row.tsx new file mode 100644 index 0000000..b216b0e --- /dev/null +++ b/src/renderer/components/metrics/disk-row.tsx @@ -0,0 +1,63 @@ +import { HardDrive } from "lucide-react"; + +import { MetricRowHeader, ValueUnit, VALUE_COLOR_BY_STATE } from "@/components/metrics/metric-row-header"; +import type { MetricsSnapshot } from "@/gen/metrics"; +import { cn } from "@/lib/utils"; +import { displayText, isLive, usageState, type MetricState } from "@/domain/metric-view"; +import { formatBytes, formatPercentParts } from "@/lib/format"; + +const FILL_BY_STATE: Record = { + ok: "bg-success", + elevated: "bg-warning", + critical: "bg-destructive", + pending: "bg-muted-foreground/30", + unavailable: "bg-muted-foreground/30", +}; + +export function DiskRow({ snapshot }: { snapshot: MetricsSnapshot | null }) { + const disk = snapshot?.disk; + const state = disk ? usageState(disk.status, disk.usedPercent) : "pending"; + const live = isLive(state); + const percent = disk ? formatPercentParts(disk.usedPercent) : undefined; + + return ( +
+ + + + + + {live && disk ? `${formatBytes(disk.usedBytes)} / ${formatBytes(disk.totalBytes)}` : null} + +
+ ); +} + +/** Rounded rail with a fill whose width encodes the value; mirrored onto ARIA. */ +function Meter({ state, percent }: { state: MetricState; percent?: number }) { + const hasValue = isLive(state) && percent !== undefined && Number.isFinite(percent); + if (!hasValue) { + return