Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src-tauri/src/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,29 @@ impl MacRuntime {
self.report_error(error);
}
}
Ok(Some(EngineEvent::RemoteNameUpdate(update))) => {
let model = self.app.state::<AppModel>();
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),
Expand Down
125 changes: 124 additions & 1 deletion src-tauri/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -361,6 +368,7 @@ pub enum EngineEvent {
replaced_response: Option<String>,
},
Response(String),
RemoteNameUpdate(RemoteNameUpdate),
PointerProfile(String),
MouseMove(MouseMoveCommand),
MouseClick(MouseClickCommand),
Expand Down Expand Up @@ -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<EngineEvent, String> {
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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1090,6 +1126,12 @@ fn required_map_string(value: &Map<String, Value>, key: &str) -> Result<String,
.ok_or_else(|| "invalid_payload".to_string())
}

fn normalize_remote_name(value: &str) -> Option<String> {
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<f64> {
value
.get(key)
Expand Down Expand Up @@ -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());
Expand Down
107 changes: 107 additions & 0 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, String> {
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
Expand Down Expand Up @@ -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()));
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/src/windows_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<AppModel>();
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()
Expand Down