release prep: rc.1 baseline and gating updates

This commit is contained in:
2026-03-02 15:54:55 -05:00
parent 97a42c8802
commit f43b2b24b6
168 changed files with 14708 additions and 982 deletions

View File

@@ -0,0 +1,19 @@
[package]
name = "bitdo_tui"
version = "0.1.0"
edition = "2021"
license = "BSD-3-Clause"
[dependencies]
anyhow = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
toml = { workspace = true }
bitdo_proto = { path = "../bitdo_proto" }
bitdo_app_core = { path = "../bitdo_app_core" }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time"] }

View File

@@ -0,0 +1,69 @@
use anyhow::{anyhow, Result};
use std::path::Path;
use std::process::{Command, Stdio};
/// Open a file or directory with the user's default desktop application.
pub(crate) fn open_path_with_default_app(path: &Path) -> Result<()> {
let mut cmd = if cfg!(target_os = "macos") {
let mut c = Command::new("open");
c.arg(path);
c
} else {
let mut c = Command::new("xdg-open");
c.arg(path);
c
};
let status = cmd.status()?;
if status.success() {
Ok(())
} else {
Err(anyhow!(
"failed to open path with default app: {}",
path.to_string_lossy()
))
}
}
/// Copy text into the system clipboard using platform-appropriate commands.
pub(crate) fn copy_text_to_clipboard(text: &str) -> Result<()> {
if cfg!(target_os = "macos") {
return copy_via_command("pbcopy", &[], text);
}
if command_exists("wl-copy") {
return copy_via_command("wl-copy", &[], text);
}
if command_exists("xclip") {
return copy_via_command("xclip", &["-selection", "clipboard"], text);
}
Err(anyhow!(
"no clipboard utility found (tried pbcopy/wl-copy/xclip)"
))
}
fn command_exists(name: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!("command -v {name} >/dev/null 2>&1"))
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn copy_via_command(command: &str, args: &[&str], text: &str) -> Result<()> {
let mut child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write as _;
stdin.write_all(text.as_bytes())?;
}
let status = child.wait()?;
if status.success() {
Ok(())
} else {
Err(anyhow!("clipboard command failed: {command}"))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
use crate::ReportSaveMode;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedSettings {
#[serde(default = "default_settings_schema_version")]
schema_version: u32,
#[serde(default)]
advanced_mode: bool,
#[serde(default)]
report_save_mode: ReportSaveMode,
}
const fn default_settings_schema_version() -> u32 {
1
}
/// Persist beginner/advanced preferences in a small TOML config file.
pub(crate) fn persist_user_settings(
path: &Path,
advanced_mode: bool,
report_save_mode: ReportSaveMode,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(&PersistedSettings {
schema_version: default_settings_schema_version(),
advanced_mode,
report_save_mode,
})
.map_err(|err| anyhow!("failed to serialize user settings: {err}"))?;
std::fs::write(path, body)?;
Ok(())
}

View File

@@ -0,0 +1,217 @@
use crate::AppDevice;
use anyhow::{anyhow, Result};
use bitdo_app_core::FirmwareFinalReport;
use bitdo_proto::{DiagProbeResult, SupportLevel, SupportTier};
use chrono::Utc;
use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
const REPORT_MAX_COUNT: usize = 20;
const REPORT_MAX_AGE_DAYS: u64 = 30;
#[derive(Clone, Debug, Serialize)]
struct SupportReport {
schema_version: u32,
generated_at_utc: String,
operation: String,
device: Option<SupportReportDevice>,
status: String,
message: String,
diag: Option<DiagProbeResult>,
firmware: Option<FirmwareFinalReport>,
}
#[derive(Clone, Debug, Serialize)]
struct SupportReportDevice {
vid: u16,
pid: u16,
name: String,
canonical_id: String,
runtime_label: String,
serial: Option<String>,
support_level: String,
support_tier: String,
}
/// Persist a troubleshooting report as TOML.
///
/// Reports are intended for failure/support paths and are named with a timestamp plus
/// a serial-or-VID/PID token so users can share deterministic artifacts with support.
pub(crate) async fn persist_support_report(
operation: &str,
device: Option<&AppDevice>,
status: &str,
message: String,
diag: Option<&DiagProbeResult>,
firmware: Option<&FirmwareFinalReport>,
) -> Result<PathBuf> {
let now = Utc::now();
let report = SupportReport {
schema_version: 1,
generated_at_utc: now.to_rfc3339(),
operation: operation.to_owned(),
device: device.map(|d| SupportReportDevice {
vid: d.vid_pid.vid,
pid: d.vid_pid.pid,
name: d.name.clone(),
canonical_id: d.name.clone(),
runtime_label: d.support_status().as_str().to_owned(),
serial: d.serial.clone(),
support_level: match d.support_level {
SupportLevel::Full => "full".to_owned(),
SupportLevel::DetectOnly => "detect-only".to_owned(),
},
support_tier: match d.support_tier {
SupportTier::Full => "full".to_owned(),
SupportTier::CandidateReadOnly => "candidate-readonly".to_owned(),
SupportTier::DetectOnly => "detect-only".to_owned(),
},
}),
status: status.to_owned(),
message,
diag: diag.cloned(),
firmware: firmware.cloned(),
};
let report_dir = default_report_directory();
tokio::fs::create_dir_all(&report_dir).await?;
let token = report_subject_token(device);
let file_name = format!(
"{}-{}-{}.toml",
sanitize_token(operation),
now.format("%Y%m%d-%H%M%S"),
token
);
let path = report_dir.join(file_name);
let body = toml::to_string_pretty(&report)
.map_err(|err| anyhow!("failed to serialize support report: {err}"))?;
tokio::fs::write(&path, body).await?;
let _ = prune_reports_on_write().await;
Ok(path)
}
/// Startup pruning is age-based to keep stale files out of user systems.
pub(crate) async fn prune_reports_on_startup() -> Result<()> {
prune_reports_by_age().await
}
/// Write-time pruning is count-based to keep growth bounded deterministically.
async fn prune_reports_on_write() -> Result<()> {
prune_reports_by_count().await
}
pub(crate) fn report_subject_token(device: Option<&AppDevice>) -> String {
if let Some(device) = device {
if let Some(serial) = device.serial.as_deref() {
let cleaned = sanitize_token(serial);
if !cleaned.is_empty() {
return cleaned;
}
}
return format!("{:04x}{:04x}", device.vid_pid.vid, device.vid_pid.pid);
}
"unknown".to_owned()
}
fn sanitize_token(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else {
out.push('_');
}
}
out.trim_matches('_').to_owned()
}
fn default_report_directory() -> PathBuf {
if cfg!(target_os = "macos") {
return home_directory()
.join("Library")
.join("Application Support")
.join("OpenBitdo")
.join("reports");
}
if cfg!(target_os = "linux") {
if let Some(xdg_data_home) = std::env::var_os("XDG_DATA_HOME") {
return PathBuf::from(xdg_data_home)
.join("openbitdo")
.join("reports");
}
return home_directory()
.join(".local")
.join("share")
.join("openbitdo")
.join("reports");
}
std::env::temp_dir().join("openbitdo").join("reports")
}
async fn list_report_files() -> Result<Vec<PathBuf>> {
let report_dir = default_report_directory();
let mut out = Vec::new();
let mut entries = match tokio::fs::read_dir(&report_dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(err) => return Err(err.into()),
};
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
out.push(path);
}
}
Ok(out)
}
async fn prune_reports_by_count() -> Result<()> {
let mut files = list_report_files().await?;
files.sort_by_key(|path| {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
});
files.reverse();
for path in files.into_iter().skip(REPORT_MAX_COUNT) {
let _ = tokio::fs::remove_file(path).await;
}
Ok(())
}
async fn prune_reports_by_age() -> Result<()> {
let now = SystemTime::now();
let max_age = Duration::from_secs(REPORT_MAX_AGE_DAYS * 24 * 60 * 60);
for path in list_report_files().await? {
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
let Ok(modified) = meta.modified() else {
continue;
};
if now.duration_since(modified).unwrap_or_default() > max_age {
let _ = tokio::fs::remove_file(path).await;
}
}
Ok(())
}
fn home_directory() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
}

View File

@@ -0,0 +1,370 @@
use super::*;
use crate::support_report::report_subject_token;
use bitdo_app_core::{FirmwareOutcome, OpenBitdoCoreConfig};
use bitdo_proto::SupportLevel;
#[test]
fn about_state_roundtrip_returns_home() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Test".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("SERIAL1".to_owned()),
connected: true,
}]);
app.open_about();
assert_eq!(app.state, TuiWorkflowState::About);
app.close_overlay();
assert_eq!(app.state, TuiWorkflowState::Home);
}
#[test]
fn refresh_devices_without_any_device_enters_wait_state() {
let mut app = TuiApp::default();
app.refresh_devices(Vec::new());
assert_eq!(app.state, TuiWorkflowState::WaitForDevice);
assert!(app.selected.is_none());
}
#[test]
fn refresh_devices_autoselects_single_device() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "One".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: None,
connected: true,
}]);
assert_eq!(app.state, TuiWorkflowState::Home);
assert_eq!(app.selected_index, 0);
assert_eq!(app.selected, Some(VidPid::new(0x2dc8, 0x6009)));
}
#[test]
fn serial_token_prefers_serial_then_vidpid() {
let with_serial = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "S".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("ABC 123".to_owned()),
connected: true,
};
assert_eq!(report_subject_token(Some(&with_serial)), "ABC_123");
let without_serial = AppDevice {
serial: None,
..with_serial
};
assert_eq!(report_subject_token(Some(&without_serial)), "2dc86009");
}
#[test]
fn launch_options_default_to_failure_only_reports() {
let opts = TuiLaunchOptions::default();
assert_eq!(opts.report_save_mode, ReportSaveMode::FailureOnly);
}
#[test]
fn blocked_panel_text_matches_support_tier() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x2100),
name: "Candidate".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::CandidateReadOnly,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability {
supports_mode: true,
supports_profile_rw: true,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
},
evidence: bitdo_proto::SupportEvidence::Inferred,
serial: None,
connected: true,
}]);
let selected = app.selected_device().expect("selected");
let text = blocked_action_panel_text(selected);
assert!(text.contains("blocked"));
assert!(text.contains("Status shown as Blocked"));
assert_eq!(beginner_status_label(selected), "Blocked");
}
#[test]
fn non_advanced_report_mode_skips_off_setting() {
let mut app = TuiApp {
advanced_mode: false,
..Default::default()
};
assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly);
app.cycle_report_save_mode().expect("cycle");
assert_eq!(app.report_save_mode, ReportSaveMode::Always);
app.cycle_report_save_mode().expect("cycle");
assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly);
}
#[test]
fn unknown_device_label_is_beginner_friendly() {
let device = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0xabcd),
name: "PID_UNKNOWN".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::DetectOnly,
protocol_family: bitdo_proto::ProtocolFamily::Unknown,
capability: bitdo_proto::PidCapability::identify_only(),
evidence: bitdo_proto::SupportEvidence::Untested,
serial: None,
connected: true,
};
let label = super::display_device_name(&device);
assert!(label.contains("Unknown 8BitDo Device"));
assert!(label.contains("2dc8:abcd"));
}
#[tokio::test]
async fn home_refresh_loads_devices() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
assert!(!app.devices.is_empty());
assert!(app.selected_device().is_some());
}
#[tokio::test]
async fn run_tui_app_no_ui_blocks_detect_only_pid() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let result = run_tui_app(
core,
TuiLaunchOptions {
no_ui: true,
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x2100)),
..Default::default()
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn run_tui_app_no_ui_full_support_completes() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
run_tui_app(
core,
TuiLaunchOptions {
no_ui: true,
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x6009)),
..Default::default()
},
)
.await
.expect("run app");
}
#[tokio::test]
async fn tui_flow_with_manual_path_completes() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let path = std::env::temp_dir().join("openbitdo-tui-flow.bin");
tokio::fs::write(&path, vec![1u8; 128])
.await
.expect("write");
let report = run_tui_flow(
core,
TuiRunRequest {
vid_pid: VidPid::new(0x2dc8, 0x6009),
firmware_path: path.clone(),
allow_unsafe: true,
brick_risk_ack: true,
experimental: true,
chunk_size: Some(32),
acknowledged_risk: true,
no_ui: true,
},
)
.await
.expect("run tui flow");
assert_eq!(report.status, FirmwareOutcome::Completed);
let _ = tokio::fs::remove_file(path).await;
}
#[tokio::test]
async fn support_report_is_toml_file() {
let device = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Test".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("RPT-1".to_owned()),
connected: true,
};
let report_path = persist_support_report(
"diag-probe",
Some(&device),
"ok",
"all checks passed".to_owned(),
None,
None,
)
.await
.expect("report path");
assert_eq!(
report_path.extension().and_then(|s| s.to_str()),
Some("toml")
);
let _ = tokio::fs::remove_file(report_path).await;
}
#[tokio::test]
async fn update_action_enters_jp108_wizard_for_jp108_device() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let jp108_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x5209)
.expect("jp108 fixture");
app.select_index(jp108_idx);
app.state = TuiWorkflowState::Home;
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_home_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
HomeAction::Update,
)
.await
.expect("execute");
assert_eq!(app.state, TuiWorkflowState::Jp108Mapping);
assert!(!app.jp108_mappings.is_empty());
}
#[tokio::test]
async fn update_action_enters_u2_wizard_for_ultimate2_device() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let u2_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x6012)
.expect("u2 fixture");
app.select_index(u2_idx);
app.state = TuiWorkflowState::Home;
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_home_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
HomeAction::Update,
)
.await
.expect("execute");
assert_eq!(app.state, TuiWorkflowState::U2CoreProfile);
assert!(app.u2_profile.is_some());
}
#[tokio::test]
async fn device_flow_backup_apply_sets_backup_id() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let jp108_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x5209)
.expect("jp108 fixture");
app.select_index(jp108_idx);
app.begin_jp108_mapping(
core.jp108_read_dedicated_mapping(VidPid::new(0x2dc8, 0x5209))
.await
.expect("read"),
);
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_device_flow_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
DeviceFlowAction::BackupApply,
)
.await
.expect("apply");
assert!(app.latest_backup.is_some());
}