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>
This commit is contained in:
2026-07-21 12:18:10 -07:00
commit 6507f5f8b3
121 changed files with 15415 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

40
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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);
}
}

View 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)
}

View 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,
}
}

View 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(())
}

View File

@@ -0,0 +1,6 @@
pub mod devices;
pub mod diagnostics;
pub mod mapping;
pub mod processes;
pub mod profiles;
pub mod system;

View 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))
}

View 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()
}

View 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(),
}
}

View 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.",
)
}

View 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
View 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>;

View 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()))?;
}
}
}

View 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
View 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
View 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()
}

View 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,
}
}

View 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()));
}
}

View 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());
});
}
}

View 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
View 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>>,
}

View 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
View 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"
]
}
}