diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index e23f3f6a..fec93556 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -322,6 +322,7 @@ void PeerEventHandler::OnReceiveDataBuffer( if (props && source_id == props->control_data_label_) { std::lock_guard lock(props->remote_version_mutex_); props->remote_host_info_received_ = true; + props->remote_platform_ = remote_action.i.platform; props->remote_app_version_ = IsValidAppVersion(remote_action.i.app_version) ? remote_action.i.app_version : ""; } diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index ad18d475..ce3ea772 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -32,6 +32,19 @@ #endif namespace crossdesk { +namespace { + +#if defined(_WIN32) +constexpr HostPlatform kClientPlatform = HostPlatform::Windows; +#elif defined(__APPLE__) +constexpr HostPlatform kClientPlatform = HostPlatform::MacOS; +#elif defined(__linux__) +constexpr HostPlatform kClientPlatform = HostPlatform::Linux; +#else +constexpr HostPlatform kClientPlatform = HostPlatform::Unknown; +#endif + +} // namespace void PeerEventHandler::SendClientInfo(PeerPtr* peer, const std::string& client_id) { @@ -39,19 +52,9 @@ void PeerEventHandler::SendClientInfo(PeerPtr* peer, return; } -#if defined(_WIN32) - constexpr const char* kClientPlatform = "windows"; -#elif defined(__APPLE__) - constexpr const char* kClientPlatform = "macos"; -#elif defined(__linux__) - constexpr const char* kClientPlatform = "linux"; -#else - constexpr const char* kClientPlatform = "unknown"; -#endif - const nlohmann::json message = {{"type", "client_info"}, {"version", CROSSDESK_VERSION}, - {"platform", kClientPlatform}}; + {"platform", HostPlatformName(kClientPlatform)}}; const std::string payload = message.dump(); if (SendSignalMessage(peer, payload.data(), payload.size()) != 0) { LOG_WARN("[{}] failed to report client information", client_id); @@ -270,7 +273,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, { RemoteAction remote_action = MakeHostInformation( GetHostName(), runtime->devices_.host_display_list(), - /*supports_privacy_screen=*/true, CROSSDESK_VERSION); + /*supports_privacy_screen=*/true, CROSSDESK_VERSION, + kClientPlatform); remote_action.i.supports_video_settings = true; for (std::size_t i = 0; i < remote_action.i.display_num; i++) { LOG_INFO("Local display [{}:{}]", i + 1, @@ -304,6 +308,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, std::lock_guard lock(props->remote_version_mutex_); props->remote_host_info_received_ = false; props->remote_app_version_.clear(); + props->remote_platform_ = HostPlatform::Unknown; props->remote_update_dismissed_ = false; } props->net_traffic_stats_.Reset(); @@ -401,7 +406,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, { RemoteAction remote_action = MakeHostInformation( GetHostName(), runtime->devices_.host_display_list(), - /*supports_privacy_screen=*/true, CROSSDESK_VERSION); + /*supports_privacy_screen=*/true, CROSSDESK_VERSION, + kClientPlatform); remote_action.i.supports_video_settings = true; for (std::size_t i = 0; i < remote_action.i.display_num; i++) { LOG_INFO("Local display [{}:{}]", i + 1, diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 4470b78b..9b098c45 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -124,10 +124,12 @@ struct RemoteSession { bool tab_selected_ = false; bool tab_opened_ = true; std::string remote_host_name_; - // Received version and dismissal state belong to this connection. + // Host metadata and dismissal state belong to this connection and are + // protected by remote_version_mutex_. std::mutex remote_version_mutex_; bool remote_host_info_received_ = false; std::string remote_app_version_; + HostPlatform remote_platform_ = HostPlatform::Unknown; bool remote_update_dismissed_ = false; bool remote_service_status_received_ = false; std::mutex video_settings_mutex_; diff --git a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj index 4c4a4650..0ea1224d 100644 --- a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj +++ b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; }; 100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; }; 100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; }; 100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; }; @@ -29,6 +30,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = ""; }; 200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = ""; }; 200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = ""; }; 200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = ""; }; @@ -96,6 +98,7 @@ 200000000000000000000013 /* ThumbnailFileStore.swift */, 200000000000000000000012 /* PrivacyPreferences.swift */, 200000000000000000000002 /* RemoteSessionModel.swift */, + 200000000000000000000018 /* RecentConnection.swift */, 200000000000000000000016 /* RemoteVideoSettings.swift */, 200000000000000000000017 /* RemoteNetworkStatistics.swift */, 200000000000000000000003 /* ContentView.swift */, @@ -261,6 +264,7 @@ files = ( 100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */, 100000000000000000000002 /* RemoteSessionModel.swift in Sources */, + 100000000000000000000018 /* RecentConnection.swift in Sources */, 100000000000000000000016 /* RemoteVideoSettings.swift in Sources */, 100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */, 100000000000000000000003 /* ContentView.swift in Sources */, diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index ae61e3a4..1ff0a1f2 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -86,6 +86,7 @@ typedef struct { - (void)rtcBridge:(CrossDeskRTCBridge *)bridge didReceiveHostName:(NSString *)hostName appVersion:(NSString *)appVersion + platform:(NSString *)platform displayNames:(NSArray *)displayNames displaySizes:(NSArray *)displaySizes supportsVideoSettings:(BOOL)supportsVideoSettings; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index abaead70..3dee9878 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -1343,6 +1343,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id, : "iPhone-" + self->_state->identity_base; RemoteAction action{}; action.type = ControlType::host_infomation; + action.i.platform = crossdesk::HostPlatform::IOS; CopyCString(action.i.host_name, sizeof(action.i.host_name), controller_name); action.i.host_name_size = std::strlen(action.i.host_name); @@ -1634,6 +1635,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id, } if (action.type != ControlType::host_infomation) return; const BOOL supports_video_settings = action.i.supports_video_settings; + NSString *platform = [NSString stringWithUTF8String: + crossdesk::HostPlatformName(action.i.platform)]; NSString *host_name = [[NSString alloc] initWithBytes:action.i.host_name length:action.i.host_name_size @@ -1663,11 +1666,12 @@ Params MakeParams(const RTCState &state, const std::string &user_id, if (![self isControllerGenerationActive:generation]) return; id delegate = self.delegate; if ([delegate respondsToSelector: - @selector(rtcBridge:didReceiveHostName:appVersion:displayNames: + @selector(rtcBridge:didReceiveHostName:appVersion:platform:displayNames: displaySizes:supportsVideoSettings:)]) { [delegate rtcBridge:self didReceiveHostName:host_name ?: @"" appVersion:app_version ?: @"" + platform:platform displayNames:display_names displaySizes:display_sizes supportsVideoSettings:supports_video_settings]; diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index 65a336fb..277178da 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -542,6 +542,26 @@ private struct RecentConnectionCard: View { let connect: () -> Void let delete: () -> Void + private var platformColors: [Color] { + switch connection.platform { + case .windows: + return [Color(red: 0.12, green: 0.38, blue: 0.70), + Color(red: 0.20, green: 0.48, blue: 0.77)] + case .linux: + return [Color(red: 0.63, green: 0.31, blue: 0.08), + Color(red: 0.73, green: 0.39, blue: 0.12)] + case .macos: + return [Color(red: 0.43, green: 0.29, blue: 0.68), + Color(red: 0.55, green: 0.39, blue: 0.77)] + case .ios: + return [Color(red: 0.08, green: 0.43, blue: 0.40), + Color(red: 0.12, green: 0.51, blue: 0.47)] + case .unknown: + return [Color(red: 0.35, green: 0.40, blue: 0.46), + Color(red: 0.45, green: 0.49, blue: 0.55)] + } + } + var body: some View { Button(action: connect) { VStack(alignment: .leading, spacing: 0) { @@ -551,13 +571,16 @@ private struct RecentConnectionCard: View { .resizable() .scaledToFill() } else { - LinearGradient(colors: [ - Color(red: 0.18, green: 0.48, blue: 0.86), - Color(red: 0.38, green: 0.68, blue: 0.96) - ], startPoint: .topLeading, endPoint: .bottomTrailing) - Image(systemName: "display") - .font(.system(size: 34, weight: .medium)) - .foregroundStyle(.white.opacity(0.9)) + LinearGradient(colors: platformColors, + startPoint: .topLeading, + endPoint: .bottomTrailing) + Text(connection.platform.displayName) + .font(.title3.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .foregroundStyle(.white) + .padding(8) + .accessibilityLabel("被控端平台:\(connection.platform.displayName)") } } .frame(maxWidth: .infinity) diff --git a/apps/ios/CrossDeskMobile/RecentConnection.swift b/apps/ios/CrossDeskMobile/RecentConnection.swift new file mode 100644 index 00000000..7533a3be --- /dev/null +++ b/apps/ios/CrossDeskMobile/RecentConnection.swift @@ -0,0 +1,58 @@ +import Foundation + +enum RemoteHostPlatform: String, Codable { + case unknown, windows, macos, linux, ios + + var displayName: String { + switch self { + case .windows: return "Windows" + case .macos: return "macOS" + case .linux: return "Linux" + case .ios: return "iOS" + case .unknown: return "未知" + } + } +} + +struct RecentConnection: Codable, Identifiable, Equatable { + let remoteID: String + var displayName: String + var lastConnectedAt: Date + var remembersPassword: Bool + var thumbnailFileName: String? + var platform: RemoteHostPlatform + + var id: String { remoteID } + + init(remoteID: String, displayName: String, lastConnectedAt: Date, + remembersPassword: Bool, thumbnailFileName: String?, + platform: RemoteHostPlatform = .unknown) { + self.remoteID = remoteID + self.displayName = displayName + self.lastConnectedAt = lastConnectedAt + self.remembersPassword = remembersPassword + self.thumbnailFileName = thumbnailFileName + self.platform = platform + } + + private enum CodingKeys: String, CodingKey { + case remoteID, displayName, lastConnectedAt, remembersPassword, thumbnailFileName, platform + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + remoteID = try values.decode(String.self, forKey: .remoteID) + displayName = try values.decode(String.self, forKey: .displayName) + lastConnectedAt = try values.decode(Date.self, forKey: .lastConnectedAt) + remembersPassword = try values.decode(Bool.self, forKey: .remembersPassword) + thumbnailFileName = try values.decodeIfPresent(String.self, forKey: .thumbnailFileName) + // Adding platform metadata must not discard records saved by older apps. + platform = (try? values.decode(RemoteHostPlatform.self, forKey: .platform)) ?? .unknown + } + + mutating func updateHostInfo(name: String, platform: RemoteHostPlatform) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { displayName = trimmed } + self.platform = platform + } +} diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index c116f8aa..dcabe5fd 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -98,16 +98,6 @@ private struct RemoteVideoFrame { let captureUptime: TimeInterval } -struct RecentConnection: Codable, Identifiable, Equatable { - let remoteID: String - var displayName: String - var lastConnectedAt: Date - var remembersPassword: Bool - var thumbnailFileName: String? - - var id: String { remoteID } -} - private enum RecentConnectionStore { private static let defaultsKey = "crossdesk.mobile.recent-connections.v1" private static let thumbnails = ThumbnailFileStore(directory: @@ -345,6 +335,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe @Published private(set) var deviceOfflineAlertVisible = false @Published private(set) var connectionFailureMessage: String? @Published private(set) var remoteUpdateMessage: String? + @Published private(set) var remotePlatform: RemoteHostPlatform = .unknown let bridge = CrossDeskRTCBridge() private let audioPlayer = RemoteAudioPlayer() @@ -553,7 +544,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe private func beginRemoteConnection(_ identifier: String) { networkStatistics = RemoteNetworkStatistics() - resetRemoteVersionCheck() + resetRemoteHostInfo() cancelVideoRecovery() resetVideoSettings() cancelPresenceProbe() @@ -647,7 +638,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe } private func resetConnection() { - resetRemoteVersionCheck() + resetRemoteHostInfo() resetVideoSettings() connectionFailureMessage = nil cancelVideoRecovery() @@ -673,7 +664,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe } func retry() { - resetRemoteVersionCheck() + resetRemoteHostInfo() cancelVideoRecovery() bridge.disconnect() connect() @@ -683,12 +674,13 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe remoteUpdateMessage = nil } - private func resetRemoteVersionCheck() { + private func resetRemoteHostInfo() { remoteVersionCheckTask?.cancel() remoteVersionCheckTask = nil remoteVersionCheckGeneration = UUID() remoteVersionCheckAttempted = false remoteAppVersion = "" + remotePlatform = .unknown remoteHostInfoReceived = false remoteUpdateMessage = nil } @@ -891,7 +883,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe displayName: title, lastConnectedAt: Date(), remembersPassword: pendingRememberPassword, - thumbnailFileName: previous?.thumbnailFileName + thumbnailFileName: previous?.thumbnailFileName, + platform: remoteHostInfoReceived ? remotePlatform : (previous?.platform ?? .unknown) ) recentConnections.removeAll { $0.remoteID == activeRemoteID } recentConnections.insert(connection, at: 0) @@ -899,14 +892,14 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe refreshRecentConnectionPresence() } - private func updateActiveConnectionName(_ name: String) { + private func updateActiveConnectionHostInfo(_ name: String) { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, !activeRemoteID.isEmpty else { return } - activeDisplayName = trimmed + guard !activeRemoteID.isEmpty else { return } + if !trimmed.isEmpty { activeDisplayName = trimmed } guard let index = recentConnections.firstIndex(where: { $0.remoteID == activeRemoteID }) else { return } - recentConnections[index].displayName = trimmed + recentConnections[index].updateHostInfo(name: trimmed, platform: remotePlatform) RecentConnectionStore.save(recentConnections) } @@ -1183,6 +1176,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveHostName hostName: String, appVersion: String, + platform: String, displayNames remoteDisplayNames: [String], displaySizes remoteDisplaySizes: [NSValue], supportsVideoSettings: Bool) { @@ -1196,8 +1190,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe } remoteHostInfoReceived = true remoteAppVersion = appVersion + remotePlatform = RemoteHostPlatform(rawValue: platform) ?? .unknown checkRemoteVersionIfNeeded() - updateActiveConnectionName(hostName) + updateActiveConnectionHostInfo(hostName) let names = remoteDisplayNames.enumerated().map { index, displayName in let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? "显示器 \(index + 1)" : name diff --git a/apps/ios/README.md b/apps/ios/README.md index 3afb423a..b7e87ceb 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -237,6 +237,17 @@ Receive loss rates are fractions converted to percentages. As on desktop, MiniRTC's total loss is the sum of the media loss rates, not a packet-weighted overall loss rate. +The reliable control channel's `host_info` also advertises `platform` as +`windows`, `linux`, `macos`, `ios`, or `unknown`. Desktop and iOS controllers +retain the received platform for the current session. Missing, malformed, or +unrecognized values from older or other peers map to unknown (`未知` on iOS), +without rejecting the remaining host information. Reconnecting clears the +previous peer's platform; iOS sends `ios` in its own host information. +On iOS, the received platform is also saved with each recent connection. +When preview saving is off or no preview is available, the thumbnail placeholder +shows the platform name. Existing history without platform metadata shows +`未知` until host information is received from a subsequent connection. + For black-screen diagnosis, keep Xcode's debug console open and look for `CrossDesk` and `VideoToolbox` messages. With VideoToolbox H.264 decoding selected, look for `VideoToolbox decoded frame` from MiniRTC and `CrossDesk delivered latest frame` diff --git a/apps/ios/tests/recent_connection_test.py b/apps/ios/tests/recent_connection_test.py new file mode 100644 index 00000000..f31f23ea --- /dev/null +++ b/apps/ios/tests/recent_connection_test.py @@ -0,0 +1,95 @@ +"""Check platform persistence without losing existing recent connections.""" + +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime") +class RecentConnectionTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory(prefix="crossdesk-recent-connections-") + cls.addClassCleanup(cls.temp.cleanup) + root = Path(cls.temp.name) + harness = root / "RecentConnectionTests.swift" + harness.write_text(r''' +import Foundation + +func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { fatalError(message) } +} + +@main enum RecentConnectionTests { + static func main() throws { + let test = CommandLine.arguments[1] + let decoder = JSONDecoder() + let legacy = #"{"remoteID":"123456","displayName":"Work PC","lastConnectedAt":100,"remembersPassword":true,"thumbnailFileName":"123456.jpg"}"# + if test == "legacy" { + for field in ["", #", "platform":null"#, #", "platform":23"#, + #", "platform":"future-os""#] { + let record = String(legacy.dropLast()) + field + "}" + let connections = try decoder.decode([RecentConnection].self, + from: Data("[\(record)]".utf8)) + expect(connections.count == 1, "Old history must not be discarded") + let connection = connections[0] + expect(connection.platform == .unknown, "Missing or invalid platform is unknown") + expect(connection.platform.displayName == "未知", "Unknown has a readable placeholder") + expect(connection.remoteID == "123456" && connection.displayName == "Work PC", + "Existing identity and name survive migration") + expect(connection.lastConnectedAt == Date(timeIntervalSinceReferenceDate: 100) && + connection.remembersPassword && connection.thumbnailFileName == "123456.jpg", + "Migration preserves dates, saved-password flags and previews") + } + } else if test == "round_trip" { + let platforms: [RemoteHostPlatform] = [.windows, .linux, .macos, .ios, .unknown] + let connections = platforms.enumerated().map { index, platform in + RecentConnection(remoteID: String(index), displayName: "Host \(index)", + lastConnectedAt: Date(timeIntervalSinceReferenceDate: Double(index)), + remembersPassword: false, thumbnailFileName: nil, platform: platform) + } + let data = try JSONEncoder().encode(connections) + let reloaded = try decoder.decode([RecentConnection].self, from: data) + expect(reloaded == connections, "Platforms persist per device without requiring an image") + } else if test == "host_info" { + var connection = try decoder.decode(RecentConnection.self, from: Data(legacy.utf8)) + connection.updateHostInfo(name: " ", platform: .windows) + expect(connection.displayName == "Work PC" && connection.platform == .windows, + "An empty host name must not prevent a platform update") + connection.updateHostInfo(name: " Linux workstation ", platform: .linux) + expect(connection.displayName == "Linux workstation" && connection.platform == .linux, + "New host information replaces the cached platform") + connection.updateHostInfo(name: "", platform: .unknown) + let data = try JSONEncoder().encode(connection) + let reloaded = try decoder.decode(RecentConnection.self, from: data) + expect(reloaded.platform == .unknown && reloaded.thumbnailFileName == "123456.jpg", + "Legacy peers replace the old platform without removing a saved preview") + } else { + fatalError("Unknown test") + } + } +} +''') + source = Path(__file__).resolve().parents[1] / "CrossDeskMobile/RecentConnection.swift" + cls.binary = root / "recent-connection-tests" + subprocess.run(["xcrun", "swiftc", str(source), str(harness), "-o", str(cls.binary)], + check=True, capture_output=True, text=True) + + def run_case(self, case): + result = subprocess.run([str(self.binary), case], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + + def test_existing_history_survives_platform_migration(self): + self.run_case("legacy") + + def test_platforms_persist_without_saved_previews(self): + self.run_case("round_trip") + + def test_received_host_info_replaces_cached_platform(self): + self.run_case("host_info") + + +if __name__ == "__main__": + unittest.main() diff --git a/libs/wire/include/remote_action.h b/libs/wire/include/remote_action.h index fa4c770d..301086c0 100644 --- a/libs/wire/include/remote_action.h +++ b/libs/wire/include/remote_action.h @@ -116,11 +116,18 @@ struct CursorState { CursorHiddenReason hidden_reason; }; +enum class HostPlatform { Unknown, Windows, MacOS, Linux, IOS }; + +const char* HostPlatformName(HostPlatform platform); +HostPlatform ParseHostPlatform(const std::string& platform); + struct HostInfo { char host_name[64]; std::size_t host_name_size; // Optional; an empty string means the peer version is unknown. char app_version[64]; + // Optional on the wire; missing or unrecognized values are unknown. + HostPlatform platform; char** display_list; std::size_t display_num; int* left; @@ -219,7 +226,8 @@ struct HostDisplay { RemoteAction MakeHostInformation(const std::string& host_name, const std::vector& displays, bool supports_privacy_screen, - const std::string& app_version = {}); + const std::string& app_version = {}, + HostPlatform platform = HostPlatform::Unknown); } // namespace crossdesk diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index 54312169..578c425a 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -17,6 +17,7 @@ void ResetHostInfo(HostInfo& info) { info.host_name[0] = '\0'; info.host_name_size = 0; info.app_version[0] = '\0'; + info.platform = HostPlatform::Unknown; info.display_list = nullptr; info.display_num = 0; info.left = nullptr; @@ -49,10 +50,29 @@ char* DuplicateString(const std::string& value) { } // namespace +const char* HostPlatformName(HostPlatform platform) { + switch (platform) { + case HostPlatform::Windows: return "windows"; + case HostPlatform::MacOS: return "macos"; + case HostPlatform::Linux: return "linux"; + case HostPlatform::IOS: return "ios"; + default: return "unknown"; + } +} + +HostPlatform ParseHostPlatform(const std::string& platform) { + if (platform == "windows") return HostPlatform::Windows; + if (platform == "macos") return HostPlatform::MacOS; + if (platform == "linux") return HostPlatform::Linux; + if (platform == "ios") return HostPlatform::IOS; + return HostPlatform::Unknown; +} + RemoteAction MakeHostInformation(const std::string& host_name, const std::vector& displays, bool supports_privacy_screen, - const std::string& app_version) { + const std::string& app_version, + HostPlatform platform) { RemoteAction action{}; action.type = ControlType::host_infomation; ResetHostInfo(action.i); @@ -73,6 +93,7 @@ RemoteAction MakeHostInformation(const std::string& host_name, } action.i.supports_privacy_screen = supports_privacy_screen; + action.i.platform = platform; const std::size_t name_size = std::min(host_name.size(), sizeof(action.i.host_name) - 1); std::memcpy(action.i.host_name, host_name.data(), name_size); @@ -192,6 +213,7 @@ std::string RemoteAction::ToJson(const RemoteAction& action) { object["host_info"] = { {"host_name", action.i.host_name}, {"app_version", action.i.app_version}, + {"platform", HostPlatformName(action.i.platform)}, {"display_num", action.i.display_num}, {"displays", displays}, {"supports_privacy_screen", action.i.supports_privacy_screen}, @@ -368,6 +390,11 @@ bool RemoteAction::FromJson(const std::string& json_string, ResetHostInfo(output.i); owns_host_info = true; const auto& host_info_object = object.at("host_info"); + const auto platform = host_info_object.find("platform"); + if (platform != host_info_object.end() && platform->is_string()) { + output.i.platform = + ParseHostPlatform(platform->get_ref()); + } const auto app_version = host_info_object.find("app_version"); if (app_version != host_info_object.end() && app_version->is_string()) { const auto& version = app_version->get_ref(); diff --git a/libs/wire/tests/wire_test.cpp b/libs/wire/tests/wire_test.cpp index 7d7526e5..43cd6c7d 100644 --- a/libs/wire/tests/wire_test.cpp +++ b/libs/wire/tests/wire_test.cpp @@ -135,6 +135,7 @@ int main() { host.type = crossdesk::ControlType::host_infomation; std::strcpy(host.i.host_name, "Test Mac"); std::strcpy(host.i.app_version, "1.5.3-2-20260928"); + host.i.platform = crossdesk::HostPlatform::MacOS; host.i.host_name_size = std::strlen(host.i.host_name); host.i.display_list = display_names; host.i.display_num = 1; @@ -147,6 +148,7 @@ int main() { parsed_host.i.display_num == 1 && std::string(parsed_host.i.host_name) == "Test Mac" && std::string(parsed_host.i.app_version) == "1.5.3-2-20260928" && + parsed_host.i.platform == crossdesk::HostPlatform::MacOS && std::string(parsed_host.i.display_list[0]) == "Built-in Display" && parsed_host.i.right[0] == 2560 && @@ -162,6 +164,48 @@ int main() { crossdesk::FreeRemoteAction(parsed_host); crossdesk::FreeRemoteAction(advertised); + // The platform is optional metadata, not a requirement for connecting. + for (const auto& platform : std::vector{ + "windows", "linux", "macos", "ios", "unknown"}) { + advertised = crossdesk::MakeHostInformation( + "Host", {}, true, "1.5.3-20260928", + crossdesk::ParseHostPlatform(platform)); + const auto message = advertised.to_json(); + ok &= Expect(message.find("\"platform\":\"" + platform + "\"") != + std::string::npos, + "host factory must advertise the canonical platform string"); + ok &= Expect(parsed_host.from_json(message) && + crossdesk::HostPlatformName(parsed_host.i.platform) == platform, + "host platform must survive a JSON round trip"); + crossdesk::FreeRemoteAction(parsed_host); + crossdesk::FreeRemoteAction(advertised); + } + + for (const auto& platform_field : std::vector{ + "", ",\"platform\":null", ",\"platform\":123", ",\"platform\":true", + ",\"platform\":[]", ",\"platform\":{}", ",\"platform\":\"\"", + ",\"platform\":\"future-os\"", ",\"platform\":\"windows\\u0000extra\"", + ",\"platform\":\"" + std::string(256, 'x') + "\""}) { + const auto message = + "{\"type\":3,\"host_info\":{\"host_name\":\"Legacy\"," + "\"display_num\":1,\"displays\":[{\"name\":\"Screen\",\"left\":0," + "\"top\":0,\"right\":1920,\"bottom\":1080}]" + platform_field + "}}"; + parsed_host.i.platform = crossdesk::HostPlatform::Windows; + ok &= Expect(parsed_host.from_json(message) && + parsed_host.i.platform == crossdesk::HostPlatform::Unknown && + std::string(parsed_host.i.host_name) == "Legacy" && + parsed_host.i.display_num == 1 && + parsed_host.i.right[0] == 1920 && parsed_host.i.bottom[0] == 1080, + "missing or invalid platform must be unknown without losing host info"); + crossdesk::FreeRemoteAction(parsed_host); + } + advertised = crossdesk::MakeHostInformation("Legacy", {}, false); + ok &= Expect(advertised.i.platform == crossdesk::HostPlatform::Unknown && + advertised.to_json().find("\"platform\":\"unknown\"") != + std::string::npos, + "unspecified platform must be advertised as unknown"); + crossdesk::FreeRemoteAction(advertised); + // Missing and malformed optional versions must not break legacy host info. for (const auto& version_field : std::vector{ "", ",\"app_version\":null", ",\"app_version\":123",