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 <cursoragent@cursor.com>
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5393
src-tauri/Cargo.lock
generated
Normal file
40
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "eightbitdo-control-center"
|
||||
version = "0.1.0"
|
||||
description = "Advanced Linux configuration utility for 8BitDo Ultimate controllers"
|
||||
authors = ["8BitDo Control Center Contributors"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "eightbitdo_control_center_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-log = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
dirs = "6"
|
||||
evdev = "0.13"
|
||||
udev = "0.9"
|
||||
sysinfo = "0.37"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"os:default",
|
||||
"dialog:default",
|
||||
"log:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
78
src-tauri/src/calibration.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! Calibration sampling and recommendation helpers.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AxisRange {
|
||||
pub minimum: f32,
|
||||
pub maximum: f32,
|
||||
pub neutral: f32,
|
||||
}
|
||||
|
||||
impl Default for AxisRange {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
minimum: 1.0,
|
||||
maximum: -1.0,
|
||||
neutral: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AxisSampler {
|
||||
samples: Vec<f32>,
|
||||
}
|
||||
|
||||
impl AxisSampler {
|
||||
pub fn push(&mut self, value: f32) {
|
||||
self.samples.push(value.clamp(-1.0, 1.0));
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Option<AxisRange> {
|
||||
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::<f32>() / 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);
|
||||
}
|
||||
}
|
||||
24
src-tauri/src/commands/devices.rs
Normal file
@@ -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<ControllerInfo> {
|
||||
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)
|
||||
}
|
||||
27
src-tauri/src/commands/diagnostics.rs
Normal file
@@ -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<String>,
|
||||
pub permissions: PermissionStatus,
|
||||
pub devices: Vec<ControllerInfo>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
150
src-tauri/src/commands/mapping.rs
Normal file
@@ -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<bool> {
|
||||
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(())
|
||||
}
|
||||
6
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod devices;
|
||||
pub mod diagnostics;
|
||||
pub mod mapping;
|
||||
pub mod processes;
|
||||
pub mod profiles;
|
||||
pub mod system;
|
||||
7
src-tauri/src/commands/processes.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use crate::error::AppResult;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detected_game_profile() -> AppResult<Option<String>> {
|
||||
let profiles = crate::profile_manager::load_all_profiles()?;
|
||||
Ok(crate::process_monitor::matching_profile_id(&profiles))
|
||||
}
|
||||
60
src-tauri/src/commands/profiles.rs
Normal file
@@ -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<Vec<ProfileSummary>> {
|
||||
profile_manager::list_profiles()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_profile(id: String) -> AppResult<Profile> {
|
||||
profile_manager::load_profile(&id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_profile(profile: Profile) -> AppResult<Profile> {
|
||||
profile_manager::save_profile(profile)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_profile(name: String) -> AppResult<Profile> {
|
||||
profile_manager::create_profile(&name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_profile(id: String, new_name: String) -> AppResult<Profile> {
|
||||
profile_manager::rename_profile(&id, &new_name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn duplicate_profile(id: String, new_name: String) -> AppResult<Profile> {
|
||||
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> {
|
||||
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> {
|
||||
profile_manager::import_profile(&source)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn profiles_directory() -> String {
|
||||
profile_manager::profiles_dir()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
21
src-tauri/src/commands/system.rs
Normal file
@@ -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(),
|
||||
}
|
||||
}
|
||||
116
src-tauri/src/default_profiles.rs
Normal file
@@ -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<Profile> {
|
||||
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.",
|
||||
)
|
||||
}
|
||||
240
src-tauri/src/device_detection.rs
Normal file
@@ -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<ButtonCapability>,
|
||||
pub axes: Vec<AxisCapability>,
|
||||
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<u8>,
|
||||
pub firmware_version: Option<String>,
|
||||
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<ControllerInfo> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<ControllerInfo> {
|
||||
let mut controllers = evdev::enumerate()
|
||||
.filter_map(|(path, device)| probe_device(&path, &device))
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>()},
|
||||
"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
|
||||
}
|
||||
40
src-tauri/src/error.rs
Normal file
@@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
349
src-tauri/src/evdev_reader.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<bool> {
|
||||
app.state::<AppState>()
|
||||
.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<AbsoluteAxisCode, (i32, i32)>,
|
||||
) -> AppResult<()> {
|
||||
let profile = app
|
||||
.state::<AppState>()
|
||||
.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::<AppState>();
|
||||
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::<AppState>().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::<AppState>().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::<HashMap<_, _>>();
|
||||
// #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()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
211
src-tauri/src/input_transform.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
61
src-tauri/src/lib.rs
Normal file
@@ -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");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -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()
|
||||
}
|
||||
44
src-tauri/src/permissions.rs
Normal file
@@ -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<String>,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
45
src-tauri/src/process_monitor.rs
Normal file
@@ -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<String> {
|
||||
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<String> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
298
src-tauri/src/profile_manager.rs
Normal file
@@ -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<Vec<ProfileSummary>> {
|
||||
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<Vec<Profile>> {
|
||||
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<Profile> {
|
||||
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<bool> {
|
||||
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> {
|
||||
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<Profile> {
|
||||
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<Profile> {
|
||||
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<Profile> {
|
||||
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<Profile> {
|
||||
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<Profile> {
|
||||
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<T>(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());
|
||||
});
|
||||
}
|
||||
}
|
||||
401
src-tauri/src/profile_schema.rs
Normal file
@@ -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<MappingTarget> },
|
||||
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<f32>,
|
||||
#[serde(default)]
|
||||
pub long_press_ms: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub double_tap_window_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[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<CurvePoint>,
|
||||
}
|
||||
|
||||
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<ControlId, ButtonMapping>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub shortcut_combo: Vec<ControlId>,
|
||||
#[serde(default)]
|
||||
pub button_mappings: HashMap<ControlId, ButtonMapping>,
|
||||
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<ShiftLayer>,
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<Profile> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
13
src-tauri/src/state.rs
Normal file
@@ -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<HashSet<String>>,
|
||||
pub virtual_controller: Mutex<Option<evdev::uinput::VirtualDevice>>,
|
||||
pub remapping_enabled: Mutex<bool>,
|
||||
pub active_remap_profile: Mutex<Option<crate::profile_schema::Profile>>,
|
||||
}
|
||||
69
src-tauri/src/uinput_writer.rs
Normal file
@@ -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<VirtualDevice> {
|
||||
let mut keys = AttributeSet::<KeyCode>::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)
|
||||
}
|
||||
37
src-tauri/tauri.conf.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||