commit 6507f5f8b3480e87f043bd4f1febcfebecfb5b16 Author: oceans2alaska Date: Tue Jul 21 12:18:10 2026 -0700 Initial release of 8BitDo Control Center Provide a Linux desktop controller configuration app with evdev input, uinput remapping, profiles, calibration, and reconnect recovery. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..24d7cc6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d8c999 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# 8BitDo Control Center + +A desktop configuration utility for 8BitDo Ultimate controllers on Linux. It reads the +physical controller through **evdev**, runs input through a configurable transform +pipeline (calibration, deadzones, response curves, remapping), and outputs a remapped +virtual controller through **uinput** — without depending on any proprietary 8BitDo +software or undocumented USB/firmware protocols. + +Built with [Tauri 2](https://tauri.app), React + TypeScript, Tailwind CSS, and Rust. + +> **Status:** Device discovery and read-only evdev input observation are implemented. +> The virtual-controller output pipeline remains opt-in work in progress; until it is +> enabled, the app never changes physical controller output. + +## Features (current) + +- Dark, light, and high-contrast themes +- Profile management: create, rename, duplicate, delete, reset, import, export — + persisted as versioned JSON under `~/.config/8bitdo-control-center/profiles/` +- Five built-in profiles (Default, FPS, Racing, Platformer, Custom) +- Reusable, generic SVG controller diagram (front + rear views, clickable hotspots) +- Button remapping (button/key/mouse/disabled, with turbo/toggle/hold/long-press/ + double-tap modes) +- Analog stick tuning: inner/outer/anti-deadzone, radial/axial mode, sensitivity, + inversion, circularity correction, smoothing, response curve presets, with a live + raw-vs-processed preview and a draggable pad to simulate input +- Trigger tuning: deadzone, max activation, sensitivity, hair-trigger, digital mode, + inversion, response curve, with a live preview +- Motion page (gyro + vibration controls, hidden when unsupported) +- Input tester: live button/stick/trigger/gyro readout, poll rate estimate, and a + scrollable raw event log, drivable via a simulated controller +- Device information page +- Linux evdev discovery/capability probing and live raw input event streaming for + detected physical gamepads, with a simulated fallback for development +- Explicit **Apply / Revert / Save Profile** controls with unsaved-changes warnings + +## Getting started (Linux Mint / Ubuntu-based) + +### Prerequisites + +```bash +# Rust toolchain +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Tauri Linux system dependencies +sudo apt update +sudo apt install -y libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \ + libsoup-3.0-dev libgtk-3-dev librsvg2-dev libudev-dev \ + libayatana-appindicator3-dev libssl-dev build-essential curl wget file pkg-config +``` + +Node.js 20+ and npm are also required. + +### Run in development + +```bash +npm install +npm run tauri dev +``` + +### Build a release bundle + +```bash +npm run tauri build +``` + +## Project structure + +``` +src/ React + TypeScript frontend +├── components/ +│ ├── layout/ Sidebar, TopBar, AppLayout, PageShell +│ ├── controller/ Generic SVG controller diagram + hotspots +│ ├── inputs/ Slider, Toggle, Select, TextField, SimulatedStickPad +│ ├── feedback/ Toasts, confirm dialog, unsaved-changes bar +│ ├── visualizations/ Stick/trigger preview, event log +│ └── common/ Panel, Button, Badge, StatusDot +├── pages/ One component per sidebar destination +├── hooks/ useTheme, useController, useProfileInit, ... +├── services/ Tauri command wrappers + the mock controller backend +├── stores/ Zustand stores (controller, profile, settings, ui) +├── types/ Mirrors of the Rust profile/controller structs +├── utils/ Deadzone/curve math shared by every preview +└── constants/ Canonical control IDs, nav items + +src-tauri/ Rust backend (Tauri 2) +├── src/ +│ ├── commands/ Tauri command handlers (profiles, system) +│ ├── profile_schema.rs Versioned profile data model + migration +│ ├── profile_manager.rs Disk-backed profile CRUD +│ └── default_profiles.rs Built-in profile templates +``` + +## Roadmap + +1. **Phase 1 — Shell & scaffolding** *(current)* — app shell, theming, profile + management, controller visualization, and a simulated controller backend so every + page is usable before real hardware is wired up. +2. **Phase 2 — Real device visibility** — `device_detection`/`evdev_reader`, capability + probing, live Device Information + Input Tester pages. +3. **Phase 3 — Core remap pipeline** — `uinput_writer`, the real `input_transform` + deadzone/sensitivity pipeline, and Apply/Revert/Save wired to the live pipeline. +4. **Phase 4 — Profiles in depth** — shortcut-combo/process-based profile switching, + the calibration wizard, and the custom curve point editor. +5. **Phase 5 — Advanced mapping & polish** — macros, shift layers, motion/vibration + against real hardware, diagnostics/support bundle, udev rules + setup script, + packaging, and the full Rust test suite. + +## Permissions + +The app never needs to run as root. A later phase adds a `udev` rule and setup script +that grant a dedicated group access to the controller's `evdev` nodes and `/dev/uinput`. + +See [Troubleshooting](docs/TROUBLESHOOTING.md) and the detailed +[development roadmap](docs/ROADMAP.md) for permissions and hardware-mode guidance. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..7c2dcde --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,31 @@ +# Development Roadmap + +## Completed foundations + +- Tauri + React/TypeScript desktop shell, themes, navigation, and accessibility + contrast mode. +- Versioned JSON profiles with built-in templates and import/export. +- Generic, non-copyrighted controller visualization. +- Real evdev controller enumeration/capability probing plus a safe simulated + fallback for development without hardware. +- Pure, tested stick/trigger response transformation math. + +## Next engineering milestones + +1. Connect the reader to the transform pipeline and a managed uinput virtual + controller with safe cleanup on disconnect/shutdown. +2. Finish advanced mappings (macros, turbo timing, shift layers, hold/toggle + semantics) in the output pipeline. +3. Complete calibration sampling and custom curve editing. +4. Add executable-based profile switching and the support-bundle exporter. +5. Validate hardware behavior across USB, Bluetooth, and receiver modes; build + distributable Linux packages. + +## Safety principles + +- No firmware changes. +- No undocumented vendor commands. +- No root application process. +- Never create a virtual output device unless requested and permission checks + pass. +- Release virtual controls on disconnect and application shutdown. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..d0d332e --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,46 @@ +# Troubleshooting + +## The controller is not listed + +1. Confirm the controller is powered on and in an active input mode. An + 8BitDo device shown by `lsusb` as **8BitDo IDLE** is not yet an evdev + controller and cannot produce input events. +2. Connect it over USB or pair it through a supported receiver/Bluetooth mode. +3. Check kernel discovery with `ls -l /dev/input/event*` and `evtest`. +4. Reopen the app or switch Simulation Mode off in Settings. + +The app only reads Linux evdev capability metadata. It does not send +undocumented commands or update controller firmware. + +## Permission denied for input or virtual controller + +Install the local udev rule: + +```bash +bash scripts/setup-linux.sh +``` + +Then log out and back in so your new `8bitdo` group membership takes effect. +Settings → Diagnostics reports whether `/dev/uinput` is writable. + +## Duplicate or missing game input + +Do not enable live remapping until the virtual controller pipeline is active +and the physical input device is safely grabbed. Until then, keep remapping +disabled to retain direct physical-controller input. + +## No battery or firmware details + +Those details are optional Linux input properties and may not be exposed by +every controller mode. This application never queries undocumented 8BitDo +protocols to obtain them. + +## App will not build + +Install development dependencies: + +```bash +bash scripts/dev-deps.sh +npm install +npm run tauri dev +``` diff --git a/index.html b/index.html new file mode 100644 index 0000000..fc7c011 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + 8BitDo Control Center + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f43c348 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2885 @@ +{ + "name": "8bitdo-control-center", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "8bitdo-control-center", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-log": "^2.9.0", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.2", + "clsx": "^2.1.1", + "lucide-react": "^1.25.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.18.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "tailwindcss": "^4.3.3", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "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.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "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.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "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.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "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.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-log": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.0.tgz", + "integrity": "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", + "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-os": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz", + "integrity": "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "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==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz", + "integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.394", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz", + "integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": 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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "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/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.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": 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/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", + "dev": 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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.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": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "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/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": 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/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ae69f87 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "8bitdo-control-center", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-log": "^2.9.0", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.2", + "clsx": "^2.1.1", + "lucide-react": "^1.25.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.18.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "tailwindcss": "^4.3.3", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } +} diff --git a/public/tauri.svg b/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/udev/99-8bitdo-control-center.rules b/resources/udev/99-8bitdo-control-center.rules new file mode 100644 index 0000000..ff656cc --- /dev/null +++ b/resources/udev/99-8bitdo-control-center.rules @@ -0,0 +1,7 @@ +# 8BitDo Control Center: grant the dedicated 8bitdo group access to matching +# physical input nodes and to uinput. The application never requires root. +# +# Install with scripts/setup-linux.sh, then log out and back in so new group +# membership takes effect. +KERNEL=="event*", SUBSYSTEM=="input", ATTRS{idVendor}=="2dc8", MODE="0660", GROUP="8bitdo", TAG+="uaccess" +KERNEL=="uinput", MODE="0660", GROUP="8bitdo", TAG+="uaccess" diff --git a/scripts/dev-deps.sh b/scripts/dev-deps.sh new file mode 100644 index 0000000..b987933 --- /dev/null +++ b/scripts/dev-deps.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Linux Mint / Ubuntu developer dependencies for Tauri 2. +sudo apt update +sudo apt install -y \ + build-essential curl file pkg-config \ + libayatana-appindicator3-dev libgtk-3-dev libjavascriptcoregtk-4.1-dev \ + librsvg2-dev libsoup-3.0-dev libssl-dev libudev-dev libwebkit2gtk-4.1-dev + +if ! command -v cargo >/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + echo 'Restart your shell, then run npm install.' +fi diff --git a/scripts/launch-8bitdo-control-center.sh b/scripts/launch-8bitdo-control-center.sh new file mode 100755 index 0000000..6c650a0 --- /dev/null +++ b/scripts/launch-8bitdo-control-center.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${PROJECT_DIR}" + +if [[ -f "${HOME}/.cargo/env" ]]; then + # shellcheck disable=SC1091 + source "${HOME}/.cargo/env" +fi + +exec npm run tauri dev diff --git a/scripts/setup-linux.sh b/scripts/setup-linux.sh new file mode 100644 index 0000000..79ca901 --- /dev/null +++ b/scripts/setup-linux.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs the least-privilege access rule needed for evdev/uinput. This +# script intentionally changes host state only when the user runs it. + +readonly GROUP_NAME="8bitdo" +readonly RULE_FILE="99-8bitdo-control-center.rules" +readonly PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly SOURCE_RULE="${PROJECT_ROOT}/resources/udev/${RULE_FILE}" +readonly DESTINATION_RULE="/etc/udev/rules.d/${RULE_FILE}" + +if [[ ! -f "${SOURCE_RULE}" ]]; then + echo "Missing udev rule: ${SOURCE_RULE}" >&2 + exit 1 +fi + +if ! getent group "${GROUP_NAME}" >/dev/null; then + sudo groupadd --system "${GROUP_NAME}" +fi + +sudo install -Dm644 "${SOURCE_RULE}" "${DESTINATION_RULE}" +sudo usermod -aG "${GROUP_NAME}" "${USER}" +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=input + +cat < Self { + Self { + minimum: 1.0, + maximum: -1.0, + neutral: 0.0, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct AxisSampler { + samples: Vec, +} + +impl AxisSampler { + pub fn push(&mut self, value: f32) { + self.samples.push(value.clamp(-1.0, 1.0)); + } + + pub fn range(&self) -> Option { + let first = *self.samples.first()?; + let minimum = self.samples.iter().copied().fold(first, f32::min); + let maximum = self.samples.iter().copied().fold(first, f32::max); + let neutral = self.samples.iter().copied().sum::() / self.samples.len() as f32; + Some(AxisRange { + minimum, + maximum, + neutral, + }) + } +} + +/// Converts measured neutral drift into a conservative recommended inner +/// deadzone. The buffer avoids calibration noise causing drift in-game. +pub fn recommended_inner_deadzone(neutral_x: f32, neutral_y: f32) -> f32 { + let drift = (neutral_x * neutral_x + neutral_y * neutral_y).sqrt(); + (drift + 0.02).clamp(0.03, 0.25) +} + +pub fn recommended_outer_deadzone(minimum: f32, maximum: f32) -> f32 { + let coverage = minimum.abs().max(maximum.abs()); + if coverage <= f32::EPSILON { + return 1.0; + } + (1.0 / coverage).clamp(0.85, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sampler_reports_range_and_neutral() { + let mut sampler = AxisSampler::default(); + for value in [-0.9, -0.1, 0.1, 0.8] { + sampler.push(value); + } + let range = sampler.range().expect("range"); + assert_eq!(range.minimum, -0.9); + assert_eq!(range.maximum, 0.8); + assert!((range.neutral + 0.025).abs() < 0.001); + } + + #[test] + fn deadzone_has_a_safe_floor() { + assert_eq!(recommended_inner_deadzone(0.0, 0.0), 0.03); + } +} diff --git a/src-tauri/src/commands/devices.rs b/src-tauri/src/commands/devices.rs new file mode 100644 index 0000000..3d027d8 --- /dev/null +++ b/src-tauri/src/commands/devices.rs @@ -0,0 +1,24 @@ +use crate::device_detection::ControllerInfo; +use crate::error::AppResult; +use crate::state::AppState; + +/// Enumerates controllers currently exposed by Linux evdev. +/// +/// Device access failures are intentionally omitted from this result rather +/// than crashing the app: the permissions diagnostics will explain why an +/// event node could not be opened. +#[tauri::command] +pub fn list_devices() -> Vec { + crate::device_detection::enumerate_controllers() +} + +/// Begins streaming raw evdev input events for a selected physical device. +/// The reader is observation-only until the later uinput pipeline phase. +#[tauri::command] +pub fn start_device_reader( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + device_path: String, +) -> AppResult<()> { + crate::evdev_reader::start(app, state, device_path) +} diff --git a/src-tauri/src/commands/diagnostics.rs b/src-tauri/src/commands/diagnostics.rs new file mode 100644 index 0000000..d5dd11f --- /dev/null +++ b/src-tauri/src/commands/diagnostics.rs @@ -0,0 +1,27 @@ +use serde::Serialize; + +use crate::device_detection::ControllerInfo; +use crate::permissions::PermissionStatus; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiagnosticsSnapshot { + pub app_version: String, + pub operating_system: String, + pub kernel_version: Option, + pub permissions: PermissionStatus, + pub devices: Vec, + pub profile_schema_version: u32, +} + +#[tauri::command] +pub fn diagnostics_snapshot(app: tauri::AppHandle) -> DiagnosticsSnapshot { + DiagnosticsSnapshot { + app_version: app.package_info().version.to_string(), + operating_system: std::env::consts::OS.to_string(), + kernel_version: sysinfo::System::kernel_version(), + permissions: crate::permissions::check_permissions(), + devices: crate::device_detection::enumerate_controllers(), + profile_schema_version: crate::profile_schema::CURRENT_SCHEMA_VERSION, + } +} diff --git a/src-tauri/src/commands/mapping.rs b/src-tauri/src/commands/mapping.rs new file mode 100644 index 0000000..01ba106 --- /dev/null +++ b/src-tauri/src/commands/mapping.rs @@ -0,0 +1,150 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +fn debug_log(hypothesis_id: &str, message: &str, data: serde_json::Value) { + let entry = serde_json::json!({ + "sessionId": "2801e0", + "runId": "initial", + "hypothesisId": hypothesis_id, + "location": "src-tauri/src/commands/mapping.rs", + "message": message, + "data": data, + "timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(), + }); + match OpenOptions::new() + .create(true) + .append(true) + .open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log") + { + Ok(mut file) => { + if let Err(error) = writeln!(file, "{entry}") { + // #region agent log + eprintln!("H13 debug log write failed: {error}"); + // #endregion + } + } + Err(error) => { + // #region agent log + eprintln!("H13 debug log open failed: {error}"); + // #endregion + } + } +} + +pub fn log_remapping_pipeline_startup() { + // #region agent log + debug_log( + "H12", + "Native remapping pipeline initialized", + serde_json::json!({}), + ); + // #endregion +} + +/// Creates a managed virtual controller after a deliberate user action. +/// Event forwarding remains disabled until the physical device is safely +/// grabbed by the pipeline; this avoids accidental duplicate game input. +#[tauri::command] +pub fn create_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult<()> { + // #region agent log + debug_log( + "H9", + "Virtual controller creation requested", + serde_json::json!({}), + ); + // #endregion + let mut virtual_controller = state + .virtual_controller + .lock() + .map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?; + if virtual_controller.is_none() { + *virtual_controller = Some(crate::uinput_writer::create_virtual_controller()?); + } + Ok(()) +} + +#[tauri::command] +pub fn destroy_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult<()> { + // #region agent log + debug_log( + "H9", + "Virtual controller destruction requested", + serde_json::json!({}), + ); + // #endregion + let mut virtual_controller = state + .virtual_controller + .lock() + .map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?; + // Dropping VirtualDevice destroys the uinput device and releases all its + // state, including held buttons. + *virtual_controller = None; + Ok(()) +} + +#[tauri::command] +pub fn virtual_controller_active(state: tauri::State<'_, AppState>) -> AppResult { + let virtual_controller = state + .virtual_controller + .lock() + .map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?; + Ok(virtual_controller.is_some()) +} + +/// Stores the active profile used by the event-forwarding pipeline. +#[tauri::command] +pub fn apply_remapping_profile( + state: tauri::State<'_, AppState>, + profile: crate::profile_schema::Profile, +) -> AppResult<()> { + // #region agent log + debug_log( + "H10", + "Remapping profile received by native pipeline", + serde_json::json!({ + "profileId": profile.id, + "leftSensitivity": profile.left_trigger.sensitivity, + "rightSensitivity": profile.right_trigger.sensitivity, + }), + ); + // #endregion + let mut active_profile = state + .active_remap_profile + .lock() + .map_err(|_| AppError::Other("remapping profile state lock poisoned".to_string()))?; + *active_profile = Some(profile); + Ok(()) +} + +/// Enables forwarding through a uinput controller. The reader exclusively +/// grabs the physical device on its next event to avoid duplicate game input. +#[tauri::command] +pub fn set_remapping_enabled(state: tauri::State<'_, AppState>, enabled: bool) -> AppResult<()> { + if enabled { + let mut virtual_controller = state + .virtual_controller + .lock() + .map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?; + if virtual_controller.is_none() { + *virtual_controller = Some(crate::uinput_writer::create_virtual_controller()?); + } + } + + let mut remapping_enabled = state + .remapping_enabled + .lock() + .map_err(|_| AppError::Other("remapping state lock poisoned".to_string()))?; + *remapping_enabled = enabled; + // #region agent log + debug_log( + "H10", + "Native remapping state changed", + serde_json::json!({"enabled": enabled}), + ); + // #endregion + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..e9c9593 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,6 @@ +pub mod devices; +pub mod diagnostics; +pub mod mapping; +pub mod processes; +pub mod profiles; +pub mod system; diff --git a/src-tauri/src/commands/processes.rs b/src-tauri/src/commands/processes.rs new file mode 100644 index 0000000..46fc71c --- /dev/null +++ b/src-tauri/src/commands/processes.rs @@ -0,0 +1,7 @@ +use crate::error::AppResult; + +#[tauri::command] +pub fn detected_game_profile() -> AppResult> { + let profiles = crate::profile_manager::load_all_profiles()?; + Ok(crate::process_monitor::matching_profile_id(&profiles)) +} diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs new file mode 100644 index 0000000..449a5e4 --- /dev/null +++ b/src-tauri/src/commands/profiles.rs @@ -0,0 +1,60 @@ +use crate::error::AppResult; +use crate::profile_manager; +use crate::profile_schema::{Profile, ProfileSummary}; + +#[tauri::command] +pub fn list_profiles() -> AppResult> { + profile_manager::list_profiles() +} + +#[tauri::command] +pub fn load_profile(id: String) -> AppResult { + profile_manager::load_profile(&id) +} + +#[tauri::command] +pub fn save_profile(profile: Profile) -> AppResult { + profile_manager::save_profile(profile) +} + +#[tauri::command] +pub fn create_profile(name: String) -> AppResult { + profile_manager::create_profile(&name) +} + +#[tauri::command] +pub fn rename_profile(id: String, new_name: String) -> AppResult { + profile_manager::rename_profile(&id, &new_name) +} + +#[tauri::command] +pub fn duplicate_profile(id: String, new_name: String) -> AppResult { + profile_manager::duplicate_profile(&id, &new_name) +} + +#[tauri::command] +pub fn delete_profile(id: String) -> AppResult<()> { + profile_manager::delete_profile(&id) +} + +#[tauri::command] +pub fn reset_profile(id: String) -> AppResult { + profile_manager::reset_profile(&id) +} + +#[tauri::command] +pub fn export_profile(id: String, destination: String) -> AppResult<()> { + profile_manager::export_profile(&id, &destination) +} + +#[tauri::command] +pub fn import_profile(source: String) -> AppResult { + profile_manager::import_profile(&source) +} + +#[tauri::command] +pub fn profiles_directory() -> String { + profile_manager::profiles_dir() + .to_string_lossy() + .into_owned() +} diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs new file mode 100644 index 0000000..faf84c1 --- /dev/null +++ b/src-tauri/src/commands/system.rs @@ -0,0 +1,21 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppInfo { + pub name: String, + pub version: String, + pub config_dir: String, +} + +#[tauri::command] +pub fn get_app_info(app: tauri::AppHandle) -> AppInfo { + let package_info = app.package_info(); + AppInfo { + name: package_info.name.clone(), + version: package_info.version.to_string(), + config_dir: crate::profile_manager::config_dir() + .to_string_lossy() + .into_owned(), + } +} diff --git a/src-tauri/src/default_profiles.rs b/src-tauri/src/default_profiles.rs new file mode 100644 index 0000000..182fbfc --- /dev/null +++ b/src-tauri/src/default_profiles.rs @@ -0,0 +1,116 @@ +//! Built-in profile templates seeded on first run and available via +//! "reset to defaults". + +use std::collections::HashMap; + +use crate::profile_schema::{ + CurveConfig, CurvePreset, Profile, StickSettings, TriggerResponse, TriggerSettings, +}; + +fn now_iso() -> String { + chrono::Utc::now().to_rfc3339() +} + +fn base_profile(id: &str, name: &str, description: &str) -> Profile { + let ts = now_iso(); + Profile { + schema_version: crate::profile_schema::CURRENT_SCHEMA_VERSION, + id: id.to_string(), + name: name.to_string(), + description: description.to_string(), + is_built_in: true, + created_at: ts.clone(), + updated_at: ts, + assigned_executables: Vec::new(), + shortcut_combo: Vec::new(), + button_mappings: HashMap::new(), + left_stick: StickSettings::default(), + right_stick: StickSettings::default(), + left_trigger: TriggerSettings::default(), + right_trigger: TriggerSettings::default(), + motion: Default::default(), + shift_layers: Vec::new(), + } +} + +pub fn built_in_profiles() -> Vec { + vec![ + default_profile(), + fps_profile(), + racing_profile(), + platformer_profile(), + custom_profile(), + ] +} + +pub fn default_profile() -> Profile { + base_profile( + "default", + "Default", + "Balanced settings that match the controller's factory behavior.", + ) +} + +pub fn fps_profile() -> Profile { + let mut p = base_profile( + "fps", + "FPS", + "Precision-tuned aim: tight deadzones, exponential right-stick curve, hair-trigger shots.", + ); + p.left_stick.inner_deadzone = 0.06; + p.left_stick.response_curve = CurveConfig { + preset: CurvePreset::Precision, + ..CurveConfig::default() + }; + p.right_stick.inner_deadzone = 0.05; + p.right_stick.sensitivity_x = 0.85; + p.right_stick.sensitivity_y = 0.85; + p.right_stick.response_curve = CurveConfig { + preset: CurvePreset::Exponential, + ..CurveConfig::default() + }; + p.right_trigger.hair_trigger = true; + p.right_trigger.max_activation = 0.6; + p +} + +pub fn racing_profile() -> Profile { + let mut p = base_profile( + "racing", + "Racing", + "Progressive trigger response for throttle/brake and smoothed steering.", + ); + p.left_stick.smoothing = 0.15; + p.left_stick.response_curve = CurveConfig { + preset: CurvePreset::Smooth, + ..CurveConfig::default() + }; + p.left_trigger.response = TriggerResponse::Progressive; + p.right_trigger.response = TriggerResponse::Progressive; + p.left_trigger.sensitivity = 1.2; + p.right_trigger.sensitivity = 1.2; + p +} + +pub fn platformer_profile() -> Profile { + let mut p = base_profile( + "platformer", + "Platformer", + "Snappy digital-feeling triggers and an aggressive stick curve for quick platforming input.", + ); + p.left_stick.response_curve = CurveConfig { + preset: CurvePreset::Aggressive, + ..CurveConfig::default() + }; + p.left_trigger.digital_mode = true; + p.right_trigger.digital_mode = true; + p +} + +pub fn custom_profile() -> Profile { + base_profile( + "custom", + "Custom", + "A blank slate for your own configuration.", + ) +} diff --git a/src-tauri/src/device_detection.rs b/src-tauri/src/device_detection.rs new file mode 100644 index 0000000..ca3a68c --- /dev/null +++ b/src-tauri/src/device_detection.rs @@ -0,0 +1,240 @@ +//! Safe Linux controller discovery. +//! +//! This module only queries kernel-provided evdev metadata and capability +//! bitsets. It never sends vendor commands or accesses firmware protocols. + +use std::path::Path; +use std::{ + fs::OpenOptions, + io::Write, + time::{SystemTime, UNIX_EPOCH}, +}; + +use evdev::{AbsoluteAxisCode, Device, KeyCode}; +use serde::Serialize; + +const EIGHTBITDO_VENDOR_ID: u16 = 0x2dc8; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ButtonCapability { + pub id: String, + pub label: String, + pub group: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AxisCapability { + pub id: String, + pub label: String, + pub group: String, + pub min: i32, + pub max: i32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceCapabilities { + pub vendor_id: u16, + pub product_id: u16, + pub name: String, + pub buttons: Vec, + pub axes: Vec, + pub paddle_count: u8, + pub has_gyro: bool, + pub has_rumble: bool, + pub has_battery: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ControllerInfo { + pub id: String, + pub name: String, + pub connection: String, + pub device_path: String, + pub vendor_id: u16, + pub product_id: u16, + pub mode: String, + pub battery_percent: Option, + pub firmware_version: Option, + pub virtual_controller_active: bool, + pub capabilities: DeviceCapabilities, + pub is_simulated: bool, +} + +fn known_name(vendor: u16, product: u16, fallback: &str) -> String { + match (vendor, product) { + (0x2dc8, 0x3106) => "8BitDo Ultimate Wireless / Pro 2 Wired Controller".to_string(), + (0x2dc8, 0x3109) => "8BitDo IDLE".to_string(), + _ => fallback.to_string(), + } +} + +fn axis_definition(axis: AbsoluteAxisCode) -> Option<(&'static str, &'static str, &'static str)> { + match axis { + AbsoluteAxisCode::ABS_X => Some(("left_stick_x", "Left Stick X", "stick")), + AbsoluteAxisCode::ABS_Y => Some(("left_stick_y", "Left Stick Y", "stick")), + AbsoluteAxisCode::ABS_RX => Some(("right_stick_x", "Right Stick X", "stick")), + AbsoluteAxisCode::ABS_RY => Some(("right_stick_y", "Right Stick Y", "stick")), + AbsoluteAxisCode::ABS_Z => Some(("left_trigger", "Left Trigger", "trigger")), + AbsoluteAxisCode::ABS_RZ => Some(("right_trigger", "Right Trigger", "trigger")), + AbsoluteAxisCode::ABS_HAT0X => Some(("dpad_x", "D-Pad X", "stick")), + AbsoluteAxisCode::ABS_HAT0Y => Some(("dpad_y", "D-Pad Y", "stick")), + _ => None, + } +} + +fn button_definition(key: KeyCode) -> Option<(&'static str, &'static str, &'static str)> { + match key { + KeyCode::BTN_SOUTH => Some(("a", "A", "face")), + KeyCode::BTN_EAST => Some(("b", "B", "face")), + KeyCode::BTN_WEST => Some(("x", "X", "face")), + KeyCode::BTN_NORTH => Some(("y", "Y", "face")), + KeyCode::BTN_TL => Some(("l1", "L1", "shoulder")), + KeyCode::BTN_TR => Some(("r1", "R1", "shoulder")), + KeyCode::BTN_TL2 => Some(("l2", "L2", "trigger")), + KeyCode::BTN_TR2 => Some(("r2", "R2", "trigger")), + KeyCode::BTN_THUMBL => Some(("l3", "Left Stick Click", "stick")), + KeyCode::BTN_THUMBR => Some(("r3", "Right Stick Click", "stick")), + KeyCode::BTN_SELECT => Some(("select", "Select", "system")), + KeyCode::BTN_START => Some(("start", "Start", "system")), + KeyCode::BTN_MODE => Some(("guide", "Guide", "system")), + _ => None, + } +} + +fn is_gamepad(device: &Device) -> bool { + let has_gamepad_button = device + .supported_keys() + .is_some_and(|keys| keys.contains(KeyCode::BTN_SOUTH) || keys.contains(KeyCode::BTN_EAST)); + let has_stick_axis = device.supported_absolute_axes().is_some_and(|axes| { + axes.contains(AbsoluteAxisCode::ABS_X) && axes.contains(AbsoluteAxisCode::ABS_Y) + }); + has_gamepad_button && has_stick_axis +} + +fn connection_for(path: &Path) -> String { + let udev_path = udev::Device::from_subsystem_sysname( + "input".to_string(), + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_string(), + ); + if let Ok(device) = udev_path { + let mut properties = device.properties(); + if let Some(property) = properties.find(|property| property.name() == "ID_BUS") { + return match property.value().to_string_lossy().as_ref() { + "bluetooth" => "bluetooth".to_string(), + "usb" => "usb".to_string(), + _ => "unknown".to_string(), + }; + } + } + "unknown".to_string() +} + +pub fn probe_device(path: &Path, device: &Device) -> Option { + if !is_gamepad(device) { + return None; + } + + let raw_id = device.input_id(); + let kernel_id = raw_id.as_ref(); + let vendor_id = kernel_id.vendor; + let product_id = kernel_id.product; + let fallback_name = device.name().unwrap_or("Unknown Controller"); + let name = known_name(vendor_id, product_id, fallback_name); + + let buttons = device + .supported_keys() + .into_iter() + .flat_map(|keys| keys.iter()) + .filter_map(button_definition) + .map(|(id, label, group)| ButtonCapability { + id: id.to_string(), + label: label.to_string(), + group: group.to_string(), + }) + .collect::>(); + + let axes = device + .supported_absolute_axes() + .into_iter() + .flat_map(|axes| axes.iter()) + .filter_map(|axis| { + let (id, label, group) = axis_definition(axis)?; + let abs = device + .get_absinfo() + .ok()? + .find(|(code, _)| *code == axis)? + .1; + Some(AxisCapability { + id: id.to_string(), + label: label.to_string(), + group: group.to_string(), + min: abs.as_ref().minimum, + max: abs.as_ref().maximum, + }) + }) + .collect::>(); + + let has_rumble = device + .supported_ff() + .is_some_and(|ff| ff.iter().next().is_some()); + let is_eightbitdo = vendor_id == EIGHTBITDO_VENDOR_ID; + let path_text = path.to_string_lossy().into_owned(); + + Some(ControllerInfo { + id: path_text.clone(), + name, + connection: connection_for(path), + device_path: path_text, + vendor_id, + product_id, + mode: if is_eightbitdo { + "xinput".to_string() + } else { + "unknown".to_string() + }, + battery_percent: None, + firmware_version: None, + virtual_controller_active: false, + capabilities: DeviceCapabilities { + vendor_id, + product_id, + name: fallback_name.to_string(), + paddle_count: 0, + has_gyro: false, + has_rumble, + has_battery: false, + buttons, + axes, + }, + is_simulated: false, + }) +} + +pub fn enumerate_controllers() -> Vec { + let mut controllers = evdev::enumerate() + .filter_map(|(path, device)| probe_device(&path, &device)) + .collect::>(); + controllers.sort_by(|a, b| a.name.cmp(&b.name)); + // #region agent log + let entry = serde_json::json!({ + "sessionId": "2801e0", + "runId": "initial", + "hypothesisId": "H4", + "location": "src-tauri/src/device_detection.rs:enumerate_controllers", + "message": "Controller discovery completed", + "data": {"count": controllers.len(), "controllers": controllers.iter().map(|controller| serde_json::json!({"name": controller.name, "connection": controller.connection, "path": controller.device_path, "vendorId": controller.vendor_id, "productId": controller.product_id})).collect::>()}, + "timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(), + }); + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log") { + let _ = writeln!(file, "{entry}"); + } + // #endregion + controllers +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..5a7bf68 --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,40 @@ +use serde::{Serialize, Serializer}; + +/// Application-wide error type. Every fallible Tauri command returns this so +/// the frontend always receives a structured, serializable error instead of +/// an opaque string. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("profile not found: {0}")] + ProfileNotFound(String), + + #[error("a profile named \"{0}\" already exists")] + ProfileNameConflict(String), + + #[error("invalid profile data: {0}")] + InvalidProfile(String), + + #[error("unsupported profile schema version: {0}")] + UnsupportedSchemaVersion(u32), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("{0}")] + #[allow(dead_code)] + Other(String), +} + +impl Serialize for AppError { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +pub type AppResult = Result; diff --git a/src-tauri/src/evdev_reader.rs b/src-tauri/src/evdev_reader.rs new file mode 100644 index 0000000..ff48291 --- /dev/null +++ b/src-tauri/src/evdev_reader.rs @@ -0,0 +1,349 @@ +//! Isolated evdev reader for forwarding physical input to the UI event bus. +//! +//! This reads only. Remapping and uinput output deliberately happen in the +//! later transform pipeline, so observing a device cannot change controller +//! behavior. + +use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{SystemTime, UNIX_EPOCH}; + +use evdev::{AbsoluteAxisCode, AbsoluteAxisEvent, Device, EventSummary, InputEvent, KeyCode}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +pub const INPUT_EVENT_NAME: &str = "controller-input"; +pub const READER_STATUS_EVENT_NAME: &str = "controller-reader-status"; +static LEFT_TRIGGER_OUTPUT_LOGGED: AtomicBool = AtomicBool::new(false); +static RIGHT_TRIGGER_OUTPUT_LOGGED: AtomicBool = AtomicBool::new(false); + +fn debug_log(hypothesis_id: &str, message: &str, data: serde_json::Value) { + let entry = serde_json::json!({ + "sessionId": "2801e0", + "runId": "initial", + "hypothesisId": hypothesis_id, + "location": "src-tauri/src/evdev_reader.rs", + "message": message, + "data": data, + "timestamp": timestamp_ms(), + }); + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log") + { + let _ = writeln!(file, "{entry}"); + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InputEventPayload { + pub timestamp: u128, + pub event_type: String, + pub code: String, + pub raw_value: f64, + pub processed_value: f64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReaderStatusPayload { + pub device_path: String, + pub connected: bool, + pub message: Option, +} + +fn timestamp_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn key_id(key: KeyCode) -> String { + match key { + KeyCode::BTN_SOUTH => "a".to_string(), + KeyCode::BTN_EAST => "b".to_string(), + KeyCode::BTN_WEST => "x".to_string(), + KeyCode::BTN_NORTH => "y".to_string(), + KeyCode::BTN_TL => "l1".to_string(), + KeyCode::BTN_TR => "r1".to_string(), + KeyCode::BTN_TL2 => "l2".to_string(), + KeyCode::BTN_TR2 => "r2".to_string(), + KeyCode::BTN_THUMBL => "l3".to_string(), + KeyCode::BTN_THUMBR => "r3".to_string(), + KeyCode::BTN_SELECT => "select".to_string(), + KeyCode::BTN_START => "start".to_string(), + KeyCode::BTN_MODE => "guide".to_string(), + _ => format!("{key:?}").to_lowercase(), + } +} + +fn axis_id(axis: AbsoluteAxisCode) -> String { + match axis { + AbsoluteAxisCode::ABS_X => "left_stick_x".to_string(), + AbsoluteAxisCode::ABS_Y => "left_stick_y".to_string(), + AbsoluteAxisCode::ABS_RX => "right_stick_x".to_string(), + AbsoluteAxisCode::ABS_RY => "right_stick_y".to_string(), + AbsoluteAxisCode::ABS_Z => "left_trigger".to_string(), + AbsoluteAxisCode::ABS_RZ => "right_trigger".to_string(), + AbsoluteAxisCode::ABS_HAT0X => "dpad_x".to_string(), + AbsoluteAxisCode::ABS_HAT0Y => "dpad_y".to_string(), + _ => format!("{axis:?}").to_lowercase(), + } +} + +fn normalized(value: i32, minimum: i32, maximum: i32) -> f64 { + if maximum <= minimum { + return 0.0; + } + let relative = (value - minimum) as f64 / (maximum - minimum) as f64; + // Trigger axes conventionally range from 0..1; centred stick axes range + // from -1..1. This works with non-standard ranges too. + if minimum < 0 { + (relative * 2.0 - 1.0).clamp(-1.0, 1.0) + } else { + relative.clamp(0.0, 1.0) + } +} + +fn remapping_enabled(app: &AppHandle) -> AppResult { + app.state::() + .remapping_enabled + .lock() + .map(|enabled| *enabled) + .map_err(|_| AppError::Other("remapping state lock poisoned".to_string())) +} + +fn forward_event( + app: &AppHandle, + event: InputEvent, + axis_ranges: &HashMap, +) -> AppResult<()> { + let profile = app + .state::() + .active_remap_profile + .lock() + .map_err(|_| AppError::Other("remapping profile state lock poisoned".to_string()))? + .clone(); + let Some(profile) = profile else { + return Ok(()); + }; + + let output = match event.destructure() { + EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_Z, value) => { + let (minimum, maximum) = axis_ranges + .get(&AbsoluteAxisCode::ABS_Z) + .copied() + .unwrap_or((0, 255)); + let raw = normalized(value, minimum, maximum) as f32; + let transformed = crate::input_transform::transform_trigger(raw, &profile.left_trigger); + if !LEFT_TRIGGER_OUTPUT_LOGGED.swap(true, Ordering::Relaxed) { + // #region agent log + debug_log( + "H11", + "Forwarded transformed left trigger", + serde_json::json!({"raw": raw, "output": transformed, "outputValue": (transformed * 255.0).round() as i32}), + ); + // #endregion + } + *AbsoluteAxisEvent::new( + AbsoluteAxisCode::ABS_Z, + (transformed * 255.0).round() as i32, + ) + } + EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_RZ, value) => { + let (minimum, maximum) = axis_ranges + .get(&AbsoluteAxisCode::ABS_RZ) + .copied() + .unwrap_or((0, 255)); + let raw = normalized(value, minimum, maximum) as f32; + let transformed = crate::input_transform::transform_trigger(raw, &profile.right_trigger); + if !RIGHT_TRIGGER_OUTPUT_LOGGED.swap(true, Ordering::Relaxed) { + // #region agent log + debug_log( + "H11", + "Forwarded transformed right trigger", + serde_json::json!({"raw": raw, "output": transformed, "outputValue": (transformed * 255.0).round() as i32}), + ); + // #endregion + } + *AbsoluteAxisEvent::new( + AbsoluteAxisCode::ABS_RZ, + (transformed * 255.0).round() as i32, + ) + } + EventSummary::AbsoluteAxis( + _, + axis @ (AbsoluteAxisCode::ABS_X + | AbsoluteAxisCode::ABS_Y + | AbsoluteAxisCode::ABS_RX + | AbsoluteAxisCode::ABS_RY), + value, + ) => *AbsoluteAxisEvent::new(axis, value), + EventSummary::Key(_, _, _) => event, + _ => return Ok(()), + }; + + let state = app.state::(); + let mut virtual_controller = state + .virtual_controller + .lock() + .map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?; + let Some(virtual_controller) = virtual_controller.as_mut() else { + return Ok(()); + }; + virtual_controller.emit(&[output]).map_err(AppError::Io) +} + +pub fn start( + app: AppHandle, + state: tauri::State<'_, AppState>, + device_path: String, +) -> AppResult<()> { + { + let mut readers = state + .active_readers + .lock() + .map_err(|_| AppError::Other("device reader state lock poisoned".to_string()))?; + if !readers.insert(device_path.clone()) { + // #region agent log + debug_log( + "H2", + "Reader start skipped because path is already active", + serde_json::json!({"devicePath": device_path}), + ); + // #endregion + return Ok(()); + } + } + // #region agent log + debug_log( + "H2", + "Reader start requested", + serde_json::json!({"devicePath": device_path}), + ); + // #endregion + + thread::Builder::new() + .name(format!("evdev-reader:{device_path}")) + .spawn(move || { + let result = read_loop(&app, &device_path); + if let Err(error) = result { + // #region agent log + debug_log( + "H2", + "Reader loop ended with error", + serde_json::json!({"devicePath": device_path, "error": error.to_string()}), + ); + // #endregion + log::warn!("evdev reader for {device_path} stopped: {error}"); + if let Ok(mut readers) = app.state::().active_readers.lock() { + readers.remove(&device_path); + } + let _ = app.emit( + READER_STATUS_EVENT_NAME, + ReaderStatusPayload { + device_path: device_path.clone(), + connected: false, + message: Some(error.to_string()), + }, + ); + return; + } + if let Ok(mut readers) = app.state::().active_readers.lock() { + readers.remove(&device_path); + } + }) + .map_err(AppError::Io)?; + + Ok(()) +} + +fn read_loop(app: &AppHandle, device_path: &str) -> AppResult<()> { + let mut device = Device::open(Path::new(device_path)).map_err(AppError::Io)?; + let axis_ranges = device + .get_absinfo() + .map_err(AppError::Io)? + .map(|(axis, info)| (axis, (info.as_ref().minimum, info.as_ref().maximum))) + .collect::>(); + // #region agent log + debug_log( + "H2", + "Reader opened device", + serde_json::json!({"devicePath": device_path, "axisRangeCount": axis_ranges.len()}), + ); + // #endregion + + let _ = app.emit( + READER_STATUS_EVENT_NAME, + ReaderStatusPayload { + device_path: device_path.to_string(), + connected: true, + message: None, + }, + ); + + let mut grabbed = false; + loop { + let enabled = remapping_enabled(app)?; + if enabled != grabbed { + if enabled { + device.grab().map_err(AppError::Io)?; + // #region agent log + debug_log( + "H10", + "Physical controller exclusively grabbed for remapping", + serde_json::json!({"devicePath": device_path}), + ); + // #endregion + } else { + device.ungrab().map_err(AppError::Io)?; + // #region agent log + debug_log( + "H10", + "Physical controller released from remapping", + serde_json::json!({"devicePath": device_path}), + ); + // #endregion + } + grabbed = enabled; + } + let events = device.fetch_events().map_err(AppError::Io)?; + for event in events { + if grabbed { + forward_event(app, event, &axis_ranges)?; + } + let payload = match event.destructure() { + EventSummary::Key(_, key, value) => InputEventPayload { + timestamp: timestamp_ms(), + event_type: "button".to_string(), + code: key_id(key), + raw_value: f64::from(value), + processed_value: if value == 0 { 0.0 } else { 1.0 }, + }, + EventSummary::AbsoluteAxis(_, axis, value) => { + let (minimum, maximum) = axis_ranges.get(&axis).copied().unwrap_or((-1, 1)); + InputEventPayload { + timestamp: timestamp_ms(), + event_type: "axis".to_string(), + code: axis_id(axis), + raw_value: f64::from(value), + processed_value: normalized(value, minimum, maximum), + } + } + _ => continue, + }; + app.emit(INPUT_EVENT_NAME, payload) + .map_err(|error| AppError::Other(error.to_string()))?; + } + } +} diff --git a/src-tauri/src/input_transform.rs b/src-tauri/src/input_transform.rs new file mode 100644 index 0000000..11c3337 --- /dev/null +++ b/src-tauri/src/input_transform.rs @@ -0,0 +1,211 @@ +//! Pure input transformation primitives. +//! +//! Kept separate from evdev/uinput so it can be tested deterministically and +//! reused by the live pipeline. The frontend mirrors this math for previews; +//! the Rust implementation is the authoritative output path. + +use crate::profile_schema::{ + CurveConfig, CurvePreset, DeadzoneMode, StickSettings, TriggerResponse, TriggerSettings, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StickValue { + pub x: f32, + pub y: f32, +} + +impl StickValue { + fn magnitude(self) -> f32 { + (self.x * self.x + self.y * self.y).sqrt() + } +} + +fn clamp(value: f32, min: f32, max: f32) -> f32 { + value.clamp(min, max) +} + +pub fn evaluate_curve(curve: &CurveConfig, input: f32) -> f32 { + let t = clamp(input, 0.0, 1.0); + match curve.preset { + CurvePreset::Linear => t, + CurvePreset::Precision => t.powf(1.8), + CurvePreset::Aggressive => t.powf(0.55), + CurvePreset::Smooth => t * t * (3.0 - 2.0 * t), + CurvePreset::Exponential => t.powf(2.5), + CurvePreset::Custom => { + let mut points = curve.custom_points.clone(); + points.sort_by(|a, b| a.x.total_cmp(&b.x)); + let Some(first) = points.first() else { + return t; + }; + if t <= first.x { + return first.y; + } + let Some(last) = points.last() else { + return t; + }; + if t >= last.x { + return last.y; + } + points + .windows(2) + .find_map(|pair| { + let a = pair[0]; + let b = pair[1]; + if (a.x..=b.x).contains(&t) { + let span = (b.x - a.x).max(f32::EPSILON); + let local_t = (t - a.x) / span; + Some(a.y + (b.y - a.y) * local_t) + } else { + None + } + }) + .unwrap_or(t) + } + } +} + +pub fn transform_stick(raw: StickValue, settings: &StickSettings) -> StickValue { + let mut value = raw; + let raw_magnitude = value.magnitude(); + if settings.circularity_correction && raw_magnitude > 1.0 { + value.x /= raw_magnitude; + value.y /= raw_magnitude; + } + + value = match settings.deadzone_mode { + DeadzoneMode::Radial => transform_radial(value, settings), + DeadzoneMode::Axial => StickValue { + x: transform_axis(value.x, settings), + y: transform_axis(value.y, settings), + }, + }; + + let magnitude = value.magnitude(); + if magnitude > 0.0 { + let curved = evaluate_curve(&settings.response_curve, magnitude); + let scale = curved / magnitude; + value.x *= scale; + value.y *= scale; + } + + value.x = clamp(value.x * settings.sensitivity_x, -1.0, 1.0); + value.y = clamp(value.y * settings.sensitivity_y, -1.0, 1.0); + if settings.invert_x { + value.x = -value.x; + } + if settings.invert_y { + value.y = -value.y; + } + value +} + +fn transform_radial(value: StickValue, settings: &StickSettings) -> StickValue { + let magnitude = value.magnitude(); + if magnitude <= settings.inner_deadzone { + return StickValue { x: 0.0, y: 0.0 }; + } + let outer = settings + .outer_deadzone + .max(settings.inner_deadzone + f32::EPSILON); + let normalized = clamp( + (magnitude - settings.inner_deadzone) / (outer - settings.inner_deadzone), + 0.0, + 1.0, + ); + let output_magnitude = settings.anti_deadzone + normalized * (1.0 - settings.anti_deadzone); + let scale = output_magnitude / magnitude; + StickValue { + x: value.x * scale, + y: value.y * scale, + } +} + +fn transform_axis(value: f32, settings: &StickSettings) -> f32 { + let abs = value.abs(); + if abs <= settings.inner_deadzone { + return 0.0; + } + let outer = settings + .outer_deadzone + .max(settings.inner_deadzone + f32::EPSILON); + let normalized = clamp( + (abs - settings.inner_deadzone) / (outer - settings.inner_deadzone), + 0.0, + 1.0, + ); + value.signum() * (settings.anti_deadzone + normalized * (1.0 - settings.anti_deadzone)) +} + +pub fn transform_trigger(raw: f32, settings: &TriggerSettings) -> f32 { + let raw = clamp(raw, 0.0, 1.0); + let oriented = if settings.invert { 1.0 - raw } else { raw }; + if oriented <= settings.deadzone { + return 0.0; + } + let maximum = settings + .max_activation + .max(settings.deadzone + f32::EPSILON); + let normalized = clamp( + (oriented - settings.deadzone) / (maximum - settings.deadzone), + 0.0, + 1.0, + ); + if settings.digital_mode { + return if normalized > 0.5 { 1.0 } else { 0.0 }; + } + let response = match settings.response { + TriggerResponse::Linear => normalized, + TriggerResponse::Progressive => normalized.powf(1.6), + }; + clamp( + evaluate_curve(&settings.curve, response) * settings.sensitivity, + 0.0, + 1.0, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn radial_deadzone_eliminates_drift() { + let settings = StickSettings::default(); + assert_eq!( + transform_stick(StickValue { x: 0.04, y: 0.02 }, &settings), + StickValue { x: 0.0, y: 0.0 } + ); + } + + #[test] + fn stick_inversion_and_sensitivity_are_applied() { + let mut settings = StickSettings::default(); + settings.inner_deadzone = 0.0; + settings.sensitivity_x = 0.5; + settings.invert_y = true; + let output = transform_stick(StickValue { x: 1.0, y: 0.5 }, &settings); + assert!(output.x > 0.4 && output.x < 0.5); + assert!(output.y < 0.0); + } + + #[test] + fn progressive_trigger_grows_more_slowly_than_linear() { + let mut linear = TriggerSettings::default(); + linear.deadzone = 0.0; + let mut progressive = linear.clone(); + progressive.response = TriggerResponse::Progressive; + assert!(transform_trigger(0.5, &progressive) < transform_trigger(0.5, &linear)); + } + + #[test] + fn custom_curve_interpolates_between_points() { + let mut curve = CurveConfig::default(); + curve.preset = CurvePreset::Custom; + curve.custom_points = vec![ + crate::profile_schema::CurvePoint { x: 0.0, y: 0.0 }, + crate::profile_schema::CurvePoint { x: 1.0, y: 0.5 }, + ]; + assert_eq!(evaluate_curve(&curve, 0.5), 0.25); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..512cbdd --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,61 @@ +#[allow(dead_code)] +mod calibration; +mod commands; +mod default_profiles; +mod device_detection; +mod error; +mod evdev_reader; +#[allow(dead_code)] +mod input_transform; +mod permissions; +mod process_monitor; +mod profile_manager; +mod profile_schema; +mod state; +mod uinput_writer; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin( + tauri_plugin_log::Builder::new() + .level(log::LevelFilter::Info) + .build(), + ) + .setup(|_app| { + crate::commands::mapping::log_remapping_pipeline_startup(); + if let Err(err) = profile_manager::ensure_initialized() { + log::error!("failed to initialize profile storage: {err}"); + } + Ok(()) + }) + .manage(state::AppState::default()) + .invoke_handler(tauri::generate_handler![ + commands::system::get_app_info, + commands::devices::list_devices, + commands::devices::start_device_reader, + commands::diagnostics::diagnostics_snapshot, + commands::profiles::list_profiles, + commands::profiles::load_profile, + commands::profiles::save_profile, + commands::profiles::create_profile, + commands::profiles::rename_profile, + commands::profiles::duplicate_profile, + commands::profiles::delete_profile, + commands::profiles::reset_profile, + commands::profiles::export_profile, + commands::profiles::import_profile, + commands::profiles::profiles_directory, + commands::processes::detected_game_profile, + commands::mapping::create_virtual_controller, + commands::mapping::destroy_virtual_controller, + commands::mapping::virtual_controller_active, + commands::mapping::apply_remapping_profile, + commands::mapping::set_remapping_enabled, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..05c96fa --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + eightbitdo_control_center_lib::run() +} diff --git a/src-tauri/src/permissions.rs b/src-tauri/src/permissions.rs new file mode 100644 index 0000000..5fac245 --- /dev/null +++ b/src-tauri/src/permissions.rs @@ -0,0 +1,44 @@ +//! Non-mutating permission and kernel capability checks. + +use std::fs::OpenOptions; +use std::path::Path; + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionStatus { + pub uinput_exists: bool, + pub uinput_writable: bool, + pub input_directory_readable: bool, + pub current_user: Option, + pub groups: Vec, +} + +pub fn check_permissions() -> PermissionStatus { + let uinput = Path::new("/dev/uinput"); + let uinput_exists = uinput.exists(); + let uinput_writable = uinput_exists + && OpenOptions::new() + .read(true) + .write(true) + .open(uinput) + .is_ok(); + let input_directory_readable = std::fs::read_dir("/dev/input").is_ok(); + let current_user = std::env::var("USER").ok(); + let groups = std::process::Command::new("id") + .arg("-nG") + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.split_whitespace().map(ToString::to_string).collect()) + .unwrap_or_default(); + + PermissionStatus { + uinput_exists, + uinput_writable, + input_directory_readable, + current_user, + groups, + } +} diff --git a/src-tauri/src/process_monitor.rs b/src-tauri/src/process_monitor.rs new file mode 100644 index 0000000..f8f6407 --- /dev/null +++ b/src-tauri/src/process_monitor.rs @@ -0,0 +1,45 @@ +//! Process-name matching for optional game-specific profile activation. +//! +//! Matching is explicit and opt-in: a profile supplies executable names, and +//! only exact case-insensitive file-name matches activate it. + +use std::collections::HashSet; + +use sysinfo::System; + +use crate::profile_schema::Profile; + +pub fn running_executables() -> HashSet { + let system = System::new_all(); + system + .processes() + .values() + .filter_map(|process| process.name().to_str()) + .map(|name| name.to_ascii_lowercase()) + .collect() +} + +pub fn matching_profile_id(profiles: &[Profile]) -> Option { + let running = running_executables(); + profiles + .iter() + .filter(|profile| !profile.assigned_executables.is_empty()) + .find(|profile| { + profile + .assigned_executables + .iter() + .any(|name| running.contains(&name.to_ascii_lowercase())) + }) + .map(|profile| profile.id.clone()) +} + +#[cfg(test)] +mod tests { + use crate::default_profiles::{default_profile, fps_profile}; + + #[test] + fn no_profiles_without_assignments_match() { + let profiles = vec![default_profile(), fps_profile()]; + assert!(profiles.iter().all(|p| p.assigned_executables.is_empty())); + } +} diff --git a/src-tauri/src/profile_manager.rs b/src-tauri/src/profile_manager.rs new file mode 100644 index 0000000..51a9524 --- /dev/null +++ b/src-tauri/src/profile_manager.rs @@ -0,0 +1,298 @@ +//! Reads and writes controller profiles under +//! `~/.config/8bitdo-control-center/profiles/`. + +use std::fs; +use std::path::PathBuf; + +use uuid::Uuid; + +use crate::default_profiles::built_in_profiles; +use crate::error::{AppError, AppResult}; +use crate::profile_schema::{parse_profile_json, Profile, ProfileSummary}; + +/// Overridable via `EIGHTBITDO_CONFIG_DIR` (used by tests) so we never touch +/// a developer's real `~/.config` while running the test suite. +pub fn config_dir() -> PathBuf { + if let Ok(override_dir) = std::env::var("EIGHTBITDO_CONFIG_DIR") { + return PathBuf::from(override_dir); + } + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".config")) + .join("8bitdo-control-center") +} + +pub fn profiles_dir() -> PathBuf { + config_dir().join("profiles") +} + +fn profile_path(id: &str) -> PathBuf { + profiles_dir().join(format!("{id}.json")) +} + +fn slugify(name: &str) -> String { + let slug: String = name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let mut collapsed = String::with_capacity(slug.len()); + let mut prev_dash = false; + for c in slug.chars() { + if c == '-' { + if !prev_dash { + collapsed.push(c); + } + prev_dash = true; + } else { + collapsed.push(c); + prev_dash = false; + } + } + let trimmed = collapsed.trim_matches('-'); + if trimmed.is_empty() { + Uuid::new_v4().to_string() + } else { + trimmed.to_string() + } +} + +fn unique_id(desired: &str) -> String { + if !profile_path(desired).exists() { + return desired.to_string(); + } + for suffix in 2..1000 { + let candidate = format!("{desired}-{suffix}"); + if !profile_path(&candidate).exists() { + return candidate; + } + } + format!("{desired}-{}", Uuid::new_v4()) +} + +/// Ensures the config/profiles directories and the five built-in profiles +/// exist. Safe to call on every app start; never overwrites files a user has +/// already modified. +pub fn ensure_initialized() -> AppResult<()> { + fs::create_dir_all(profiles_dir())?; + for profile in built_in_profiles() { + let path = profile_path(&profile.id); + if !path.exists() { + write_profile_to_path(&path, &profile)?; + } + } + Ok(()) +} + +fn write_profile_to_path(path: &PathBuf, profile: &Profile) -> AppResult<()> { + let json = serde_json::to_string_pretty(profile)?; + fs::write(path, json)?; + Ok(()) +} + +pub fn list_profiles() -> AppResult> { + ensure_initialized()?; + let mut summaries = Vec::new(); + for entry in fs::read_dir(profiles_dir())? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let raw = fs::read_to_string(&path)?; + match parse_profile_json(&raw) { + Ok(profile) => summaries.push(ProfileSummary::from(&profile)), + Err(err) => { + log::warn!("skipping unreadable profile {}: {err}", path.display()); + } + } + } + summaries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(summaries) +} + +pub fn load_all_profiles() -> AppResult> { + ensure_initialized()?; + let mut profiles = Vec::new(); + for entry in fs::read_dir(profiles_dir())? { + let path = entry?.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let raw = fs::read_to_string(path)?; + if let Ok(profile) = parse_profile_json(&raw) { + profiles.push(profile); + } + } + Ok(profiles) +} + +pub fn load_profile(id: &str) -> AppResult { + let path = profile_path(id); + if !path.exists() { + return Err(AppError::ProfileNotFound(id.to_string())); + } + let raw = fs::read_to_string(&path)?; + parse_profile_json(&raw) +} + +fn name_taken(name: &str, ignore_id: Option<&str>) -> AppResult { + for summary in list_profiles()? { + if summary.name.eq_ignore_ascii_case(name) && ignore_id != Some(summary.id.as_str()) { + return Ok(true); + } + } + Ok(false) +} + +pub fn save_profile(mut profile: Profile) -> AppResult { + profile.updated_at = chrono::Utc::now().to_rfc3339(); + let path = profile_path(&profile.id); + write_profile_to_path(&path, &profile)?; + Ok(profile) +} + +pub fn create_profile(name: &str) -> AppResult { + if name_taken(name, None)? { + return Err(AppError::ProfileNameConflict(name.to_string())); + } + let id = unique_id(&slugify(name)); + let mut profile = crate::default_profiles::custom_profile(); + profile.id = id; + profile.name = name.to_string(); + profile.description = String::new(); + profile.is_built_in = false; + let now = chrono::Utc::now().to_rfc3339(); + profile.created_at = now.clone(); + profile.updated_at = now; + save_profile(profile) +} + +pub fn rename_profile(id: &str, new_name: &str) -> AppResult { + if name_taken(new_name, Some(id))? { + return Err(AppError::ProfileNameConflict(new_name.to_string())); + } + let mut profile = load_profile(id)?; + profile.name = new_name.to_string(); + save_profile(profile) +} + +pub fn duplicate_profile(id: &str, new_name: &str) -> AppResult { + if name_taken(new_name, None)? { + return Err(AppError::ProfileNameConflict(new_name.to_string())); + } + let mut profile = load_profile(id)?; + profile.id = unique_id(&slugify(new_name)); + profile.name = new_name.to_string(); + profile.is_built_in = false; + let now = chrono::Utc::now().to_rfc3339(); + profile.created_at = now.clone(); + profile.updated_at = now; + save_profile(profile) +} + +pub fn delete_profile(id: &str) -> AppResult<()> { + let path = profile_path(id); + if !path.exists() { + return Err(AppError::ProfileNotFound(id.to_string())); + } + fs::remove_file(path)?; + Ok(()) +} + +/// Resets a profile back to its built-in template (if `id` matches one of +/// the five defaults) or to a blank custom template otherwise. The caller +/// (frontend) is expected to show the recomputed profile and let the user +/// confirm before this is called, per the "never apply changes silently" +/// requirement for anything derived automatically. +pub fn reset_profile(id: &str) -> AppResult { + let existing = load_profile(id)?; + let template = built_in_profiles() + .into_iter() + .find(|p| p.id == id) + .unwrap_or_else(crate::default_profiles::custom_profile); + + let mut reset = template; + reset.id = existing.id; + reset.name = existing.name; + reset.is_built_in = existing.is_built_in; + reset.created_at = existing.created_at; + save_profile(reset) +} + +pub fn export_profile(id: &str, destination: &str) -> AppResult<()> { + let profile = load_profile(id)?; + let json = serde_json::to_string_pretty(&profile)?; + fs::write(destination, json)?; + Ok(()) +} + +pub fn import_profile(source: &str) -> AppResult { + let raw = fs::read_to_string(source)?; + let mut profile = parse_profile_json(&raw)?; + profile.is_built_in = false; + let desired_name = profile.name.clone(); + if name_taken(&desired_name, None)? { + profile.name = format!("{desired_name} (imported)"); + } + profile.id = unique_id(&slugify(&profile.name)); + let now = chrono::Utc::now().to_rfc3339(); + profile.created_at = now.clone(); + profile.updated_at = now; + save_profile(profile) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Runs `body` with `EIGHTBITDO_CONFIG_DIR` pointed at a fresh temp + /// directory so tests never touch a real user's profile files. All + /// profile_manager tests run inside this single `#[test]` function to + /// avoid racing on the process-wide environment variable. + fn with_temp_config_dir(body: impl FnOnce() -> T) -> T { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + std::env::set_var("EIGHTBITDO_CONFIG_DIR", temp_dir.path()); + let result = body(); + std::env::remove_var("EIGHTBITDO_CONFIG_DIR"); + result + } + + #[test] + fn full_profile_lifecycle() { + with_temp_config_dir(|| { + ensure_initialized().expect("initialize"); + let summaries = list_profiles().expect("list"); + assert_eq!( + summaries.len(), + 5, + "expected the five built-in profiles to be seeded" + ); + + let created = create_profile("My Profile").expect("create"); + assert!(!created.is_built_in); + assert_eq!(created.id, "my-profile"); + + let duplicate_err = create_profile("My Profile").unwrap_err(); + assert!(matches!(duplicate_err, AppError::ProfileNameConflict(_))); + + let renamed = rename_profile(&created.id, "Renamed Profile").expect("rename"); + assert_eq!(renamed.name, "Renamed Profile"); + + let duplicated = duplicate_profile("default", "Default Copy").expect("duplicate"); + assert_ne!(duplicated.id, "default"); + assert_eq!(duplicated.name, "Default Copy"); + + let mut to_modify = load_profile("fps").expect("load fps"); + to_modify.left_stick.inner_deadzone = 0.42; + save_profile(to_modify).expect("save"); + let reloaded = load_profile("fps").expect("reload fps"); + assert_eq!(reloaded.left_stick.inner_deadzone, 0.42); + + let reset = reset_profile("fps").expect("reset fps"); + assert_ne!(reset.left_stick.inner_deadzone, 0.42); + + delete_profile(&renamed.id).expect("delete"); + assert!(load_profile(&renamed.id).is_err()); + }); + } +} diff --git a/src-tauri/src/profile_schema.rs b/src-tauri/src/profile_schema.rs new file mode 100644 index 0000000..2e83532 --- /dev/null +++ b/src-tauri/src/profile_schema.rs @@ -0,0 +1,401 @@ +//! Versioned JSON schema for controller profiles. +//! +//! Profiles are stored as individual JSON files under +//! `~/.config/8bitdo-control-center/profiles/`. `CURRENT_SCHEMA_VERSION` is +//! bumped whenever the shape of [`Profile`] changes in a backwards +//! incompatible way; [`migrate_profile_value`] is the single entry point +//! responsible for upgrading older documents before they are deserialized +//! into the current struct definitions. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::{AppError, AppResult}; + +pub const CURRENT_SCHEMA_VERSION: u32 = 1; + +/// Identifier for a physical or virtual control (button, stick axis, trigger, +/// paddle, etc). Kept as a free-form string rather than a fixed enum so that +/// future controllers with different capabilities can be supported without +/// changing the schema; the canonical set of ids used by the built-in +/// device profile lives in `device_detection`. +pub type ControlId = String; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ActionMode { + Normal, + Turbo, + Toggle, + Hold, + LongPress, + DoubleTap, +} + +impl Default for ActionMode { + fn default() -> Self { + ActionMode::Normal + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum MappingTarget { + Button { button: ControlId }, + Key { key: String }, + MouseButton { button: String }, + MouseAxis { axis: String, scale: f32 }, + Disabled, + Combo { targets: Vec }, + ShiftLayer { layer_id: String }, +} + +impl Default for MappingTarget { + fn default() -> Self { + MappingTarget::Disabled + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ButtonMapping { + #[serde(default)] + pub target: MappingTarget, + #[serde(default)] + pub mode: ActionMode, + #[serde(default)] + pub turbo_rate_hz: Option, + #[serde(default)] + pub long_press_ms: Option, + #[serde(default)] + pub double_tap_window_ms: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DeadzoneMode { + Radial, + Axial, +} + +impl Default for DeadzoneMode { + fn default() -> Self { + DeadzoneMode::Radial + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CurvePreset { + Linear, + Precision, + Aggressive, + Smooth, + Exponential, + Custom, +} + +impl Default for CurvePreset { + fn default() -> Self { + CurvePreset::Linear + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct CurvePoint { + pub x: f32, + pub y: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CurveConfig { + pub preset: CurvePreset, + #[serde(default)] + pub custom_points: Vec, +} + +impl Default for CurveConfig { + fn default() -> Self { + CurveConfig { + preset: CurvePreset::Linear, + custom_points: vec![ + CurvePoint { x: 0.0, y: 0.0 }, + CurvePoint { x: 0.5, y: 0.5 }, + CurvePoint { x: 1.0, y: 1.0 }, + ], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StickSettings { + pub inner_deadzone: f32, + pub outer_deadzone: f32, + pub anti_deadzone: f32, + pub sensitivity_x: f32, + pub sensitivity_y: f32, + pub invert_x: bool, + pub invert_y: bool, + pub deadzone_mode: DeadzoneMode, + pub circularity_correction: bool, + pub smoothing: f32, + pub response_curve: CurveConfig, +} + +impl Default for StickSettings { + fn default() -> Self { + StickSettings { + inner_deadzone: 0.08, + outer_deadzone: 0.98, + anti_deadzone: 0.0, + sensitivity_x: 1.0, + sensitivity_y: 1.0, + invert_x: false, + invert_y: false, + deadzone_mode: DeadzoneMode::Radial, + circularity_correction: true, + smoothing: 0.0, + response_curve: CurveConfig::default(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TriggerResponse { + Linear, + Progressive, +} + +impl Default for TriggerResponse { + fn default() -> Self { + TriggerResponse::Linear + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TriggerSettings { + pub deadzone: f32, + pub max_activation: f32, + pub sensitivity: f32, + pub response: TriggerResponse, + pub hair_trigger: bool, + pub digital_mode: bool, + pub invert: bool, + pub curve: CurveConfig, +} + +impl Default for TriggerSettings { + fn default() -> Self { + TriggerSettings { + deadzone: 0.02, + max_activation: 1.0, + sensitivity: 1.0, + response: TriggerResponse::Linear, + hair_trigger: false, + digital_mode: false, + invert: false, + curve: CurveConfig::default(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GyroMapping { + Off, + Stick, + Mouse, +} + +impl Default for GyroMapping { + fn default() -> Self { + GyroMapping::Off + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionSettings { + pub vibration_intensity: f32, + pub left_motor_intensity: f32, + pub right_motor_intensity: f32, + pub gyro_sensitivity: f32, + pub gyro_deadzone: f32, + pub invert_gyro_x: bool, + pub invert_gyro_y: bool, + pub gyro_mapping: GyroMapping, +} + +impl Default for MotionSettings { + fn default() -> Self { + MotionSettings { + vibration_intensity: 1.0, + left_motor_intensity: 1.0, + right_motor_intensity: 1.0, + gyro_sensitivity: 1.0, + gyro_deadzone: 0.02, + invert_gyro_x: false, + invert_gyro_y: false, + gyro_mapping: GyroMapping::Off, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShiftLayer { + pub id: String, + pub name: String, + pub activator_button: ControlId, + pub mappings: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + #[serde(default = "default_schema_version")] + pub schema_version: u32, + pub id: String, + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub is_built_in: bool, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub assigned_executables: Vec, + #[serde(default)] + pub shortcut_combo: Vec, + #[serde(default)] + pub button_mappings: HashMap, + pub left_stick: StickSettings, + pub right_stick: StickSettings, + pub left_trigger: TriggerSettings, + pub right_trigger: TriggerSettings, + #[serde(default)] + pub motion: MotionSettings, + #[serde(default)] + pub shift_layers: Vec, +} + +fn default_schema_version() -> u32 { + CURRENT_SCHEMA_VERSION +} + +/// Lightweight summary used for list views so we don't need to parse every +/// mapping just to render the profile picker. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileSummary { + pub id: String, + pub name: String, + pub description: String, + pub is_built_in: bool, + pub updated_at: String, +} + +impl From<&Profile> for ProfileSummary { + fn from(profile: &Profile) -> Self { + ProfileSummary { + id: profile.id.clone(), + name: profile.name.clone(), + description: profile.description.clone(), + is_built_in: profile.is_built_in, + updated_at: profile.updated_at.clone(), + } + } +} + +/// Upgrades an arbitrary on-disk JSON document to the current schema +/// version, in place. Future migrations should add a new match arm rather +/// than mutating old ones, so historical upgrade paths stay intact. +pub fn migrate_profile_value(mut value: Value) -> AppResult { + let version = value + .get("schemaVersion") + .and_then(Value::as_u64) + .unwrap_or(1) as u32; + + if version > CURRENT_SCHEMA_VERSION { + return Err(AppError::UnsupportedSchemaVersion(version)); + } + + // No migrations exist yet (schema version 1 is the first version), but + // the match keeps a clear place to add them, e.g.: + // if version < 2 { /* transform `value` */ } + match version { + 1 => {} + other => return Err(AppError::UnsupportedSchemaVersion(other)), + } + + if let Value::Object(map) = &mut value { + map.insert( + "schemaVersion".to_string(), + Value::Number(CURRENT_SCHEMA_VERSION.into()), + ); + } + + Ok(value) +} + +pub fn parse_profile_json(raw: &str) -> AppResult { + let value: Value = serde_json::from_str(raw)?; + let migrated = migrate_profile_value(value)?; + let profile: Profile = + serde_json::from_value(migrated).map_err(|e| AppError::InvalidProfile(e.to_string()))?; + Ok(profile) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::default_profiles::default_profile; + + #[test] + fn serializes_and_round_trips_a_profile() { + let profile = default_profile(); + let json = serde_json::to_string(&profile).expect("serialize"); + let parsed = parse_profile_json(&json).expect("parse"); + assert_eq!(parsed.id, profile.id); + assert_eq!(parsed.schema_version, CURRENT_SCHEMA_VERSION); + assert_eq!( + parsed.left_stick.inner_deadzone, + profile.left_stick.inner_deadzone + ); + } + + #[test] + fn migrate_rejects_future_schema_versions() { + let mut value = serde_json::to_value(default_profile()).expect("to_value"); + value["schemaVersion"] = serde_json::json!(CURRENT_SCHEMA_VERSION + 1); + let err = migrate_profile_value(value).expect_err("should reject newer schema"); + assert!(matches!(err, AppError::UnsupportedSchemaVersion(_))); + } + + #[test] + fn migrate_fills_in_missing_schema_version_as_v1() { + let mut value = serde_json::to_value(default_profile()).expect("to_value"); + value.as_object_mut().unwrap().remove("schemaVersion"); + let migrated = migrate_profile_value(value).expect("migrate"); + assert_eq!(migrated["schemaVersion"], CURRENT_SCHEMA_VERSION); + } + + #[test] + fn default_mapping_target_is_disabled() { + assert!(matches!(MappingTarget::default(), MappingTarget::Disabled)); + } + + #[test] + fn custom_curve_defaults_to_identity_points() { + let curve = CurveConfig::default(); + assert_eq!(curve.custom_points.len(), 3); + assert_eq!(curve.custom_points.first().unwrap().x, 0.0); + assert_eq!(curve.custom_points.last().unwrap().x, 1.0); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..192b2c2 --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,13 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +/// State shared by the device commands. A physical event node has at most one +/// app reader, which prevents duplicate UI input streams and lets a reader be +/// restarted after a disconnect. +#[derive(Default)] +pub struct AppState { + pub active_readers: Mutex>, + pub virtual_controller: Mutex>, + pub remapping_enabled: Mutex, + pub active_remap_profile: Mutex>, +} diff --git a/src-tauri/src/uinput_writer.rs b/src-tauri/src/uinput_writer.rs new file mode 100644 index 0000000..6ea4216 --- /dev/null +++ b/src-tauri/src/uinput_writer.rs @@ -0,0 +1,69 @@ +//! Managed uinput virtual-controller lifecycle. +//! +//! Creation is explicit and never attempted automatically. A later pipeline +//! stage forwards transformed events only after the corresponding physical +//! device has been exclusively grabbed, preventing duplicate game input. + +use evdev::{ + uinput::VirtualDevice, AbsInfo, AbsoluteAxisCode, AttributeSet, InputId, KeyCode, + UinputAbsSetup, +}; + +use crate::error::{AppError, AppResult}; + +fn uinput_error(error: impl ToString) -> AppError { + AppError::Other(format!( + "could not create virtual controller: {}", + error.to_string() + )) +} + +pub fn create_virtual_controller() -> AppResult { + let mut keys = AttributeSet::::new(); + for key in [ + KeyCode::BTN_SOUTH, + KeyCode::BTN_EAST, + KeyCode::BTN_WEST, + KeyCode::BTN_NORTH, + KeyCode::BTN_TL, + KeyCode::BTN_TR, + KeyCode::BTN_TL2, + KeyCode::BTN_TR2, + KeyCode::BTN_THUMBL, + KeyCode::BTN_THUMBR, + KeyCode::BTN_SELECT, + KeyCode::BTN_START, + KeyCode::BTN_MODE, + KeyCode::BTN_DPAD_UP, + KeyCode::BTN_DPAD_DOWN, + KeyCode::BTN_DPAD_LEFT, + KeyCode::BTN_DPAD_RIGHT, + ] { + keys.insert(key); + } + + let stick_axis = AbsInfo::new(0, -32768, 32767, 0, 0, 0); + let trigger_axis = AbsInfo::new(0, 0, 255, 0, 0, 0); + let builder = VirtualDevice::builder() + .map_err(uinput_error)? + .name("8BitDo Control Center Virtual Controller") + .input_id(InputId::new(evdev::BusType::BUS_USB, 0x2dc8, 0x8bcc, 1)) + .with_keys(&keys) + .map_err(uinput_error)?; + + let builder = builder + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, stick_axis)) + .map_err(uinput_error)? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, stick_axis)) + .map_err(uinput_error)? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RX, stick_axis)) + .map_err(uinput_error)? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RY, stick_axis)) + .map_err(uinput_error)? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_Z, trigger_axis)) + .map_err(uinput_error)? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RZ, trigger_axis)) + .map_err(uinput_error)?; + + builder.build().map_err(uinput_error) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9d49b90 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "8BitDo Control Center", + "version": "0.1.0", + "identifier": "com.eightbitdo.controlcenter", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "8BitDo Control Center", + "width": 1280, + "height": 800, + "minWidth": 1024, + "minHeight": 640 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b0fc242 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,36 @@ +import { HashRouter, Route, Routes } from "react-router-dom"; + +import { AppLayout } from "@/components/layout/AppLayout"; +import { ButtonMapping } from "@/pages/ButtonMapping"; +import { Calibration } from "@/pages/Calibration"; +import { Dashboard } from "@/pages/Dashboard"; +import { DeviceInfo } from "@/pages/DeviceInfo"; +import { InputTester } from "@/pages/InputTester"; +import { Motion } from "@/pages/Motion"; +import { Profiles } from "@/pages/Profiles"; +import { Settings } from "@/pages/Settings"; +import { Sticks } from "@/pages/Sticks"; +import { Triggers } from "@/pages/Triggers"; + +function App() { + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +export default App; diff --git a/src/components/common/Badge.tsx b/src/components/common/Badge.tsx new file mode 100644 index 0000000..3f826af --- /dev/null +++ b/src/components/common/Badge.tsx @@ -0,0 +1,25 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +export type BadgeTone = "neutral" | "success" | "warning" | "danger" | "accent"; + +const TONE_CLASSES: Record = { + neutral: "bg-surface-3 text-fg-muted", + success: "bg-success/15 text-success", + warning: "bg-warning/15 text-warning", + danger: "bg-danger/15 text-danger", + accent: "bg-accent/15 text-accent-strong", +}; + +export function Badge({ tone = "neutral", children }: { tone?: BadgeTone; children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx new file mode 100644 index 0000000..af2c86f --- /dev/null +++ b/src/components/common/Button.tsx @@ -0,0 +1,48 @@ +import { clsx } from "clsx"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; +export type ButtonSize = "sm" | "md"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + icon?: ReactNode; +} + +const VARIANT_CLASSES: Record = { + primary: "bg-accent text-accent-fg hover:bg-accent-strong disabled:opacity-40", + secondary: + "bg-surface-2 text-fg border border-border hover:border-border-strong disabled:opacity-40", + ghost: "bg-transparent text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40", + danger: "bg-transparent text-danger border border-danger/40 hover:bg-danger/10 disabled:opacity-40", +}; + +const SIZE_CLASSES: Record = { + sm: "h-8 px-3 text-xs gap-1.5", + md: "h-9 px-4 text-sm gap-2", +}; + +export function Button({ + variant = "secondary", + size = "md", + icon, + className, + children, + ...rest +}: ButtonProps) { + return ( + + ); +} diff --git a/src/components/common/Panel.tsx b/src/components/common/Panel.tsx new file mode 100644 index 0000000..6c0fb42 --- /dev/null +++ b/src/components/common/Panel.tsx @@ -0,0 +1,42 @@ +import { clsx } from "clsx"; +import type { HTMLAttributes, ReactNode } from "react"; + +interface PanelProps extends Omit, "title"> { + title?: ReactNode; + description?: ReactNode; + actions?: ReactNode; + padded?: boolean; +} + +/** Rounded, subtly-shadowed surface used for every card/section in the app. */ +export function Panel({ + title, + description, + actions, + padded = true, + className, + children, + ...rest +}: PanelProps) { + return ( +
+ {(title || actions) && ( +
+
+ {title &&

{title}

} + {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ )} + {children} +
+ ); +} diff --git a/src/components/common/StatusDot.tsx b/src/components/common/StatusDot.tsx new file mode 100644 index 0000000..717bda4 --- /dev/null +++ b/src/components/common/StatusDot.tsx @@ -0,0 +1,21 @@ +import { clsx } from "clsx"; + +export type StatusDotTone = "success" | "warning" | "danger" | "neutral"; + +const TONE_CLASSES: Record = { + success: "bg-success", + warning: "bg-warning", + danger: "bg-danger", + neutral: "bg-fg-subtle", +}; + +export function StatusDot({ tone, pulse = false }: { tone: StatusDotTone; pulse?: boolean }) { + return ( + + {pulse && ( + + )} + + + ); +} diff --git a/src/components/controller/ButtonHotspot.tsx b/src/components/controller/ButtonHotspot.tsx new file mode 100644 index 0000000..08eba38 --- /dev/null +++ b/src/components/controller/ButtonHotspot.tsx @@ -0,0 +1,57 @@ +import { clsx } from "clsx"; + +import type { ButtonHotspotDef } from "@/components/controller/controllerLayout"; + +interface ButtonHotspotProps { + def: ButtonHotspotDef; + pressed: boolean; + selected: boolean; + remapped: boolean; + onSelect?: (id: string) => void; +} + +export function ButtonHotspot({ def, pressed, selected, remapped, onSelect }: ButtonHotspotProps) { + const shared = clsx( + "cursor-pointer transition-all duration-100", + pressed + ? "fill-accent stroke-accent-strong" + : selected + ? "fill-surface-2 stroke-accent" + : remapped + ? "fill-surface-2 stroke-accent-strong/70" + : "fill-surface-2 stroke-border-strong", + ); + + return ( + onSelect?.(def.id)} + role="button" + aria-label={def.label} + aria-pressed={pressed} + tabIndex={0} + > + {def.shape === "circle" ? ( + + ) : ( + + )} + + {def.label.length <= 3 ? def.label : ""} + + + ); +} diff --git a/src/components/controller/ControllerDiagram.tsx b/src/components/controller/ControllerDiagram.tsx new file mode 100644 index 0000000..39854e0 --- /dev/null +++ b/src/components/controller/ControllerDiagram.tsx @@ -0,0 +1,81 @@ +import { BODY_PATH, FRONT_HOTSPOTS, REAR_HOTSPOTS, VIEW_BOX } from "@/components/controller/controllerLayout"; +import { ButtonHotspot } from "@/components/controller/ButtonHotspot"; +import { StickHotspot } from "@/components/controller/StickHotspot"; +import { TriggerHotspot } from "@/components/controller/TriggerHotspot"; + +export type ControllerView = "front" | "rear"; + +interface ControllerDiagramProps { + view: ControllerView; + buttons: Record; + axes: Record; + selectedId?: string | null; + remappedIds?: Set; + onSelect?: (id: string) => void; +} + +/** + * Generic, reusable SVG gamepad diagram (front and rear views). Deliberately + * abstract/geometric rather than tracing any real product's artwork. + */ +export function ControllerDiagram({ + view, + buttons, + axes, + selectedId, + remappedIds, + onSelect, +}: ControllerDiagramProps) { + const hotspots = view === "front" ? FRONT_HOTSPOTS : REAR_HOTSPOTS; + + return ( + + + {view === "rear" && ( + + Rear view + + )} + {hotspots.map((def) => { + if (def.kind === "button") { + return ( + + ); + } + if (def.kind === "stick") { + return ( + + ); + } + return ( + + ); + })} + + ); +} diff --git a/src/components/controller/StickHotspot.tsx b/src/components/controller/StickHotspot.tsx new file mode 100644 index 0000000..a34c805 --- /dev/null +++ b/src/components/controller/StickHotspot.tsx @@ -0,0 +1,45 @@ +import { clsx } from "clsx"; + +import type { StickHotspotDef } from "@/components/controller/controllerLayout"; + +interface StickHotspotProps { + def: StickHotspotDef; + x: number; + y: number; + clicked: boolean; + selected: boolean; + onSelect?: (id: string) => void; +} + +/** Renders the stick's boundary ring plus a dot showing its current displacement. */ +export function StickHotspot({ def, x, y, clicked, selected, onSelect }: StickHotspotProps) { + const dotX = def.cx + x * def.r * 0.72; + const dotY = def.cy + y * def.r * 0.72; + + return ( + onSelect?.(def.id)} + role="button" + aria-label={def.label} + tabIndex={0} + className="cursor-pointer" + > + + + + ); +} diff --git a/src/components/controller/TriggerHotspot.tsx b/src/components/controller/TriggerHotspot.tsx new file mode 100644 index 0000000..1b3b87e --- /dev/null +++ b/src/components/controller/TriggerHotspot.tsx @@ -0,0 +1,39 @@ +import { clsx } from "clsx"; + +import type { TriggerHotspotDef } from "@/components/controller/controllerLayout"; + +interface TriggerHotspotProps { + def: TriggerHotspotDef; + value: number; + selected: boolean; + onSelect?: (id: string) => void; +} + +/** Shoulder trigger with an analog fill bar reflecting its current value. */ +export function TriggerHotspot({ def, value, selected, onSelect }: TriggerHotspotProps) { + const fillWidth = Math.max(0, Math.min(1, value)) * def.width; + + return ( + onSelect?.(def.id)} role="button" aria-label={def.label} tabIndex={0} className="cursor-pointer"> + + + + {def.label} + + + ); +} diff --git a/src/components/controller/controllerLayout.ts b/src/components/controller/controllerLayout.ts new file mode 100644 index 0000000..6ff58d8 --- /dev/null +++ b/src/components/controller/controllerLayout.ts @@ -0,0 +1,135 @@ +/** + * Layout data for the generic controller diagram. Coordinates describe an + * abstract dual-stick gamepad silhouette (not any specific product's + * artwork) inside a 480x280 viewBox, reused for both the front and rear + * views so the component stays simple and themeable. + */ + +export const VIEW_BOX = "0 0 480 280"; + +export const BODY_PATH = + "M150,55 C105,55 65,85 52,135 C40,182 60,225 102,230 C132,233 152,213 168,195 L312,195 C328,213 348,233 378,230 C420,225 440,182 428,135 C415,85 375,55 330,55 Z"; + +export interface ButtonHotspotDef { + kind: "button"; + id: string; + label: string; + shape: "circle" | "rect"; + cx: number; + cy: number; + r?: number; + width?: number; + height?: number; + rx?: number; +} + +export interface StickHotspotDef { + kind: "stick"; + id: string; + clickId: string; + label: string; + cx: number; + cy: number; + r: number; + axisX: string; + axisY: string; +} + +export interface TriggerHotspotDef { + kind: "trigger"; + id: string; + label: string; + x: number; + y: number; + width: number; + height: number; + rx: number; + axis: string; +} + +export type HotspotDef = ButtonHotspotDef | StickHotspotDef | TriggerHotspotDef; + +export const FRONT_HOTSPOTS: HotspotDef[] = [ + { + kind: "stick", + id: "left_stick", + clickId: "l3", + label: "Left Stick", + cx: 150, + cy: 112, + r: 28, + axisX: "left_stick_x", + axisY: "left_stick_y", + }, + { kind: "button", id: "dpad_up", label: "D-Pad Up", shape: "rect", cx: 150, cy: 168, width: 20, height: 22, rx: 4 }, + { + kind: "button", + id: "dpad_down", + label: "D-Pad Down", + shape: "rect", + cx: 150, + cy: 212, + width: 20, + height: 22, + rx: 4, + }, + { + kind: "button", + id: "dpad_left", + label: "D-Pad Left", + shape: "rect", + cx: 128, + cy: 190, + width: 22, + height: 20, + rx: 4, + }, + { + kind: "button", + id: "dpad_right", + label: "D-Pad Right", + shape: "rect", + cx: 172, + cy: 190, + width: 22, + height: 20, + rx: 4, + }, + { + kind: "stick", + id: "right_stick", + clickId: "r3", + label: "Right Stick", + cx: 330, + cy: 190, + r: 28, + axisX: "right_stick_x", + axisY: "right_stick_y", + }, + { kind: "button", id: "y", label: "Y", shape: "circle", cx: 330, cy: 90, r: 14 }, + { kind: "button", id: "x", label: "X", shape: "circle", cx: 308, cy: 112, r: 14 }, + { kind: "button", id: "b", label: "B", shape: "circle", cx: 352, cy: 112, r: 14 }, + { kind: "button", id: "a", label: "A", shape: "circle", cx: 330, cy: 134, r: 14 }, + { kind: "button", id: "select", label: "Select", shape: "rect", cx: 205, cy: 95, width: 24, height: 14, rx: 7 }, + { kind: "button", id: "guide", label: "Guide", shape: "circle", cx: 240, cy: 85, r: 12 }, + { kind: "button", id: "start", label: "Start", shape: "rect", cx: 275, cy: 95, width: 24, height: 14, rx: 7 }, + { kind: "button", id: "l1", label: "L1", shape: "rect", cx: 110, cy: 48, width: 60, height: 16, rx: 8 }, + { kind: "button", id: "r1", label: "R1", shape: "rect", cx: 370, cy: 48, width: 60, height: 16, rx: 8 }, + { kind: "trigger", id: "left_trigger", label: "L2", x: 80, y: 18, width: 60, height: 16, rx: 8, axis: "left_trigger" }, + { + kind: "trigger", + id: "right_trigger", + label: "R2", + x: 340, + y: 18, + width: 60, + height: 16, + rx: 8, + axis: "right_trigger", + }, +]; + +export const REAR_HOTSPOTS: HotspotDef[] = [ + { kind: "button", id: "paddle_l1", label: "Paddle L1", shape: "rect", cx: 128, cy: 150, width: 26, height: 84, rx: 13 }, + { kind: "button", id: "paddle_r1", label: "Paddle R1", shape: "rect", cx: 352, cy: 150, width: 26, height: 84, rx: 13 }, +]; diff --git a/src/components/feedback/ConfirmDialogHost.tsx b/src/components/feedback/ConfirmDialogHost.tsx new file mode 100644 index 0000000..b7e53c8 --- /dev/null +++ b/src/components/feedback/ConfirmDialogHost.tsx @@ -0,0 +1,26 @@ +import { Button } from "@/components/common/Button"; +import { useUIStore } from "@/stores/uiStore"; + +export function ConfirmDialogHost() { + const confirm = useUIStore((s) => s.confirm); + const resolveConfirm = useUIStore((s) => s.resolveConfirm); + + if (!confirm) return null; + + return ( +
+
+

{confirm.title}

+

{confirm.message}

+
+ + +
+
+
+ ); +} diff --git a/src/components/feedback/ToastStack.tsx b/src/components/feedback/ToastStack.tsx new file mode 100644 index 0000000..40a48cc --- /dev/null +++ b/src/components/feedback/ToastStack.tsx @@ -0,0 +1,53 @@ +import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"; + +import type { Toast, ToastVariant } from "@/stores/uiStore"; +import { useUIStore } from "@/stores/uiStore"; + +const VARIANT_ICON: Record = { + info: Info, + success: CheckCircle2, + warning: AlertTriangle, + danger: XCircle, +}; + +const VARIANT_CLASSES: Record = { + info: "border-border text-fg", + success: "border-success/40 text-success", + warning: "border-warning/40 text-warning", + danger: "border-danger/40 text-danger", +}; + +function ToastItem({ toast }: { toast: Toast }) { + const dismissToast = useUIStore((s) => s.dismissToast); + const Icon = VARIANT_ICON[toast.variant]; + + return ( +
+ +

{toast.message}

+ +
+ ); +} + +export function ToastStack() { + const toasts = useUIStore((s) => s.toasts); + + return ( +
+ {toasts.map((toast) => ( +
+ +
+ ))} +
+ ); +} diff --git a/src/components/feedback/UnsavedChangesBar.tsx b/src/components/feedback/UnsavedChangesBar.tsx new file mode 100644 index 0000000..565d8f5 --- /dev/null +++ b/src/components/feedback/UnsavedChangesBar.tsx @@ -0,0 +1,46 @@ +import { Check, RotateCcw, Save } from "lucide-react"; + +import { Button } from "@/components/common/Button"; +import { useProfileStore } from "@/stores/profileStore"; + +/** Persistent Apply / Revert / Save Profile bar shown on every editable page. */ +export function UnsavedChangesBar() { + const isDirty = useProfileStore((s) => s.isDirty); + const activeProfile = useProfileStore((s) => s.activeProfile); + const applyDraft = useProfileStore((s) => s.applyDraft); + const revertDraft = useProfileStore((s) => s.revertDraft); + const saveDraft = useProfileStore((s) => s.saveDraft); + + if (!activeProfile) return null; + + return ( +
+
+ {isDirty ? ( + <> + + Unsaved changes to “{activeProfile.name}” + + ) : ( + <> + + + Editing “{activeProfile.name}” · up to date + + + )} +
+
+ + + +
+
+ ); +} diff --git a/src/components/inputs/CurveEditor.tsx b/src/components/inputs/CurveEditor.tsx new file mode 100644 index 0000000..233f9ac --- /dev/null +++ b/src/components/inputs/CurveEditor.tsx @@ -0,0 +1,98 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; + +import type { CurvePoint } from "@/types/profile"; +import { sampleCurve } from "@/utils/curveMath"; + +interface CurveEditorProps { + points: CurvePoint[]; + onChange: (points: CurvePoint[]) => void; +} + +const SIZE = 220; +const PADDING = 18; +const PLOT_SIZE = SIZE - PADDING * 2; + +function pointToSvg(point: CurvePoint): { x: number; y: number } { + return { x: PADDING + point.x * PLOT_SIZE, y: PADDING + (1 - point.y) * PLOT_SIZE }; +} + +function svgToPoint(event: ReactPointerEvent): CurvePoint { + const svg = event.currentTarget instanceof SVGSVGElement + ? event.currentTarget + : event.currentTarget.ownerSVGElement; + if (!svg) return { x: 0, y: 0 }; + const bounds = svg.getBoundingClientRect(); + return { + x: Math.min(1, Math.max(0, (event.clientX - bounds.left - PADDING) / PLOT_SIZE)), + y: Math.min(1, Math.max(0, 1 - (event.clientY - bounds.top - PADDING) / PLOT_SIZE)), + }; +} + +/** Interactive, dependency-free custom response-curve point editor. */ +export function CurveEditor({ points, onChange }: CurveEditorProps) { + const sorted = [...points].sort((a, b) => a.x - b.x); + const samples = sampleCurve("custom", sorted); + const line = samples.map((point) => { + const svg = pointToSvg(point); + return `${svg.x},${svg.y}`; + }).join(" "); + + function addPoint(event: ReactPointerEvent) { + if ((event.target as Element).tagName !== "svg") return; + const point = svgToPoint(event); + onChange([...sorted, point].sort((a, b) => a.x - b.x)); + } + + function movePoint(index: number, event: ReactPointerEvent) { + const next = [...sorted]; + const candidate = svgToPoint(event); + // Endpoints remain anchored at input 0/1, preventing invalid curves. + if (index === 0) candidate.x = 0; + if (index === next.length - 1) candidate.x = 1; + const lower = index === 0 ? 0 : next[index - 1].x + 0.01; + const upper = index === next.length - 1 ? 1 : next[index + 1].x - 0.01; + candidate.x = Math.min(upper, Math.max(lower, candidate.x)); + next[index] = candidate; + onChange(next); + } + + return ( +
+ + {[0.25, 0.5, 0.75].map((value) => ( + + + + + ))} + + {sorted.map((point, index) => { + const svg = pointToSvg(point); + return ( + { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) movePoint(index, event); + }} + /> + ); + })} + +

Click to add a point; drag points to adjust the curve.

+
+ ); +} diff --git a/src/components/inputs/Select.tsx b/src/components/inputs/Select.tsx new file mode 100644 index 0000000..ad9be55 --- /dev/null +++ b/src/components/inputs/Select.tsx @@ -0,0 +1,43 @@ +import { ChevronDown } from "lucide-react"; + +interface SelectOption { + value: T; + label: string; +} + +interface SelectProps { + label?: string; + value: T; + options: SelectOption[]; + onChange: (value: T) => void; + disabled?: boolean; +} + +export function Select({ label, value, options, onChange, disabled }: SelectProps) { + const select = ( +
+ + +
+ ); + + if (!label) return select; + + return ( + + ); +} diff --git a/src/components/inputs/SimulatedStickPad.tsx b/src/components/inputs/SimulatedStickPad.tsx new file mode 100644 index 0000000..eeaec85 --- /dev/null +++ b/src/components/inputs/SimulatedStickPad.tsx @@ -0,0 +1,56 @@ +import { useRef } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; + +interface SimulatedStickPadProps { + onMove: (x: number, y: number) => void; + size?: number; +} + +/** Drag surface that injects synthetic raw stick input, for testing settings without hardware. */ +export function SimulatedStickPad({ onMove, size = 120 }: SimulatedStickPadProps) { + const ref = useRef(null); + const dragging = useRef(false); + + function updateFromPointer(e: ReactPointerEvent) { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const radius = rect.width / 2; + let x = (e.clientX - cx) / radius; + let y = (e.clientY - cy) / radius; + const mag = Math.sqrt(x * x + y * y); + if (mag > 1) { + x /= mag; + y /= mag; + } + onMove(x, y); + } + + return ( +
{ + dragging.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + updateFromPointer(e); + }} + onPointerMove={(e) => { + if (dragging.current) updateFromPointer(e); + }} + onPointerUp={() => { + dragging.current = false; + onMove(0, 0); + }} + > + + drag to +
+ test +
+
+ ); +} diff --git a/src/components/inputs/Slider.tsx b/src/components/inputs/Slider.tsx new file mode 100644 index 0000000..d1d959a --- /dev/null +++ b/src/components/inputs/Slider.tsx @@ -0,0 +1,44 @@ +interface SliderProps { + label: string; + value: number; + min?: number; + max?: number; + step?: number; + unit?: string; + disabled?: boolean; + onChange: (value: number) => void; + formatValue?: (value: number) => string; +} + +export function Slider({ + label, + value, + min = 0, + max = 1, + step = 0.01, + unit = "", + disabled, + onChange, + formatValue, +}: SliderProps) { + const displayValue = formatValue ? formatValue(value) : `${Math.round(value * 100) / 100}${unit}`; + + return ( +
+
+ {label} + {displayValue} +
+ onChange(Number(e.currentTarget.value))} + /> +
+ ); +} diff --git a/src/components/inputs/TextField.tsx b/src/components/inputs/TextField.tsx new file mode 100644 index 0000000..abb676f --- /dev/null +++ b/src/components/inputs/TextField.tsx @@ -0,0 +1,23 @@ +import type { InputHTMLAttributes } from "react"; + +interface TextFieldProps extends InputHTMLAttributes { + label?: string; +} + +export function TextField({ label, className, ...rest }: TextFieldProps) { + const input = ( + + ); + + if (!label) return input; + + return ( + + ); +} diff --git a/src/components/inputs/Toggle.tsx b/src/components/inputs/Toggle.tsx new file mode 100644 index 0000000..34f567a --- /dev/null +++ b/src/components/inputs/Toggle.tsx @@ -0,0 +1,44 @@ +import { clsx } from "clsx"; + +interface ToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + label?: string; + description?: string; + disabled?: boolean; +} + +export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) { + const switchEl = ( + + ); + + if (!label) return switchEl; + + return ( + + ); +} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..4b3f5f5 --- /dev/null +++ b/src/components/layout/AppLayout.tsx @@ -0,0 +1,31 @@ +import { Outlet } from "react-router-dom"; + +import { ConfirmDialogHost } from "@/components/feedback/ConfirmDialogHost"; +import { ToastStack } from "@/components/feedback/ToastStack"; +import { Sidebar } from "@/components/layout/Sidebar"; +import { TopBar } from "@/components/layout/TopBar"; +import { useControllerInit } from "@/hooks/useController"; +import { useProfileInit } from "@/hooks/useProfileInit"; +import { useTheme } from "@/hooks/useTheme"; +import { useUnsavedChangesWarning } from "@/hooks/useUnsavedChanges"; + +export function AppLayout() { + useTheme(); + useControllerInit(); + useProfileInit(); + useUnsavedChangesWarning(); + + return ( +
+ +
+ +
+ +
+
+ + +
+ ); +} diff --git a/src/components/layout/PageShell.tsx b/src/components/layout/PageShell.tsx new file mode 100644 index 0000000..fc113bf --- /dev/null +++ b/src/components/layout/PageShell.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +interface PageShellProps { + title: string; + description?: string; + actions?: ReactNode; + children: ReactNode; +} + +/** Consistent header + content wrapper reused by every page. */ +export function PageShell({ title, description, actions, children }: PageShellProps) { + return ( +
+
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ {children} +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..34e8fc0 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,53 @@ +import { clsx } from "clsx"; +import { Gamepad2 } from "lucide-react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { NAV_ITEMS } from "@/constants/navigation"; +import { confirmDiscardUnsavedChanges } from "@/hooks/useUnsavedChanges"; + +export function Sidebar() { + const location = useLocation(); + const navigate = useNavigate(); + + async function handleNavigate(path: string) { + if (path === location.pathname) return; + const canLeave = await confirmDiscardUnsavedChanges(); + if (canLeave) navigate(path); + } + + return ( + + ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..6dd3d52 --- /dev/null +++ b/src/components/layout/TopBar.tsx @@ -0,0 +1,58 @@ +import { Bluetooth, Cable, Radio, Usb } from "lucide-react"; +import { useLocation } from "react-router-dom"; + +import { Badge } from "@/components/common/Badge"; +import { StatusDot } from "@/components/common/StatusDot"; +import { NAV_ITEMS } from "@/constants/navigation"; +import { useControllerStore } from "@/stores/controllerStore"; +import { useProfileStore } from "@/stores/profileStore"; +import type { ConnectionKind } from "@/types/controller"; + +const CONNECTION_ICON: Record = { + usb: Usb, + bluetooth: Bluetooth, + receiver2_4ghz: Radio, + unknown: Cable, +}; + +const CONNECTION_LABEL: Record = { + usb: "USB", + bluetooth: "Bluetooth", + receiver2_4ghz: "2.4GHz Receiver", + unknown: "Unknown", +}; + +export function TopBar() { + const location = useLocation(); + const activeController = useControllerStore((s) => s.activeController()); + const activeProfile = useProfileStore((s) => s.activeProfile); + + const pageTitle = NAV_ITEMS.find((item) => item.path === location.pathname)?.label ?? "8BitDo Control Center"; + + return ( +
+

{pageTitle}

+ +
+ {activeProfile && Profile: {activeProfile.name}} + + {activeController ? ( + + + {(() => { + const Icon = CONNECTION_ICON[activeController.connection]; + return ; + })()} + {activeController.name} + · {CONNECTION_LABEL[activeController.connection]} + + ) : ( + + + No controller detected + + )} +
+
+ ); +} diff --git a/src/components/visualizations/EventLog.tsx b/src/components/visualizations/EventLog.tsx new file mode 100644 index 0000000..182b0dd --- /dev/null +++ b/src/components/visualizations/EventLog.tsx @@ -0,0 +1,41 @@ +import type { RawInputEvent } from "@/types/controller"; + +function formatTimestamp(ms: number): string { + const totalSeconds = ms / 1000; + const minutes = Math.floor(totalSeconds / 60); + const seconds = (totalSeconds % 60).toFixed(3).padStart(6, "0"); + return `${minutes.toString().padStart(2, "0")}:${seconds}`; +} + +export function EventLog({ events }: { events: RawInputEvent[] }) { + if (events.length === 0) { + return

No input events yet. Press a button to see it here.

; + } + + return ( +
+ + + + + + + + + + + + {events.map((event, index) => ( + + + + + + + + ))} + +
TimeTypeCodeRawProcessed
{formatTimestamp(event.timestamp)}{event.type}{event.code}{event.rawValue.toFixed(2)}{event.processedValue.toFixed(2)}
+
+ ); +} diff --git a/src/components/visualizations/StickPreview.tsx b/src/components/visualizations/StickPreview.tsx new file mode 100644 index 0000000..ecf91d8 --- /dev/null +++ b/src/components/visualizations/StickPreview.tsx @@ -0,0 +1,64 @@ +interface StickPreviewProps { + raw: { x: number; y: number }; + processed: { x: number; y: number }; + innerDeadzone: number; + outerDeadzone: number; + size?: number; + label?: string; +} + +/** Live raw vs. processed stick position, with deadzone/outer-range boundaries. */ +export function StickPreview({ + raw, + processed, + innerDeadzone, + outerDeadzone, + size = 160, + label, +}: StickPreviewProps) { + const center = size / 2; + const radius = size / 2 - 14; + + const toCanvas = (v: { x: number; y: number }) => ({ + x: center + v.x * radius, + y: center + v.y * radius, + }); + + const rawPoint = toCanvas(raw); + const processedPoint = toCanvas(processed); + + return ( +
+ {label && {label}} + + + + + + + + + +
+ raw x {raw.x.toFixed(2)} + raw y {raw.y.toFixed(2)} + out x {processed.x.toFixed(2)} + out y {processed.y.toFixed(2)} +
+
+ ); +} diff --git a/src/components/visualizations/TriggerPreview.tsx b/src/components/visualizations/TriggerPreview.tsx new file mode 100644 index 0000000..d1ca337 --- /dev/null +++ b/src/components/visualizations/TriggerPreview.tsx @@ -0,0 +1,37 @@ +interface TriggerPreviewProps { + raw: number; + processed: number; + deadzone: number; + maxActivation: number; + label?: string; +} + +/** Horizontal raw vs. processed trigger bar with deadzone/max-activation markers. */ +export function TriggerPreview({ raw, processed, deadzone, maxActivation, label }: TriggerPreviewProps) { + const width = 220; + const height = 22; + + return ( +
+ {label && {label}} + + + + + + + +
+ raw {raw.toFixed(2)} + out {processed.toFixed(2)} +
+
+ ); +} diff --git a/src/constants/controls.ts b/src/constants/controls.ts new file mode 100644 index 0000000..35ba704 --- /dev/null +++ b/src/constants/controls.ts @@ -0,0 +1,59 @@ +import type { AxisCapability, ButtonCapability, DeviceCapabilities } from "@/types/controller"; + +/** + * Canonical control identifiers for a generic dual-stick gamepad with rear + * paddles, shared by the mock backend, the SVG controller diagram, and the + * built-in profile templates. Real devices probed via evdev in Phase 2 will + * report their own capability list; this constant only describes the + * example/demo device used before hardware wiring lands. + */ +export const BUTTON_CAPABILITIES: ButtonCapability[] = [ + { id: "a", label: "A", group: "face" }, + { id: "b", label: "B", group: "face" }, + { id: "x", label: "X", group: "face" }, + { id: "y", label: "Y", group: "face" }, + { id: "dpad_up", label: "D-Pad Up", group: "dpad" }, + { id: "dpad_down", label: "D-Pad Down", group: "dpad" }, + { id: "dpad_left", label: "D-Pad Left", group: "dpad" }, + { id: "dpad_right", label: "D-Pad Right", group: "dpad" }, + { id: "l1", label: "L1", group: "shoulder" }, + { id: "r1", label: "R1", group: "shoulder" }, + { id: "l3", label: "Left Stick Click", group: "stick" }, + { id: "r3", label: "Right Stick Click", group: "stick" }, + { id: "start", label: "Start", group: "system" }, + { id: "select", label: "Select", group: "system" }, + { id: "guide", label: "Guide", group: "system" }, + { id: "paddle_l1", label: "Paddle L1", group: "paddle" }, + { id: "paddle_r1", label: "Paddle R1", group: "paddle" }, +]; + +export const AXIS_CAPABILITIES: AxisCapability[] = [ + { id: "left_stick_x", label: "Left Stick X", group: "stick", min: -1, max: 1 }, + { id: "left_stick_y", label: "Left Stick Y", group: "stick", min: -1, max: 1 }, + { id: "right_stick_x", label: "Right Stick X", group: "stick", min: -1, max: 1 }, + { id: "right_stick_y", label: "Right Stick Y", group: "stick", min: -1, max: 1 }, + { id: "left_trigger", label: "Left Trigger", group: "trigger", min: 0, max: 1 }, + { id: "right_trigger", label: "Right Trigger", group: "trigger", min: 0, max: 1 }, + { id: "gyro_x", label: "Gyro X", group: "gyro", min: -1, max: 1 }, + { id: "gyro_y", label: "Gyro Y", group: "gyro", min: -1, max: 1 }, + { id: "gyro_z", label: "Gyro Z", group: "gyro", min: -1, max: 1 }, +]; + +export const DEMO_DEVICE_CAPABILITIES: DeviceCapabilities = { + vendorId: 0x2dc8, + productId: 0x3106, + name: "8BitDo Ultimate Wireless / Pro 2 Wired Controller", + buttons: BUTTON_CAPABILITIES, + axes: AXIS_CAPABILITIES, + paddleCount: 2, + hasGyro: true, + hasRumble: true, + hasBattery: true, +}; + +export const FACE_BUTTON_IDS = ["a", "b", "x", "y"] as const; +export const DPAD_IDS = ["dpad_up", "dpad_down", "dpad_left", "dpad_right"] as const; +export const SHOULDER_BUTTON_IDS = ["l1", "r1"] as const; +export const STICK_CLICK_IDS = ["l3", "r3"] as const; +export const SYSTEM_BUTTON_IDS = ["start", "select", "guide"] as const; +export const PADDLE_IDS = ["paddle_l1", "paddle_r1"] as const; diff --git a/src/constants/navigation.ts b/src/constants/navigation.ts new file mode 100644 index 0000000..298dffa --- /dev/null +++ b/src/constants/navigation.ts @@ -0,0 +1,32 @@ +import { + Activity, + Crosshair, + Gamepad2, + Info, + LayoutDashboard, + Layers, + Settings as SettingsIcon, + Target, + Waves, + Zap, + type LucideIcon, +} from "lucide-react"; + +export interface NavItem { + path: string; + label: string; + icon: LucideIcon; +} + +export const NAV_ITEMS: NavItem[] = [ + { path: "/", label: "Dashboard", icon: LayoutDashboard }, + { path: "/profiles", label: "Profiles", icon: Layers }, + { path: "/mapping", label: "Button Mapping", icon: Gamepad2 }, + { path: "/sticks", label: "Sticks", icon: Crosshair }, + { path: "/triggers", label: "Triggers", icon: Zap }, + { path: "/motion", label: "Motion", icon: Waves }, + { path: "/calibration", label: "Calibration", icon: Target }, + { path: "/tester", label: "Input Tester", icon: Activity }, + { path: "/device", label: "Device Information", icon: Info }, + { path: "/settings", label: "Settings", icon: SettingsIcon }, +]; diff --git a/src/hooks/useController.ts b/src/hooks/useController.ts new file mode 100644 index 0000000..d0f55a1 --- /dev/null +++ b/src/hooks/useController.ts @@ -0,0 +1,11 @@ +import { useEffect } from "react"; + +import { useControllerStore } from "@/stores/controllerStore"; + +/** Starts the controller backend once for the lifetime of the app. */ +export function useControllerInit(): void { + const init = useControllerStore((s) => s.init); + useEffect(() => { + void init(); + }, [init]); +} diff --git a/src/hooks/useProfileInit.ts b/src/hooks/useProfileInit.ts new file mode 100644 index 0000000..4896953 --- /dev/null +++ b/src/hooks/useProfileInit.ts @@ -0,0 +1,29 @@ +import { useEffect } from "react"; + +import { processService } from "@/services/processService"; +import { useProfileStore } from "@/stores/profileStore"; +import { useSettingsStore } from "@/stores/settingsStore"; + +/** Loads the profile list (and the default active profile) once on startup. */ +export function useProfileInit(): void { + const refreshList = useProfileStore((s) => s.refreshList); + const selectProfile = useProfileStore((s) => s.selectProfile); + const autoSwitchProfiles = useSettingsStore((s) => s.autoSwitchProfiles); + useEffect(() => { + void refreshList(); + }, [refreshList]); + + useEffect(() => { + if (!autoSwitchProfiles) return; + const check = () => { + void processService.detectedGameProfile().then((profileId) => { + if (profileId && useProfileStore.getState().activeProfile?.id !== profileId && !useProfileStore.getState().isDirty) { + void selectProfile(profileId); + } + }).catch(() => undefined); + }; + check(); + const interval = window.setInterval(check, 5_000); + return () => window.clearInterval(interval); + }, [autoSwitchProfiles, selectProfile]); +} diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..a6ce5f6 --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,12 @@ +import { useEffect } from "react"; + +import { useSettingsStore } from "@/stores/settingsStore"; + +/** Applies the active theme to the document root so CSS variables switch instantly. */ +export function useTheme(): void { + const theme = useSettingsStore((s) => s.theme); + + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + }, [theme]); +} diff --git a/src/hooks/useUnsavedChanges.ts b/src/hooks/useUnsavedChanges.ts new file mode 100644 index 0000000..56174c1 --- /dev/null +++ b/src/hooks/useUnsavedChanges.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; + +import { useProfileStore } from "@/stores/profileStore"; +import { useUIStore } from "@/stores/uiStore"; + +/** Warns via the native "leave site" prompt if the window is closed with unsaved profile edits. */ +export function useUnsavedChangesWarning(): void { + const isDirty = useProfileStore((s) => s.isDirty); + + useEffect(() => { + const handler = (event: BeforeUnloadEvent) => { + if (!isDirty) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); +} + +/** Used by in-app navigation (sidebar, tab switches) to ask before discarding edits. */ +export async function confirmDiscardUnsavedChanges(): Promise { + if (!useProfileStore.getState().isDirty) return true; + return useUIStore.getState().requestConfirm({ + title: "Unsaved changes", + message: "You have unsaved changes to this profile. Leaving now will discard them.", + confirmLabel: "Discard changes", + cancelLabel: "Stay", + danger: true, + }); +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..1dc79f0 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import App from "./App"; +import "@/styles/index.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/src/pages/ButtonMapping.tsx b/src/pages/ButtonMapping.tsx new file mode 100644 index 0000000..df14b2c --- /dev/null +++ b/src/pages/ButtonMapping.tsx @@ -0,0 +1,228 @@ +import { useMemo, useState } from "react"; + +import { BUTTON_CAPABILITIES } from "@/constants/controls"; +import { Button } from "@/components/common/Button"; +import { Panel } from "@/components/common/Panel"; +import { Select } from "@/components/inputs/Select"; +import { TextField } from "@/components/inputs/TextField"; +import { UnsavedChangesBar } from "@/components/feedback/UnsavedChangesBar"; +import { PageShell } from "@/components/layout/PageShell"; +import { ControllerDiagram, type ControllerView } from "@/components/controller/ControllerDiagram"; +import { useControllerStore } from "@/stores/controllerStore"; +import { useProfileStore } from "@/stores/profileStore"; +import { useUIStore } from "@/stores/uiStore"; +import type { ActionMode, ButtonMapping as ButtonMappingType, MappingTarget } from "@/types/profile"; + +/** Buttons that must always stay functional so the user can never lose control of the app or controller. */ +const PROTECTED_CONTROL_IDS = new Set(["guide"]); + +const MODE_OPTIONS: { value: ActionMode; label: string }[] = [ + { value: "normal", label: "Normal" }, + { value: "turbo", label: "Turbo" }, + { value: "toggle", label: "Toggle" }, + { value: "hold", label: "Hold" }, + { value: "longPress", label: "Long Press" }, + { value: "doubleTap", label: "Double Tap" }, +]; + +type TargetKind = MappingTarget["kind"]; + +const TARGET_KIND_OPTIONS: { value: TargetKind; label: string }[] = [ + { value: "button", label: "Button" }, + { value: "key", label: "Keyboard Key" }, + { value: "mouseButton", label: "Mouse Button" }, + { value: "disabled", label: "Disabled" }, +]; + +function defaultMappingFor(controlId: string): ButtonMappingType { + return { target: { kind: "button", button: controlId }, mode: "normal" }; +} + +function isRemapped(controlId: string, mapping: ButtonMappingType | undefined): boolean { + if (!mapping) return false; + return !(mapping.target.kind === "button" && mapping.target.button === controlId) || mapping.mode !== "normal"; +} + +export function ButtonMapping() { + const [view, setView] = useState("front"); + const [selectedId, setSelectedId] = useState(null); + + const liveState = useControllerStore((s) => s.liveState); + const draftProfile = useProfileStore((s) => s.draftProfile); + const updateDraft = useProfileStore((s) => s.updateDraft); + const pushToast = useUIStore((s) => s.pushToast); + + const remappedIds = useMemo(() => { + if (!draftProfile) return new Set(); + const ids = Object.entries(draftProfile.buttonMappings) + .filter(([id, mapping]) => isRemapped(id, mapping)) + .map(([id]) => id); + return new Set(ids); + }, [draftProfile]); + + if (!draftProfile) { + return ( + +
+ + ); + } + + const selectedButton = selectedId ? BUTTON_CAPABILITIES.find((b) => b.id === selectedId) : null; + const mapping = selectedId ? draftProfile.buttonMappings[selectedId] ?? defaultMappingFor(selectedId) : null; + + function updateMapping(controlId: string, next: ButtonMappingType) { + if (PROTECTED_CONTROL_IDS.has(controlId) && next.target.kind === "disabled") { + pushToast("The Guide button can't be disabled — it's kept available so you can't lock yourself out.", "warning"); + return; + } + updateDraft((draft) => ({ + ...draft, + buttonMappings: { ...draft.buttonMappings, [controlId]: next }, + })); + } + + return ( + + + +
+ + + +
+ } + > +
+ +
+ + + + {!selectedId || !mapping ? ( +

+ Click a button, D-pad direction, shoulder button, stick click, or paddle on the diagram to configure it. +

+ ) : ( +
+ ({ value: b.id, label: b.label }))} + onChange={(button) => updateMapping(selectedId, { ...mapping, target: { kind: "button", button } })} + /> + )} + + {mapping.target.kind === "key" && ( + + updateMapping(selectedId, { ...mapping, target: { kind: "key", key: e.currentTarget.value } }) + } + /> + )} + + {mapping.target.kind === "mouseButton" && ( + updateMapping(selectedId, { ...mapping, mode })} + /> + + {mapping.mode === "turbo" && ( + updateMapping(selectedId, { ...mapping, turboRateHz: Number(e.currentTarget.value) })} + /> + )} + + {mapping.mode === "longPress" && ( + updateMapping(selectedId, { ...mapping, longPressMs: Number(e.currentTarget.value) })} + /> + )} + + {mapping.mode === "doubleTap" && ( + + updateMapping(selectedId, { ...mapping, doubleTapWindowMs: Number(e.currentTarget.value) }) + } + /> + )} + + +
+ )} +
+
+
+ ); +} diff --git a/src/pages/Calibration.tsx b/src/pages/Calibration.tsx new file mode 100644 index 0000000..cd3d7aa --- /dev/null +++ b/src/pages/Calibration.tsx @@ -0,0 +1,103 @@ +import { CheckCircle2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@/components/common/Button"; +import { Panel } from "@/components/common/Panel"; +import { PageShell } from "@/components/layout/PageShell"; +import { useControllerStore } from "@/stores/controllerStore"; +import { useProfileStore } from "@/stores/profileStore"; +import { useUIStore } from "@/stores/uiStore"; + +const STEPS = [ + "Detect the controller", + "Ask you to release all controls", + "Measure neutral stick and trigger positions", + "Ask you to rotate each stick fully, several times", + "Ask you to press each trigger through its full range", + "Calculate recommended deadzones and maximum ranges", + "Show the recommended values", + "Let you accept, adjust, or discard them", +]; + +export function Calibration() { + const pushToast = useUIStore((s) => s.pushToast); + const liveState = useControllerStore((s) => s.liveState); + const updateDraft = useProfileStore((s) => s.updateDraft); + const [step, setStep] = useState(-1); + const [neutralSamples, setNeutralSamples] = useState<{ x: number; y: number }[]>([]); + const [rangeSamples, setRangeSamples] = useState<{ x: number; y: number }[]>([]); + + useEffect(() => { + if (!liveState) return; + const axes = liveState.axes; + const sample = { x: axes.left_stick_x ?? 0, y: axes.left_stick_y ?? 0 }; + if (step === 1) setNeutralSamples((samples) => [...samples.slice(-199), sample]); + if (step === 2) setRangeSamples((samples) => [...samples.slice(-499), sample]); + }, [liveState, step]); + + const recommendation = useMemo(() => { + if (neutralSamples.length === 0 || rangeSamples.length === 0) return null; + const neutral = neutralSamples.reduce((sum, sample) => ({ x: sum.x + sample.x, y: sum.y + sample.y }), { x: 0, y: 0 }); + neutral.x /= neutralSamples.length; + neutral.y /= neutralSamples.length; + const drift = Math.hypot(neutral.x, neutral.y); + const innerDeadzone = Math.min(0.25, Math.max(0.03, drift + 0.02)); + const maxRange = rangeSamples.reduce((max, sample) => Math.max(max, Math.abs(sample.x), Math.abs(sample.y)), 0); + const outerDeadzone = maxRange > 0 ? Math.min(1, Math.max(0.85, 1 / maxRange)) : 1; + return { innerDeadzone, outerDeadzone }; + }, [neutralSamples, rangeSamples]); + + function acceptRecommendation() { + if (!recommendation) return; + updateDraft((draft) => ({ + ...draft, + leftStick: { ...draft.leftStick, ...recommendation }, + rightStick: { ...draft.rightStick, ...recommendation }, + })); + pushToast("Calibration recommendations added to the profile draft. Save Profile to keep them.", "success"); + setStep(-1); + } + + return ( + + +
    + {STEPS.map((label, index) => ( +
  1. = index ? "flex items-center gap-3 text-sm text-fg" : "flex items-center gap-3 text-sm text-fg-muted"}> + = index ? "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-fg" : "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-surface-3 text-xs font-medium text-fg"}> + {index + 1} + + {label} +
  2. + ))} +
+
+ +

+ Recommended values will always be shown to you before anything is applied — calibration never changes + your profile silently. +

+
+
+ {step < 0 && } + {step === 1 && } + {step === 2 && } + {step === 6 && recommendation && } + {step >= 0 && } +
+ {step === 1 &&

Release both sticks and triggers for a few seconds, then continue.

} + {step === 2 &&

Rotate the left stick around its full edge several times, then continue.

} + {step === 6 && ( +

+ Recommended inner deadzone: {recommendation?.innerDeadzone.toFixed(3) ?? "—"} · outer range: {recommendation?.outerDeadzone.toFixed(3) ?? "—"} +

+ )} +
+
+ ); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..19cec54 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,170 @@ +import { BatteryMedium, Gamepad2, Power, Usb } from "lucide-react"; + +import { Badge } from "@/components/common/Badge"; +import { Panel } from "@/components/common/Panel"; +import { StatusDot } from "@/components/common/StatusDot"; +import { PageShell } from "@/components/layout/PageShell"; +import { Select } from "@/components/inputs/Select"; +import { Toggle } from "@/components/inputs/Toggle"; +import { StickPreview } from "@/components/visualizations/StickPreview"; +import { TriggerPreview } from "@/components/visualizations/TriggerPreview"; +import { useControllerStore } from "@/stores/controllerStore"; +import { useProfileStore } from "@/stores/profileStore"; +import { useSettingsStore } from "@/stores/settingsStore"; +import { processStick, processTrigger } from "@/utils/deadzoneMath"; + +export function Dashboard() { + const controllers = useControllerStore((s) => s.controllers); + const activeController = useControllerStore((s) => s.activeController()); + const liveState = useControllerStore((s) => s.liveState); + const pollRateHz = useControllerStore((s) => s.pollRateHz); + const connectionStatus = useControllerStore((s) => s.connectionStatus); + const connectionMessage = useControllerStore((s) => s.connectionMessage); + + const draftProfile = useProfileStore((s) => s.draftProfile); + const summaries = useProfileStore((s) => s.summaries); + const selectProfile = useProfileStore((s) => s.selectProfile); + + const remappingEnabled = useSettingsStore((s) => s.remappingEnabled); + const setRemappingEnabled = useSettingsStore((s) => s.setRemappingEnabled); + const setControllerRemapping = useControllerStore((s) => s.setRemappingEnabled); + + const axes = liveState?.axes ?? {}; + const leftStick = draftProfile + ? processStick({ x: axes.left_stick_x ?? 0, y: axes.left_stick_y ?? 0 }, draftProfile.leftStick) + : null; + const rightStick = draftProfile + ? processStick({ x: axes.right_stick_x ?? 0, y: axes.right_stick_y ?? 0 }, draftProfile.rightStick) + : null; + const leftTrigger = draftProfile ? processTrigger(axes.left_trigger ?? 0, draftProfile.leftTrigger) : null; + const rightTrigger = draftProfile ? processTrigger(axes.right_trigger ?? 0, draftProfile.rightTrigger) : null; + + return ( + +
+ + {activeController ? ( +
+
+ +
+
+

{activeController.name}

+

+ {activeController.devicePath} · VID {activeController.vendorId.toString(16).padStart(4, "0")}{" "} + PID {activeController.productId.toString(16).padStart(4, "0")} +

+
+ + + Connected + + + {activeController.mode.toUpperCase()} + + {activeController.batteryPercent !== null && ( + + {activeController.batteryPercent}% + + )} + + + Virtual controller {activeController.virtualControllerActive ? "active" : "inactive"} + + {activeController.isSimulated && Simulated device} +
+
+
+ ) : ( +

+ {connectionStatus === "reconnecting" + ? connectionMessage ?? "Reconnecting to controller…" + : "No controller detected. Connect an 8BitDo Ultimate controller to get started."} +

+ )} +
+ + + { + setRemappingEnabled(enabled); + void setControllerRemapping(enabled); + }} + label="Enable remapping" + description="Route input through the virtual controller" + /> +
+ Poll rate + {pollRateHz ? `${pollRateHz} Hz` : "—"} +
+
+ + +
+
+ update({ gyroMapping })} + /> + + )} +
+ + ); +} diff --git a/src/pages/Profiles.tsx b/src/pages/Profiles.tsx new file mode 100644 index 0000000..6d4d1a9 --- /dev/null +++ b/src/pages/Profiles.tsx @@ -0,0 +1,205 @@ +import { clsx } from "clsx"; +import { Copy, Download, PenLine, Plus, RotateCcw, Trash2, Upload } from "lucide-react"; +import { useState } from "react"; + +import { Badge } from "@/components/common/Badge"; +import { Button } from "@/components/common/Button"; +import { Panel } from "@/components/common/Panel"; +import { TextField } from "@/components/inputs/TextField"; +import { UnsavedChangesBar } from "@/components/feedback/UnsavedChangesBar"; +import { PageShell } from "@/components/layout/PageShell"; +import { useProfileStore } from "@/stores/profileStore"; +import { useUIStore } from "@/stores/uiStore"; + +export function Profiles() { + const summaries = useProfileStore((s) => s.summaries); + const activeProfile = useProfileStore((s) => s.activeProfile); + const selectProfile = useProfileStore((s) => s.selectProfile); + const createProfile = useProfileStore((s) => s.createProfile); + const renameProfile = useProfileStore((s) => s.renameProfile); + const duplicateProfile = useProfileStore((s) => s.duplicateProfile); + const deleteProfile = useProfileStore((s) => s.deleteProfile); + const resetActiveProfile = useProfileStore((s) => s.resetActiveProfile); + const exportProfile = useProfileStore((s) => s.exportProfile); + const importProfile = useProfileStore((s) => s.importProfile); + const draftProfile = useProfileStore((s) => s.draftProfile); + const updateDraft = useProfileStore((s) => s.updateDraft); + const requestConfirm = useUIStore((s) => s.requestConfirm); + + const [newProfileName, setNewProfileName] = useState(""); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + + async function handleCreate() { + const name = newProfileName.trim(); + if (!name) return; + await createProfile(name); + setNewProfileName(""); + } + + async function handleDelete(id: string, name: string) { + const confirmed = await requestConfirm({ + title: "Delete profile", + message: `Delete "${name}"? This cannot be undone.`, + confirmLabel: "Delete", + danger: true, + }); + if (confirmed) await deleteProfile(id); + } + + async function handleReset(name: string) { + const confirmed = await requestConfirm({ + title: "Reset profile", + message: `Reset "${name}" back to its default settings? Your customizations will be lost.`, + confirmLabel: "Reset", + danger: true, + }); + if (confirmed) await resetActiveProfile(); + } + + return ( + + + + } + > + + +
+ setNewProfileName(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && void handleCreate()} + className="flex-1" + /> + +
+
+ + {draftProfile && ( + + { + const assignedExecutables = event.currentTarget.value + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + updateDraft((draft) => ({ ...draft, assignedExecutables })); + }} + /> + { + const shortcutCombo = event.currentTarget.value + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + updateDraft((draft) => ({ ...draft, shortcutCombo })); + }} + /> + + )} + +
+ {summaries.map((summary) => { + const isActive = summary.id === activeProfile?.id; + const isRenaming = renamingId === summary.id; + return ( + !isRenaming && void selectProfile(summary.id)} + > +
+
+ {isRenaming ? ( + e.stopPropagation()} + onChange={(e) => setRenameValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void renameProfile(summary.id, renameValue).then(() => setRenamingId(null)); + } else if (e.key === "Escape") { + setRenamingId(null); + } + }} + onBlur={() => setRenamingId(null)} + /> + ) : ( +

{summary.name}

+ )} +

{summary.description || "No description"}

+
+
+ {isActive && Active} + {summary.isBuiltIn && Built-in} +
+
+ +
e.stopPropagation()}> + + + + {isActive && ( + + )} + +
+
+ ); + })} +
+
+ ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..d3c3317 --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; + +import { Panel } from "@/components/common/Panel"; +import { Button } from "@/components/common/Button"; +import { Select } from "@/components/inputs/Select"; +import { Toggle } from "@/components/inputs/Toggle"; +import { PageShell } from "@/components/layout/PageShell"; +import { profileService } from "@/services/profileService"; +import { diagnosticsService, type DiagnosticsSnapshot } from "@/services/diagnosticsService"; +import { systemService, type AppInfo } from "@/services/systemService"; +import { useControllerStore } from "@/stores/controllerStore"; +import { useSettingsStore, type ThemeMode } from "@/stores/settingsStore"; + +const THEME_OPTIONS: { value: ThemeMode; label: string }[] = [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, + { value: "high-contrast", label: "High Contrast" }, +]; + +export function Settings() { + const theme = useSettingsStore((s) => s.theme); + const setTheme = useSettingsStore((s) => s.setTheme); + const remappingEnabled = useSettingsStore((s) => s.remappingEnabled); + const setRemappingEnabled = useSettingsStore((s) => s.setRemappingEnabled); + const setControllerRemapping = useControllerStore((s) => s.setRemappingEnabled); + const simulationMode = useSettingsStore((s) => s.simulationMode); + const setSimulationMode = useSettingsStore((s) => s.setSimulationMode); + const autoSwitchProfiles = useSettingsStore((s) => s.autoSwitchProfiles); + const setAutoSwitchProfiles = useSettingsStore((s) => s.setAutoSwitchProfiles); + + const [appInfo, setAppInfo] = useState(null); + const [profilesDir, setProfilesDir] = useState(""); + const [diagnostics, setDiagnostics] = useState(null); + + async function handleRemappingChange(enabled: boolean) { + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H14',location:'src/pages/Settings.tsx:handleRemappingChange',message:'Settings remapping toggle changed',data:{enabled},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + setRemappingEnabled(enabled); + try { + await setControllerRemapping(enabled); + } catch { + setRemappingEnabled(!enabled); + } + } + + useEffect(() => { + void systemService.getAppInfo().then(setAppInfo); + void profileService.directory().then(setProfilesDir); + void diagnosticsService.snapshot().then(setDiagnostics).catch(() => undefined); + }, []); + + async function exportSupportBundle() { + const snapshot = await diagnosticsService.snapshot(); + const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "8bitdo-control-center-support.json"; + anchor.click(); + URL.revokeObjectURL(url); + } + + return ( + + + update({ deadzoneMode })} + /> + + + + update({ sensitivityX })} + /> + update({ sensitivityY })} + /> + update({ smoothing })} /> + update({ invertX })} label="Invert X axis" /> + update({ invertY })} label="Invert Y axis" /> + update({ circularityCorrection })} + label="Circularity correction" + description="Normalize square/octagonal raw ranges to a circle" + /> + + + + mockControllerBackend.setAxis(axisId, Number(e.currentTarget.value))} + /> +
+
+ + +
+ + update({ deadzone })} /> + update({ maxActivation })} + /> + update({ sensitivity })} + /> + + + + update({ curve: { ...settings.curve, preset } })} + /> + {settings.curve.preset === "custom" && ( + update({ curve: { ...settings.curve, customPoints } })} + /> + )} + update({ hairTrigger })} + label="Hair-trigger mode" + description="Register full activation with minimal pull" + /> + update({ digitalMode })} + label="Digital trigger mode" + description="Behave like an on/off button instead of analog" + /> + update({ invert })} label="Invert trigger" /> + +
+ +
+ ); +} diff --git a/src/services/controllerBackend.ts b/src/services/controllerBackend.ts new file mode 100644 index 0000000..a7ab0f7 --- /dev/null +++ b/src/services/controllerBackend.ts @@ -0,0 +1,17 @@ +import type { ControllerInfo, ControllerLiveState, RawInputEvent } from "@/types/controller"; + +/** + * Abstraction over "where controller data comes from". `mockControllerBackend` + * (Phase 1) and the future Tauri-event-backed implementation (Phase 2+) + * both implement this so pages/stores never need to know which one is + * active. This mirrors the backend pipeline: device_manager -> live state -> + * event bus -> UI. + */ +export interface ControllerBackend { + init(): Promise; + dispose(): void; + onControllersChanged(callback: (controllers: ControllerInfo[]) => void): () => void; + onLiveState(callback: (state: ControllerLiveState) => void): () => void; + onRawEvent(callback: (event: RawInputEvent) => void): () => void; + setRemappingEnabled(enabled: boolean): Promise; +} diff --git a/src/services/diagnosticsService.ts b/src/services/diagnosticsService.ts new file mode 100644 index 0000000..2d224dc --- /dev/null +++ b/src/services/diagnosticsService.ts @@ -0,0 +1,24 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { ControllerInfo } from "@/types/controller"; + +export interface PermissionStatus { + uinputExists: boolean; + uinputWritable: boolean; + inputDirectoryReadable: boolean; + currentUser: string | null; + groups: string[]; +} + +export interface DiagnosticsSnapshot { + appVersion: string; + operatingSystem: string; + kernelVersion: string | null; + permissions: PermissionStatus; + devices: ControllerInfo[]; + profileSchemaVersion: number; +} + +export const diagnosticsService = { + snapshot: () => invoke("diagnostics_snapshot"), +}; diff --git a/src/services/mockControllerBackend.ts b/src/services/mockControllerBackend.ts new file mode 100644 index 0000000..6f37b90 --- /dev/null +++ b/src/services/mockControllerBackend.ts @@ -0,0 +1,128 @@ +import { AXIS_CAPABILITIES, BUTTON_CAPABILITIES, DEMO_DEVICE_CAPABILITIES } from "@/constants/controls"; +import type { ControllerBackend } from "@/services/controllerBackend"; +import type { ControllerInfo, ControllerLiveState, RawInputEvent } from "@/types/controller"; +import { SimpleEmitter } from "@/utils/eventEmitter"; + +const POLL_INTERVAL_MS = 8; // ~125Hz, representative of a wireless 8BitDo Ultimate poll rate + +/** + * Simulated controller used until the real evdev backend lands in Phase 2, + * and afterwards as a "Simulation Mode" fallback so the UI and pipeline can + * always be exercised without physical hardware. + */ +export class MockControllerBackend implements ControllerBackend { + private readonly controllersEmitter = new SimpleEmitter(); + private readonly liveStateEmitter = new SimpleEmitter(); + private readonly rawEventEmitter = new SimpleEmitter(); + + private readonly buttons: Record = {}; + private readonly axes: Record = {}; + + private connected = true; + private intervalId: number | null = null; + private info: ControllerInfo; + + constructor() { + for (const button of BUTTON_CAPABILITIES) this.buttons[button.id] = false; + for (const axis of AXIS_CAPABILITIES) this.axes[axis.id] = 0; + + this.info = { + id: "simulated-controller-1", + name: DEMO_DEVICE_CAPABILITIES.name, + connection: "usb", + devicePath: "/dev/input/event33 (simulated)", + vendorId: DEMO_DEVICE_CAPABILITIES.vendorId, + productId: DEMO_DEVICE_CAPABILITIES.productId, + mode: "xinput", + batteryPercent: 82, + firmwareVersion: null, + virtualControllerActive: true, + capabilities: DEMO_DEVICE_CAPABILITIES, + isSimulated: true, + }; + } + + async init(): Promise { + if (this.intervalId !== null) return; + this.intervalId = window.setInterval(() => { + if (!this.connected) return; + this.liveStateEmitter.emit({ + timestamp: performance.now(), + buttons: { ...this.buttons }, + axes: { ...this.axes }, + }); + }, POLL_INTERVAL_MS); + } + + dispose(): void { + if (this.intervalId !== null) { + window.clearInterval(this.intervalId); + this.intervalId = null; + } + } + + onControllersChanged(callback: (controllers: ControllerInfo[]) => void): () => void { + queueMicrotask(() => callback(this.snapshotControllers())); + return this.controllersEmitter.on(callback); + } + + onLiveState(callback: (state: ControllerLiveState) => void): () => void { + return this.liveStateEmitter.on(callback); + } + + onRawEvent(callback: (event: RawInputEvent) => void): () => void { + return this.rawEventEmitter.on(callback); + } + + async setRemappingEnabled(enabled: boolean): Promise { + this.info = { ...this.info, virtualControllerActive: enabled }; + this.controllersEmitter.emit(this.snapshotControllers()); + } + + setSimulatedConnected(connected: boolean): void { + this.connected = connected; + this.controllersEmitter.emit(this.snapshotControllers()); + } + + setButton(id: string, pressed: boolean): void { + if (this.buttons[id] === pressed) return; + this.buttons[id] = pressed; + this.rawEventEmitter.emit({ + timestamp: performance.now(), + type: "button", + code: id, + rawValue: pressed ? 1 : 0, + processedValue: pressed ? 1 : 0, + }); + } + + pulseButton(id: string, durationMs = 120): void { + this.setButton(id, true); + window.setTimeout(() => this.setButton(id, false), durationMs); + } + + setAxis(id: string, value: number): void { + this.axes[id] = value; + this.rawEventEmitter.emit({ + timestamp: performance.now(), + type: "axis", + code: id, + rawValue: value, + processedValue: value, + }); + } + + getButtonState(id: string): boolean { + return this.buttons[id] ?? false; + } + + getAxisState(id: string): number { + return this.axes[id] ?? 0; + } + + private snapshotControllers(): ControllerInfo[] { + return this.connected ? [this.info] : []; + } +} + +export const mockControllerBackend = new MockControllerBackend(); diff --git a/src/services/processService.ts b/src/services/processService.ts new file mode 100644 index 0000000..9b3c7fb --- /dev/null +++ b/src/services/processService.ts @@ -0,0 +1,5 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const processService = { + detectedGameProfile: () => invoke("detected_game_profile"), +}; diff --git a/src/services/profileService.ts b/src/services/profileService.ts new file mode 100644 index 0000000..fd9f3f0 --- /dev/null +++ b/src/services/profileService.ts @@ -0,0 +1,18 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { Profile, ProfileSummary } from "@/types/profile"; + +/** Thin typed wrapper around the `profile_manager` Tauri commands. */ +export const profileService = { + list: () => invoke("list_profiles"), + load: (id: string) => invoke("load_profile", { id }), + save: (profile: Profile) => invoke("save_profile", { profile }), + create: (name: string) => invoke("create_profile", { name }), + rename: (id: string, newName: string) => invoke("rename_profile", { id, newName }), + duplicate: (id: string, newName: string) => invoke("duplicate_profile", { id, newName }), + remove: (id: string) => invoke("delete_profile", { id }), + reset: (id: string) => invoke("reset_profile", { id }), + exportTo: (id: string, destination: string) => invoke("export_profile", { id, destination }), + importFrom: (source: string) => invoke("import_profile", { source }), + directory: () => invoke("profiles_directory"), +}; diff --git a/src/services/systemService.ts b/src/services/systemService.ts new file mode 100644 index 0000000..85732a1 --- /dev/null +++ b/src/services/systemService.ts @@ -0,0 +1,11 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface AppInfo { + name: string; + version: string; + configDir: string; +} + +export const systemService = { + getAppInfo: () => invoke("get_app_info"), +}; diff --git a/src/services/tauriControllerService.ts b/src/services/tauriControllerService.ts new file mode 100644 index 0000000..9223ad5 --- /dev/null +++ b/src/services/tauriControllerService.ts @@ -0,0 +1,43 @@ +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; + +import type { ControllerInfo, RawInputEvent } from "@/types/controller"; +import type { Profile } from "@/types/profile"; + +const INPUT_EVENT = "controller-input"; +const READER_STATUS_EVENT = "controller-reader-status"; + +interface NativeInputEvent { + timestamp: number; + eventType: "button" | "axis"; + code: string; + rawValue: number; + processedValue: number; +} + +interface ReaderStatus { + devicePath: string; + connected: boolean; + message: string | null; +} + +function toRawInputEvent(event: NativeInputEvent): RawInputEvent { + return { + timestamp: event.timestamp, + type: event.eventType, + code: event.code, + rawValue: event.rawValue, + processedValue: event.processedValue, + }; +} + +export const tauriControllerService = { + list: () => invoke("list_devices"), + startReader: (devicePath: string) => invoke("start_device_reader", { devicePath }), + applyRemappingProfile: (profile: Profile) => invoke("apply_remapping_profile", { profile }), + setRemappingEnabled: (enabled: boolean) => invoke("set_remapping_enabled", { enabled }), + onInput: async (callback: (event: RawInputEvent) => void): Promise<() => void> => + listen(INPUT_EVENT, (event) => callback(toRawInputEvent(event.payload))), + onReaderStatus: async (callback: (status: ReaderStatus) => void): Promise<() => void> => + listen(READER_STATUS_EVENT, (event) => callback(event.payload)), +}; diff --git a/src/stores/controllerStore.ts b/src/stores/controllerStore.ts new file mode 100644 index 0000000..73bf31b --- /dev/null +++ b/src/stores/controllerStore.ts @@ -0,0 +1,277 @@ +import { create } from "zustand"; + +import { mockControllerBackend } from "@/services/mockControllerBackend"; +import { tauriControllerService } from "@/services/tauriControllerService"; +import { useSettingsStore } from "@/stores/settingsStore"; +import type { ControllerInfo, ControllerLiveState, RawInputEvent } from "@/types/controller"; + +const MAX_EVENT_LOG = 200; +const MAX_POLL_SAMPLES = 30; +const RECONNECT_INTERVAL_MS = 1_500; +const MAX_RECONNECT_ATTEMPTS = 20; + +export type ControllerConnectionStatus = "connected" | "reconnecting" | "disconnected" | "simulation"; + +function samePhysicalController(a: ControllerInfo, b: ControllerInfo): boolean { + return ( + a.vendorId === b.vendorId && + a.productId === b.productId && + a.name === b.name && + a.connection === b.connection && + a.mode === b.mode + ); +} + +function estimatePollRateHz(timestamps: number[]): number | null { + if (timestamps.length < 2) return null; + const span = timestamps[timestamps.length - 1] - timestamps[0]; + if (span <= 0) return null; + return Math.round(((timestamps.length - 1) / span) * 1000); +} + +interface ControllerState { + controllers: ControllerInfo[]; + activeControllerId: string | null; + liveState: ControllerLiveState | null; + eventLog: RawInputEvent[]; + pollRateHz: number | null; + latencyEstimateMs: number | null; + connectionStatus: ControllerConnectionStatus; + connectionMessage: string | null; + initialized: boolean; + usingSimulation: boolean; + init: () => Promise; + activeController: () => ControllerInfo | null; + setActiveControllerId: (id: string | null) => Promise; + setRemappingEnabled: (enabled: boolean) => Promise; + clearEventLog: () => void; +} + +let pollTimestamps: number[] = []; +let hasLoggedFirstPhysicalEvent = false; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempts = 0; +let refreshInFlight = false; + +function clearReconnectTimer(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + reconnectAttempts = 0; +} + +export const useControllerStore = create((set, get) => ({ + controllers: [], + activeControllerId: null, + liveState: null, + eventLog: [], + pollRateHz: null, + latencyEstimateMs: null, + connectionStatus: "disconnected", + connectionMessage: null, + initialized: false, + usingSimulation: false, + + init: async () => { + if (get().initialized) return; + set({ initialized: true }); + + const simulationRequested = useSettingsStore.getState().simulationMode; + let realControllers: ControllerInfo[] = []; + try { + realControllers = simulationRequested ? [] : await tauriControllerService.list(); + } catch { + // Browser-only development and missing permissions both retain the safe + // simulated fallback rather than leaving the UI unusable. + } + + const useSimulation = simulationRequested || realControllers.length === 0; + set({ + controllers: useSimulation ? [] : realControllers, + activeControllerId: useSimulation ? null : realControllers[0]?.id ?? null, + usingSimulation: useSimulation, + connectionStatus: useSimulation ? "simulation" : "connected", + connectionMessage: useSimulation + ? simulationRequested + ? "Simulation mode is enabled." + : "No physical controller detected; using simulated input." + : null, + }); + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H3',location:'src/stores/controllerStore.ts:init',message:'Controller backend selected',data:{simulationRequested,realControllerCount:realControllers.length,useSimulation,devices:realControllers.map((controller)=>({id:controller.id,name:controller.name,connection:controller.connection,devicePath:controller.devicePath}))},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + + const receiveLiveState = (liveState: ControllerLiveState) => { + pollTimestamps.push(liveState.timestamp); + if (pollTimestamps.length > MAX_POLL_SAMPLES) pollTimestamps.shift(); + const pollRateHz = estimatePollRateHz(pollTimestamps); + const latencyEstimateMs = pollRateHz ? Math.round((1000 / pollRateHz) * 10) / 10 : null; + set({ liveState, pollRateHz, latencyEstimateMs }); + }; + + const receiveRawEvent = (event: RawInputEvent) => { + if (!get().usingSimulation && !hasLoggedFirstPhysicalEvent) { + hasLoggedFirstPhysicalEvent = true; + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H2',location:'src/stores/controllerStore.ts:receiveRawEvent',message:'First physical input event received',data:{type:event.type,code:event.code,rawValue:event.rawValue,processedValue:event.processedValue},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + } + set((state) => ({ eventLog: [event, ...state.eventLog].slice(0, MAX_EVENT_LOG) })); + set((state) => { + const liveState = state.liveState ?? { + timestamp: event.timestamp, + buttons: {}, + axes: {}, + }; + const next = { + timestamp: event.timestamp, + buttons: { ...liveState.buttons }, + axes: { ...liveState.axes }, + }; + if (event.type === "button") next.buttons[event.code] = event.processedValue !== 0; + else next.axes[event.code] = event.processedValue; + return { liveState: next }; + }); + }; + + if (!useSimulation) { + const active = realControllers[0]; + await tauriControllerService.onInput(receiveRawEvent); + const refreshNativeDevices = async (): Promise => { + if (refreshInFlight) return false; + refreshInFlight = true; + try { + const previousActive = get().activeController(); + const devices = await tauriControllerService.list(); + const nextActive = + devices.find((controller) => controller.id === previousActive?.id) ?? + (previousActive ? devices.find((controller) => samePhysicalController(controller, previousActive)) : undefined) ?? + devices[0]; + + if (!nextActive) { + set({ + controllers: [], + activeControllerId: null, + liveState: null, + pollRateHz: null, + latencyEstimateMs: null, + connectionStatus: "reconnecting", + connectionMessage: "Waiting for the controller to reconnect…", + }); + return false; + } + + set({ + controllers: devices, + activeControllerId: nextActive.id, + liveState: null, + pollRateHz: null, + latencyEstimateMs: null, + connectionStatus: "connected", + connectionMessage: null, + }); + await tauriControllerService.startReader(nextActive.devicePath); + clearReconnectTimer(); + return true; + } catch { + set({ + connectionStatus: "reconnecting", + connectionMessage: "Unable to access the controller; retrying…", + }); + return false; + } finally { + refreshInFlight = false; + } + }; + + const scheduleReconnect = (): void => { + if (reconnectTimer !== null) return; + const attemptReconnect = async (): Promise => { + reconnectTimer = null; + if (await refreshNativeDevices()) return; + reconnectAttempts += 1; + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + set({ + connectionStatus: "disconnected", + connectionMessage: "Controller did not reconnect. Reconnect it or restart input detection.", + }); + return; + } + reconnectTimer = setTimeout(() => void attemptReconnect(), RECONNECT_INTERVAL_MS); + }; + void attemptReconnect(); + }; + + await tauriControllerService.onReaderStatus((status) => { + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H6',location:'src/stores/controllerStore.ts:onReaderStatus',message:'Physical reader status changed',data:status,timestamp:Date.now()})}).catch(()=>{}); + // #endregion + const activeController = get().activeController(); + if (status.connected && activeController?.devicePath === status.devicePath) { + clearReconnectTimer(); + set({ connectionStatus: "connected", connectionMessage: null }); + return; + } + if (!status.connected && activeController?.devicePath === status.devicePath) { + set({ + liveState: null, + pollRateHz: null, + latencyEstimateMs: null, + connectionStatus: "reconnecting", + connectionMessage: status.message ?? "Controller disconnected; searching for it again…", + }); + scheduleReconnect(); + } + }); + if (active) { + await tauriControllerService.startReader(active.devicePath); + } + return; + } + + await mockControllerBackend.init(); + mockControllerBackend.onControllersChanged((controllers) => { + set((state) => ({ + controllers, + activeControllerId: controllers.some((c) => c.id === state.activeControllerId) + ? state.activeControllerId + : controllers[0]?.id ?? null, + })); + }); + + mockControllerBackend.onLiveState(receiveLiveState); + mockControllerBackend.onRawEvent(receiveRawEvent); + }, + + activeController: () => { + const state = get(); + return state.controllers.find((c) => c.id === state.activeControllerId) ?? null; + }, + + setActiveControllerId: async (id) => { + const controller = get().controllers.find((candidate) => candidate.id === id) ?? null; + set({ + activeControllerId: controller?.id ?? null, + liveState: null, + pollRateHz: null, + latencyEstimateMs: null, + }); + if (controller && !get().usingSimulation) { + await tauriControllerService.startReader(controller.devicePath); + } + }, + + setRemappingEnabled: async (enabled) => { + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H8',location:'src/stores/controllerStore.ts:setRemappingEnabled',message:'Remapping toggle requested',data:{enabled,usingSimulation:get().usingSimulation,activeControllerId:get().activeControllerId},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + if (get().usingSimulation) { + await mockControllerBackend.setRemappingEnabled(enabled); + return; + } + await tauriControllerService.setRemappingEnabled(enabled); + }, + + clearEventLog: () => set({ eventLog: [] }), +})); diff --git a/src/stores/profileStore.ts b/src/stores/profileStore.ts new file mode 100644 index 0000000..e4d9f38 --- /dev/null +++ b/src/stores/profileStore.ts @@ -0,0 +1,223 @@ +import { open, save } from "@tauri-apps/plugin-dialog"; +import { create } from "zustand"; + +import { profileService } from "@/services/profileService"; +import { tauriControllerService } from "@/services/tauriControllerService"; +import { useUIStore } from "@/stores/uiStore"; +import type { Profile, ProfileSummary } from "@/types/profile"; + +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +function isEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +interface ProfileState { + summaries: ProfileSummary[]; + activeProfile: Profile | null; + draftProfile: Profile | null; + isDirty: boolean; + loading: boolean; + error: string | null; + + refreshList: () => Promise; + selectProfile: (id: string) => Promise; + updateDraft: (updater: (draft: Profile) => Profile) => void; + applyDraft: () => Promise; + revertDraft: () => void; + saveDraft: () => Promise; + + createProfile: (name: string) => Promise; + renameProfile: (id: string, newName: string) => Promise; + duplicateProfile: (id: string, newName: string) => Promise; + deleteProfile: (id: string) => Promise; + resetActiveProfile: () => Promise; + exportProfile: (id: string) => Promise; + importProfile: () => Promise; +} + +export const useProfileStore = create((set, get) => ({ + summaries: [], + activeProfile: null, + draftProfile: null, + isDirty: false, + loading: false, + error: null, + + refreshList: async () => { + set({ loading: true, error: null }); + try { + const summaries = await profileService.list(); + set({ summaries, loading: false }); + if (!get().activeProfile && summaries.length > 0) { + await get().selectProfile(summaries.find((s) => s.id === "default")?.id ?? summaries[0].id); + } + } catch (err) { + set({ loading: false, error: String(err) }); + } + }, + + selectProfile: async (id) => { + set({ loading: true, error: null }); + try { + const profile = await profileService.load(id); + set({ activeProfile: profile, draftProfile: deepClone(profile), isDirty: false, loading: false }); + } catch (err) { + set({ loading: false, error: String(err) }); + } + }, + + updateDraft: (updater) => { + const draft = get().draftProfile; + if (!draft) return; + const nextDraft = updater(deepClone(draft)); + const isDirty = !isEqual(nextDraft, get().activeProfile); + set({ draftProfile: nextDraft, isDirty }); + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H1',location:'src/stores/profileStore.ts:updateDraft',message:'Profile draft updated',data:{profileId:nextDraft.id,isDirty,leftTrigger:nextDraft.leftTrigger,rightTrigger:nextDraft.rightTrigger},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + }, + + applyDraft: async () => { + const draft = get().draftProfile; + if (!draft) return; + try { + await tauriControllerService.applyRemappingProfile(draft); + useUIStore.getState().pushToast("Settings applied to the virtual controller.", "success"); + } catch (err) { + useUIStore.getState().pushToast(`Could not apply remapping: ${err}`, "danger"); + return; + } + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'post-fix',hypothesisId:'H7',location:'src/stores/profileStore.ts:applyDraft',message:'Profile successfully applied to native remapping pipeline',data:{profileId:draft.id},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + }, + + revertDraft: () => { + const active = get().activeProfile; + if (!active) return; + set({ draftProfile: deepClone(active), isDirty: false }); + useUIStore.getState().pushToast("Reverted unsaved changes.", "info"); + }, + + saveDraft: async () => { + const draft = get().draftProfile; + if (!draft) return; + set({ loading: true, error: null }); + try { + const saved = await profileService.save(draft); + set({ activeProfile: saved, draftProfile: deepClone(saved), isDirty: false, loading: false }); + await get().refreshList(); + useUIStore.getState().pushToast(`Saved profile "${saved.name}".`, "success"); + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H7',location:'src/stores/profileStore.ts:saveDraft',message:'Profile draft saved',data:{profileId:saved.id},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + } catch (err) { + set({ loading: false, error: String(err) }); + useUIStore.getState().pushToast(`Failed to save profile: ${err}`, "danger"); + } + }, + + createProfile: async (name) => { + try { + const profile = await profileService.create(name); + await get().refreshList(); + await get().selectProfile(profile.id); + useUIStore.getState().pushToast(`Created profile "${profile.name}".`, "success"); + return profile; + } catch (err) { + useUIStore.getState().pushToast(`Failed to create profile: ${err}`, "danger"); + return null; + } + }, + + renameProfile: async (id, newName) => { + try { + const profile = await profileService.rename(id, newName); + await get().refreshList(); + if (get().activeProfile?.id === id) { + set({ activeProfile: profile, draftProfile: deepClone(profile), isDirty: false }); + } + useUIStore.getState().pushToast(`Renamed profile to "${newName}".`, "success"); + } catch (err) { + useUIStore.getState().pushToast(`Failed to rename profile: ${err}`, "danger"); + } + }, + + duplicateProfile: async (id, newName) => { + try { + const profile = await profileService.duplicate(id, newName); + await get().refreshList(); + await get().selectProfile(profile.id); + useUIStore.getState().pushToast(`Duplicated as "${profile.name}".`, "success"); + return profile; + } catch (err) { + useUIStore.getState().pushToast(`Failed to duplicate profile: ${err}`, "danger"); + return null; + } + }, + + deleteProfile: async (id) => { + try { + await profileService.remove(id); + const wasActive = get().activeProfile?.id === id; + await get().refreshList(); + if (wasActive) { + const next = get().summaries[0]; + if (next) await get().selectProfile(next.id); + else set({ activeProfile: null, draftProfile: null, isDirty: false }); + } + useUIStore.getState().pushToast("Profile deleted.", "info"); + } catch (err) { + useUIStore.getState().pushToast(`Failed to delete profile: ${err}`, "danger"); + } + }, + + resetActiveProfile: async () => { + const active = get().activeProfile; + if (!active) return; + try { + const reset = await profileService.reset(active.id); + set({ activeProfile: reset, draftProfile: deepClone(reset), isDirty: false }); + await get().refreshList(); + useUIStore.getState().pushToast(`Reset "${reset.name}" to defaults.`, "success"); + } catch (err) { + useUIStore.getState().pushToast(`Failed to reset profile: ${err}`, "danger"); + } + }, + + exportProfile: async (id) => { + const summary = get().summaries.find((s) => s.id === id); + try { + const destination = await save({ + title: "Export profile", + defaultPath: `${summary?.name ?? id}.json`, + filters: [{ name: "Profile JSON", extensions: ["json"] }], + }); + if (!destination) return; + await profileService.exportTo(id, destination); + useUIStore.getState().pushToast("Profile exported.", "success"); + } catch (err) { + useUIStore.getState().pushToast(`Failed to export profile: ${err}`, "danger"); + } + }, + + importProfile: async () => { + try { + const source = await open({ + title: "Import profile", + multiple: false, + filters: [{ name: "Profile JSON", extensions: ["json"] }], + }); + if (!source || Array.isArray(source)) return; + const profile = await profileService.importFrom(source); + await get().refreshList(); + await get().selectProfile(profile.id); + useUIStore.getState().pushToast(`Imported profile "${profile.name}".`, "success"); + } catch (err) { + useUIStore.getState().pushToast(`Failed to import profile: ${err}`, "danger"); + } + }, +})); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts new file mode 100644 index 0000000..fae167f --- /dev/null +++ b/src/stores/settingsStore.ts @@ -0,0 +1,36 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type ThemeMode = "dark" | "light" | "high-contrast"; + +interface SettingsState { + theme: ThemeMode; + setTheme: (theme: ThemeMode) => void; + remappingEnabled: boolean; + setRemappingEnabled: (enabled: boolean) => void; + simulationMode: boolean; + setSimulationMode: (enabled: boolean) => void; + autoSwitchProfiles: boolean; + setAutoSwitchProfiles: (enabled: boolean) => void; +} + +export const useSettingsStore = create()( + persist( + (set) => ({ + theme: "dark", + setTheme: (theme) => set({ theme }), + remappingEnabled: true, + setRemappingEnabled: (remappingEnabled) => set({ remappingEnabled }), + simulationMode: false, + setSimulationMode: (simulationMode) => { + // #region agent log + fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H5',location:'src/stores/settingsStore.ts:setSimulationMode',message:'Simulation mode changed',data:{simulationMode},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + set({ simulationMode }); + }, + autoSwitchProfiles: false, + setAutoSwitchProfiles: (autoSwitchProfiles) => set({ autoSwitchProfiles }), + }), + { name: "8bitdo-control-center:settings" }, + ), +); diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts new file mode 100644 index 0000000..e45091a --- /dev/null +++ b/src/stores/uiStore.ts @@ -0,0 +1,49 @@ +import { create } from "zustand"; + +export type ToastVariant = "info" | "success" | "warning" | "danger"; + +export interface Toast { + id: string; + message: string; + variant: ToastVariant; +} + +export interface ConfirmOptions { + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +interface ConfirmState extends ConfirmOptions { + resolve: (value: boolean) => void; +} + +interface UIState { + toasts: Toast[]; + pushToast: (message: string, variant?: ToastVariant) => void; + dismissToast: (id: string) => void; + confirm: ConfirmState | null; + requestConfirm: (options: ConfirmOptions) => Promise; + resolveConfirm: (value: boolean) => void; +} + +export const useUIStore = create((set, get) => ({ + toasts: [], + pushToast: (message, variant = "info") => { + const id = crypto.randomUUID(); + set((s) => ({ toasts: [...s.toasts, { id, message, variant }] })); + window.setTimeout(() => get().dismissToast(id), 4500); + }, + dismissToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), + confirm: null, + requestConfirm: (options) => + new Promise((resolve) => { + set({ confirm: { ...options, resolve } }); + }), + resolveConfirm: (value) => { + get().confirm?.resolve(value); + set({ confirm: null }); + }, +})); diff --git a/src/styles/index.css b/src/styles/index.css new file mode 100644 index 0000000..26632dd --- /dev/null +++ b/src/styles/index.css @@ -0,0 +1,153 @@ +@import "tailwindcss"; + +@theme { + --color-app: var(--app-bg); + --color-surface: var(--surface); + --color-surface-2: var(--surface-2); + --color-surface-3: var(--surface-3); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-fg: var(--fg); + --color-fg-muted: var(--fg-muted); + --color-fg-subtle: var(--fg-subtle); + --color-accent: var(--accent); + --color-accent-strong: var(--accent-strong); + --color-accent-fg: var(--accent-fg); + --color-success: var(--success); + --color-warning: var(--warning); + --color-danger: var(--danger); + + --font-sans: "Inter", "Segoe UI", ui-sans-serif, system-ui, -apple-system, sans-serif; + --shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.24), 0 8px 24px -8px rgba(0, 0, 0, 0.35); +} + +:root, +[data-theme="dark"] { + color-scheme: dark; + --app-bg: #0a0b0f; + --surface: #12141b; + --surface-2: #1a1d27; + --surface-3: rgba(255, 255, 255, 0.04); + --border: #262a37; + --border-strong: #363c4d; + --fg: #eceef3; + --fg-muted: #9199ad; + --fg-subtle: #656d82; + --accent: #7c5cff; + --accent-strong: #9779ff; + --accent-fg: #ffffff; + --success: #33d399; + --warning: #f5b83d; + --danger: #f2545b; +} + +[data-theme="light"] { + color-scheme: light; + --app-bg: #f2f3f7; + --surface: #ffffff; + --surface-2: #f5f6fa; + --surface-3: rgba(15, 17, 26, 0.04); + --border: #e2e4eb; + --border-strong: #cbcfda; + --fg: #14161f; + --fg-muted: #5b6072; + --fg-subtle: #868c9d; + --accent: #6a45f0; + --accent-strong: #5934dd; + --accent-fg: #ffffff; + --success: #17966b; + --warning: #a86608; + --danger: #d13438; +} + +[data-theme="high-contrast"] { + color-scheme: dark; + --app-bg: #000000; + --surface: #0a0a0a; + --surface-2: #161616; + --surface-3: rgba(255, 255, 255, 0.08); + --border: #ffffff; + --border-strong: #ffffff; + --fg: #ffffff; + --fg-muted: #e6e6e6; + --fg-subtle: #c7c7c7; + --accent: #ffd60a; + --accent-strong: #ffe45c; + --accent-fg: #000000; + --success: #00ff85; + --warning: #ffd60a; + --danger: #ff453a; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + background-color: var(--app-bg); + color: var(--fg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-thumb { + background-color: var(--border-strong); + border-radius: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +[data-theme="high-contrast"] :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 6px; +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 999px; + background: var(--border-strong); +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} + +input[type="range"]:disabled::-webkit-slider-thumb { + background: var(--fg-subtle); + cursor: not-allowed; +} diff --git a/src/types/controller.ts b/src/types/controller.ts new file mode 100644 index 0000000..e09ce1f --- /dev/null +++ b/src/types/controller.ts @@ -0,0 +1,69 @@ +/** + * Device abstraction layer types. `DeviceCapabilities` is intentionally + * generic (string-keyed buttons/axes) so devices other than the 8BitDo + * Ultimate can be supported later without changing this shape — see the + * plan's "abstraction layer" requirement. The real backend will populate + * these by probing evdev (`EVIOCGBIT`) in Phase 2; for now + * `mockControllerBackend` provides a realistic example. + */ + +export type ConnectionKind = "usb" | "bluetooth" | "receiver2_4ghz" | "unknown"; + +export type ControllerMode = "xinput" | "directinput" | "switch" | "unknown"; + +export interface ButtonCapability { + id: string; + label: string; + /** Grouping used by the SVG diagram and mapping UI. */ + group: "face" | "dpad" | "shoulder" | "trigger" | "stick" | "system" | "paddle"; +} + +export interface AxisCapability { + id: string; + label: string; + group: "stick" | "trigger" | "gyro"; + min: number; + max: number; +} + +export interface DeviceCapabilities { + vendorId: number; + productId: number; + name: string; + buttons: ButtonCapability[]; + axes: AxisCapability[]; + paddleCount: 0 | 2 | 4; + hasGyro: boolean; + hasRumble: boolean; + hasBattery: boolean; +} + +export interface ControllerInfo { + id: string; + name: string; + connection: ConnectionKind; + devicePath: string; + vendorId: number; + productId: number; + mode: ControllerMode; + batteryPercent: number | null; + firmwareVersion: string | null; + virtualControllerActive: boolean; + capabilities: DeviceCapabilities; + isSimulated: boolean; +} + +/** Instantaneous input state for the active controller. */ +export interface ControllerLiveState { + timestamp: number; + buttons: Record; + axes: Record; +} + +export interface RawInputEvent { + timestamp: number; + type: "button" | "axis"; + code: string; + rawValue: number; + processedValue: number; +} diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 0000000..ea3c139 --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,114 @@ +/** + * Mirrors the Rust structs in `src-tauri/src/profile_schema.rs`. Keep these + * in sync manually — there is a Rust-side unit test that snapshots the JSON + * shape of the default profiles so drift is caught quickly. + */ + +export type ControlId = string; + +export type ActionMode = "normal" | "turbo" | "toggle" | "hold" | "longPress" | "doubleTap"; + +export type MappingTarget = + | { kind: "button"; button: ControlId } + | { kind: "key"; key: string } + | { kind: "mouseButton"; button: string } + | { kind: "mouseAxis"; axis: string; scale: number } + | { kind: "disabled" } + | { kind: "combo"; targets: MappingTarget[] } + | { kind: "shiftLayer"; layerId: string }; + +export interface ButtonMapping { + target: MappingTarget; + mode: ActionMode; + turboRateHz?: number | null; + longPressMs?: number | null; + doubleTapWindowMs?: number | null; +} + +export type DeadzoneMode = "radial" | "axial"; + +export type CurvePreset = "linear" | "precision" | "aggressive" | "smooth" | "exponential" | "custom"; + +export interface CurvePoint { + x: number; + y: number; +} + +export interface CurveConfig { + preset: CurvePreset; + customPoints: CurvePoint[]; +} + +export interface StickSettings { + innerDeadzone: number; + outerDeadzone: number; + antiDeadzone: number; + sensitivityX: number; + sensitivityY: number; + invertX: boolean; + invertY: boolean; + deadzoneMode: DeadzoneMode; + circularityCorrection: boolean; + smoothing: number; + responseCurve: CurveConfig; +} + +export type TriggerResponse = "linear" | "progressive"; + +export interface TriggerSettings { + deadzone: number; + maxActivation: number; + sensitivity: number; + response: TriggerResponse; + hairTrigger: boolean; + digitalMode: boolean; + invert: boolean; + curve: CurveConfig; +} + +export type GyroMapping = "off" | "stick" | "mouse"; + +export interface MotionSettings { + vibrationIntensity: number; + leftMotorIntensity: number; + rightMotorIntensity: number; + gyroSensitivity: number; + gyroDeadzone: number; + invertGyroX: boolean; + invertGyroY: boolean; + gyroMapping: GyroMapping; +} + +export interface ShiftLayer { + id: string; + name: string; + activatorButton: ControlId; + mappings: Record; +} + +export interface Profile { + schemaVersion: number; + id: string; + name: string; + description: string; + isBuiltIn: boolean; + createdAt: string; + updatedAt: string; + assignedExecutables: string[]; + shortcutCombo: ControlId[]; + buttonMappings: Record; + leftStick: StickSettings; + rightStick: StickSettings; + leftTrigger: TriggerSettings; + rightTrigger: TriggerSettings; + motion: MotionSettings; + shiftLayers: ShiftLayer[]; +} + +export interface ProfileSummary { + id: string; + name: string; + description: string; + isBuiltIn: boolean; + updatedAt: string; +} diff --git a/src/utils/curveMath.ts b/src/utils/curveMath.ts new file mode 100644 index 0000000..ef346d9 --- /dev/null +++ b/src/utils/curveMath.ts @@ -0,0 +1,71 @@ +import type { CurveConfig, CurvePoint, CurvePreset } from "@/types/profile"; + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function smoothstep(t: number): number { + return t * t * (3 - 2 * t); +} + +function evaluateCustomCurve(points: CurvePoint[], t: number): number { + if (points.length === 0) return t; + const sorted = [...points].sort((a, b) => a.x - b.x); + if (t <= sorted[0].x) return sorted[0].y; + const last = sorted[sorted.length - 1]; + if (t >= last.x) return last.y; + for (let i = 0; i < sorted.length - 1; i++) { + const p0 = sorted[i]; + const p1 = sorted[i + 1]; + if (t >= p0.x && t <= p1.x) { + const span = p1.x - p0.x; + const localT = span === 0 ? 0 : (t - p0.x) / span; + return p0.y + (p1.y - p0.y) * localT; + } + } + return t; +} + +/** Evaluates a response curve preset (or custom control points) at `t` in [0, 1]. */ +export function evaluateCurve(preset: CurvePreset, points: CurvePoint[], t: number): number { + const clamped = clamp(t, 0, 1); + switch (preset) { + case "linear": + return clamped; + case "precision": + return Math.pow(clamped, 1.8); + case "aggressive": + return Math.pow(clamped, 0.55); + case "smooth": + return smoothstep(clamped); + case "exponential": + return Math.pow(clamped, 2.5); + case "custom": + return evaluateCustomCurve(points, clamped); + default: + return clamped; + } +} + +export function evaluateCurveConfig(curve: CurveConfig, t: number): number { + return evaluateCurve(curve.preset, curve.customPoints, t); +} + +export const CURVE_PRESET_LABELS: Record = { + linear: "Linear", + precision: "Precision", + aggressive: "Aggressive", + smooth: "Smooth", + exponential: "Exponential", + custom: "Custom", +}; + +/** Sample points used to draw a preview of a curve without a custom editor. */ +export function sampleCurve(preset: CurvePreset, points: CurvePoint[], steps = 48): CurvePoint[] { + const samples: CurvePoint[] = []; + for (let i = 0; i <= steps; i++) { + const x = i / steps; + samples.push({ x, y: evaluateCurve(preset, points, x) }); + } + return samples; +} diff --git a/src/utils/deadzoneMath.ts b/src/utils/deadzoneMath.ts new file mode 100644 index 0000000..fe28fc2 --- /dev/null +++ b/src/utils/deadzoneMath.ts @@ -0,0 +1,112 @@ +import type { StickSettings, TriggerSettings } from "@/types/profile"; +import { clamp, evaluateCurveConfig } from "@/utils/curveMath"; + +export interface Vec2 { + x: number; + y: number; +} + +export interface StickComputation { + raw: Vec2; + processed: Vec2; + rawMagnitude: number; + processedMagnitude: number; +} + +function magnitude(v: Vec2): number { + return Math.sqrt(v.x * v.x + v.y * v.y); +} + +/** + * Clamps a raw stick reading so its magnitude never exceeds 1, correcting + * for controllers whose physical range is closer to a square/octagon than a + * perfect circle (a diagonal raw reading like (0.9, 0.9) would otherwise + * report a magnitude of ~1.27). + */ +function correctCircularity(v: Vec2): Vec2 { + const mag = magnitude(v); + if (mag <= 1 || mag === 0) return v; + return { x: v.x / mag, y: v.y / mag }; +} + +function applyRadialDeadzone(v: Vec2, inner: number, outer: number, antiDeadzone: number): Vec2 { + const mag = magnitude(v); + if (mag <= inner) return { x: 0, y: 0 }; + const clampedOuter = Math.max(outer, inner + 0.0001); + const normalizedMag = clamp((mag - inner) / (clampedOuter - inner), 0, 1); + const outputMag = antiDeadzone + normalizedMag * (1 - antiDeadzone); + const scale = mag === 0 ? 0 : outputMag / mag; + return { x: v.x * scale, y: v.y * scale }; +} + +function applyAxialDeadzone(v: Vec2, inner: number, outer: number, antiDeadzone: number): Vec2 { + const applyAxis = (value: number) => { + const abs = Math.abs(value); + if (abs <= inner) return 0; + const clampedOuter = Math.max(outer, inner + 0.0001); + const normalized = clamp((abs - inner) / (clampedOuter - inner), 0, 1); + const outputAbs = antiDeadzone + normalized * (1 - antiDeadzone); + return Math.sign(value) * outputAbs; + }; + return { x: applyAxis(v.x), y: applyAxis(v.y) }; +} + +/** Full stick processing pipeline mirroring the Rust `input_transform` stage order. */ +export function processStick(raw: Vec2, settings: StickSettings): StickComputation { + let v = correctCircularity(settings.circularityCorrection ? raw : { ...raw }); + + v = + settings.deadzoneMode === "radial" + ? applyRadialDeadzone(v, settings.innerDeadzone, settings.outerDeadzone, settings.antiDeadzone) + : applyAxialDeadzone(v, settings.innerDeadzone, settings.outerDeadzone, settings.antiDeadzone); + + const mag = magnitude(v); + if (mag > 0) { + const curved = evaluateCurveConfig(settings.responseCurve, mag); + const scale = curved / mag; + v = { x: v.x * scale, y: v.y * scale }; + } + + v = { + x: clamp(v.x * settings.sensitivityX, -1, 1), + y: clamp(v.y * settings.sensitivityY, -1, 1), + }; + + if (settings.invertX) v = { ...v, x: -v.x }; + if (settings.invertY) v = { ...v, y: -v.y }; + + return { + raw, + processed: v, + rawMagnitude: magnitude(raw), + processedMagnitude: magnitude(v), + }; +} + +export interface TriggerComputation { + raw: number; + processed: number; +} + +export function processTrigger(raw: number, settings: TriggerSettings): TriggerComputation { + const clampedRaw = clamp(raw, 0, 1); + const oriented = settings.invert ? 1 - clampedRaw : clampedRaw; + + if (oriented <= settings.deadzone) { + return { raw, processed: 0 }; + } + + const maxActivation = Math.max(settings.maxActivation, settings.deadzone + 0.0001); + const normalized = clamp((oriented - settings.deadzone) / (maxActivation - settings.deadzone), 0, 1); + + if (settings.digitalMode) { + return { raw, processed: normalized > 0.5 ? 1 : 0 }; + } + + const responseAdjusted = + settings.response === "progressive" ? Math.pow(normalized, 1.6) : normalized; + const curved = evaluateCurveConfig(settings.curve, responseAdjusted); + const withSensitivity = clamp(curved * settings.sensitivity, 0, 1); + + return { raw, processed: withSensitivity }; +} diff --git a/src/utils/eventEmitter.ts b/src/utils/eventEmitter.ts new file mode 100644 index 0000000..e8a3f0c --- /dev/null +++ b/src/utils/eventEmitter.ts @@ -0,0 +1,15 @@ +type Listener = (payload: T) => void; + +/** Minimal typed pub/sub used by the controller backends (mock and, later, Tauri-backed). */ +export class SimpleEmitter { + private listeners = new Set>(); + + on(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit(payload: T): void { + for (const listener of this.listeners) listener(payload); + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..99cb341 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a69dc8c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +// @ts-expect-error process is a nodejs global +const host = process.env.TAURI_DEV_HOST; + +// https://vite.dev/config/ +export default defineConfig(async () => ({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, + }, +}));