From ac4234bc159362d0b790ce49dce05851b7c5fae7 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 21 Aug 2026 22:18:58 +0100 Subject: [PATCH 1/2] feat: persist remote name updates --- README.md | 2 + src-tauri/src/macos.rs | 23 +++++++ src-tauri/src/protocol.rs | 114 ++++++++++++++++++++++++++++++- src-tauri/src/state.rs | 104 ++++++++++++++++++++++++++++ src-tauri/src/windows_runtime.rs | 21 ++++++ 5 files changed, 263 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a0e6281..f7532ca3 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. + 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..f2d24ceb 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::(); + if model + .apply_remote_name(&update.device_id, &update.device_name) + .is_err() + { + 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); + 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..051250d5 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,10 @@ impl ProtocolEngine { } } + pub fn complete_remote_name_update(&self, update: &RemoteNameUpdate) -> String { + ack_response(&update.id) + } + 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 +602,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 +744,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 +1118,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 +1604,84 @@ 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) + .contains("\"type\":\"ack\"")); + } + + #[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..b85b74d6 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -434,6 +434,49 @@ 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) + { + device.device_name = previous.0; + } + 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 +908,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..c55a8220 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::(); + if model + .apply_remote_name(&update.device_id, &update.device_name) + .is_err() + { + 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) + }; + Some(response) + } Some(EngineEvent::PointerProfile(id)) => { let settings = shared .lock() From 393e220f68a7e930d0a2add342aa4295c0177613 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 21 Aug 2026 22:27:04 +0100 Subject: [PATCH 2/2] fix: handle remote name persistence failures --- README.md | 2 +- src-tauri/src/macos.rs | 8 ++++---- src-tauri/src/protocol.rs | 17 ++++++++++++++--- src-tauri/src/state.rs | 5 ++++- src-tauri/src/windows_runtime.rs | 8 ++++---- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f7532ca3..e251746e 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ 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. +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). diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index f2d24ceb..084c62d5 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -721,10 +721,10 @@ impl MacRuntime { } Ok(Some(EngineEvent::RemoteNameUpdate(update))) => { let model = self.app.state::(); - if model + let saved = model .apply_remote_name(&update.device_id, &update.device_name) - .is_err() - { + .unwrap_or(false); + if !saved { set_activity( &self.shared, ActivityKind::Error, @@ -736,7 +736,7 @@ impl MacRuntime { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .engine - .complete_remote_name_update(&update); + .complete_remote_name_update(&update, saved); if let Err(error) = self.enqueue_message(&response) { self.report_error(error); } diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index 051250d5..21e8f9a8 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -557,8 +557,16 @@ impl ProtocolEngine { } } - pub fn complete_remote_name_update(&self, update: &RemoteNameUpdate) -> String { - ack_response(&update.id) + 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 { @@ -1642,8 +1650,11 @@ mod tests { assert_eq!(update.device_id, "remote-1"); assert_eq!(update.device_name, "Kitchen Remote 📱"); assert!(engine - .complete_remote_name_update(&update) + .complete_remote_name_update(&update, true) .contains("\"type\":\"ack\"")); + assert!(engine + .complete_remote_name_update(&update, false) + .contains("name_update_failed")); } #[test] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b85b74d6..568e8753 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -469,10 +469,13 @@ impl AppModel { .paired_devices .iter_mut() .find(|device| device.device_id == device_id) + .filter(|device| device.device_name == device_name) { device.device_name = previous.0; } - data.state.connected_device_name = previous.1; + if data.state.connected_device_name.as_deref() == Some(device_name) { + data.state.connected_device_name = previous.1; + } return Err(error); } Ok(true) diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index c55a8220..e13ac6f2 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -824,10 +824,10 @@ fn process_frame( Some(EngineEvent::RemoteNameUpdate(update)) => { let response = { let model = app.state::(); - if model + let saved = model .apply_remote_name(&update.device_id, &update.device_name) - .is_err() - { + .unwrap_or(false); + if !saved { set_activity( shared, ActivityKind::Error, @@ -838,7 +838,7 @@ fn process_frame( .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .engine - .complete_remote_name_update(&update) + .complete_remote_name_update(&update, saved) }; Some(response) }