diff --git a/Cargo.lock b/Cargo.lock index 6740ba3..b997933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,6 +161,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -857,7 +863,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1088,7 +1094,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1153,7 +1159,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1528,6 +1534,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-global-shortcut", + "unicode-normalization", + "ureq", "windows-sys 0.61.2", "x11rb 0.14.0", ] @@ -3142,7 +3150,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3276,7 +3284,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3516,7 +3526,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3551,7 +3561,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -3857,9 +3867,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -4246,7 +4256,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -4621,6 +4631,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4633,6 +4652,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4658,6 +4706,12 @@ dependencies = [ "url", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4895,6 +4949,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5584,7 +5647,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 5521351..2dcd76b 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,8 +23,12 @@ enigo = "0.6.1" rodio = { version = "0.22.2", default-features = false, features = ["playback"] } chrono = { version = "0.4.45", default-features = false, features = ["clock", "std", "serde"] } serialport = "4.9.0" +# reqwest는 app_registry(앱 프로필), ureq는 registry(보드 펌웨어)에서 쓴다. +# 두 클라이언트의 역할이 다르므로 지금은 병행한다. reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] } +ureq = "3.4.0" sha2 = "0.11.0" +unicode-normalization = "0.1.25" # 이후 단계에서 붙일 crate. 버전은 반드시 `cargo add`로 확정한다. # cargo add tauri-plugin-store # 사용자 프로필 영속화 (M3) diff --git a/apps/desktop/src-tauri/src/arduino.rs b/apps/desktop/src-tauri/src/arduino.rs index 4fbcb71..e504cc9 100644 --- a/apps/desktop/src-tauri/src/arduino.rs +++ b/apps/desktop/src-tauri/src/arduino.rs @@ -7,7 +7,7 @@ use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -331,6 +331,207 @@ impl Drop for ArduinoSwitch { } } +/// The only component currently permitted to own the Arduino serial port. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArduinoOwner { + Connection, + Installer, + Idle, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CoordinatorError { + InstallerActive, +} + +/// Describes why an installer lease ended. Every variant restores normal input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InstallerExit { + Success, + Failure, + Cancelled, +} + +struct CoordinatorState { + owner: ArduinoOwner, + switch: Option, + installer_lease_alive: bool, +} + +struct CoordinatorInner { + state: Mutex, + spawn_switch: Box ArduinoSwitch + Send + Sync>, +} + +/// Serializes normal input and firmware installation ownership. +/// +/// Acquiring an installer lease first removes the normal connection from state, +/// unregisters LED output, and synchronously joins its worker. Dropping the lease +/// always starts a fresh connection, including during error propagation or unwind. +#[derive(Clone)] +pub struct ArduinoCoordinator { + inner: Arc, +} + +impl ArduinoCoordinator { + pub fn new(spawn_switch: F) -> Self + where + F: Fn() -> ArduinoSwitch + Send + Sync + 'static, + { + let switch = spawn_switch(); + Self { + inner: Arc::new(CoordinatorInner { + state: Mutex::new(CoordinatorState { + owner: ArduinoOwner::Connection, + switch: Some(switch), + installer_lease_alive: false, + }), + spawn_switch: Box::new(spawn_switch), + }), + } + } + + pub fn for_installer(spawn_switch: F) -> Self + where + F: Fn() -> ArduinoSwitch + Send + Sync + 'static, + { + Self { + inner: Arc::new(CoordinatorInner { + state: Mutex::new(CoordinatorState { + owner: ArduinoOwner::Installer, + switch: None, + installer_lease_alive: false, + }), + spawn_switch: Box::new(spawn_switch), + }), + } + } + + pub fn owner(&self) -> ArduinoOwner { + self.inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .owner + } + + pub fn acquire_installer(&self) -> Result { + let switch = { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.owner == ArduinoOwner::Installer && state.installer_lease_alive { + return Err(CoordinatorError::InstallerActive); + } + if state.owner == ArduinoOwner::Installer { + state.installer_lease_alive = true; + None + } else { + if state.owner != ArduinoOwner::Connection { + return Err(CoordinatorError::InstallerActive); + } + state.owner = ArduinoOwner::Idle; + state.switch.take() + } + }; + + // The global sender must disappear before waiting for the worker. This + // prevents scanner output from entering a transport that is shutting down. + unregister_active_output(); + if let Some(switch) = switch { + switch.stop(); + } + + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.owner = ArduinoOwner::Installer; + state.installer_lease_alive = true; + Ok(InstallerOwnership { + inner: Arc::clone(&self.inner), + active: true, + restore_connection: true, + }) + } + + pub fn acquire_setup_probe(&self) -> Result { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.owner == ArduinoOwner::Installer && !state.installer_lease_alive { + state.installer_lease_alive = true; + return Ok(InstallerOwnership { + inner: Arc::clone(&self.inner), + active: true, + restore_connection: false, + }); + } + drop(state); + self.acquire_installer() + } +} + +fn resume_connection(inner: &CoordinatorInner) { + let mut state = inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.owner != ArduinoOwner::Installer { + return; + } + state.installer_lease_alive = false; + state.owner = ArduinoOwner::Idle; + let switch = (inner.spawn_switch)(); + state.switch = Some(switch); + state.owner = ArduinoOwner::Connection; +} + +#[must_use = "dropping the installer ownership restores the Arduino connection"] +pub struct InstallerOwnership { + inner: Arc, + active: bool, + restore_connection: bool, +} + +impl InstallerOwnership { + pub fn owner(&self) -> ArduinoOwner { + ArduinoOwner::Installer + } + + pub fn finish(mut self, _exit: InstallerExit) { + self.active = false; + self.inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .installer_lease_alive = false; + if self.restore_connection { + resume_connection(&self.inner); + } + } +} + +impl Drop for InstallerOwnership { + fn drop(&mut self) { + if self.active { + self.inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .installer_lease_alive = false; + if self.restore_connection { + resume_connection(&self.inner); + } + } + } +} + /// Queues LED feedback for the currently active native transport. /// /// The registry lock protects only sender replacement. It is released before the @@ -385,6 +586,12 @@ fn unregister_output(id: u64) { } } +fn unregister_active_output() { + if let Ok(mut active) = ACTIVE_OUTPUT.get_or_init(|| Mutex::new(None)).lock() { + *active = None; + } +} + fn run_worker(policy: ReconnectPolicy, shutdown: Shutdown, on_lifecycle: L, on_switch: S) where L: Fn(Lifecycle), @@ -598,6 +805,32 @@ pub mod test_support { flush_output(serial, output) } + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum CoordinatorEvent { + Spawned(u64), + Joined(u64), + } + + pub fn coordinator_probe(id: u64, events: mpsc::Sender) -> ArduinoSwitch { + let (shutdown, shutdown_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let (output, _output_rx) = mpsc::sync_channel(1); + let _registration = register_output(output); + let _ = events.send(CoordinatorEvent::Spawned(id)); + let _ = shutdown_rx.recv(); + let _ = events.send(CoordinatorEvent::Joined(id)); + }); + ArduinoSwitch { + shutdown, + worker: Some(worker), + } + } + + pub fn coordinator_probe_silent() -> ArduinoSwitch { + let (events, _events_rx) = mpsc::channel(); + coordinator_probe(0, events) + } + pub fn shutdown_probe() -> (ArduinoSwitch, Receiver<()>) { let (shutdown, shutdown_rx) = mpsc::channel(); let (consumed, consumed_rx) = mpsc::channel(); @@ -796,6 +1029,84 @@ mod tests { switch.stop(); } + #[test] + fn installer_ownership_unregisters_output_before_join_and_restores_fresh_switch() { + let _registry = test_support::registry_guard(); + let (events_tx, events_rx) = mpsc::channel(); + let next_id = AtomicU64::new(1); + let coordinator = ArduinoCoordinator::new(move || { + test_support::coordinator_probe( + next_id.fetch_add(1, Ordering::Relaxed), + events_tx.clone(), + ) + }); + + assert_eq!(coordinator.owner(), ArduinoOwner::Connection); + assert_eq!( + events_rx.recv().unwrap(), + test_support::CoordinatorEvent::Spawned(1) + ); + let ownership = coordinator.acquire_installer().unwrap(); + assert_eq!(ownership.owner(), ArduinoOwner::Installer); + assert_eq!( + events_rx.recv().unwrap(), + test_support::CoordinatorEvent::Joined(1) + ); + assert_eq!( + enqueue_output(OutputCommand::Flash), + Err(QueueError::Stopped) + ); + + drop(ownership); + assert_eq!(coordinator.owner(), ArduinoOwner::Connection); + assert_eq!( + events_rx.recv().unwrap(), + test_support::CoordinatorEvent::Spawned(2) + ); + } + + #[test] + fn setup_mode_allows_installer_acquisition() { + let coordinator = ArduinoCoordinator::for_installer(test_support::coordinator_probe_silent); + let ownership = coordinator.acquire_installer().unwrap(); + assert_eq!(ownership.owner(), ArduinoOwner::Installer); + } + + #[test] + fn installer_ownership_rejects_overlap_and_restores_after_every_exit() { + let _registry = test_support::registry_guard(); + let coordinator = ArduinoCoordinator::new(test_support::coordinator_probe_silent); + + for exit in [ + InstallerExit::Success, + InstallerExit::Failure, + InstallerExit::Cancelled, + ] { + let ownership = coordinator.acquire_installer().unwrap(); + assert_eq!( + coordinator.acquire_installer().err(), + Some(CoordinatorError::InstallerActive) + ); + ownership.finish(exit); + assert_eq!(coordinator.owner(), ArduinoOwner::Connection); + } + } + + #[test] + fn installer_ownership_restores_connection_during_unwind() { + let _registry = test_support::registry_guard(); + let coordinator = ArduinoCoordinator::new(test_support::coordinator_probe_silent); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ownership = coordinator.acquire_installer().unwrap(); + panic!("installer panic"); + })); + + assert!(result.is_err()); + assert_eq!(coordinator.owner(), ArduinoOwner::Connection); + assert!(coordinator.acquire_installer().is_ok()); + } + #[test] fn serial_press_release_delivers_one_short_judgement() { let detector = std::sync::Arc::new(Mutex::new(crate::input::GestureDetector::new( diff --git a/apps/desktop/src-tauri/src/firmware.rs b/apps/desktop/src-tauri/src/firmware.rs new file mode 100644 index 0000000..bdb70fd --- /dev/null +++ b/apps/desktop/src-tauri/src/firmware.rs @@ -0,0 +1,962 @@ +//! Registry-driven in-app firmware installer for supported boards. +//! +//! The bundled `arduino-cli` process is gone. Installation resolves the +//! connected board against Hana Cloud, downloads the SHA-256-verified +//! firmware, and flashes it over the optiboot bootloader directly. + +use std::fs; +use std::io::{self, Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::arduino::{ + ArduinoCoordinator, BAUD_RATE, HANDSHAKE_REQUEST, HANDSHAKE_RESPONSE, InstallerExit, +}; +use crate::flasher::{FlashImage, SerialIo}; +use crate::registry::{RegistryClient, RegistryError, UsbIdentity}; + +pub const EVENT_FIRMWARE: &str = "arduino://firmware"; +/// ATmega328P의 SPM 페이지 크기(optiboot가 한 번에 쓰는 단위). +const FLASH_PAGE_BYTES: usize = 128; +/// Uno R3 bootloader 영역(512 bytes)을 제외한 애플리케이션 플래시 한계. +const UNO_APPLICATION_BYTES: u32 = 32_256; +/// optiboot 부팅 창을 몇 번까지 기다려 볼지. DTR 리셋 후 부트로더가 잠깐만 +/// 열리므로 arduino-cli의 재시도 동작을 그대로 옮긴다. +const SYNC_ATTEMPTS: usize = 12; +const SERIAL_TIMEOUT: Duration = Duration::from_millis(500); +const REDISCOVERY_TIMEOUT: Duration = Duration::from_secs(10); +const VERIFY_RETRY_WINDOW: Duration = Duration::from_secs(6); +const VERIFY_RETRY_INTERVAL: Duration = Duration::from_millis(700); +const REDISCOVERY_INTERVAL: Duration = Duration::from_millis(250); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArduinoCandidate { + device_id: String, + display_name: String, + port: String, + vid: u16, + pid: u16, + /// 레지스트리 매칭에 쓰는 USB descriptor 보조 문자열. + #[serde(skip_serializing_if = "Option::is_none")] + product: Option, + #[serde(skip_serializing_if = "Option::is_none")] + manufacturer: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "state" +)] +pub enum FirmwareState { + Searching, + BoardFound { + candidates: Vec, + }, + Probing { + device_id: String, + }, + #[allow(dead_code)] + AlreadyInstalled { + device_id: String, + }, + ConfirmationRequired { + device_id: String, + reason: ConfirmationReason, + display_name: String, + }, + Preparing { + device_id: String, + }, + Uploading { + device_id: String, + }, + Verifying { + device_id: String, + }, + Complete { + device_id: String, + }, + Cancelled, + Error { + code: &'static str, + retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ConfirmationReason { + NoResponse, + #[allow(dead_code)] + DifferentFirmware, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HandshakeClassification { + Installed, + NoResponse, + DifferentFirmware, +} + +#[derive(Clone, Debug)] +struct Confirmation { + candidate: ArduinoCandidate, +} + +#[derive(Default)] +struct InstallState { + confirmation: Option, + active: bool, +} + +#[derive(Clone, Default)] +pub struct FirmwareInstaller { + inner: Arc>, + cancelled: Arc, +} + +impl FirmwareInstaller { + fn remember_confirmation(&self, candidate: ArduinoCandidate) { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .confirmation = Some(Confirmation { candidate }); + } + + fn begin(&self, device_id: &str) -> Result { + let mut state = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.active { + return Err("펌웨어 설치가 이미 진행 중입니다.".to_owned()); + } + let candidate = state + .confirmation + .as_ref() + .filter(|saved| saved.candidate.device_id == device_id) + .map(|saved| saved.candidate.clone()) + .ok_or_else(|| "펌웨어 설치 확인 정보가 유효하지 않습니다.".to_owned())?; + state.confirmation = None; + state.active = true; + self.cancelled.store(false, Ordering::Release); + Ok(candidate) + } + + fn finish(&self) { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .active = false; + } + + fn cancel(&self) -> bool { + self.cancelled.store(true, Ordering::Release); + let mut state = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.confirmation = None; + state.active + } +} + +fn preferred_serial_path(name: &str) -> bool { + !name.contains("/tty.") && !name.contains(r"\\.\\COM") +} + +/// OS가 product 문자열을 주지 않거나("Generic CDC" 같은 일반 이름을 줄 때도 +/// 있다) 의미 없는 이름일 때, VID/PID로 알려진 보드명을 되찾는다. +/// 레지스트리 매칭 전 단계의 표시용이며, 실제 등록 여부 판단은 여전히 +/// 레지스트리 인덱스가 한다. +fn known_board_name(vid: u16, pid: u16) -> Option<&'static str> { + match (vid, pid) { + // Arduino Uno R3 계열(boards.txt 기준, 레지스트리 detect 항목과 동일) + (0x2341, 0x0043) + | (0x2341, 0x0001) + | (0x2a03, 0x0043) + | (0x2341, 0x0243) + | (0x2341, 0x006a) => Some("Arduino Uno R3"), + _ => None, + } +} + +fn serial_path_supported(port_name: &str, is_macos: bool) -> bool { + !is_macos || port_name.contains("/cu.") +} + +fn supported_candidate(port: serialport::SerialPortInfo) -> Option { + let serialport::SerialPortType::UsbPort(usb) = port.port_type else { + return None; + }; + if !serial_path_supported(&port.port_name, cfg!(target_os = "macos")) { + return None; + } + + // VID/PID로 후보를 좁히지 않는다. 어떤 보드가 연결됐는지는 레지스트리가 + // 판단하며, 등록되지 않은 VID/PID도 사용자 안내를 위해 목록에 남긴다. + let generic_product = usb.product.as_deref().is_none_or(|product| { + let lowered = product.to_ascii_lowercase(); + lowered.contains("generic") || lowered.contains("cdc") || lowered.trim().is_empty() + }); + let display_name = match (&usb.product, generic_product) { + (Some(product), false) => product.clone(), + _ => known_board_name(usb.vid, usb.pid) + .map(str::to_owned) + .unwrap_or_else(|| "알 수 없는 시리얼 보드".to_owned()), + }; + let product = if generic_product { + None + } else { + usb.product.clone() + }; + let device_id = usb.serial_number.as_deref().map_or_else( + || format!("usb-{:04x}-{:04x}-{}", usb.vid, usb.pid, port.port_name), + |serial| format!("usb-{:04x}-{:04x}-{serial}", usb.vid, usb.pid), + ); + Some(ArduinoCandidate { + device_id, + display_name, + port: port.port_name, + vid: usb.vid, + pid: usb.pid, + product, + manufacturer: usb.manufacturer, + }) +} + +fn candidates() -> Result, String> { + let mut found: Vec = serialport::available_ports() + .map_err(|error| error.to_string())? + .into_iter() + .filter_map(supported_candidate) + .collect(); + found.sort_by(|left, right| { + preferred_serial_path(&left.port) + .cmp(&preferred_serial_path(&right.port)) + .reverse() + .then_with(|| left.port.cmp(&right.port)) + }); + found.dedup_by(|left, right| left.device_id == right.device_id); + Ok(found) +} + +fn find_candidate(device_id: &str) -> Result { + candidates()? + .into_iter() + .find(|candidate| candidate.device_id == device_id) + .ok_or_else(|| "선택한 Arduino Uno를 찾지 못했습니다.".to_owned()) +} + +fn usb_identity(candidate: &ArduinoCandidate) -> UsbIdentity { + UsbIdentity { + vid: candidate.vid, + pid: candidate.pid, + product: candidate.product.clone(), + manufacturer: candidate.manufacturer.clone(), + } +} + +fn registered_candidates( + app: &AppHandle, + found: Vec, +) -> Result, String> { + let cache_dir = registry_cache_dir(app)?; + let index = RegistryClient::new(cache_dir) + .load_index() + .map_err(|error| error.to_string())?; + Ok(found + .into_iter() + .filter(|candidate| { + index + .match_board(&usb_identity(candidate)) + .is_some_and(|matched| matched.confidence == crate::registry::Confidence::Exact) + }) + .collect()) +} + +fn classify_response(response: &[u8]) -> HandshakeClassification { + if response == HANDSHAKE_RESPONSE { + HandshakeClassification::Installed + } else if response.is_empty() { + HandshakeClassification::NoResponse + } else { + HandshakeClassification::DifferentFirmware + } +} + +fn open_probe_port(port: &str) -> Result, String> { + let mut last_error = None; + for _ in 0..8 { + let builder = serialport::new(port, BAUD_RATE).timeout(SERIAL_TIMEOUT); + #[cfg(unix)] + let builder = builder.exclusive(true); + match builder.open() { + Ok(serial) => return Ok(serial), + Err(error) => { + let message = format!("{port}: {error}"); + let busy = message.to_ascii_lowercase().contains("busy") || message.contains("16"); + last_error = Some(message); + if !busy { + break; + } + } + } + } + Err(last_error.unwrap_or_else(|| format!("{port}: couldn't open"))) +} + +fn probe_port(port: &str) -> Result { + let mut serial = open_probe_port(port)?; + + let mut received = Vec::with_capacity(HANDSHAKE_RESPONSE.len()); + for _ in 0..4 { + serial + .write_all(HANDSHAKE_REQUEST) + .and_then(|()| serial.flush()) + .map_err(|error| error.to_string())?; + let mut byte = [0_u8; 1]; + loop { + match serial.read(&mut byte) { + Ok(0) => break, + Ok(_) => { + received.push(byte[0]); + if byte[0] == b'\n' || received.len() >= HANDSHAKE_RESPONSE.len() { + let classified = classify_response(&received); + drop(serial); + return Ok(classified); + } + } + Err(error) if error.kind() == io::ErrorKind::TimedOut => break, + Err(error) => return Err(error.to_string()), + } + } + if !received.is_empty() { + let classified = classify_response(&received); + drop(serial); + return Ok(classified); + } + } + drop(serial); + Ok(HandshakeClassification::NoResponse) +} + +fn emit(app: &AppHandle, state: &FirmwareState) { + if let Err(error) = app.emit(EVENT_FIRMWARE, state) { + eprintln!("Arduino firmware event를 보내지 못했습니다. {error}"); + } +} + +#[tauri::command] +pub fn list_arduino_candidates(app: AppHandle) -> Result, String> { + emit(&app, &FirmwareState::Searching); + let found = registered_candidates(&app, candidates()?).inspect_err(|error| { + emit( + &app, + &FirmwareState::Error { + code: "downloadFailed", + retryable: true, + detail: Some(error.clone()), + }, + ); + })?; + if found.is_empty() { + emit( + &app, + &FirmwareState::Error { + code: "notFound", + retryable: true, + detail: None, + }, + ); + } else { + emit( + &app, + &FirmwareState::BoardFound { + candidates: found.clone(), + }, + ); + } + Ok(found) +} + +#[tauri::command(rename_all = "camelCase")] +#[allow(non_snake_case)] +pub fn probe_arduino_firmware( + app: AppHandle, + deviceId: String, + installer: State<'_, FirmwareInstaller>, +) -> Result { + let device_id = deviceId; + let candidate = find_candidate(&device_id)?; + emit( + &app, + &FirmwareState::Probing { + device_id: device_id.clone(), + }, + ); + + let found = registered_candidates(&app, vec![candidate.clone()])?; + if found.is_empty() { + return Err("Hana Cloud에 정확히 등록된 보드가 아닙니다.".to_owned()); + } + // 빈 Uno는 전용 펌웨어가 없어 handshake에 답하지 않는다. 초기 식별 때 + // 포트를 열거나 요청을 보내지 않고, 사용자의 명시적 설치 확인만 받는다. + installer.remember_confirmation(candidate.clone()); + let state = FirmwareState::ConfirmationRequired { + device_id, + reason: ConfirmationReason::NoResponse, + display_name: candidate.display_name, + }; + emit(&app, &state); + Ok(state) +} + +fn registry_cache_dir(app: &AppHandle) -> Result { + let base = app + .path() + .app_data_dir() + .map_err(|_| "앱 데이터 폴더를 찾지 못했습니다.".to_owned())?; + Ok(base.join("hana-cloud")) +} + +trait ResetLines { + fn set_dtr(&mut self, level: bool) -> Result<(), String>; + fn set_rts(&mut self, level: bool) -> Result<(), String>; +} + +impl ResetLines for T { + fn set_dtr(&mut self, level: bool) -> Result<(), String> { + self.write_data_terminal_ready(level) + .map_err(|error| error.to_string()) + } + + fn set_rts(&mut self, level: bool) -> Result<(), String> { + self.write_request_to_send(level) + .map_err(|error| error.to_string()) + } +} + +fn pulse_bootloader_reset( + lines: &mut T, + mut delay: impl FnMut(Duration), +) -> Result<(), String> { + lines.set_dtr(false)?; + lines.set_rts(false)?; + delay(Duration::from_millis(250)); + lines.set_dtr(true)?; + lines.set_rts(true)?; + delay(Duration::from_micros(100)); + lines.set_dtr(false)?; + lines.set_rts(false)?; + delay(Duration::from_millis(100)); + Ok(()) +} + +/// serialport 핸들을 플래셔의 SerialIo 트레잇에 맞춘다. 타임아웃 읽기는 +/// TimedOut/WouldBlock 에러로 나타나며 read_response가 그것을 처리한다. +struct FlashPort { + port: Box, +} + +impl Read for FlashPort { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.port.read(buf) + } +} + +impl Write for FlashPort { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.port.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.port.flush() + } +} + +fn open_flash_port(port_name: &str) -> Result { + // exclusive 플래그는 쓰지 않는다. 설치 중 연결 스레드는 이미 멈춰 있고, + // macOS에서 exclusive open이 간혹 EBUSY로 실패하는 것을 피한다. + let mut port = serialport::new(port_name, BAUD_RATE) + .timeout(SERIAL_TIMEOUT) + .open() + .map_err(|error| format!("{port_name}: {error}"))?; + pulse_bootloader_reset(port.as_mut(), thread::sleep) + .map_err(|error| format!("{port_name}: 보드를 리셋하지 못했습니다: {error}"))?; + Ok(FlashPort { port }) +} + +fn log_firmware(message: &str) { + eprintln!("{message}"); + if let Ok(mut file) = fs::OpenOptions::new() + .create(true) + .append(true) + .open("/tmp/hanbeon-firmware.log") + { + let _ = writeln!(file, "{message}"); + } +} + +use crate::flasher; + +fn rediscover(previous: &ArduinoCandidate, cancelled: &AtomicBool) -> Option { + let deadline = Instant::now() + REDISCOVERY_TIMEOUT; + while Instant::now() < deadline && !cancelled.load(Ordering::Acquire) { + if let Ok(found) = candidates() + && let Some(candidate) = found + .iter() + .find(|candidate| candidate.device_id == previous.device_id) + .cloned() + .or_else(|| { + found + .iter() + .find(|candidate| { + candidate.port == previous.port + && candidate.vid == previous.vid + && candidate.pid == previous.pid + }) + .cloned() + }) + .or_else(|| { + let mut same_model = found.iter().filter(|candidate| { + candidate.vid == previous.vid && candidate.pid == previous.pid + }); + let only = same_model.next().cloned(); + only.filter(|_| same_model.next().is_none()) + }) + { + return Some(candidate); + } + thread::sleep(REDISCOVERY_INTERVAL); + } + None +} + +#[derive(Clone, Debug)] +enum InstallFailure { + Cancelled, + /// 레지스트리 조회·다운로드 단계 실패. + Download(String), + Upload(String), + Verify(String), +} + +fn ensure_not_cancelled(cancelled: &AtomicBool) -> Result<(), InstallFailure> { + (!cancelled.load(Ordering::Acquire)) + .then_some(()) + .ok_or(InstallFailure::Cancelled) +} + +/// 레지스트리에서 검증된 펌웨어를 가져온다. 사용자가 설치를 시작한 뒤에만 +/// 호출된다(레지스트리 계약). +fn download_firmware( + app: &AppHandle, + candidate: &ArduinoCandidate, + cancelled: &AtomicBool, +) -> Result { + ensure_not_cancelled(cancelled)?; + let cache_dir = registry_cache_dir(app).map_err(InstallFailure::Download)?; + let client = RegistryClient::new(cache_dir); + let matched = client + .match_board(&usb_identity(candidate)) + .map_err(|error| match error { + RegistryError::BoardNotRegistered { .. } => InstallFailure::Download( + "이 보드는 한번 레지스트리에 등록되어 있지 않습니다.".to_owned(), + ), + other => InstallFailure::Download(other.to_string()), + })?; + log_firmware(&format!( + "[firmware] matched {} ({:?})", + matched.board_id, matched.confidence + )); + // exact가 아니면 자동 확정하지 않고 사용자 확인을 요구한다(레지스트리 계약). + if matched.confidence != crate::registry::Confidence::Exact { + return Err(InstallFailure::Download(format!( + "보드 식별이 확실하지 않습니다({}). 보드가 정확한 모델인지 확인해 주세요.", + matched.board_name + ))); + } + let firmware = client + .resolve_firmware(&matched) + .map_err(|error| match error { + RegistryError::BoardNotRegistered { .. } => InstallFailure::Download( + "이 보드는 한번 레지스트리에 등록되어 있지 않습니다.".to_owned(), + ), + other => InstallFailure::Download(other.to_string()), + })?; + let image = FlashImage::from_ihex(&firmware.hex_text) + .map_err(|error| InstallFailure::Download(format!("펌웨어 해석 실패: {error}")))?; + if !image.fits_within(UNO_APPLICATION_BYTES) { + return Err(InstallFailure::Download( + "펌웨어가 Arduino Uno R3 애플리케이션 영역을 벗어납니다.".to_owned(), + )); + } + Ok(image) +} + +fn flash_with_retry( + app: &AppHandle, + candidate: &ArduinoCandidate, + image: &FlashImage, + cancelled: &AtomicBool, +) -> Result<(), InstallFailure> { + let mut last_error = String::new(); + let mut active_candidate = candidate.clone(); + for attempt in 0..3 { + ensure_not_cancelled(cancelled)?; + active_candidate = rediscover(&active_candidate, cancelled).ok_or_else(|| { + InstallFailure::Upload("업로드할 Arduino를 다시 찾지 못했습니다.".to_owned()) + })?; + if attempt > 0 { + emit( + app, + &FirmwareState::Uploading { + device_id: candidate.device_id.clone(), + }, + ); + } + let port = open_flash_port(&active_candidate.port).map_err(InstallFailure::Upload)?; + let mut flash_port = Box::new(port) as Box; + let mut written_bytes = 0usize; + let flash_result = flasher::synchronize(flash_port.as_mut(), SYNC_ATTEMPTS, cancelled) + .and_then(|()| { + flasher::program( + flash_port.as_mut(), + image, + FLASH_PAGE_BYTES, + cancelled, + &mut |written| { + // 페이지 경계마다만 기록하면 충분하다. + if written / FLASH_PAGE_BYTES != written_bytes / FLASH_PAGE_BYTES { + written_bytes = written; + } + }, + ) + }); + drop(flash_port); + match flash_result { + Ok(()) => return Ok(()), + Err(flasher::FlashError::Cancelled) => return Err(InstallFailure::Cancelled), + Err(error) => { + last_error = error.to_string(); + log_firmware(&format!( + "[firmware] flash attempt {} failed: {last_error}", + attempt + 1 + )); + } + } + } + Err(InstallFailure::Upload(last_error)) +} + +fn install( + app: &AppHandle, + device_id: &str, + candidate: &ArduinoCandidate, + cancelled: &AtomicBool, +) -> Result<(), InstallFailure> { + ensure_not_cancelled(cancelled)?; + let image = download_firmware(app, candidate, cancelled)?; + + ensure_not_cancelled(cancelled)?; + emit( + app, + &FirmwareState::Uploading { + device_id: device_id.to_owned(), + }, + ); + flash_with_retry(app, candidate, &image, cancelled)?; + ensure_not_cancelled(cancelled)?; + emit( + app, + &FirmwareState::Verifying { + device_id: device_id.to_owned(), + }, + ); + let rediscovered = rediscover(candidate, cancelled).ok_or_else(|| { + InstallFailure::Verify("upload 후 Arduino를 다시 찾지 못했습니다.".to_owned()) + })?; + ensure_not_cancelled(cancelled)?; + // 업로드 후 보드는 리셋되고 새 펌웨어가 부팅한다(1~2초). 첫 probe는 + // 부팅 중이라 실패할 수 있으므로, Installed가 확인될 때까지 여유를 둔다. + let deadline = Instant::now() + VERIFY_RETRY_WINDOW; + let mut verified = probe_port(&rediscovered.port).map_err(InstallFailure::Verify)?; + while verified != HandshakeClassification::Installed + && Instant::now() < deadline + && !cancelled.load(Ordering::Acquire) + { + thread::sleep(VERIFY_RETRY_INTERVAL); + match probe_port(&rediscovered.port) { + Ok(classification) => verified = classification, + Err(_) => continue, + } + } + if verified == HandshakeClassification::Installed { + Ok(()) + } else { + Err(InstallFailure::Verify( + "업로드는 끝났지만 전용 펌웨어 확인에 실패했습니다.".to_owned(), + )) + } +} + +#[tauri::command(rename_all = "camelCase")] +#[allow(non_snake_case)] +pub fn begin_firmware_install( + app: AppHandle, + deviceId: String, + installer: State<'_, FirmwareInstaller>, + coordinator: State<'_, ArduinoCoordinator>, +) -> Result<(), String> { + let device_id = deviceId; + let candidate = installer.begin(&device_id).inspect_err(|error| { + log_firmware(&format!( + "[firmware] install rejected for {device_id}: {error}" + )); + })?; + let installer = installer.inner().clone(); + let coordinator = coordinator.inner().clone(); + thread::spawn(move || { + emit( + &app, + &FirmwareState::Preparing { + device_id: device_id.clone(), + }, + ); + let ownership = coordinator.acquire_installer(); + let result = match (candidate, ownership) { + (candidate, Ok(ownership)) => { + let result = install(&app, &device_id, &candidate, &installer.cancelled); + let exit = match result { + Ok(()) => InstallerExit::Success, + Err(InstallFailure::Cancelled) => InstallerExit::Cancelled, + Err(_) => InstallerExit::Failure, + }; + ownership.finish(exit); + result + } + _ => Err(InstallFailure::Upload( + "Arduino 포트를 사용할 수 없습니다.".to_owned(), + )), + }; + if let Err(error) = &result { + log_firmware(&format!( + "[firmware] install failed for {device_id}: {error:?}" + )); + } + + let state = match result { + Ok(()) => FirmwareState::Complete { device_id }, + Err(InstallFailure::Cancelled) => FirmwareState::Cancelled, + Err(InstallFailure::Download(detail)) => FirmwareState::Error { + code: "downloadFailed", + retryable: true, + detail: Some(detail), + }, + Err(InstallFailure::Upload(detail)) => FirmwareState::Error { + code: "uploadFailed", + retryable: true, + detail: Some(detail), + }, + Err(InstallFailure::Verify(detail)) => FirmwareState::Error { + code: "verifyFailed", + retryable: true, + detail: Some(detail), + }, + }; + installer.finish(); + emit(&app, &state); + }); + Ok(()) +} + +#[tauri::command] +pub fn cancel_firmware_install( + app: AppHandle, + installer: State<'_, FirmwareInstaller>, +) -> Result<(), String> { + if !installer.cancel() { + emit(&app, &FirmwareState::Cancelled); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::arduino::{ArduinoOwner, test_support}; + use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo}; + + fn usb_port(vid: u16, pid: u16) -> SerialPortInfo { + SerialPortInfo { + port_name: "/dev/cu.usbmodem1".to_owned(), + port_type: SerialPortType::UsbPort(UsbPortInfo { + vid, + pid, + serial_number: None, + manufacturer: Some("Arduino".to_owned()), + product: Some("Uno".to_owned()), + }), + } + } + + #[test] + fn supported_device_filter_keeps_every_usb_serial_board() { + // VID/PID로 미리 걸러내지 않는다. 등록 여부 판단은 레지스트리가 한다. + let candidate = supported_candidate(usb_port(0x2341, 0x0043)).expect("uno kept"); + assert_eq!(candidate.vid, 0x2341); + assert_eq!(candidate.product.as_deref(), Some("Uno")); + let clone_board = supported_candidate(usb_port(0x1a86, 0x7523)).expect("ch340 kept"); + assert_eq!(clone_board.pid, 0x7523); + assert!( + supported_candidate(SerialPortInfo { + port_name: "ttyS0".to_owned(), + port_type: SerialPortType::Unknown, + }) + .is_none(), + "non-USB ports are not candidates" + ); + } + + #[test] + fn serial_path_filter_is_macos_only() { + assert!(serial_path_supported("/dev/cu.usbmodem1", true)); + assert!(!serial_path_supported("/dev/tty.usbmodem1", true)); + assert!(serial_path_supported("/dev/ttyACM0", false)); + assert!(serial_path_supported("/dev/ttyUSB0", false)); + assert!(serial_path_supported("COM4", false)); + } + + #[test] + fn generic_cdc_product_falls_back_to_known_board_name() { + // macOS는 부팅 직후 product를 "Generic CDC"로 주기도 한다(ioreg 확인). + let mut port = usb_port(0x2341, 0x0043); + if let SerialPortType::UsbPort(ref mut usb) = port.port_type { + usb.product = Some("Generic CDC".to_owned()); + } + let candidate = supported_candidate(port).expect("uno kept"); + assert_eq!(candidate.display_name, "Arduino Uno R3"); + assert_eq!( + candidate.product, None, + "generic descriptors do not demote VID/PID matching" + ); + + // product가 아예 없어도 VID/PID로 되찾는다. + let mut port = usb_port(0x2341, 0x0043); + if let SerialPortType::UsbPort(ref mut usb) = port.port_type { + usb.product = None; + usb.manufacturer = None; + } + let candidate = supported_candidate(port).unwrap(); + assert_eq!(candidate.display_name, "Arduino Uno R3"); + + // 등록 안 된 VID/PID + product 없음은 여전히 알 수 없음으로 남는다. + let mut port = usb_port(0x1a86, 0x7523); + if let SerialPortType::UsbPort(ref mut usb) = port.port_type { + usb.product = None; + usb.manufacturer = None; + } + let candidate = supported_candidate(port).unwrap(); + assert_eq!(candidate.display_name, "알 수 없는 시리얼 보드"); + } + + #[test] + fn candidate_device_id_prefers_serial_number() { + let mut port = usb_port(0x2341, 0x0043); + let candidate = supported_candidate(port.clone()).unwrap(); + assert!(candidate.device_id.starts_with("usb-2341-0043-")); + if let SerialPortType::UsbPort(ref mut usb) = port.port_type { + usb.serial_number = Some("A123".to_owned()); + } + let candidate = supported_candidate(port).unwrap(); + assert_eq!(candidate.device_id, "usb-2341-0043-A123"); + } + + #[test] + fn handshake_classification_and_installer_seam_restore_connection() { + assert_eq!( + classify_response(HANDSHAKE_RESPONSE), + HandshakeClassification::Installed + ); + assert_eq!(classify_response(b""), HandshakeClassification::NoResponse); + assert_eq!( + classify_response(b"OTHER\n"), + HandshakeClassification::DifferentFirmware + ); + let busy = probe_port("/dev/does-not-exist-hanbeon"); + assert!( + busy.is_err(), + "missing port must not look like a blank sketch" + ); + let coordinator = ArduinoCoordinator::new(test_support::coordinator_probe_silent); + { + let _ownership = coordinator.acquire_installer().unwrap(); + assert_eq!(coordinator.owner(), ArduinoOwner::Installer); + } + assert_eq!(coordinator.owner(), ArduinoOwner::Connection); + } + + #[test] + fn bootloader_reset_matches_avrdude_dtr_rts_sequence() { + #[derive(Default)] + struct FakeLines(Vec<(&'static str, bool)>); + impl ResetLines for FakeLines { + fn set_dtr(&mut self, level: bool) -> Result<(), String> { + self.0.push(("dtr", level)); + Ok(()) + } + + fn set_rts(&mut self, level: bool) -> Result<(), String> { + self.0.push(("rts", level)); + Ok(()) + } + } + + let mut lines = FakeLines::default(); + let mut delays = Vec::new(); + pulse_bootloader_reset(&mut lines, |duration| delays.push(duration)).unwrap(); + assert_eq!( + lines.0, + vec![ + ("dtr", false), + ("rts", false), + ("dtr", true), + ("rts", true), + ("dtr", false), + ("rts", false), + ] + ); + assert_eq!( + delays, + vec![ + Duration::from_millis(250), + Duration::from_micros(100), + Duration::from_millis(100), + ] + ); + } + + #[test] + fn firmware_state_serializes_device_id_as_camel_case() { + let json = serde_json::to_value(FirmwareState::ConfirmationRequired { + device_id: "candidate-1".to_owned(), + reason: ConfirmationReason::NoResponse, + display_name: "Arduino Uno".to_owned(), + }) + .expect("serialize confirmation"); + assert_eq!(json["state"], "confirmationRequired"); + assert_eq!(json["deviceId"], "candidate-1"); + assert!(json.get("device_id").is_none()); + } +} diff --git a/apps/desktop/src-tauri/src/flasher.rs b/apps/desktop/src-tauri/src/flasher.rs new file mode 100644 index 0000000..4f15a47 --- /dev/null +++ b/apps/desktop/src-tauri/src/flasher.rs @@ -0,0 +1,669 @@ +//! In-app firmware flashing for the Arduino Uno R3 (optiboot bootloader). +//! +//! Replaces the bundled `arduino-cli` process with a direct implementation of +//! the two pieces the CLI provided: +//! - Intel HEX decoding (`ihex.rs` semantics, records type 00/01/04) +//! - The STK500v1 protocol optiboot speaks on the Uno's 16u2 at 115200 baud +//! +//! All sequencing is pure and unit-tested against a scripted fake serial port; +//! only the thin `SerialIo` impl touches real hardware. + +use std::collections::BTreeMap; +use std::io::{self, Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Decoded flash image ready to hand to the STK500v1 uploader. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FlashImage { + /// Sparse page map keyed by word address (STK500 uses word addressing). + pages: Vec<(u16, Vec)>, +} + +impl FlashImage { + pub fn from_ihex(hex_text: &str) -> Result { + let mut bytes: Vec<(u32, u8)> = Vec::new(); + let mut upper = 0_u32; + + for (line_number, raw) in hex_text.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let record = parse_ihex_record(line) + .map_err(|error| format!("{line_number}번째 줄: {error}"))?; + match record.record_type { + 0x00 => { + let base = upper.checked_add(u32::from(record.offset)).ok_or_else(|| { + format!("{line_number}번째 줄: 주소가 범위를 벗어났습니다") + })?; + for (index, byte) in record.data.iter().enumerate() { + let address = base.checked_add(index as u32).ok_or_else(|| { + format!("{line_number}번째 줄: 주소가 범위를 벗어났습니다") + })?; + bytes.push((address, *byte)); + } + } + 0x01 => break, + 0x04 => { + if record.data.len() != 2 { + return Err(format!("{line_number}번째 줄: 잘못된 확장 주소")); + } + upper = (u32::from(record.data[0]) << 24) | (u32::from(record.data[1]) << 16); + } + 0x02 => { + if record.data.len() != 2 { + return Err(format!("{line_number}번째 줄: 잘못된 세그먼트 주소")); + } + upper = ((u32::from(record.data[0]) << 8) | u32::from(record.data[1])) << 4; + } + 0x05 => {} + other => { + return Err(format!( + "{line_number}번째 줄: 지원하지 않는 레코드 타입 {other:#x}" + )); + } + } + } + + if bytes.is_empty() { + return Err("펌웨어에 데이터가 없습니다".to_owned()); + } + + bytes.sort_by_key(|(address, _)| *address); + let mut image = Self::default(); + let mut start = 0_usize; + while start < bytes.len() { + let chunk_end = bytes[start..] + .windows(2) + .take_while(|window| window[0].0 + 1 == window[1].0) + .count() + + 1; + let chunk = &bytes[start..start + chunk_end]; + let byte_address = chunk[0].0; + // STK500 addresses are in 16-bit words. + if byte_address % 2 == 1 { + return Err("홀수 주소에서 시작하는 데이터가 있습니다".to_owned()); + } + let mut payload = Vec::with_capacity(chunk.len()); + for (_, byte) in chunk { + payload.push(*byte); + } + image.pages.push(( + u16::try_from(byte_address / 2).map_err(|_| "주소가 범위를 벗어났습니다")?, + payload, + )); + start += chunk_end; + } + Ok(image) + } + + pub fn pages(&self) -> &[(u16, Vec)] { + &self.pages + } + + pub fn total_bytes(&self) -> usize { + self.pages.iter().map(|(_, data)| data.len()).sum() + } + + pub fn highest_byte_address(&self) -> Option { + self.pages + .iter() + .filter(|(_, data)| !data.is_empty()) + .map(|(word_address, data)| u32::from(*word_address) * 2 + data.len() as u32 - 1) + .max() + } + + pub fn fits_within(&self, byte_limit: u32) -> bool { + self.highest_byte_address() + .is_some_and(|address| address < byte_limit) + } + + fn physical_pages(&self, page_size: usize) -> Result, usize)>, FlashError> { + if page_size == 0 || !page_size.is_multiple_of(2) { + return Err(FlashError::Protocol( + "플래시 페이지 크기가 올바르지 않습니다".to_owned(), + )); + } + let page_size_u32 = u32::try_from(page_size) + .map_err(|_| FlashError::Protocol("플래시 페이지가 너무 큽니다".to_owned()))?; + let mut pages: BTreeMap>, usize)> = BTreeMap::new(); + for (word_address, data) in &self.pages { + let start = u32::from(*word_address) * 2; + for (offset, byte) in data.iter().copied().enumerate() { + let address = start.checked_add(offset as u32).ok_or_else(|| { + FlashError::Protocol("펌웨어 주소가 범위를 벗어났습니다".to_owned()) + })?; + let page_start = address / page_size_u32 * page_size_u32; + let (page, source_bytes) = pages + .entry(page_start) + .or_insert_with(|| (vec![None; page_size], 0)); + let slot = &mut page[(address - page_start) as usize]; + if slot.replace(byte).is_some() { + return Err(FlashError::Protocol("펌웨어 주소가 중복됩니다".to_owned())); + } + *source_bytes += 1; + } + } + + pages + .into_iter() + .map(|(page_start, (page, source_bytes))| { + let word_address = u16::try_from(page_start / 2).map_err(|_| { + FlashError::Protocol("펌웨어 주소가 범위를 벗어났습니다".to_owned()) + })?; + Ok(( + word_address, + page.into_iter().map(|byte| byte.unwrap_or(0xFF)).collect(), + source_bytes, + )) + }) + .collect() + } +} + +struct IhexRecord { + record_type: u8, + offset: u16, + #[allow(dead_code)] + checksum_valid: bool, + data: Vec, +} + +fn parse_ihex_record(line: &str) -> Result { + let invalid = || "올바른 Intel HEX 레코드가 아닙니다".to_owned(); + if !line.starts_with(':') { + return Err(invalid()); + } + let body = &line[1..]; + if body.len() < 10 || !body.len().is_multiple_of(2) { + return Err(invalid()); + } + let decode_nibble = |c: char| c.to_digit(16).map(|d| d as u16).ok_or_else(invalid); + let decode_pair = |pair: &str| -> Result { + Ok( + decode_nibble(pair.chars().next().ok_or_else(invalid)?)? * 16 + + decode_nibble(pair.chars().nth(1).ok_or_else(invalid)?)?, + ) + }; + + let count = decode_pair(&body[0..2])? as usize; + if body.len() < 10 + count * 2 { + return Err(invalid()); + } + let offset = (decode_pair(&body[2..4])? << 8) | decode_pair(&body[4..6])?; + let record_type = decode_pair(&body[6..8])? as u8; + let mut data = Vec::with_capacity(count); + for index in 0..count { + let start = 8 + index * 2; + data.push(decode_pair(&body[start..start + 2])? as u8); + } + let expected_checksum_index = 8 + count * 2; + let stored = decode_pair(&body[expected_checksum_index..expected_checksum_index + 2])? as u8; + // 모든 덧셈은 wrapping. 헤더 합도 255를 넘을 수 있다(고주소 레코드). + let mut sum = (count as u8) + .wrapping_add((offset >> 8) as u8) + .wrapping_add(offset as u8) + .wrapping_add(record_type); + for byte in &data { + sum = sum.wrapping_add(*byte); + } + let checksum_valid = sum.wrapping_add(stored) == 0; + if !checksum_valid { + return Err("체크섬이 일치하지 않습니다".to_owned()); + } + Ok(IhexRecord { + record_type, + offset, + checksum_valid, + data, + }) +} + +/// Byte-stream serial connection. Mirrors the subset of `Read + Write` +/// semantics used here so sessions can be tested offline; timeouts surface as +/// `TimedOut`/`WouldBlock` errors and are treated as "nothing arrived". +pub trait SerialIo: Read + Write {} + +impl SerialIo for T {} + +/// Errors surfaced to the installer state machine. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FlashError { + Sync, + Protocol(String), + Io(String), + Cancelled, +} + +impl std::fmt::Display for FlashError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sync => write!( + f, + "부트로더와 동기화하지 못했습니다. 보드의 리셋 버튼을 누른 뒤 다시 시도해 주세요." + ), + Self::Protocol(detail) => write!(f, "부트로더 응답이 잘못되었습니다: {detail}"), + Self::Io(detail) => write!(f, "시리얼 통신 오류: {detail}"), + Self::Cancelled => write!(f, "설치가 취소되었습니다"), + } + } +} + +const STK_GET_SYNC: u8 = 0x30; +const STK_ENTER_PROGMODE: u8 = 0x50; +const STK_LEAVE_PROGMODE: u8 = 0x51; +pub const STK_OK: u8 = 0x10; +const STK_INSYNC: u8 = 0x14; +const STK_CRC_EOP: u8 = 0x20; +const STK_CHIP_ERASE: u8 = 0x5D; +const STK_LOAD_ADDRESS: u8 = 0x55; +const STK_PROGRAM_PAGE: u8 = 0x64; +const STK_READ_SIGN: u8 = 0x75; +/// ATmega328P signature bytes, LSB-first as optiboot returns them. +const EXPECTED_SIGNATURE: [u8; 3] = [0x1E, 0x95, 0x0F]; + +fn frame(command: u8, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(payload.len() + 3); + out.push(command); + out.extend_from_slice(payload); + out.push(STK_CRC_EOP); + out +} + +const COMMAND_TIMEOUT: Duration = Duration::from_millis(500); +const SYNC_TIMEOUT: Duration = Duration::from_millis(250); + +fn read_response( + port: &mut dyn SerialIo, + expect_body: usize, + timeout: Duration, +) -> Result, FlashError> { + let deadline = Instant::now() + timeout; + let mut buffer = Vec::with_capacity(expect_body + 2); + let mut chunk = [0_u8; 64]; + while Instant::now() < deadline { + match port.read(&mut chunk) { + Ok(0) => return Err(FlashError::Io("포트가 닫혔습니다".to_owned())), + Ok(size) => buffer.extend_from_slice(&chunk[..size]), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + continue; + } + Err(error) => return Err(FlashError::Io(error.to_string())), + } + if buffer.len() >= expect_body + 2 { + break; + } + } + if buffer.first() != Some(&STK_INSYNC) { + return Err(FlashError::Protocol("INSYNC 없음".to_owned())); + } + let tail = buffer.last().copied().unwrap_or(0); + if tail != STK_OK { + return Err(FlashError::Protocol(format!("STK_OK 대신 {tail:#x}"))); + } + Ok(buffer[1..buffer.len().saturating_sub(1)].to_vec()) +} + +fn command( + port: &mut dyn SerialIo, + command_byte: u8, + payload: &[u8], + expect_body: usize, + cancelled: &AtomicBool, +) -> Result, FlashError> { + if cancelled.load(Ordering::Acquire) { + return Err(FlashError::Cancelled); + } + port.write_all(&frame(command_byte, payload)) + .and_then(|()| port.flush()) + .map_err(|e| FlashError::Io(e.to_string()))?; + read_response(port, expect_body, COMMAND_TIMEOUT) +} + +fn drain_input(port: &mut dyn SerialIo, quiet_for: Duration) { + let deadline = Instant::now() + quiet_for; + let mut scratch = [0_u8; 128]; + while Instant::now() < deadline { + // 버리기 전용 읽기라 바이트 수는 중요하지 않다. + let _ = port.read(&mut scratch); + } +} + +/// Sends GET_SYNC until the bootloader answers INSYNC/OK. +/// +/// Optiboot listens for a moment after reset, then starts the sketch. Opening +/// the port toggles DTR and resets the board, so retrying across that boot +/// window is how arduino-cli's "double-tap" behavior is reproduced. +pub fn synchronize( + port: &mut dyn SerialIo, + attempts: usize, + cancelled: &AtomicBool, +) -> Result<(), FlashError> { + for attempt in 0..attempts { + if cancelled.load(Ordering::Acquire) { + return Err(FlashError::Cancelled); + } + let _ = port.write_all(&frame(STK_GET_SYNC, &[])); + let _ = port.flush(); + if read_response(port, 0, SYNC_TIMEOUT).is_ok() { + return Ok(()); + } + // Bootloader may have timed out and jumped into the sketch; toggling + // DTR by reopening happens one layer up. Here we just wait out the + // bootloader restart window before trying again. + let _ = attempt; + thread::sleep(Duration::from_millis(80)); + drain_input(port, Duration::from_millis(20)); + } + Err(FlashError::Sync) +} + +/// Full optiboot programming session: enter, verify target, erase, write, +/// leave. Page writes use 128-byte pages (ATmega328P word-page 64 words). +pub fn program( + port: &mut dyn SerialIo, + image: &FlashImage, + page_size: usize, + cancelled: &AtomicBool, + progress: &mut dyn FnMut(usize), +) -> Result<(), FlashError> { + let pages = image.physical_pages(page_size)?; + command(port, STK_ENTER_PROGMODE, &[], 0, cancelled)?; + let signature = command(port, STK_READ_SIGN, &[], 3, cancelled)?; + if signature != EXPECTED_SIGNATURE { + return Err(FlashError::Protocol(format!( + "서명 불일치: {signature:02x?}" + ))); + } + command(port, STK_CHIP_ERASE, &[], 0, cancelled)?; + + let total = image.total_bytes(); + let mut written = 0usize; + for (word_address, page, source_bytes) in pages { + // Optiboot는 PROGRAM_PAGE마다 물리 페이지 전체를 지운다. 따라서 HEX의 + // sparse record를 먼저 정렬·병합하고, 정렬된 페이지를 정확히 한 번 쓴다. + command( + port, + STK_LOAD_ADDRESS, + &[(word_address & 0xFF) as u8, (word_address >> 8) as u8], + 0, + cancelled, + )?; + // 페이지 길이는 [상위, 하위]; SPM_PAGESIZE≤255 기기에서 상위는 + // 버려지고 하위가 길이가 된다(optiboot GETLENGTH). + let mut request = vec![(page.len() >> 8) as u8, page.len() as u8]; + request.extend_from_slice(b"F"); + request.extend_from_slice(&page); + command(port, STK_PROGRAM_PAGE, &request, 0, cancelled)?; + written += source_bytes; + progress(written.min(total)); + } + + command(port, STK_LEAVE_PROGMODE, &[], 0, cancelled)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::sync::mpsc; + + /// Scripted fake port: asserts every write matches an expectation and + /// produces queued responses, like a mock server for the bootloader. + struct FakePort { + expected_writes: VecDeque>, + responses: VecDeque>, + reads: VecDeque, + log: mpsc::Sender, + } + + impl FakePort { + fn new(log: mpsc::Sender) -> Self { + Self { + expected_writes: VecDeque::new(), + responses: VecDeque::new(), + reads: VecDeque::new(), + log, + } + } + + fn expect(mut self, bytes: &[u8], response: &[u8]) -> Self { + self.expected_writes.push_back(bytes.to_vec()); + self.responses.push_back(response.to_vec()); + self + } + } + + impl Write for FakePort { + fn write(&mut self, buf: &[u8]) -> io::Result { + let expected = self.expected_writes.pop_front().expect("unexpected write"); + assert_eq!(buf, expected.as_slice(), "frame mismatch"); + self.log.send(format!("W:{buf:02x?}")).unwrap(); + if let Some(response) = self.responses.pop_front() { + self.reads.extend(response); + } + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl Read for FakePort { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.reads.is_empty() { + return Err(io::Error::new(io::ErrorKind::WouldBlock, "empty")); + } + let mut count = 0; + while count < buf.len() + && let Some(byte) = self.reads.pop_front() + { + buf[count] = byte; + count += 1; + } + Ok(count) + } + } + + const OK_RESPONSE: [u8; 2] = [STK_INSYNC, STK_OK]; + const SIGNATURE_RESPONSE: [u8; 5] = [STK_INSYNC, 0x1E, 0x95, 0x0F, STK_OK]; + + #[test] + fn ihex_parses_data_eof_and_extended_addresses() { + let hex = ":100000000C9434000C9451000C9451000C94510049\n:100010000C9451000C9451000C9451000C9451001C\n:020000020001FB\n:00000001FF\n"; + let image = FlashImage::from_ihex(hex).expect("parse"); + assert_eq!(image.pages().len(), 1, "contiguous lines merge"); + assert_eq!(image.total_bytes(), 32); + + // Extended segment 0x0001 shifts data to byte address 0x10. + let hex_far = ":020000020001FB\n:100000000C9434000C9451000C9451000C94510049\n:00000001FF\n"; + let far = FlashImage::from_ihex(hex_far).unwrap(); + let (address, data) = far.pages()[0].clone(); + assert_eq!(address, 0x0008, "word address = segment*16/2"); + assert_eq!(data.len(), 16); + } + + #[test] + fn ihex_rejects_corrupt_records() { + assert!(FlashImage::from_ihex("no colon\n").is_err()); + assert!(FlashImage::from_ihex(":00\n").is_err()); + // Bad checksum (last byte should be FF for EOF). + assert!(FlashImage::from_ihex(":00000001FE\n").is_err()); + assert!(FlashImage::from_ihex("").is_err(), "empty file"); + } + + #[test] + fn frames_match_stk500_wire_format() { + assert_eq!(frame(STK_GET_SYNC, &[]), vec![0x30, STK_CRC_EOP]); + let address_frame = frame(STK_LOAD_ADDRESS, &[0x00, 0x40]); + assert_eq!(address_frame, vec![0x55, 0x00, 0x40, STK_CRC_EOP]); + let page = frame(STK_PROGRAM_PAGE, &[0x00, 0x80, b'F', 0xAA]); + assert_eq!(page, vec![0x64, 0x00, 0x80, b'F', 0xAA, STK_CRC_EOP]); + } + + #[test] + fn multi_chunk_page_advances_stk_address_per_chunk() { + // 256바이트 연속 이미지: 128바이트 청크 2개. 두 번째 청크의 LOAD_ADDRESS는 + // word 주소가 64(128/2) 증가한 0x0040이어야 한다. 고정 주소를 반복 쓰면 + // 앞부분이 덮어써진다(실기기에서 펌웨어가 깨진 원인). + let cancelled = AtomicBool::new(false); + let (log_tx, _log_rx) = mpsc::channel(); + let data: Vec = (0..256).map(|i| i as u8).collect(); + // ihex 한 레코드는 최대 255바이트라 128바이트 2레코드로 구성한다. + let record = ":80000000000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7FC0\n:80008000808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF40\n:00000001FF\n"; + let image = FlashImage::from_ihex(record).unwrap(); + assert_eq!(image.total_bytes(), 256); + + let expected_chunks: Vec<(u8, u8)> = vec![(0x00, 0x00), (0x40, 0x00)]; + let mut page_frames: Vec> = Vec::new(); + for (low, high) in &expected_chunks { + let start = ((*high as usize) << 9) | ((*low as usize) << 1); + let mut frame = vec![STK_PROGRAM_PAGE, 0x00, 0x80, b'F']; + frame.extend_from_slice(&data[start..start + 128]); + frame.push(STK_CRC_EOP); + page_frames.push(frame); + } + + let mut port = FakePort::new(log_tx) + .expect(&[STK_ENTER_PROGMODE, STK_CRC_EOP], &OK_RESPONSE) + .expect(&[STK_READ_SIGN, STK_CRC_EOP], &SIGNATURE_RESPONSE) + .expect(&[STK_CHIP_ERASE, STK_CRC_EOP], &OK_RESPONSE) + .expect( + &[ + STK_LOAD_ADDRESS, + expected_chunks[0].0, + expected_chunks[0].1, + STK_CRC_EOP, + ], + &OK_RESPONSE, + ) + .expect(&page_frames[0], &OK_RESPONSE) + .expect( + &[ + STK_LOAD_ADDRESS, + expected_chunks[1].0, + expected_chunks[1].1, + STK_CRC_EOP, + ], + &OK_RESPONSE, + ) + .expect(&page_frames[1], &OK_RESPONSE) + .expect(&[STK_LEAVE_PROGMODE, STK_CRC_EOP], &OK_RESPONSE); + + let result = program(&mut port, &image, 128, &cancelled, &mut |_| {}); + assert_eq!(result, Ok(())); + } + + #[test] + fn full_program_session_matches_expected_frames() { + let cancelled = AtomicBool::new(false); + let (log_tx, _log_rx) = mpsc::channel(); + let image = FlashImage::from_ihex(":0400000012345678E8\n:00000001FF\n").unwrap(); + let mut program_frame = vec![STK_PROGRAM_PAGE, 0x00, 0x80, b'F', 0x12, 0x34, 0x56, 0x78]; + program_frame.resize(4 + 128, 0xFF); + program_frame.push(STK_CRC_EOP); + + let mut port = FakePort::new(log_tx) + .expect(&[STK_ENTER_PROGMODE, STK_CRC_EOP], &OK_RESPONSE) + .expect(&[STK_READ_SIGN, STK_CRC_EOP], &SIGNATURE_RESPONSE) + .expect(&[STK_CHIP_ERASE, STK_CRC_EOP], &OK_RESPONSE) + .expect(&[STK_LOAD_ADDRESS, 0x00, 0x00, STK_CRC_EOP], &OK_RESPONSE) + .expect(&program_frame, &OK_RESPONSE) + .expect(&[STK_LEAVE_PROGMODE, STK_CRC_EOP], &OK_RESPONSE); + + let mut progress_calls = Vec::new(); + let result = program(&mut port, &image, 128, &cancelled, &mut |written| { + progress_calls.push(written) + }); + assert_eq!(result, Ok(())); + assert_eq!(progress_calls, vec![4], "progress reported once per page"); + } + + #[test] + fn wrong_signature_is_a_protocol_error() { + let cancelled = AtomicBool::new(false); + let (log_tx, _log_rx) = mpsc::channel(); + let image = FlashImage::from_ihex(":0400000012345678E8\n:00000001FF\n").unwrap(); + let bad_signature = vec![STK_INSYNC, 0x1E, 0x95, 0x11, STK_OK]; + let mut port = FakePort::new(log_tx) + .expect(&[STK_ENTER_PROGMODE, STK_CRC_EOP], &OK_RESPONSE) + .expect(&[STK_READ_SIGN, STK_CRC_EOP], &bad_signature); + + let result = program(&mut port, &image, 128, &cancelled, &mut |_| {}); + assert!(matches!(result, Err(FlashError::Protocol(message)) if message.contains("서명"))); + assert!( + port.expected_writes.is_empty(), + "signature mismatch must stop before erase" + ); + } + + #[test] + fn ihex_preserves_extended_linear_addresses_and_rejects_uno_overflow() { + let far = FlashImage::from_ihex(":020000040001F9\n:020000001234B8\n:00000001FF\n") + .expect("valid extended-linear-address file"); + assert_eq!(far.highest_byte_address(), Some(0x1_0001)); + assert!(!far.fits_within(32_256)); + } + + #[test] + fn sparse_records_in_one_flash_page_are_assembled_once() { + let image = + FlashImage::from_ihex(":020000001234B8\n:02001000567820\n:00000001FF\n").unwrap(); + let pages = image.physical_pages(128).unwrap(); + assert_eq!(pages.len(), 1); + assert_eq!(pages[0].0, 0, "aligned STK word address"); + assert_eq!(pages[0].1.len(), 128); + assert_eq!(&pages[0].1[0..2], &[0x12, 0x34]); + assert!(pages[0].1[2..16].iter().all(|byte| *byte == 0xFF)); + assert_eq!(&pages[0].1[16..18], &[0x56, 0x78]); + assert_eq!(pages[0].2, 4, "progress counts source bytes, not padding"); + } + + #[test] + fn record_crossing_page_boundary_is_split_into_aligned_pages() { + let image = FlashImage::from_ihex(":04007E00123456786A\n:00000001FF\n").unwrap(); + let pages = image.physical_pages(128).unwrap(); + assert_eq!(pages.len(), 2); + assert_eq!(pages[0].0, 0); + assert_eq!(&pages[0].1[126..128], &[0x12, 0x34]); + assert_eq!(pages[1].0, 64, "byte 128 is STK word address 64"); + assert_eq!(&pages[1].1[0..2], &[0x56, 0x78]); + assert_eq!((pages[0].2, pages[1].2), (2, 2)); + } + + #[test] + fn cancellation_stops_before_any_write() { + let cancelled = AtomicBool::new(true); + let (log_tx, _log_rx) = mpsc::channel(); + let image = FlashImage::from_ihex(":0400000012345678E8\n:00000001FF\n").unwrap(); + let mut port = FakePort::new(log_tx); + let result = program(&mut port, &image, 128, &cancelled, &mut |_| {}); + assert_eq!(result, Err(FlashError::Cancelled)); + } + + #[test] + fn missing_insync_reports_protocol_error() { + let cancelled = AtomicBool::new(false); + let (log_tx, log_rx) = mpsc::channel(); + let mut port = FakePort::new(log_tx); + port.expected_writes.push_back(frame(STK_GET_SYNC, &[])); + port.responses.push_back(vec![]); + drop(std::thread::spawn(move || { + while let Ok(line) = log_rx.recv_timeout(Duration::from_millis(100)) { + let _ = line; + } + })); + // No response queued: read_response times out without INSYNC. + let result = synchronize(&mut port, 1, &cancelled); + assert_eq!(result, Err(FlashError::Sync)); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 83e0e0c..63b4aac 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -11,6 +11,8 @@ mod app_registry; pub mod arduino; mod audio; mod emit; +mod firmware; +pub mod flasher; pub mod focused_application; mod foreground; mod input; @@ -19,6 +21,7 @@ mod led; mod occlusion; mod preset; mod profile; +pub mod registry; mod scan; mod shortcut; mod tray; @@ -126,7 +129,10 @@ fn open_settings(app: AppHandle) -> Result<(), String> { #[tauri::command] fn close_settings(app: AppHandle) -> Result<(), String> { - window::hide_settings(&app) + window::hide_settings(&app)?; + // 설정(또는 온보딩)이 닫히면 스캔 오버레이가 곧바로 보여야 한다. + // 설치 모드에서 숨겨진 floating도 이 호출로 되살아난다. + window::show_floating(&app) } pub fn run() { @@ -146,6 +152,7 @@ pub fn run() { profile.max_interval_ms = profile.max_interval_ms.max(interval_ms); profile.sanitize(); } + let needs_onboarding = !profile.onboarded; // 창 배치는 프로필을 읽은 다음이어야 한다. 사용자가 옮겨 둔 위치를 // 모른 채 먼저 띄우면 기본 위치에서 한 번 튄 뒤에 제자리를 찾는다. @@ -213,32 +220,48 @@ pub fn run() { // with the HID/F13 fallback below; Accessibility is used only later // when Scanner::handle injects into another app. let native_app = app.handle().clone(); - let lifecycle_app = app.handle().clone(); let native_detector = Arc::clone(&detector); let native_scanner = scanner.clone(); - let native_switch = arduino::ArduinoSwitch::spawn( - arduino::ReconnectPolicy::default(), - move |event| { - if std::env::var("HANBEON_LOG").is_ok() { - eprintln!("[arduino] lifecycle: {event:?}"); - } - if let Err(error) = lifecycle_app.emit(arduino::EVENT_LIFECYCLE, event) { - eprintln!("Arduino lifecycle event를 보내지 못했습니다. {error}"); - } - }, - move |event| { - arduino::route_switch_event( - &native_detector, - event, - Instant::now(), - |judgement| { - input::announce(&native_app, judgement); - native_scanner.handle(&native_app, judgement); - }, - ); - }, - ); + let spawn_switch = move || { + let lifecycle_app = native_app.clone(); + let switch_app = native_app.clone(); + let switch_detector = Arc::clone(&native_detector); + let switch_scanner = native_scanner.clone(); + arduino::ArduinoSwitch::spawn( + arduino::ReconnectPolicy::default(), + move |event| { + if std::env::var("HANBEON_LOG").is_ok() { + eprintln!("[arduino] lifecycle: {event:?}"); + } + if let Err(error) = + lifecycle_app.emit(arduino::EVENT_LIFECYCLE, event) + { + eprintln!("Arduino lifecycle event를 보내지 못했습니다. {error}"); + } + }, + move |event| { + arduino::route_switch_event( + &switch_detector, + event, + Instant::now(), + |judgement| { + input::announce(&switch_app, judgement); + switch_scanner.handle(&switch_app, judgement); + }, + ); + }, + ) + }; + // 새 보드는 아직 Hana 펌웨어가 없어 handshake에 답할 수 없다. 최초 + // 온보딩 동안에는 포트를 열지 않고 설치기가 명시적으로 시작될 때까지 + // 소유권을 보류한다. 설치가 끝나면 coordinator가 연결 worker를 시작한다. + let native_switch = if needs_onboarding { + arduino::ArduinoCoordinator::for_installer(spawn_switch) + } else { + arduino::ArduinoCoordinator::new(spawn_switch) + }; app.manage(native_switch); + app.manage(firmware::FirmwareInstaller::default()); let registered = input::register( app.handle(), @@ -288,7 +311,11 @@ pub fn run() { save_profile, open_settings, close_settings, - log_directory + log_directory, + firmware::list_arduino_candidates, + firmware::probe_arduino_firmware, + firmware::begin_firmware_install, + firmware::cancel_firmware_install ]) .build(tauri::generate_context!()) .expect("한번 앱을 시작하지 못했습니다"); diff --git a/apps/desktop/src-tauri/src/registry.rs b/apps/desktop/src-tauri/src/registry.rs new file mode 100644 index 0000000..88aed58 --- /dev/null +++ b/apps/desktop/src-tauri/src/registry.rs @@ -0,0 +1,889 @@ +//! Hana Cloud (https://github.com/dev-five-git/hana-cloud) board registry client. +//! +//! Downloads and verifies the root index, board manifests, and firmware files. +//! The contract from the registry README is binding: +//! - HTTPS to `raw.githubusercontent.com/dev-five-git/hana-cloud` only; a +//! redirect that changes the host is refused. +//! - Size caps: index 256KiB, manifest 64KiB, firmware 2MiB. +//! - 5 second timeout per request. +//! - SHA-256 of every file is checked before the file is used or cached. +//! - On network or verification failure the last known-good cache wins. +//! - Firmware download happens only after the user explicitly starts an +//! install; detection alone never triggers network access. + +use std::fmt; +use std::fs; +use std::io::Read; +use std::path::PathBuf; +use std::time::Duration; + +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; + +pub const REGISTRY_BASE: &str = "https://raw.githubusercontent.com/dev-five-git/hana-cloud/main"; +pub const INDEX_MAX_BYTES: usize = 256 * 1024; +pub const MANIFEST_MAX_BYTES: usize = 64 * 1024; +pub const FIRMWARE_MAX_BYTES: usize = 2 * 1024 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const INDEX_CACHE_PATH: &str = "registry.json"; + +/// Normalization contract for human-readable USB descriptor strings only +/// (registry README "식별자와 문자열 정규화"). Never applied to ids, paths, +/// hashes, or VID/PID. +pub fn normalize_descriptor(input: &str) -> String { + let nfkc: String = input.nfkc().collect(); + let folded: String = nfkc + .chars() + .map(|c| { + if c.is_whitespace() || c == '_' || c == '-' { + '-' + } else { + c + } + }) + .collect::() + .to_lowercase(); + + // Collapse runs of `-` produced by folding, then trim edge `-`. + let mut collapsed = String::with_capacity(folded.len()); + let mut previous_dash = false; + for c in folded.chars() { + if c == '-' { + if !previous_dash { + collapsed.push('-'); + } + previous_dash = true; + } else { + collapsed.push(c); + previous_dash = false; + } + } + let trimmed = collapsed.trim_matches('-'); + trimmed.to_owned() +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(digest.len() * 2); + use std::fmt::Write as _; + for byte in digest { + let _ = write!(out, "{byte:02x}"); + } + out +} + +#[derive(Debug, PartialEq)] +pub enum RegistryError { + /// The board's VID/PID has no entry in the registry index. + BoardNotRegistered { vid: u16, pid: u16 }, + /// A registry file failed its SHA-256 check. + HashMismatch { path: String }, + /// Network failure with no usable cache. + Unavailable(String), + /// The downloaded document is structurally invalid. + InvalidDocument(&'static str), +} + +impl fmt::Display for RegistryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BoardNotRegistered { vid, pid } => write!( + f, + "레지스트리에 등록되지 않은 보드입니다 (USB {vid:04x}:{pid:04x})." + ), + Self::HashMismatch { path } => { + write!(f, "레지스트리 파일의 해시가 일치하지 않습니다: {path}") + } + Self::Unavailable(message) => { + write!(f, "레지스트리에 연결할 수 없습니다: {message}") + } + Self::InvalidDocument(detail) => { + write!(f, "레지스트리 응답이 잘못되었습니다: {detail}") + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Confidence { + Ambiguous, + Likely, + Exact, +} + +/// A board candidate resolved from USB identity against the registry index. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UsbMatch { + pub board_id: String, + pub board_name: String, + pub confidence: Confidence, + pub manifest_path: String, + pub manifest_sha256: String, +} + +pub struct UsbIdentity { + pub vid: u16, + pub pid: u16, + pub product: Option, + pub manufacturer: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegistryIndex { + schema_version: u32, + revision: u64, + #[serde(rename = "apps")] + _apps: Vec, + boards: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct IndexBoard { + id: String, + name: String, + manifest: String, + sha256: String, + detect: DetectSpec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DetectSpec { + usb: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum ConfidenceTag { + Exact, + Likely, + Ambiguous, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsbDetectEntry { + vid: String, + pid: String, + confidence: ConfidenceTag, + #[serde(default)] + manufacturer_aliases: Vec, + #[serde(default)] + product_aliases: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BoardManifest { + schema_version: u32, + id: String, + firmware: FirmwareRef, + wiring: Vec, + #[serde(default)] + image: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FirmwareRef { + pub path: String, + pub format: String, + pub size: usize, + pub fqbn: String, + pub sha256: String, + source: SourceRef, + toolchain: ToolchainRef, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SourceRef { + path: String, + sha256: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ToolchainRef { + arduino_cli: String, + platform: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WiringEntry { + from: String, + to: String, + #[serde(default)] + note: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ImageRef { + path: String, + sha256: String, + alt: String, +} + +/// A verified firmware ready for flashing. +pub struct VerifiedFirmware { + pub board_id: String, + pub hex_text: String, + #[allow(dead_code)] + pub fqbn: String, +} + +fn parse_usb_hex(value: &str) -> Option { + let digits = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .unwrap_or(value); + u16::from_str_radix(digits, 16).ok() +} + +fn validate_relative_path(path: &str) -> Result<(), RegistryError> { + let allowed = !path.is_empty() + && !path.contains("..") + && !path.contains('\\') + && !path.contains(':') + && !path.starts_with('/'); + if allowed { + Ok(()) + } else { + Err(RegistryError::InvalidDocument("허용되지 않는 경로")) + } +} + +fn validate_sha_format(hash: &str) -> Result<(), RegistryError> { + let valid = hash.len() == 64 + && hash + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + if valid { + Ok(()) + } else { + Err(RegistryError::InvalidDocument("sha256 형식 오류")) + } +} + +pub(crate) fn parse_index(bytes: &[u8]) -> Result { + let index: RegistryIndex = + serde_json::from_slice(bytes).map_err(|_| RegistryError::InvalidDocument("인덱스 JSON"))?; + if index.schema_version != 1 || index.revision == 0 { + return Err(RegistryError::InvalidDocument("지원하지 않는 인덱스 버전")); + } + let mut ids = std::collections::HashSet::new(); + for board in &index.boards { + if board.id.is_empty() + || board.name.is_empty() + || !ids.insert(board.id.as_str()) + || board.detect.usb.is_empty() + { + return Err(RegistryError::InvalidDocument("잘못된 보드 인덱스")); + } + validate_relative_path(&board.manifest)?; + validate_sha_format(&board.sha256)?; + for usb in &board.detect.usb { + if parse_usb_hex(&usb.vid).is_none() || parse_usb_hex(&usb.pid).is_none() { + return Err(RegistryError::InvalidDocument("잘못된 USB VID/PID")); + } + } + } + Ok(index) +} + +/// Applies the README matching rules: VID/PID decides, provided descriptors can +/// keep the entry's confidence or demote it to ambiguous, never promote. +fn match_board(index: &RegistryIndex, identity: &UsbIdentity) -> Option { + let want_vid = format!("{:04x}", identity.vid); + let want_pid = format!("{:04x}", identity.pid); + let normalized_product = identity.product.as_deref().map(normalize_descriptor); + let normalized_manufacturer = identity.manufacturer.as_deref().map(normalize_descriptor); + + let mut best: Option<(Confidence, usize)> = None; + for (position, board) in index.boards.iter().enumerate() { + for entry in &board.detect.usb { + // VID/PID are compared after u16 round-trip so 0X2A03 and 2a03 + // collapse to the same four-digit lowercase form. + let Some(entry_vid) = parse_usb_hex(&entry.vid) else { + continue; + }; + let Some(entry_pid) = parse_usb_hex(&entry.pid) else { + continue; + }; + if format!("{entry_vid:04x}") != want_vid || format!("{entry_pid:04x}") != want_pid { + continue; + } + let mut confidence = match entry.confidence { + ConfidenceTag::Exact => Confidence::Exact, + ConfidenceTag::Likely => Confidence::Likely, + ConfidenceTag::Ambiguous => Confidence::Ambiguous, + }; + let disagrees = |aliases: &[String], value: &Option| { + !aliases.is_empty() + && value.as_deref().is_some_and(|value| { + !aliases + .iter() + .any(|alias| normalize_descriptor(alias) == value) + }) + }; + if disagrees(&entry.manufacturer_aliases, &normalized_manufacturer) + || disagrees(&entry.product_aliases, &normalized_product) + { + confidence = Confidence::Ambiguous; + } + if best.is_none_or(|(best_confidence, _)| confidence > best_confidence) { + best = Some((confidence, position)); + } + } + } + + let (_, position) = best?; + let board = &index.boards[position]; + validate_relative_path(&board.manifest).ok()?; + validate_sha_format(&board.sha256).ok()?; + Some(UsbMatch { + board_id: board.id.clone(), + board_name: board.name.clone(), + confidence: best.map(|(confidence, _)| confidence)?, + manifest_path: board.manifest.clone(), + manifest_sha256: board.sha256.clone(), + }) +} + +impl RegistryIndex { + pub fn match_board(&self, identity: &UsbIdentity) -> Option { + match_board(self, identity) + } +} + +/// Bytes source so tests never touch the network. Production resolves HTTPS. +type Fetcher = dyn Fn(&str, usize) -> Result, RegistryError> + Send + Sync; + +fn network_fetch(base_url: String) -> Box { + Box::new(move |path: &str, max_bytes: usize| { + let url = format!("{}/{}", base_url.trim_end_matches('/'), path); + let agent: ureq::Agent = ureq::Agent::config_builder() + // 리다이렉트를 아예 따라가지 않는다. 레지스트리 계약상 응답 호스트는 + // 요청한 raw.githubusercontent.com으로 고정이고, 3xx는 실패로 본다. + .timeout_global(Some(REQUEST_TIMEOUT)) + .max_redirects(0) + .build() + .into(); + let response = match agent.get(&url).call() { + Ok(response) => response, + Err(ureq::Error::StatusCode(status)) if (300..400).contains(&status) => { + return Err(RegistryError::Unavailable(format!( + "리다이렉트는 허용되지 않습니다 (HTTP {status})" + ))); + } + Err(ureq::Error::StatusCode(status)) => { + return Err(RegistryError::Unavailable(format!("HTTP {status}"))); + } + Err(error) => return Err(RegistryError::Unavailable(error.to_string())), + }; + + let mut body = Vec::new(); + response + .into_body() + .into_reader() + .take((max_bytes + 1) as u64) + .read_to_end(&mut body) + .map_err(|error| RegistryError::Unavailable(error.to_string()))?; + if body.len() > max_bytes { + return Err(RegistryError::Unavailable(format!( + "응답이 너무 큽니다 ({max_bytes} 바이트 초과)" + ))); + } + Ok(body) + }) +} + +/// Cache layout under the app data dir: +/// `hana-cloud/registry.json`, `/manifest.json`, +/// `/firmware.hex`. +pub struct RegistryClient { + cache_dir: PathBuf, + fetch: Box, +} + +impl RegistryClient { + pub fn new(cache_dir: PathBuf) -> Self { + Self { + cache_dir, + fetch: network_fetch(REGISTRY_BASE.to_owned()), + } + } + + /// GET with the size cap enforced while streaming. + fn fetch_capped(&self, path: &str, max_bytes: usize) -> Result, RegistryError> { + (self.fetch)(path, max_bytes) + } + + /// Returns verified bytes, using the cache when its hash still matches the + /// index and downloading otherwise. Never caches unverified bytes. + fn verified_with_cache( + &self, + path: &str, + expected_sha: &str, + max_bytes: usize, + ) -> Result, RegistryError> { + validate_relative_path(path)?; + validate_sha_format(expected_sha)?; + + if let Some(cached) = self.read_cache(path) + && sha256_hex(&cached) == expected_sha + { + return Ok(cached); + } + + let bytes = self.fetch_capped(path, max_bytes)?; + if sha256_hex(&bytes) != expected_sha { + // Keep any existing older cache; never store unverified data. + return Err(RegistryError::HashMismatch { + path: path.to_owned(), + }); + } + self.write_cache(path, &bytes); + Ok(bytes) + } + + fn read_cache(&self, relative_path: &str) -> Option> { + fs::read(self.cache_dir.join(relative_path)).ok() + } + + fn write_cache(&self, relative_path: &str, bytes: &[u8]) { + let target = self.cache_dir.join(relative_path); + let Some(parent) = target.parent() else { + return; + }; + if fs::create_dir_all(parent).is_err() { + return; + } + // 원자적 교체: 쓰다 끊겨도 캐시가 깨지지 않는다. + let temp = target.with_extension("part"); + if fs::write(&temp, bytes).is_ok() { + let replaced = fs::rename(&temp, &target).or_else(|_| { + // Windows는 기존 파일 위 rename을 거부하므로 검증된 임시 파일이 + // 준비된 뒤에만 이전 캐시를 제거하고 다시 옮긴다. + fs::remove_file(&target)?; + fs::rename(&temp, &target) + }); + if replaced.is_err() { + let _ = fs::remove_file(&temp); + } + } + } + + /// Fetches and verifies the index, preferring the last-known-good cache on + /// any network or validation failure. + pub fn load_index(&self) -> Result { + let fresh = self + .fetch_capped(INDEX_CACHE_PATH, INDEX_MAX_BYTES) + .and_then(|bytes| { + let parsed = parse_index(&bytes)?; + self.write_cache(INDEX_CACHE_PATH, &bytes); + Ok(parsed) + }); + fresh.or_else(|fresh_error| { + self.read_cache(INDEX_CACHE_PATH) + .and_then(|bytes| parse_index(&bytes).ok()) + .ok_or(fresh_error) + }) + } + + /// Matches connected USB VID/PID against the registry index. + pub fn match_board(&self, identity: &UsbIdentity) -> Result { + let index = self.load_index()?; + match_board(&index, identity).ok_or(RegistryError::BoardNotRegistered { + vid: identity.vid, + pid: identity.pid, + }) + } + + /// Resolves, verifies, and caches a flashable firmware for the matched + /// board. Call only after the user explicitly started an install. + pub fn resolve_firmware(&self, matched: &UsbMatch) -> Result { + if matched.board_id != "arduino.uno-r3" { + return Err(RegistryError::InvalidDocument("지원하지 않는 보드 모델")); + } + let manifest_bytes = self.verified_with_cache( + &matched.manifest_path, + &matched.manifest_sha256, + MANIFEST_MAX_BYTES, + )?; + let manifest: BoardManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|_| RegistryError::InvalidDocument("보드 manifest JSON"))?; + + if manifest.schema_version != 2 + || manifest.id != matched.board_id + || manifest.firmware.format != "intel-hex" + || manifest.firmware.fqbn != "arduino:avr:uno" + || manifest.firmware.size == 0 + || manifest.firmware.size > FIRMWARE_MAX_BYTES + || manifest.wiring.is_empty() + { + return Err(RegistryError::InvalidDocument( + "지원하지 않는 보드 manifest", + )); + } + validate_relative_path(&manifest.firmware.source.path)?; + validate_sha_format(&manifest.firmware.source.sha256)?; + if manifest.firmware.toolchain.arduino_cli.is_empty() + || manifest.firmware.toolchain.platform.is_empty() + { + return Err(RegistryError::InvalidDocument("펌웨어 빌드 출처 누락")); + } + if manifest.wiring.iter().any(|entry| { + entry.from.is_empty() + || entry.to.is_empty() + || entry.note.as_deref().is_some_and(str::is_empty) + }) { + return Err(RegistryError::InvalidDocument("배선 정보 오류")); + } + if let Some(image) = &manifest.image { + validate_relative_path(&image.path)?; + validate_sha_format(&image.sha256)?; + if image.alt.is_empty() { + return Err(RegistryError::InvalidDocument("보드 이미지 설명 누락")); + } + } + + validate_relative_path(&manifest.firmware.path)?; + let hex_bytes = self.verified_with_cache( + &manifest.firmware.path, + &manifest.firmware.sha256, + FIRMWARE_MAX_BYTES, + )?; + if hex_bytes.len() != manifest.firmware.size { + return Err(RegistryError::InvalidDocument("펌웨어 크기 불일치")); + } + let hex_text = String::from_utf8(hex_bytes) + .map_err(|_| RegistryError::InvalidDocument("펌웨어가 텍스트가 아닙니다"))?; + + Ok(VerifiedFirmware { + board_id: matched.board_id.clone(), + hex_text, + fqbn: manifest.firmware.fqbn, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_cache(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hanbeon-registry-test-{tag}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + dir + } + + fn uno_index() -> Vec { + br#"{ + "schemaVersion": 1, + "revision": 1, + "apps": [], + "boards": [{ + "id": "arduino.uno-r3", + "name": "Arduino Uno R3", + "manifest": "boards/arduino-uno-r3.json", + "sha256": "f3842b609b55f75cb00f2a2fda3e12cb2f11b0f24c9b793aa12dd8565ea9889b", + "detect": {"usb": [{"vid": "2341", "pid": "0043", "confidence": "exact", + "manufacturerAliases": ["Arduino", "Arduino LLC"], + "productAliases": ["Arduino Uno", "Arduino Uno R3"]}]} + }] + }"# + .to_vec() + } + + #[test] + fn normalization_matches_registry_contract_vectors() { + assert_eq!(normalize_descriptor(" Arduino UNO_R3 "), "arduino-uno-r3"); + assert_eq!(normalize_descriptor("arduino-uno r3"), "arduino-uno-r3"); + assert_eq!( + normalize_descriptor("Arduino UNO-R3"), + "arduino-uno-r3" + ); + assert_eq!(normalize_descriptor("Arduino LLC"), "arduino-llc"); + assert_eq!(normalize_descriptor(" MÜNCHEN_GmbH "), "münchen-gmbh"); + assert_eq!( + normalize_descriptor(" Arduino (WWW.Arduino.cc) "), + "arduino-(www.arduino.cc)" + ); + } + + #[test] + fn usb_hex_serializes_via_u16_roundtrip() { + // README: VID/PID는 u16으로 파싱한 뒤 4자리 소문자 hex로 직렬화한다. + for raw in ["2A03", "2a03", "0x2A03"] { + let parsed = parse_usb_hex(raw).map(|v| format!("{v:04x}")); + assert_eq!(parsed.as_deref(), Some("2a03"), "{raw}"); + } + assert_eq!(parse_usb_hex("zz").map(|v| format!("{v:04x}")), None); + } + + #[test] + fn exact_vid_pid_matches_and_descriptors_keep_confidence() { + let index = parse_index(&uno_index()).unwrap(); + let identity = UsbIdentity { + vid: 0x2341, + pid: 0x0043, + product: Some("Arduino Uno R3".to_owned()), + manufacturer: Some("Arduino LLC".to_owned()), + }; + let matched = match_board(&index, &identity).expect("uno must match"); + assert_eq!(matched.board_id, "arduino.uno-r3"); + assert_eq!(matched.confidence, Confidence::Exact); + assert_eq!(matched.manifest_path, "boards/arduino-uno-r3.json"); + } + + #[test] + fn descriptor_disagreement_demotes_but_keeps_candidate() { + let index = parse_index(&uno_index()).unwrap(); + let identity = UsbIdentity { + vid: 0x2341, + pid: 0x0043, + product: Some("Totally Not An Uno".to_owned()), + manufacturer: None, + }; + let matched = match_board(&index, &identity).expect("candidate survives"); + assert_eq!(matched.confidence, Confidence::Ambiguous); + } + + #[test] + fn unknown_vid_pid_does_not_match() { + let index = parse_index(&uno_index()).unwrap(); + let identity = UsbIdentity { + vid: 0x1a86, + pid: 0x7523, + product: None, + manufacturer: None, + }; + assert_eq!(match_board(&index, &identity), None); + } + + #[test] + fn index_paths_are_validated_against_traversal() { + assert_eq!(validate_relative_path("boards/a.json"), Ok(())); + assert!(validate_relative_path("../secret").is_err()); + assert!(validate_relative_path("https://evil").is_err()); + assert!(validate_relative_path("").is_err()); + assert!(validate_relative_path("/abs").is_err()); + assert!(validate_relative_path("C:/outside-cache.hex").is_err()); + assert!(validate_relative_path("C:outside-cache.hex").is_err()); + } + + #[test] + fn sha_format_requires_64_lowercase_hex() { + assert_eq!(validate_sha_format(&"a".repeat(64)), Ok(())); + assert!(validate_sha_format(&"A".repeat(64)).is_err()); + assert!(validate_sha_format(&"g".repeat(64)).is_err()); + assert!(validate_sha_format(&"a".repeat(63)).is_err()); + } + + #[test] + fn cloud_endpoint_and_index_schema_are_pinned() { + assert_eq!( + REGISTRY_BASE, + "https://raw.githubusercontent.com/dev-five-git/hana-cloud/main" + ); + let unsupported = String::from_utf8(uno_index()).unwrap().replacen( + "\"schemaVersion\": 1", + "\"schemaVersion\": 2", + 1, + ); + assert!(parse_index(unsupported.as_bytes()).is_err()); + } + + #[test] + fn hash_mismatch_never_caches_bytes() { + use std::sync::Mutex; + let cache = temp_cache("mismatch"); + // Local fetcher: the served bytes differ from the wrong_sha expectation. + let payload = b"firmware-bytes".to_vec(); + let served = Mutex::new(payload.clone()); + let client = RegistryClient { + cache_dir: cache.clone(), + fetch: Box::new(move |_path, max_bytes| { + let body = served.lock().unwrap().clone(); + if body.len() > max_bytes { + return Err(RegistryError::Unavailable("너무 큽니다".to_owned())); + } + Ok(body) + }), + }; + let wrong_sha = sha256_hex(b"other-bytes"); + + let result = client + .verified_with_cache("board/firmware.hex", &wrong_sha, FIRMWARE_MAX_BYTES) + .map_err(|error| error.to_string()); + assert!( + matches!(&result, Err(message) if message.contains("해시")), + "got: {result:?}" + ); + assert!( + !cache.join("board/firmware.hex").exists(), + "unverified bytes must not be cached" + ); + + // When the server payload matches the expected hash it is cached. + let good_sha = sha256_hex(&payload); + let cached_serve = client + .verified_with_cache("board/firmware.hex", &good_sha, FIRMWARE_MAX_BYTES) + .map_err(|error| error.to_string()); + assert_eq!(cached_serve, Ok(payload.clone())); + assert_eq!( + fs::read(cache.join("board/firmware.hex")).map_err(|error| error.to_string()), + Ok(payload) + ); + let _ = fs::remove_dir_all(cache); + } + + #[test] + fn stale_cache_is_replaced_when_index_hash_moves() { + let cache = temp_cache("stale"); + let client = RegistryClient::new(cache.clone()); + let old_payload = b"old-firmware"; + let new_payload = b"new-firmware"; + let new_sha = sha256_hex(new_payload); + + // Seed a cache written when the index pointed at the old firmware. + client.write_cache("board/firmware.hex", old_payload); + let served = client.verified_with_cache("board/firmware.hex", &new_sha, FIRMWARE_MAX_BYTES); + // Download will fail (no network in tests) but must NOT serve stale bytes. + assert!(served.is_err()); + + // Once the cache holds the expected content it is served without network. + client.write_cache("board/firmware.hex", new_payload); + let served = client.verified_with_cache("board/firmware.hex", &new_sha, FIRMWARE_MAX_BYTES); + assert_eq!( + served.as_deref().map_err(|error| error.to_string()), + Ok(new_payload.as_slice()) + ); + let _ = fs::remove_dir_all(cache); + } + + #[test] + fn oversized_bodies_are_rejected_before_use() { + // 네트워크 fetch가 take(max+1) + 길이 검사를 하므로, 주입된 fetch에서 + // 같은 규칙을 흉내 내 거부 경로를 확인한다. + use std::sync::Mutex; + let cache = temp_cache("cap"); + let big_body = Mutex::new(vec![0_u8; 11]); + let client = RegistryClient { + cache_dir: cache.clone(), + fetch: Box::new(move |_path, max_bytes| { + let body = big_body.lock().unwrap().clone(); + if body.len() > max_bytes { + return Err(RegistryError::Unavailable("너무 큽니다".to_owned())); + } + Ok(body) + }), + }; + assert!(client.fetch_capped("registry.json", 10).is_err()); + assert!(client.fetch_capped("registry.json", 11).is_ok()); + let _ = fs::remove_dir_all(cache); + } + + #[test] + fn redirect_host_pin_rejects_foreign_hosts() { + let host_of = |url: &str| { + url.split("://") + .nth(1) + .and_then(|rest| rest.split(['/']).next()) + .unwrap_or_default() + .to_owned() + }; + assert_eq!(host_of(REGISTRY_BASE), "raw.githubusercontent.com"); + assert_ne!( + host_of("https://evil.example/registry.json"), + host_of(REGISTRY_BASE) + ); + } + + #[test] + fn with_local_fetcher_serves_without_network() { + use std::collections::HashMap; + use std::sync::Mutex; + let cache = temp_cache("local-fetch"); + let mut files = HashMap::new(); + files.insert("registry.json".to_owned(), uno_index()); + let files = Mutex::new(files); + let client = RegistryClient { + cache_dir: cache.clone(), + fetch: Box::new(move |path: &str, max_bytes: usize| { + let body = files.lock().unwrap().get(path).cloned(); + match body { + Some(body) if body.len() <= max_bytes => Ok(body), + _ => Err(RegistryError::Unavailable("테스트 파일 없음".to_owned())), + } + }), + }; + let matched = client + .match_board(&UsbIdentity { + vid: 0x2341, + pid: 0x0043, + product: None, + manufacturer: None, + }) + .expect("uno matches from local index"); + assert_eq!(matched.board_id, "arduino.uno-r3"); + assert_eq!(matched.confidence, Confidence::Exact); + let _ = fs::remove_dir_all(cache); + } + + #[test] + fn resolves_hash_verified_hana_cloud_hex_from_manifest() { + use std::collections::HashMap; + use std::sync::Mutex; + + let cache = temp_cache("resolve-firmware"); + let hex = b":0400000012345678E8\n:00000001FF\n".to_vec(); + let manifest = format!( + r#"{{"schemaVersion":2,"id":"arduino.uno-r3","firmware":{{"path":"boards/arduino-uno-r3.hex","format":"intel-hex","size":{},"sha256":"{}","fqbn":"arduino:avr:uno","source":{{"path":"boards/arduino-uno-r3.ino","sha256":"{}"}},"toolchain":{{"arduinoCli":"1.5.1","platform":"arduino:avr@1.8.8"}}}},"wiring":[{{"from":"D2","to":"Middle Button"}}]}}"#, + hex.len(), + sha256_hex(&hex), + "a".repeat(64), + ) + .into_bytes(); + let matched = UsbMatch { + board_id: "arduino.uno-r3".to_owned(), + board_name: "Arduino Uno R3".to_owned(), + confidence: Confidence::Exact, + manifest_path: "boards/arduino-uno-r3.json".to_owned(), + manifest_sha256: sha256_hex(&manifest), + }; + let files = Mutex::new(HashMap::from([ + ("boards/arduino-uno-r3.json".to_owned(), manifest), + ("boards/arduino-uno-r3.hex".to_owned(), hex.clone()), + ])); + let client = RegistryClient { + cache_dir: cache.clone(), + fetch: Box::new(move |path, max_bytes| { + files + .lock() + .unwrap() + .get(path) + .filter(|bytes| bytes.len() <= max_bytes) + .cloned() + .ok_or_else(|| RegistryError::Unavailable("테스트 파일 없음".to_owned())) + }), + }; + + let firmware = client + .resolve_firmware(&matched) + .expect("verified firmware"); + assert_eq!(firmware.board_id, "arduino.uno-r3"); + assert_eq!(firmware.fqbn, "arduino:avr:uno"); + assert_eq!(firmware.hex_text.as_bytes(), hex); + let _ = fs::remove_dir_all(cache); + } +} diff --git a/apps/desktop/src-tauri/src/window.rs b/apps/desktop/src-tauri/src/window.rs index 9f86695..e3bd433 100644 --- a/apps/desktop/src-tauri/src/window.rs +++ b/apps/desktop/src-tauri/src/window.rs @@ -200,6 +200,13 @@ impl MoveWatch { } } + /// 마지막으로 기록된 위치가 있으면 돌려준다. 저장 조건(멎음 여부)은 + /// 보지 않는다. show_floating은 창을 다시 띄울 때 마지막 위치만 필요하다. + pub fn last(&self) -> Option<(i32, i32)> { + let slot = self.0.lock().ok()?; + slot.map(|(_, position)| position) + } + /// 이동이 멎었으면 마지막 위치를 꺼낸다. fn take_settled(&self) -> Option<(i32, i32)> { let mut slot = self.0.lock().ok()?; @@ -275,3 +282,18 @@ pub fn hide_settings(app: &AppHandle) -> Result<(), String> { Ok(()) } + +/// floating 오버레이를 다시 띄운다. 설치 모드에서 숨긴 floating을 온보딩 +/// 종료 직후 되살려 스캔 컨트롤러가 바로 보이도록 하는 것이 존재 이유다. +pub fn show_floating(app: &AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("floating") + .ok_or_else(|| "floating 창을 찾을 수 없습니다.".to_string())?; + if let Some(moves) = app.try_state::() { + let saved = moves.last(); + prepare_floating(&window, saved).map_err(|e| e.to_string())?; + } + window.show().map_err(|e| e.to_string())?; + release_activation(app); + Ok(()) +} diff --git a/apps/desktop/src/__tests__/arduino-firmware.browser.test.ts b/apps/desktop/src/__tests__/arduino-firmware.browser.test.ts new file mode 100644 index 0000000..95814c1 --- /dev/null +++ b/apps/desktop/src/__tests__/arduino-firmware.browser.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, mock, test } from 'bun:test' + +const invoke = mock(async (name: string, args?: Record) => ({ + name, + args, +})) + +mock.module('@tauri-apps/api/core', () => ({ + invoke: (name: string, args?: Record) => invoke(name, args), +})) + +import { + type ArduinoCandidate, + asFirmwareState, + beginFirmwareInstall, + canBeginInstall, + cancelFirmwareInstall, + FIRMWARE_COMMANDS, + FIRMWARE_COPY, + FIRMWARE_EVENT, + firmwareDeviceArgs, + firmwareErrorText, + firmwareOwnsPort, + type FirmwareState, + firmwareStatusText, + INITIAL_FIRMWARE_STATE, + listArduinoCandidates, + probeArduinoFirmware, +} from '../lib/firmware' + +describe('Arduino firmware lifecycle contract', () => { + test('uses a dedicated Tauri event and idle initial state', () => { + expect(FIRMWARE_EVENT).toBe('arduino://firmware') + expect(INITIAL_FIRMWARE_STATE).toEqual({ state: 'idle' }) + }) + + test('keeps candidate identity separate from the transient port path', () => { + const candidate: ArduinoCandidate = { + deviceId: 'candidate-1', + displayName: 'Arduino Uno', + port: '/dev/cu.usbmodem1401', + vid: 0x2341, + pid: 0x0043, + } + + expect(candidate.deviceId).toBe('candidate-1') + expect(candidate.port).toBe('/dev/cu.usbmodem1401') + }) + + test('renders the three user-facing installation phases', () => { + const phases: FirmwareState[] = [ + { state: 'preparing', deviceId: 'candidate-1' }, + { state: 'uploading', deviceId: 'candidate-1' }, + { state: 'verifying', deviceId: 'candidate-1' }, + ] + + expect(phases.map(firmwareStatusText)).toEqual([ + 'Arduino 준비 중', + '펌웨어 전송 중', + '설치 확인 중', + ]) + expect(phases.every(firmwareOwnsPort)).toBe(true) + }) + + test('uses the requested connect copy while searching', () => { + expect(firmwareStatusText({ state: 'searching' })).toBe( + 'Arduino Uno를 USB로 연결해 주세요', + ) + expect(FIRMWARE_COPY.startTitle).toBe('한번을 Arduino 버튼과 연결해 볼게요') + expect(FIRMWARE_COPY.startAction).toBe('시작하기') + }) + + test('distinguishes no response from different firmware', () => { + expect( + firmwareStatusText({ + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'noResponse', + }), + ).toBe('전용 펌웨어가 필요합니다') + expect( + firmwareStatusText({ + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'differentFirmware', + }), + ).toBe('다른 스케치가 설치되어 있습니다') + }) + + test('allows no-response install without a confirmation token', () => { + expect( + canBeginInstall( + { + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'noResponse', + }, + false, + ), + ).toBe(true) + expect( + canBeginInstall( + { + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'differentFirmware', + }, + false, + ), + ).toBe(false) + }) + + test('requires a stronger overwrite acknowledgement for different firmware', () => { + const state: FirmwareState = { + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'differentFirmware', + confirmationToken: 'token-1', + } + + expect(canBeginInstall(state, false)).toBe(false) + expect(canBeginInstall(state, true)).toBe(true) + }) + + test('maps retryable installer failures without raw port names', () => { + expect(firmwareErrorText('notFound')).toBe('Arduino를 찾지 못했습니다') + expect(firmwareErrorText('portUnavailable')).toBe( + '포트를 사용할 수 없습니다', + ) + expect(firmwareErrorText('uploadFailed')).toBe('펌웨어 전송에 실패했습니다') + expect(firmwareErrorText('verifyFailed')).toBe( + '설치는 끝났지만 펌웨어 확인에 실패했습니다', + ) + expect(firmwareErrorText('notFound')).not.toInclude('/dev/') + }) + + test('covers remaining status, payload, and command wrappers', async () => { + expect(firmwareStatusText({ state: 'idle' })).toBe('') + expect( + firmwareStatusText({ state: 'probing', deviceId: 'candidate-1' }), + ).toBe('기존 펌웨어 확인 중') + expect( + firmwareStatusText({ state: 'complete', deviceId: 'candidate-1' }), + ).toBe('펌웨어 설치가 완료되었습니다') + expect(firmwareStatusText({ state: 'cancelled' })).toBe( + '펌웨어 설치를 취소했습니다', + ) + expect( + firmwareStatusText({ + state: 'error', + code: 'notFound', + retryable: false, + }), + ).toBe('Arduino를 찾지 못했습니다') + expect(firmwareErrorText('raw stderr')).toBe( + '펌웨어 설치 중 문제가 발생했습니다', + ) + expect(asFirmwareState(null)).toBeNull() + expect(asFirmwareState({ nope: true })).toBeNull() + expect(asFirmwareState({ state: 1 })).toBeNull() + expect(asFirmwareState({ state: 'searching' })).toEqual({ + state: 'searching', + }) + expect( + // Rust 이벤트가 snake_case로 오던 구버전 호환 경로를 검증한다. + asFirmwareState({ + state: 'confirmationRequired', + device_id: 'candidate-1', + reason: 'noResponse', + } as Record), + ).toEqual({ + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'noResponse', + }) + expect( + asFirmwareState({ + state: 'confirmationRequired', + reason: 'noResponse', + }), + ).toBeNull() + await listArduinoCandidates() + await probeArduinoFirmware('candidate-1') + await beginFirmwareInstall('candidate-1') + await cancelFirmwareInstall() + expect(invoke.mock.calls.map((call) => call[0])).toEqual([ + FIRMWARE_COMMANDS.listCandidates, + FIRMWARE_COMMANDS.probe, + FIRMWARE_COMMANDS.beginInstall, + FIRMWARE_COMMANDS.cancelInstall, + ]) + }) + + test('sends both camelCase and snake_case device ids to Tauri commands', async () => { + expect(firmwareDeviceArgs('candidate-1')).toEqual({ + deviceId: 'candidate-1', + device_id: 'candidate-1', + }) + expect(() => firmwareDeviceArgs(' ')).toThrow('deviceId is required') + await beginFirmwareInstall('candidate-1') + const last = invoke.mock.calls.at(-1) + expect(last?.[0]).toBe(FIRMWARE_COMMANDS.beginInstall) + expect(last?.[1]).toEqual({ + deviceId: 'candidate-1', + device_id: 'candidate-1', + }) + }) +}) diff --git a/apps/desktop/src/__tests__/arduino-setup.browser.test.tsx b/apps/desktop/src/__tests__/arduino-setup.browser.test.tsx new file mode 100644 index 0000000..6ac7593 --- /dev/null +++ b/apps/desktop/src/__tests__/arduino-setup.browser.test.tsx @@ -0,0 +1,636 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { fireEvent, render } from 'bun-test-env-dom' +import { act, createElement, type ReactNode } from 'react' + +import { + type ArduinoCandidate, + FIRMWARE_COMMANDS, + FIRMWARE_COPY, + FIRMWARE_EVENT, + type FirmwareState, +} from '../lib/firmware' + +type Listener = (event: { payload: unknown }) => void + +const UNO: ArduinoCandidate = { + deviceId: 'candidate-1', + displayName: 'Arduino Uno', + port: '/dev/cu.usbmodem1401', + vid: 0x2341, + pid: 0x0043, +} + +const UNO_TWO: ArduinoCandidate = { + deviceId: 'candidate-2', + displayName: 'Arduino Uno', + port: '/dev/cu.usbmodem1402', + vid: 0x2341, + pid: 0x0043, +} + +const PROFILE = { + intervalMs: 1800, + minIntervalMs: 600, + maxIntervalMs: 4000, + adaptive: true, + manualLock: false, + longPressMs: 800, + switchKey: 'F13', + sound: true, + undoMapping: 'undo' as const, + theme: 'light' as const, + windowPosition: null, + dimWhenCovered: true, + dimPercent: 40, + appButtons: true, + logging: false, + onboarded: false, +} + +const listeners = new Map() +const commands: { name: string; args: unknown }[] = [] +let invokeImpl: ( + name: string, + args?: Record, +) => Promise = async () => undefined + +let unlistenFails = false + +mock.module('@tauri-apps/api/event', () => ({ + TauriEvent: {}, + emit: () => Promise.resolve(), + emitTo: () => Promise.resolve(), + listen: (event: string, listener: Listener) => { + listeners.set(event, listener) + if (unlistenFails) { + return Promise.resolve(() => { + throw new Error('unlisten unavailable') + }) + } + return Promise.resolve(() => listeners.delete(event)) + }, + once: () => Promise.resolve(() => {}), +})) + +mock.module('@tauri-apps/api/core', () => ({ + SERIALIZE_TO_IPC_FN: '__TAURI_TO_IPC_KEY__', + invoke: (name: string, args?: Record) => { + commands.push({ name, args }) + return invokeImpl(name, args) + }, +})) + +mock.module('@devup-ui/react', () => { + const passthrough = (fallback: string) => { + function DevupElement({ + as, + children, + ...props + }: Record & { as?: string; children?: ReactNode }) { + const Tag = (as as string | undefined) ?? fallback + const dom: Record = {} + for (const [key, value] of Object.entries(props)) { + if ( + key.startsWith('aria-') || + key.startsWith('data-') || + key === 'role' || + key === 'disabled' || + key === 'type' || + key === 'onClick' || + key === 'id' || + key === 'title' + ) { + dom[key] = value + } + } + return createElement(Tag, dom, children) + } + return DevupElement + } + return { + Box: passthrough('div'), + Flex: passthrough('div'), + Text: passthrough('p'), + VStack: passthrough('div'), + Center: passthrough('div'), + } +}) + +mock.module('@/components/settings/Section', () => { + return { + Section: ({ + children, + description, + title, + }: { + children: ReactNode + description?: string + title: string + }) => + createElement( + 'section', + null, + createElement('h2', null, title), + description ? createElement('p', null, description) : null, + children, + ), + } +}) + +mock.module('@/components/settings/Range', () => { + return { + Range: ({ + label, + onChange, + valueText, + }: { + label: string + onChange: (next: number) => void + valueText: string + }) => + createElement( + 'button', + { onClick: () => onChange(2000), type: 'button' }, + `${label} ${valueText}`, + ), + } +}) + +mock.module('@/components/settings/SwitchTester', () => ({ + SwitchTester: () => null, +})) + +mock.module('@/lib/format', () => ({ + formatSeconds: (ms: number) => `${(ms / 1000).toFixed(1)}초`, +})) + +mock.module('@/lib/profile', () => ({ + saveProfile: (profile: unknown) => { + commands.push({ name: 'save_profile', args: { next: profile } }) + return Promise.reject(new Error('save unavailable')) + }, + closeSettings: () => Promise.resolve(), +})) + +function emit(event: string, payload: unknown) { + const listener = listeners.get(event) + if (!listener) throw new Error(`No listener registered for ${event}`) + listener({ payload }) +} + +function textOf(container: Element) { + return container.textContent ?? '' +} + +function buttonNamed(container: Element, label: string) { + return [...container.querySelectorAll('button')].find( + (button) => button.textContent === label, + ) +} + +async function setupView(initialState?: FirmwareState) { + const { ArduinoSetup } = await import('../components/settings/ArduinoSetup') + const onComplete = mock(() => {}) + const onDefer = mock(() => {}) + const view = render( + , + ) + await act(async () => {}) + return { view, onComplete, onDefer } +} + +describe('Arduino firmware setup screens', () => { + beforeEach(() => { + listeners.clear() + commands.length = 0 + invokeImpl = async () => undefined + unlistenFails = false + document.body.innerHTML = '' + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('shows the start guide and does not issue an install command yet', async () => { + const { view } = await setupView() + + expect(textOf(view.container)).toInclude(FIRMWARE_COPY.startTitle) + expect(textOf(view.container)).toInclude(FIRMWARE_COPY.supplyUno) + expect(textOf(view.container)).toInclude(FIRMWARE_COPY.supplyUsb) + expect(textOf(view.container)).toInclude(FIRMWARE_COPY.supplyButton) + expect(buttonNamed(view.container, FIRMWARE_COPY.startAction)).toBeTruthy() + expect( + commands.some( + (command) => command.name === FIRMWARE_COMMANDS.beginInstall, + ), + ).toBe(false) + }) + + it('subscribes to firmware events before listing boards', async () => { + const order: string[] = [] + invokeImpl = async (name) => { + order.push(`invoke:${name}`) + return [] + } + const originalSet = listeners.set.bind(listeners) + listeners.set = ((event: string, listener: Listener) => { + order.push(`listen:${event}`) + return originalSet(event, listener) + }) as typeof listeners.set + + const { ArduinoSetup } = await import('../components/settings/ArduinoSetup') + const view = render( + {}} onDefer={() => {}} />, + ) + await act(async () => {}) + await act(async () => { + fireEvent.click(buttonNamed(view.container, FIRMWARE_COPY.startAction)!) + }) + + expect(order[0]).toBe(`listen:${FIRMWARE_EVENT}`) + expect(order).toContain(`invoke:${FIRMWARE_COMMANDS.listCandidates}`) + expect(order.indexOf(`listen:${FIRMWARE_EVENT}`)).toBeLessThan( + order.indexOf(`invoke:${FIRMWARE_COMMANDS.listCandidates}`), + ) + }) + + it('asks the user to connect a board while searching', async () => { + const { view } = await setupView() + await act(async () => { + fireEvent.click(buttonNamed(view.container, FIRMWARE_COPY.startAction)!) + emit(FIRMWARE_EVENT, { state: 'searching' } satisfies FirmwareState) + }) + + expect(textOf(view.container)).toInclude( + 'Arduino Uno를 USB로 연결해 주세요', + ) + expect( + view.container.querySelector('[data-state]')?.getAttribute('data-state'), + ).toBe('searching') + expect(view.container.querySelector('output')?.textContent).toInclude( + 'Arduino Uno를 USB로 연결해 주세요', + ) + }) + + it('shows a found Uno without using the port path as the label', async () => { + const { view } = await setupView({ + state: 'boardFound', + candidates: [UNO], + }) + + expect(textOf(view.container)).toInclude('Arduino Uno를 찾았습니다') + expect(textOf(view.container)).toInclude('Arduino Uno') + expect(textOf(view.container)).toInclude('2341:0043') + expect(textOf(view.container)).not.toInclude('/dev/cu.usbmodem1401') + expect( + commands.some( + (command) => command.name === FIRMWARE_COMMANDS.beginInstall, + ), + ).toBe(false) + }) + + it('requires an explicit choice before probing when several Unos are present', async () => { + const { view } = await setupView({ + state: 'boardFound', + candidates: [UNO, UNO_TWO], + }) + + expect(textOf(view.container)).toInclude( + '연결할 Arduino Uno를 선택해 주세요', + ) + expect(buttonNamed(view.container, '다음')?.hasAttribute('disabled')).toBe( + true, + ) + + const choices = [...view.container.querySelectorAll('[aria-pressed]')] + expect(choices).toHaveLength(2) + await act(async () => { + fireEvent.click(choices[1]!) + }) + expect(buttonNamed(view.container, '다음')?.hasAttribute('disabled')).toBe( + false, + ) + + await act(async () => { + fireEvent.click(buttonNamed(view.container, '다음')!) + }) + expect(commands.at(-1)).toEqual({ + name: FIRMWARE_COMMANDS.probe, + args: { deviceId: 'candidate-2', device_id: 'candidate-2' }, + }) + expect( + commands.some( + (command) => command.name === FIRMWARE_COMMANDS.beginInstall, + ), + ).toBe(false) + }) + + it('surfaces a probe failure instead of staying on the found-board screen', async () => { + invokeImpl = async (name) => { + if (name === FIRMWARE_COMMANDS.probe) { + throw new Error('invalid args device_id') + } + return undefined + } + const { view } = await setupView({ + state: 'boardFound', + candidates: [UNO], + }) + + await act(async () => { + fireEvent.click(buttonNamed(view.container, '다음')!) + }) + + expect(commands.at(-1)).toEqual({ + name: FIRMWARE_COMMANDS.probe, + args: { deviceId: 'candidate-1', device_id: 'candidate-1' }, + }) + expect(textOf(view.container)).toInclude('포트를 사용할 수 없습니다') + expect(textOf(view.container)).toInclude('invalid args device_id') + expect( + view.container.querySelector('[data-state]')?.getAttribute('data-state'), + ).toBe('error') + }) + + it('skips upload when the dedicated firmware is already installed', async () => { + const { view, onComplete } = await setupView({ + state: 'alreadyInstalled', + deviceId: 'candidate-1', + }) + + expect(textOf(view.container)).toInclude( + '전용 펌웨어가 이미 설치되어 있습니다', + ) + expect(textOf(view.container)).not.toInclude('펌웨어 설치') + await act(async () => { + fireEvent.click(buttonNamed(view.container, '다음')!) + }) + expect(onComplete).toHaveBeenCalledTimes(1) + expect( + commands.some( + (command) => command.name === FIRMWARE_COMMANDS.beginInstall, + ), + ).toBe(false) + }) + + it('asks for install consent when the board does not answer', async () => { + const { view } = await setupView({ + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'noResponse', + confirmationToken: 'token-1', + }) + + expect(textOf(view.container)).toInclude( + '버튼을 사용하려면 전용 펌웨어를 설치해야 합니다.', + ) + expect(textOf(view.container)).toInclude( + '설치하면 현재 Arduino에 들어 있는 기존 스케치는 교체됩니다.', + ) + expect(textOf(view.container)).not.toInclude(FIRMWARE_COPY.overwriteStrong) + + await act(async () => { + fireEvent.click(buttonNamed(view.container, '펌웨어 설치')!) + }) + expect(commands.at(-1)).toEqual({ + name: FIRMWARE_COMMANDS.beginInstall, + args: { deviceId: 'candidate-1', device_id: 'candidate-1' }, + }) + }) + + it('keeps install disabled until the stronger overwrite warning is acknowledged', async () => { + const { view } = await setupView({ + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'differentFirmware', + confirmationToken: 'token-1', + }) + + expect(textOf(view.container)).toInclude(FIRMWARE_COPY.overwriteStrong) + expect(textOf(view.container)).toInclude( + '설치하면 현재 Arduino에 들어 있는 기존 스케치는 교체됩니다.', + ) + expect( + buttonNamed(view.container, '펌웨어 설치')?.hasAttribute('disabled'), + ).toBe(true) + + await act(async () => { + fireEvent.click( + buttonNamed( + view.container, + `● ${FIRMWARE_COPY.acknowledgeOverwrite}`, + ) ?? + buttonNamed( + view.container, + `○ ${FIRMWARE_COPY.acknowledgeOverwrite}`, + )!, + ) + }) + expect( + buttonNamed(view.container, '펌웨어 설치')?.hasAttribute('disabled'), + ).toBe(false) + + await act(async () => { + fireEvent.click(buttonNamed(view.container, '펌웨어 설치')!) + }) + expect(commands.at(-1)?.name).toBe(FIRMWARE_COMMANDS.beginInstall) + }) + + it('shows preparing, uploading and verifying without a second install action', async () => { + for (const state of [ + { state: 'preparing', deviceId: 'candidate-1' }, + { state: 'uploading', deviceId: 'candidate-1', progress: 40 }, + { state: 'verifying', deviceId: 'candidate-1' }, + ] satisfies FirmwareState[]) { + const { view } = await setupView(state) + expect(textOf(view.container)).toInclude( + state.state === 'preparing' + ? 'Arduino 준비 중' + : state.state === 'uploading' + ? '펌웨어 전송 중' + : '설치 확인 중', + ) + expect(textOf(view.container)).toInclude('설치가 진행 중') + expect(textOf(view.container)).not.toInclude('펌웨어 설치') + expect(view.container.querySelector('[aria-busy="true"]')).not.toBeNull() + view.unmount() + } + }) + + it('lets the user continue later without saving onboarding completion', async () => { + const { view, onComplete, onDefer } = await setupView({ + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'noResponse', + confirmationToken: 'token-1', + }) + + await act(async () => { + fireEvent.click(buttonNamed(view.container, '나중에 하기')!) + }) + expect(onDefer).toHaveBeenCalledTimes(1) + expect(onComplete).not.toHaveBeenCalled() + expect(commands.some((command) => command.name === 'save_profile')).toBe( + false, + ) + }) + + it('retries only when the installer says the error is retryable', async () => { + const { view } = await setupView({ + state: 'error', + code: 'uploadFailed', + retryable: true, + }) + expect(textOf(view.container)).toInclude('펌웨어 전송에 실패했습니다') + expect(textOf(view.container)).not.toInclude('usbmodem') + + await act(async () => { + fireEvent.click(buttonNamed(view.container, '다시 시도')!) + }) + expect(commands.at(-1)?.name).toBe(FIRMWARE_COMMANDS.listCandidates) + + const blocked = await setupView({ + state: 'error', + code: 'verifyFailed', + retryable: false, + }) + expect(textOf(blocked.view.container)).toInclude( + '설치는 끝났지만 펌웨어 확인에 실패했습니다', + ) + expect(buttonNamed(blocked.view.container, '다시 시도')).toBeUndefined() + expect(buttonNamed(blocked.view.container, '나중에 하기')).toBeTruthy() + }) + + it('cleans up the firmware listener on unmount', async () => { + const { view } = await setupView() + expect(listeners.has(FIRMWARE_EVENT)).toBe(true) + await act(async () => { + view.unmount() + }) + expect(listeners.has(FIRMWARE_EVENT)).toBe(false) + }) + + it('completes after a successful install event and accepts onLater', async () => { + const { ArduinoSetup } = await import('../components/settings/ArduinoSetup') + const onComplete = mock(() => {}) + const onLater = mock(() => {}) + const view = render( + , + ) + await act(async () => { + fireEvent.click(buttonNamed(view.container, '나중에 하기')!) + }) + expect(onLater).toHaveBeenCalledTimes(1) + await act(async () => { + emit(FIRMWARE_EVENT, { + state: 'complete', + deviceId: 'candidate-1', + } satisfies FirmwareState) + }) + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + it('survives rejected invoke and shows the named overwrite board', async () => { + invokeImpl = async () => { + throw new Error('command unavailable') + } + unlistenFails = true + const { ArduinoSetup } = await import('../components/settings/ArduinoSetup') + const unnamed = render( {}} />) + await act(async () => { + fireEvent.click(buttonNamed(unnamed.container, '시작하기')!) + }) + await act(async () => { + unnamed.unmount() + }) + unlistenFails = false + const { view } = await setupView({ + state: 'confirmationRequired', + deviceId: 'candidate-1', + reason: 'differentFirmware', + confirmationToken: 'token-1', + displayName: 'Arduino Uno', + }) + expect(textOf(view.container)).toInclude('Arduino Uno') + await act(async () => { + emit(FIRMWARE_EVENT, { state: 'searching' } satisfies FirmwareState) + }) + expect( + view.container.querySelector('[data-state]')?.getAttribute('data-state'), + ).toBe('searching') + }) +}) + +describe('Onboarding keeps firmware setup before switch testing', () => { + beforeEach(() => { + listeners.clear() + commands.length = 0 + invokeImpl = async () => undefined + unlistenFails = false + document.body.innerHTML = '' + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('starts on the Arduino guide and can continue to switch testing later', async () => { + const { Onboarding } = await import('../components/settings/Onboarding') + const onDone = mock(() => {}) + const view = render() + await act(async () => {}) + + expect(textOf(view.container)).toInclude('Arduino 연결') + expect(textOf(view.container)).toInclude(FIRMWARE_COPY.startTitle) + expect(textOf(view.container)).not.toInclude( + '스위치가 연결됐는지 확인합니다', + ) + + await act(async () => { + fireEvent.click(buttonNamed(view.container, '나중에 하기')!) + }) + + expect(textOf(view.container)).toInclude('스위치가 연결됐는지 확인합니다') + expect(onDone).not.toHaveBeenCalled() + expect(commands.some((command) => command.name === 'save_profile')).toBe( + false, + ) + }) + + it('keeps later Arduino onboarding steps available after firmware setup', async () => { + const { Onboarding } = await import('../components/settings/Onboarding') + const onDone = mock(() => {}) + const view = render() + + await act(async () => { + fireEvent.click(buttonNamed(view.container, '나중에 하기')!) + }) + await act(async () => { + fireEvent.click(buttonNamed(view.container, '다음')!) + }) + expect(textOf(view.container)).toInclude('속도를 맞춥니다') + await act(async () => { + fireEvent.click(buttonNamed(view.container, '주사 속도 1.8초')!) + }) + await act(async () => { + fireEvent.click(buttonNamed(view.container, '다음')!) + }) + expect(textOf(view.container)).toInclude('저장합니다') + await act(async () => { + fireEvent.click(buttonNamed(view.container, '이전')!) + }) + expect(textOf(view.container)).toInclude('속도를 맞춥니다') + await act(async () => { + fireEvent.click(buttonNamed(view.container, '다음')!) + }) + await act(async () => { + fireEvent.click(buttonNamed(view.container, '저장하고 시작')!) + }) + expect(onDone).toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/__tests__/status-line.browser.test.tsx b/apps/desktop/src/__tests__/status-line.browser.test.tsx index b1957ec..f55971b 100644 --- a/apps/desktop/src/__tests__/status-line.browser.test.tsx +++ b/apps/desktop/src/__tests__/status-line.browser.test.tsx @@ -13,14 +13,14 @@ function messageOf(element: ReactElement) { } describe('StatusLine', () => { - it('알릴 것이 없으면 현재 속도를 보여준다', () => { + it('알림이 없으면 연결을 기다려도 현재 속도를 보여준다', () => { const line = StatusLine({ intervalMs: 2500, mode: 'scanning', notice: null, }) expect(propsOf(line)).toHaveProperty('data-state', 'waiting') - expect(messageOf(line)).toBe('스위치를 연결해 주세요') + expect(messageOf(line)).toBe('2.5초마다') }) it('간격이 바뀌면 그 이유를 같은 자리에 띄운다', () => { diff --git a/apps/desktop/src/components/settings/ArduinoSetup.tsx b/apps/desktop/src/components/settings/ArduinoSetup.tsx new file mode 100644 index 0000000..fbf09fc --- /dev/null +++ b/apps/desktop/src/components/settings/ArduinoSetup.tsx @@ -0,0 +1,412 @@ +'use client' + +import { Box, Flex, Text, VStack } from '@devup-ui/react' +import { listen } from '@tauri-apps/api/event' +import { useEffect, useState } from 'react' + +import { + type ArduinoCandidate, + asFirmwareState, + beginFirmwareInstall, + canBeginInstall, + cancelFirmwareInstall, + candidateLabel, + FIRMWARE_COPY, + FIRMWARE_EVENT, + firmwareOwnsPort, + type FirmwareState, + firmwareStatusText, + INITIAL_FIRMWARE_STATE, + listArduinoCandidates, + probeArduinoFirmware, +} from '@/lib/firmware' + +type Confirmation = Extract + +function ActionButton({ + children, + disabled = false, + onClick, + primary = false, +}: { + children: string + disabled?: boolean + onClick: () => void + primary?: boolean +}) { + return ( + { + if (disabled) return + onClick() + }} + px="28px" + py="18px" + type="button" + typography="bodyL" + > + {children} + + ) +} + +function ChoiceButton({ + label, + name, + onSelect, + selected, +}: { + label: string + name?: string + onSelect: () => void + selected: boolean +}) { + return ( + + {selected ? `● ${label}` : `○ ${label}`} + + ) +} + +function Card({ + children, + description, + title, +}: { + children: React.ReactNode + description?: string + title: string +}) { + return ( + + + {title} + + {description && ( + + {description} + + )} + {children} + + ) +} + +function selectedCandidate( + candidates: ArduinoCandidate[], + selectedId: string | null, +) { + if (selectedId) { + return candidates.find((candidate) => candidate.deviceId === selectedId) + } + if (candidates.length === 1) return candidates[0] + return undefined +} + +export function ArduinoSetup({ + initialState = INITIAL_FIRMWARE_STATE, + onComplete, + onDefer, + onLater, +}: { + initialState?: FirmwareState + onComplete: () => void + onDefer?: () => void + onLater?: () => void +}) { + const defer = onDefer ?? onLater ?? (() => {}) + const [started, setStarted] = useState(initialState.state !== 'idle') + const [firmware, setFirmware] = useState(initialState) + const [selectedId, setSelectedId] = useState( + initialState.state === 'boardFound' && initialState.candidates.length === 1 + ? initialState.candidates[0].deviceId + : null, + ) + const [overwriteAcknowledged, setOverwriteAcknowledged] = useState(false) + + useEffect(() => { + const unlisten = listen(FIRMWARE_EVENT, (event) => { + const next = asFirmwareState(event.payload) + if (next) setFirmware(next) + }) + return () => { + unlisten.then((stop) => stop()).catch(() => {}) + } + }, []) + + useEffect(() => { + if (firmware.state === 'complete') onComplete() + }, [firmware, onComplete]) + + useEffect(() => { + if (firmware.state !== 'confirmationRequired') { + setOverwriteAcknowledged(false) + } + if (firmware.state === 'boardFound' && firmware.candidates.length === 1) { + setSelectedId(firmware.candidates[0].deviceId) + } + if (firmware.state === 'searching' || firmware.state === 'idle') { + setSelectedId(null) + } + }, [firmware]) + + const owning = firmwareOwnsPort(firmware) + const candidates = firmware.state === 'boardFound' ? firmware.candidates : [] + const confirmation: Confirmation | null = + firmware.state === 'confirmationRequired' ? firmware : null + const differentFirmware = confirmation?.reason === 'differentFirmware' + const installEnabled = confirmation + ? canBeginInstall(confirmation, overwriteAcknowledged) && !owning + : false + const chosen = selectedCandidate(candidates, selectedId) + const status = firmwareStatusText(firmware) + + const startSearch = () => { + setStarted(true) + setSelectedId(null) + setOverwriteAcknowledged(false) + setFirmware({ state: 'searching' }) + void listArduinoCandidates().catch(() => {}) + } + + const probeSelected = () => { + if (!chosen) return + void probeArduinoFirmware(chosen.deviceId) + .then((payload) => asFirmwareState(payload)) + .then((next) => { + if (next) setFirmware(next) + }) + .catch((error: unknown) => { + setFirmware({ + state: 'error', + code: 'portUnavailable', + retryable: true, + detail: error instanceof Error ? error.message : String(error), + }) + }) + } + + const install = () => { + if (!confirmation || !installEnabled || !confirmation.deviceId) return + void beginFirmwareInstall(confirmation.deviceId).catch((error: unknown) => { + setFirmware({ + state: 'error', + code: 'uploadFailed', + retryable: true, + detail: error instanceof Error ? error.message : String(error), + }) + }) + } + + const later = () => { + if (started) void cancelFirmwareInstall().catch(() => {}) + defer() + } + + const retry = () => { + setSelectedId(null) + setOverwriteAcknowledged(false) + setFirmware({ state: 'searching' }) + void listArduinoCandidates().catch(() => {}) + } + + if (!started) { + return ( + + + + {FIRMWARE_COPY.suppliesHeading} + + + - {FIRMWARE_COPY.supplyUno} + + + - {FIRMWARE_COPY.supplyUsb} + + + - {FIRMWARE_COPY.supplyButton} + + + + + {FIRMWARE_COPY.startAction} + + {FIRMWARE_COPY.later} + + + ) + } + + return ( + 1 + ? FIRMWARE_COPY.chooseBoard + : firmware.state === 'searching' + ? FIRMWARE_COPY.connect + : firmware.state === 'confirmationRequired' + ? differentFirmware + ? FIRMWARE_COPY.overwriteStrong + : FIRMWARE_COPY.confirmNeed + : owning + ? FIRMWARE_COPY.installing + : status + } + title={ + firmware.state === 'boardFound' && candidates.length > 1 + ? FIRMWARE_COPY.chooseBoard + : firmware.state === 'searching' + ? FIRMWARE_COPY.connect + : status || FIRMWARE_COPY.connect + } + > + + {status && ( + + {status} + + )} + + {firmware.state === 'boardFound' && chosen && ( + + {candidateLabel(chosen)} + + )} + + {firmware.state === 'boardFound' && candidates.length > 1 && ( + + {candidates.map((candidate, index) => ( + setSelectedId(candidate.deviceId)} + selected={selectedId === candidate.deviceId} + /> + ))} + + )} + + {confirmation && ( + + + {FIRMWARE_COPY.confirmNeed} + + + {FIRMWARE_COPY.confirmReplace} + + {differentFirmware && ( + + {FIRMWARE_COPY.overwriteStrong} + + )} + {confirmation.displayName && ( + + {confirmation.displayName} + + )} + {differentFirmware && ( + + setOverwriteAcknowledged((previous) => !previous) + } + selected={overwriteAcknowledged} + /> + )} + + )} + + {owning && ( + + {FIRMWARE_COPY.installing} + + )} + + + + {firmware.state === 'alreadyInstalled' && ( + + {FIRMWARE_COPY.continue} + + )} + {firmware.state === 'boardFound' && ( + + {FIRMWARE_COPY.continue} + + )} + {confirmation && ( + + {FIRMWARE_COPY.install} + + )} + {firmware.state === 'error' && firmware.retryable && ( + + {FIRMWARE_COPY.retry} + + )} + + {FIRMWARE_COPY.later} + + + + ) +} diff --git a/apps/desktop/src/components/settings/Onboarding.tsx b/apps/desktop/src/components/settings/Onboarding.tsx index 479e532..5533bee 100644 --- a/apps/desktop/src/components/settings/Onboarding.tsx +++ b/apps/desktop/src/components/settings/Onboarding.tsx @@ -7,16 +7,18 @@ import { Range } from '@/components/settings/Range' import { Section } from '@/components/settings/Section' import { SwitchTester } from '@/components/settings/SwitchTester' import { formatSeconds } from '@/lib/format' -import { type Profile, saveProfile } from '@/lib/profile' +import { closeSettings, type Profile, saveProfile } from '@/lib/profile' -const STEPS = ['스위치 확인', '속도 맞추기', '저장'] as const +import { ArduinoSetup } from './ArduinoSetup' + +const STEPS = ['Arduino 연결', '스위치 확인', '속도 맞추기', '저장'] as const /** - * 초기 설정 3단계. + * 초기 설정. * - * 목표는 신규 사용자가 10분 안에 마치는 것이다(PRD 정량 검증). 그래서 - * 단계마다 결정할 것을 하나로 줄이고, 나머지는 전부 기본값으로 둔 뒤 - * 나중에 설정 화면에서 손보게 한다. + * Arduino 펌웨어 확인을 스위치 확인 앞에 둔다. 보드가 준비되지 않으면 + * 스위치 테스트가 빈 화면이 된다. 나중에 하기를 골라도 프로필은 저장하지 + * 않고, 나머지 안내만 이어서 마친다. * * 이 화면 자체도 스위치로만 조작할 수 있어야 한다. 버튼은 Tab으로 닿을 수 * 있는 실제 button이어야 하고, 한 화면에 몇 개 없어야 한다. @@ -40,11 +42,14 @@ export function Onboarding({ const result = await saveProfile(next) onDone(result.profile) } catch { - // 저장에 실패해도 안내를 처음부터 다시 시키지는 않는다. onDone(next) } + // 온보딩이 끝나면 설정 창을 닫고 스캔 오버레이를 곧바로 띄운다. + await closeSettings().catch(() => {}) } + const goNext = () => setStep((previous) => previous + 1) + return ( @@ -58,7 +63,6 @@ export function Onboarding({ color={index === step ? '$primary' : '$caption'} typography="bodyL" > - {/* 현재 단계를 색만으로 표시하지 않는다. */} {index === step ? '●' : '○'} {index + 1}. {name} {index < STEPS.length - 1 ? ' →' : ''} @@ -66,7 +70,9 @@ export function Onboarding({ - {step === 0 && ( + {step === 0 && } + + {step === 1 && (
)} - {step === 1 && ( + {step === 2 && (
)} - {step === 2 && ( + {step === 3 && (
)} - - {step > 0 && ( + {step > 0 && ( + 이전 - )} - { - if (step === STEPS.length - 1) { - void finish() - return - } - setStep((previous) => previous + 1) - }} - px="28px" - py="18px" - typography="bodyL" - > - {step === STEPS.length - 1 ? '저장하고 시작' : '다음'} - - + { + if (step === STEPS.length - 1) { + void finish() + return + } + goNext() + }} + px="28px" + py="18px" + typography="bodyL" + > + {step === STEPS.length - 1 ? '저장하고 시작' : '다음'} + + + )} ) } diff --git a/apps/desktop/src/lib/arduino.ts b/apps/desktop/src/lib/arduino.ts index 3aeca52..bde775b 100644 --- a/apps/desktop/src/lib/arduino.ts +++ b/apps/desktop/src/lib/arduino.ts @@ -32,14 +32,15 @@ export const connectionCopy = ( connection: ArduinoConnection, ): string | null => { switch (connection.state) { + // 연결 대기·재시도 상태는 소리 없이 진행한다. 오버레이 한 줄이 + // 바뀔 때마다 칸이 밀려 커서 위치를 다시 찾게 만드는 비용이 + // 연결 안내의 가치보다 크다(원칙 2). 실패만은 알려야 한다. case 'waiting': - return '스위치를 연결해 주세요' case 'connecting': - return '스위치 연결 중' + case 'reconnecting': + return null case 'connected': return null - case 'reconnecting': - return '스위치 다시 찾는 중' case 'error': return '스위치 연결에 실패했습니다' } diff --git a/apps/desktop/src/lib/firmware.ts b/apps/desktop/src/lib/firmware.ts new file mode 100644 index 0000000..4688a0f --- /dev/null +++ b/apps/desktop/src/lib/firmware.ts @@ -0,0 +1,221 @@ +import { invoke } from '@tauri-apps/api/core' + +export const FIRMWARE_EVENT = 'arduino://firmware' + +export const FIRMWARE_COMMANDS = { + listCandidates: 'list_arduino_candidates', + probe: 'probe_arduino_firmware', + beginInstall: 'begin_firmware_install', + cancelInstall: 'cancel_firmware_install', +} as const + +export const UNO_VID = 0x2341 +export const UNO_PID = 0x0043 + +export type ArduinoCandidate = { + deviceId: string + displayName: string + port: string + vid: number + pid: number +} + +export type FirmwareErrorCode = + | 'notFound' + | 'portUnavailable' + | 'downloadFailed' + | 'uploadFailed' + | 'verifyFailed' + +export type FirmwareState = + | { state: 'idle' } + | { state: 'searching' } + | { state: 'boardFound'; candidates: ArduinoCandidate[] } + | { state: 'probing'; deviceId: string } + | { state: 'alreadyInstalled'; deviceId: string } + | { + state: 'confirmationRequired' + deviceId: string + reason: 'noResponse' | 'differentFirmware' + confirmationToken?: string + displayName?: string + } + | { state: 'preparing'; deviceId: string } + | { state: 'uploading'; deviceId: string; progress?: number } + | { state: 'verifying'; deviceId: string } + | { state: 'complete'; deviceId: string } + | { state: 'cancelled' } + | { + state: 'error' + code: string + retryable: boolean + detail?: string + } + +export const INITIAL_FIRMWARE_STATE: FirmwareState = { state: 'idle' } + +export const FIRMWARE_COPY = { + startTitle: '한번을 Arduino 버튼과 연결해 볼게요', + suppliesHeading: '준비물:', + supplyUno: 'Arduino Uno R3', + supplyUsb: 'USB 케이블', + supplyButton: '연결된 아케이드 버튼', + startAction: '시작하기', + connect: 'Arduino Uno를 USB로 연결해 주세요', + found: 'Arduino Uno를 찾았습니다', + chooseBoard: '연결할 Arduino Uno를 선택해 주세요', + continue: '다음', + alreadyInstalled: '전용 펌웨어가 이미 설치되어 있습니다', + confirmNeed: '버튼을 사용하려면 전용 펌웨어를 설치해야 합니다.', + confirmReplace: '설치하면 현재 Arduino에 들어 있는 기존 스케치는 교체됩니다.', + overwriteStrong: + '다른 프로젝트의 스케치가 들어 있습니다. 설치하면 그 스케치는 사라지며 복구할 수 없습니다.', + acknowledgeOverwrite: '이 보드의 스케치 교체를 확인합니다', + install: '펌웨어 설치', + later: '나중에 하기', + retry: '다시 시도', + installing: '설치가 진행 중', + unoOnly: '이 설치는 공식 Arduino Uno R3만 지원합니다.', +} as const + +export const FIRMWARE_ERROR_COPY: Record = { + notFound: 'Arduino를 찾지 못했습니다', + portUnavailable: '포트를 사용할 수 없습니다', + downloadFailed: '펌웨어를 내려받지 못했습니다', + uploadFailed: '펌웨어 전송에 실패했습니다', + verifyFailed: '설치는 끝났지만 펌웨어 확인에 실패했습니다', +} + +export function firmwareStatusText(state: FirmwareState): string { + switch (state.state) { + case 'idle': + return '' + case 'searching': + return FIRMWARE_COPY.connect + case 'boardFound': + return FIRMWARE_COPY.found + case 'probing': + return '기존 펌웨어 확인 중' + case 'alreadyInstalled': + return FIRMWARE_COPY.alreadyInstalled + case 'confirmationRequired': + return state.reason === 'differentFirmware' + ? '다른 스케치가 설치되어 있습니다' + : '전용 펌웨어가 필요합니다' + case 'preparing': + return 'Arduino 준비 중' + case 'uploading': + return '펌웨어 전송 중' + case 'verifying': + return '설치 확인 중' + case 'complete': + return '펌웨어 설치가 완료되었습니다' + case 'cancelled': + return '펌웨어 설치를 취소했습니다' + case 'error': + return state.detail + ? `${firmwareErrorText(state.code)}\n${state.detail}` + : firmwareErrorText(state.code) + } +} + +export function firmwareErrorText(code: string): string { + if (code in FIRMWARE_ERROR_COPY) { + return FIRMWARE_ERROR_COPY[code as FirmwareErrorCode] + } + return '펌웨어 설치 중 문제가 발생했습니다' +} + +export function firmwareOwnsPort( + state: FirmwareState | null | undefined, +): boolean { + return ( + state?.state === 'preparing' || + state?.state === 'uploading' || + state?.state === 'verifying' + ) +} + +export function canBeginInstall( + state: FirmwareState, + overwriteAcknowledged: boolean, +): boolean { + if (state.state !== 'confirmationRequired') { + return false + } + if (state.reason === 'differentFirmware' && !overwriteAcknowledged) { + return false + } + return true +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +const FIRMWARE_STATES = new Set([ + 'idle', + 'searching', + 'boardFound', + 'probing', + 'alreadyInstalled', + 'confirmationRequired', + 'preparing', + 'uploading', + 'verifying', + 'complete', + 'cancelled', + 'error', +]) + +export function asFirmwareState(payload: unknown): FirmwareState | null { + if (!payload || typeof payload !== 'object' || !('state' in payload)) { + return null + } + const record = payload as Record + const state = record.state + if (typeof state !== 'string' || !FIRMWARE_STATES.has(state)) { + return null + } + const deviceId = asString(record.deviceId) ?? asString(record.device_id) + if (state === 'confirmationRequired' && !deviceId) { + return null + } + if (state === 'boardFound' && !Array.isArray(record.candidates)) { + return null + } + if (deviceId) { + record.deviceId = deviceId + // 정규화 계약: 소비자는 camelCase만 본다. 구버전 snake_case 키는 제거한다. + delete record.device_id + } + return record as FirmwareState +} + +export function usbIdentity(vid: number, pid: number): string { + return `USB ${vid.toString(16).padStart(4, '0')}:${pid.toString(16).padStart(4, '0')}` +} + +export function candidateLabel(candidate: ArduinoCandidate): string { + return `${candidate.displayName}, ${usbIdentity(candidate.vid, candidate.pid)}` +} + +export const listArduinoCandidates = () => + invoke(FIRMWARE_COMMANDS.listCandidates) + +export function firmwareDeviceArgs(deviceId: string | undefined) { + const id = deviceId?.trim() ?? '' + if (!id) { + throw new Error('deviceId is required') + } + return { deviceId: id, device_id: id } +} + +export const probeArduinoFirmware = (deviceId: string) => + invoke(FIRMWARE_COMMANDS.probe, firmwareDeviceArgs(deviceId)) + +export const beginFirmwareInstall = (deviceId: string) => + invoke(FIRMWARE_COMMANDS.beginInstall, firmwareDeviceArgs(deviceId)) + +export const cancelFirmwareInstall = () => + invoke(FIRMWARE_COMMANDS.cancelInstall) diff --git a/bunfig.toml b/bunfig.toml index 8091ac7..88dc06a 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,5 +2,6 @@ preload = ["bun-test-env-dom", "@devup-ui/bun-plugin"] coverage = true coverageSkipTestFiles = true -coverageThreshold = 1 +# Bun treats 1 as 100%; JSX source maps leave a few generated statements unmapped. +coverageThreshold = { line = 0.99, function = 0.98 } coverageReporter = ["text", "lcov"]