diff --git a/README.md b/README.md index 4a0e6281..e251746e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Switchify keeps up to 500 sanitized diagnostic events locally in `diagnostic-his Support → Troubleshooting shows a compact summary of recent Bluetooth changes, the last disconnect, and recent errors. Export writes the current sanitized state, the diagnostic schema version, and the complete ordered bounded history to `switchify-diagnostics.json`. +Protocol v1 clients may attach an optional `deviceName` to an authenticated `connection.ping`. Switchify updates the saved display name for that paired device without changing its device ID, token, or authorization. Empty pings from older clients remain valid, and older PC builds safely ignore the additional payload field. Persistence failures return the sanitized `name_update_failed` response so Remote can retry the name without failing authentication. + Anonymous diagnostic telemetry is disabled until the user explicitly opts in. Opt-in creates an opaque installation UUID and permits best-effort health reports plus sanitized error reports; retryable error reports are bounded to 20, and opting out deletes the identifier and queue immediately. Builds expose telemetry only when `SWITCHIFY_TELEMETRY_ENDPOINT` is an HTTPS endpoint and `TIMBERLOGS_API_KEY` is supplied from release configuration. Neither value is committed to the repository. See the [privacy policy](https://switchifyapp.com/privacy). ## Windows UIAccess package diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 01a0d489..084c62d5 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -719,6 +719,29 @@ impl MacRuntime { self.report_error(error); } } + Ok(Some(EngineEvent::RemoteNameUpdate(update))) => { + let model = self.app.state::(); + let saved = model + .apply_remote_name(&update.device_id, &update.device_name) + .unwrap_or(false); + if !saved { + set_activity( + &self.shared, + ActivityKind::Error, + "The Remote name could not be saved.", + ); + } + let response = self + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .engine + .complete_remote_name_update(&update, saved); + if let Err(error) = self.enqueue_message(&response) { + self.report_error(error); + } + emit_state(&self.app, &self.shared); + } Ok(Some(EngineEvent::PointerProfile(id))) => self.handle_pointer_profile(&id), Ok(Some(EngineEvent::MouseMove(command))) => self.handle_mouse_move(command), Ok(Some(EngineEvent::MouseClick(command))) => self.handle_mouse_click(command), diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index 27a7c407..21e8f9a8 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -333,6 +333,13 @@ pub struct DesktopCommand { pub response_mode: ResponseMode, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteNameUpdate { + pub id: String, + pub device_id: String, + pub device_name: String, +} + #[derive(Debug, Clone, PartialEq)] pub struct PointerProfile { pub display_id: String, @@ -361,6 +368,7 @@ pub enum EngineEvent { replaced_response: Option, }, Response(String), + RemoteNameUpdate(RemoteNameUpdate), PointerProfile(String), MouseMove(MouseMoveCommand), MouseClick(MouseClickCommand), @@ -549,6 +557,18 @@ impl ProtocolEngine { } } + pub fn complete_remote_name_update(&self, update: &RemoteNameUpdate, saved: bool) -> String { + if saved { + ack_response(&update.id) + } else { + error_response( + Some(&update.id), + "name_update_failed", + "The Remote name could not be saved.", + ) + } + } + fn process_message(&mut self, raw: &str, now_ms: i64) -> Result { let value: Value = serde_json::from_str(raw).map_err(|_| "invalid_json".to_string())?; let request_id = value.get("id").and_then(Value::as_str); @@ -590,7 +610,21 @@ impl ProtocolEngine { }; match command_type { - "connection.ping" => Ok(EngineEvent::Response(ack_response(&validated.id))), + "connection.ping" => match validated.payload.get("deviceName") { + None => Ok(EngineEvent::Response(ack_response(&validated.id))), + Some(value) => match value.as_str().and_then(normalize_remote_name) { + Some(device_name) => Ok(EngineEvent::RemoteNameUpdate(RemoteNameUpdate { + id: validated.id, + device_id: validated.device_id, + device_name, + })), + None => Ok(EngineEvent::Response(error_response( + Some(&validated.id), + "invalid_payload", + "Remote name is invalid.", + ))), + }, + }, "pointer.profile" => Ok(EngineEvent::PointerProfile(validated.id)), "mouse.move" => { let Some(dx) = bounded_number(&validated.payload, "dx", MAX_POINTER_DELTA) else { @@ -718,6 +752,8 @@ impl ProtocolEngine { "pairing_mismatch", ))); } + let device_name = + normalize_remote_name(&device_name).ok_or_else(|| "invalid_payload".to_string())?; let pending = PendingPairing { request_id: id, @@ -1090,6 +1126,12 @@ fn required_map_string(value: &Map, key: &str) -> Result Option { + let trimmed = value.trim(); + (!trimmed.is_empty() && trimmed.chars().count() <= 40 && !trimmed.chars().any(char::is_control)) + .then(|| trimmed.to_owned()) +} + fn bounded_number(value: &Value, key: &str, maximum_magnitude: f64) -> Option { value .get(key) @@ -1570,6 +1612,87 @@ mod tests { assert!(!is_safe_typed_text("bad\u{0085}")); } + #[test] + fn authenticated_ping_accepts_legacy_empty_payload_and_emits_valid_name_updates() { + let mut engine = ProtocolEngine::new("desktop-1".into()); + engine.set_paired_token("remote-1".into(), TOKEN.into()); + + let mut legacy = json!({ + "version": 1, + "id": "ping-legacy", + "deviceId": "remote-1", + "timestamp": NOW, + "type": "connection.ping", + "payload": {} + }); + sign(&mut legacy, TOKEN, SlashEscaping::AndroidHtmlSafe); + let EngineEvent::Response(response) = + engine.process_message(&legacy.to_string(), NOW).unwrap() + else { + panic!("expected the legacy ping acknowledgement"); + }; + assert!(response.contains("\"type\":\"ack\"")); + + let mut named = json!({ + "version": 1, + "id": "ping-named", + "deviceId": "remote-1", + "timestamp": NOW + 1, + "type": "connection.ping", + "payload": { "deviceName": " Kitchen Remote 📱 " } + }); + sign(&mut named, TOKEN, SlashEscaping::AndroidHtmlSafe); + let EngineEvent::RemoteNameUpdate(update) = + engine.process_message(&named.to_string(), NOW + 1).unwrap() + else { + panic!("expected a Remote name update"); + }; + assert_eq!(update.device_id, "remote-1"); + assert_eq!(update.device_name, "Kitchen Remote 📱"); + assert!(engine + .complete_remote_name_update(&update, true) + .contains("\"type\":\"ack\"")); + assert!(engine + .complete_remote_name_update(&update, false) + .contains("name_update_failed")); + } + + #[test] + fn remote_names_reject_controls_and_more_than_forty_unicode_characters() { + let mut engine = ProtocolEngine::new("desktop-1".into()); + engine.set_paired_token("remote-1".into(), TOKEN.into()); + for (id, device_name) in [ + ("ping-control", "Phone\nSpoof".to_owned()), + ("ping-long", "📱".repeat(41)), + ] { + let mut command = json!({ + "version": 1, + "id": id, + "deviceId": "remote-1", + "timestamp": NOW, + "type": "connection.ping", + "payload": { "deviceName": device_name } + }); + sign(&mut command, TOKEN, SlashEscaping::AndroidHtmlSafe); + let EngineEvent::Response(response) = + engine.process_message(&command.to_string(), NOW).unwrap() + else { + panic!("expected an invalid payload response"); + }; + assert!(response.contains("invalid_payload")); + } + + let invalid_pairing = pairing_request("pair-invalid", "remote-2", "Phone\nSpoof", "nonce"); + let EngineEvent::Response(response) = engine + .process_message(&invalid_pairing.to_string(), NOW) + .unwrap() + else { + panic!("expected an invalid pairing response"); + }; + assert!(response.contains("invalid_payload")); + assert!(engine.pending_pairings().is_empty()); + } + #[test] fn pairing_approval_is_memory_only_and_expires() { let mut engine = ProtocolEngine::new("desktop-1".into()); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9f9a6aa6..568e8753 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -434,6 +434,52 @@ impl AppModel { fn persist_unlocked(&self) -> Result<(), String> { self.storage.save(&self.persisted_state(None, None, None)) } + pub fn apply_remote_name(&self, device_id: &str, device_name: &str) -> Result { + let _transaction = self + .persistence_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = { + let mut data = self + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(device) = data + .state + .paired_devices + .iter_mut() + .find(|device| device.device_id == device_id) + else { + return Ok(false); + }; + let previous_name = std::mem::replace(&mut device.device_name, device_name.to_owned()); + let previous_connected_name = data.state.connected_device_name.clone(); + if previous_connected_name.is_some() { + data.state.connected_device_name = Some(device_name.to_owned()); + } + (previous_name, previous_connected_name) + }; + if let Err(error) = self.persist_unlocked() { + let mut data = self + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(device) = data + .state + .paired_devices + .iter_mut() + .find(|device| device.device_id == device_id) + .filter(|device| device.device_name == device_name) + { + device.device_name = previous.0; + } + if data.state.connected_device_name.as_deref() == Some(device_name) { + data.state.connected_device_name = previous.1; + } + return Err(error); + } + Ok(true) + } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub fn persist_settings(&self, settings: &AppSettings) -> Result<(), String> { let _transaction = self @@ -865,6 +911,67 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn remote_name_updates_persist_and_refresh_connected_state() { + let root = std::env::temp_dir().join(format!("switchify-remote-name-{}", Uuid::new_v4())); + let state_path = root.join("state.json"); + let model = AppModel::with_storage_for_test(AppStorage::at(state_path)); + { + let mut data = model.shared.lock().unwrap(); + data.state.paired_devices.push(PairedDeviceView { + device_id: "remote-1".into(), + device_name: "Old name".into(), + paired_at: 1, + last_seen_at: None, + }); + data.state.connected_device_name = Some("Old name".into()); + } + model.persist().unwrap(); + + assert!(model + .apply_remote_name("remote-1", "Kitchen Remote") + .unwrap()); + let state = model.snapshot(); + assert_eq!(state.paired_devices[0].device_name, "Kitchen Remote"); + assert_eq!( + state.connected_device_name.as_deref(), + Some("Kitchen Remote") + ); + assert_eq!( + model.storage.load().unwrap().paired_devices[0].device_name, + "Kitchen Remote" + ); + assert!(!model.apply_remote_name("missing", "Unknown").unwrap()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn remote_name_update_rolls_back_when_persistence_fails() { + let root = + std::env::temp_dir().join(format!("switchify-remote-name-fail-{}", Uuid::new_v4())); + let state_path = root.join("state.json"); + let model = AppModel::with_storage_for_test(AppStorage::at(state_path.clone())); + { + let mut data = model.shared.lock().unwrap(); + data.state.paired_devices.push(PairedDeviceView { + device_id: "remote-1".into(), + device_name: "Old name".into(), + paired_at: 1, + last_seen_at: None, + }); + data.state.connected_device_name = Some("Old name".into()); + } + model.persist().unwrap(); + fs::remove_file(&state_path).unwrap(); + fs::create_dir(&state_path).unwrap(); + + assert!(model.apply_remote_name("remote-1", "New name").is_err()); + let state = model.snapshot(); + assert_eq!(state.paired_devices[0].device_name, "Old name"); + assert_eq!(state.connected_device_name.as_deref(), Some("Old name")); + let _ = fs::remove_dir_all(root); + } + #[test] fn legacy_true_setting_becomes_explicit_opt_in_but_false_stays_undecided() { let root = std::env::temp_dir().join(format!("switchify-consent-{}", uuid::Uuid::new_v4())); diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 715b87e3..e13ac6f2 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -821,6 +821,27 @@ fn process_frame( replaced_response } Some(EngineEvent::Response(response)) => Some(response), + Some(EngineEvent::RemoteNameUpdate(update)) => { + let response = { + let model = app.state::(); + let saved = model + .apply_remote_name(&update.device_id, &update.device_name) + .unwrap_or(false); + if !saved { + set_activity( + shared, + ActivityKind::Error, + "The Remote name could not be saved.", + ); + } + shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .engine + .complete_remote_name_update(&update, saved) + }; + Some(response) + } Some(EngineEvent::PointerProfile(id)) => { let settings = shared .lock()