From 25fb2a3d7f27eedab1922b3273bc3c13ec148d81 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 11 Nov 2026 15:26:04 +0800 Subject: [PATCH] [feat] remember remote display resolutions on mobile clients --- .../crossdesk/mobile/BackgroundSession.java | 3 +- .../cn/crossdesk/mobile/MainActivity.java | 8 ++- .../cn/crossdesk/mobile/NativeSession.java | 10 ++- .../mobile/RemoteDisplayResolutions.java | 12 ++-- .../mobile/RemoteResolutionStore.java | 62 +++++++++++++++++++ apps/android/native/rtc_bridge.cpp | 8 ++- .../src/main/ets/mobile/MobileController.ets | 22 ++++++- .../ets/mobile/RemoteDisplayResolutions.ets | 61 +++++++++++++++--- apps/harmony/native/mobile_session.cpp | 5 ++ .../Bridge/CrossDeskRTCBridge.h | 3 +- .../Bridge/CrossDeskRTCBridge.mm | 12 +++- .../CrossDeskMobile/RemoteSessionModel.swift | 20 ++++-- .../CrossDeskMobile/RemoteVideoSettings.swift | 50 +++++++++++++-- libs/wire/include/display_resolution.h | 4 ++ libs/wire/src/remote_action.cpp | 6 ++ 15 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteResolutionStore.java diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java index 8bfbcd61..588d83d0 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java @@ -56,7 +56,8 @@ final class BackgroundSession implements NativeSession.Listener { } @Override public void data(JSONObject message) { if (view != null) { view.data(message); return; } - displayResolutions.receive(message); + RemoteDisplayResolutions.Request confirmed=displayResolutions.receive(message); + if(confirmed!=null)session.rememberDisplayResolution(confirmed.displayKey,confirmed.mode); if (message.optInt("type", -1) == 12 && videoSettings != null) videoSettings.receive(message); if (message.optInt("type", -1) == 5) { JSONObject service = message.optJSONObject("service_status"); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index ba94949a..1367f5fc 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -651,6 +651,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen private void cancelDisplayResolutionTimeout(){if(displayResolutionTimeout!=null)mainHandler.removeCallbacks(displayResolutionTimeout);displayResolutionTimeout=null;} private void updateDisplayResolution(RemoteDisplayResolutions.Mode mode){ if(!ready||session==null||!hasNetworkConsent())return; + RemoteDisplayResolutions.Display display=displayResolutions.display(selectedDisplay); + if(!displayResolutions.pending&&display!=null&&display.supports(mode)&&display.current.same(mode)){ + session.rememberDisplayResolution(display.key,mode);return; + } RemoteDisplayResolutions.Request request=displayResolutions.begin(selectedDisplay,mode);if(request==null)return; cancelDisplayResolutionTimeout();session.displayResolution(request); if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay); @@ -677,7 +681,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen @Override public void data(JSONObject message){ if(session==null)return; if(message.optInt("type",-1)==3||message.optInt("type",-1)==16){ - displayResolutions.receive(message);if(!displayResolutions.pending)cancelDisplayResolutionTimeout(); + RemoteDisplayResolutions.Request confirmed=displayResolutions.receive(message); + if(confirmed!=null&&session!=null)session.rememberDisplayResolution(confirmed.displayKey,confirmed.mode); + if(!displayResolutions.pending)cancelDisplayResolutionTimeout(); if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay); if(message.optInt("type",-1)==16)return; } diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java index 57f1191f..8505496e 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java @@ -44,6 +44,7 @@ final class NativeSession implements AutoCloseable { private final Context context; private volatile Listener listener; private final SecretStore secrets; + private final RemoteResolutionStore resolutionStore; private final String host, scope; private volatile String remote; private String password, identity = ""; @@ -80,6 +81,7 @@ final class NativeSession implements AutoCloseable { this.host = host; this.port = port; this.remote = remote; this.password = password; scope = host.toLowerCase(java.util.Locale.ROOT) + ":" + port; secrets = new SecretStore(this.context); + resolutionStore = new RemoteResolutionStore(this.context); documents = new SessionDocuments(this.context); } void start() { @@ -184,7 +186,8 @@ final class NativeSession implements AutoCloseable { int status = event.getInt("status"); if (status == 1) { if (connected) return; // Repeated transport readiness is the same desktop session. - connected = true; main.removeCallbacks(timeout); nReady(handle); + connected = true; main.removeCallbacks(timeout); + nReady(handle,resolutionStore.load(host,port,remote).toString().getBytes(StandardCharsets.UTF_8)); audio = new AudioPlayer(); nControl(handle, 2, 1); ui(() -> { listener.connected(); if(!closed){statisticsTick.run();fileTick.run();} }); @@ -247,6 +250,9 @@ final class NativeSession implements AutoCloseable { void displayResolution(RemoteDisplayResolutions.Request request){ execute(()->{if(!connected||!nDisplayResolution(handle,request.displayId,request.displayKey,request.mode.width,request.mode.height,request.id))ui(()->listener.displayResolutionFailed(request.id));}); } + void rememberDisplayResolution(String displayKey,RemoteDisplayResolutions.Mode mode){ + if(!closed&&connected)resolutionStore.remember(host,port,remote,displayKey,mode); + } void announcementRequest(JSONObject request,String requestId){ byte[] bytes=request.toString().getBytes(StandardCharsets.UTF_8); execute(()->{if(!signalReady||identity.isEmpty()||!nAnnouncementRequest(handle,bytes))ui(()->listener.announcementFailed(requestId));}); @@ -343,7 +349,7 @@ final class NativeSession implements AutoCloseable { static native String nCheckMobileUpdate(String version, byte[] releaseJson); private static native long nCreate(NativeSession owner, String host, int port, String identity, String logs, String roots); private static native boolean nConnect(long handle, String identity, String remote, String password); - private static native void nReady(long handle); + private static native void nReady(long handle,byte[] displayResolutions); private static native void nPointer(long handle, float x, float y, int flag, int wheel); private static native void nKey(long handle, int code, boolean down); private static native void nText(long handle,byte[] text); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteDisplayResolutions.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteDisplayResolutions.java index 819db15f..7c1d6635 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteDisplayResolutions.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteDisplayResolutions.java @@ -57,28 +57,30 @@ final class RemoteDisplayResolutions { requested=new Request(display,mode,next==0?1:next);pending=true;failed=false;return requested; } void expire(long id){if(pending&&requested!=null&&requested.id==id){pending=false;failed=true;}} - void receive(JSONObject message){ + Request receive(JSONObject message){ try{ int type=message.getInt("type");JSONArray values;JSONObject response=null; if(type==3){JSONObject host=message.getJSONObject("host_info");values=host.has("display_resolutions")?host.getJSONArray("display_resolutions"):new JSONArray();} - else if(type==16){response=message.getJSONObject("display_resolution");if(requested==null||!requested.matches(response)||!(response.get("accepted") instanceof Boolean))return;values=message.getJSONArray("display_resolutions");} - else return; - if(values.length()>32)return; + else if(type==16){response=message.getJSONObject("display_resolution");if(requested==null||!requested.matches(response)||!(response.get("accepted") instanceof Boolean))return null;values=message.getJSONArray("display_resolutions");} + else return null; + if(values.length()>32)return null; List actual=new ArrayList<>(); for(int i=0;i32768)throw new IllegalArgumentException(); + return (int)number; + } + synchronized JSONArray load(String host,int port,String remote) { + if(remote.isEmpty())return new JSONArray(); + try { + String text=preferences.getString(key(host,port,remote),"[]"); + if(text==null||text.length()>65536)return new JSONArray(); + JSONArray values=new JSONArray(text); + if(values.length()>32)return new JSONArray(); + HashSet keys=new HashSet<>(); + JSONArray result=new JSONArray(); + for(int i=0;i32768||mode.height<1||mode.height>32768)return; + try { + JSONArray saved=load(host,port,remote),updated=new JSONArray(); + for(int i=0;i=32)updated.remove(0); + updated.put(new JSONObject().put("display_key",displayKey).put("width",mode.width).put("height",mode.height)); + preferences.edit().putString(key(host,port,remote),updated.toString()).apply(); + } catch(Exception error) { Log.w("CrossDesk","Could not save remote display resolution",error); } + } +} diff --git a/apps/android/native/rtc_bridge.cpp b/apps/android/native/rtc_bridge.cpp index 7b234dd8..a04968f8 100644 --- a/apps/android/native/rtc_bridge.cpp +++ b/apps/android/native/rtc_bridge.cpp @@ -366,10 +366,16 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)( const auto join = s->remote + "@" + String(env, password); return MiniRtcJoinConnection(s->controller, join.c_str()) == 0; } -extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) { +extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv* env, jclass, jlong handle, jbyteArray preferences) { auto* s = From(handle); if (!s || !s->controller) return; auto action = MakeHostInformation("CrossDesk Android", {}, true, "1.6.0", HostPlatform::Android); action.i.supports_video_codec_status = true; + if (preferences && env->GetArrayLength(preferences) <= 65536) { + std::string bytes(env->GetArrayLength(preferences), '\0'); + env->GetByteArrayRegion(preferences, 0, bytes.size(), reinterpret_cast(bytes.data())); + if (!env->ExceptionCheck()) + ParseDisplayResolutionPreferences(bytes, action.preferred_display_resolutions); + } s->Send(kControlStream, action.to_json()); FreeRemoteAction(action); } extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)( diff --git a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets index 49253355..98dd9c5d 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets @@ -1,5 +1,5 @@ import { tr } from './Localization'; -import { RemoteDisplayResolutions, ResolutionDisplay, ResolutionMode, ResolutionRequest, resolutionLabel } from './RemoteDisplayResolutions'; +import { RemoteDisplayResolutions, RemoteResolutionStore, ResolutionDisplay, ResolutionMode, ResolutionRequest, ResolutionPreference, resolutionLabel } from './RemoteDisplayResolutions'; import { RemotePrivacyScreen, PrivacyStatus } from './RemotePrivacyScreen'; import bridge from 'libcrossdesk.so'; import { common, bundleManager } from '@kit.AbilityKit'; @@ -12,6 +12,7 @@ import { AnnouncementInbox, AnnouncementItem, AnnouncementRequest, AnnouncementR import { VideoSettings, VideoValues, NetworkStatistics, NetworkReport, RecentPresence, PresenceMessage, asciiKey } from './ControllerModels'; interface Command { + displayResolutions?: ResolutionPreference[]; displayId?: number; displayKey?: string; width?: number; height?: number; op: string; session?: number; connection?: number; server?: string; port?: number; identity?: string; logs?: string; certificates?: string; directory?: string; previewDirectory?: string; remote?: string; password?: string; version?: string; @@ -106,6 +107,8 @@ export class MobileController { video: VideoSettings = new VideoSettings(); private resolutions: RemoteDisplayResolutions = new RemoteDisplayResolutions(); private resolutionTimeout: number = -1; + private resolutionScope: string = ''; + private resolutionStore: RemoteResolutionStore; private privacyScreen: RemotePrivacyScreen = new RemotePrivacyScreen(); network: NetworkStatistics = new NetworkStatistics(); presence: RecentPresence; @@ -141,6 +144,8 @@ export class MobileController { private privacy: string = ''; constructor(context: common.UIAbilityContext) { this.context = context; this.store = new MobileStore(context); + this.resolutionStore = new RemoteResolutionStore((key: string) => this.store.text(key, '[]'), + (key: string, value: string) => this.store.put(key, value)); this.state.consent = this.store.flag('consent'); this.state.server = this.store.text('server', 'api.crossdesk.cn'); this.state.port = this.store.text('port', '9099'); this.state.remote = this.store.text('lastRemote'); this.state.relative = this.store.flag('relative', true); this.state.videoPreference = this.store.number('videoPreference', 1); @@ -248,6 +253,7 @@ export class MobileController { begin(password: string): void { if (!this.state.consent || !/^[0-9]{9}$/.test(this.state.remote) || !password || password.length > 128 || password.includes('@')) { this.toast(tr("请输入有效的访问密码")); return; } this.joinSent = false; this.connection++; this.connectingId = this.state.remote; this.pendingPassword = password; + this.resolutionScope = this.scope(); this.state.connecting = true; this.state.ready = false; this.state.modal = 'connecting'; this.state.notice = ''; this.state.muted = false; this.state.platform = ''; this.state.hostName = ''; this.state.receivedPath = this.state.receivedName = this.state.transfer = ''; this.state.remoteClipboard = ''; this.state.displays = []; this.state.width = 16; this.state.height = 9; this.state.hasFrame = false; @@ -265,7 +271,8 @@ export class MobileController { private joinSent: boolean = false; private joinIfReady(): void { if (!this.state.connecting || this.joinSent || this.state.signal !== 1 || !this.state.identity) return; - this.joinSent = true; this.send({ op: 'connect', connection: this.connection, remote: this.connectingId, password: this.pendingPassword }); + this.joinSent = true; this.send({ op: 'connect', connection: this.connection, remote: this.connectingId, password: this.pendingPassword, + displayResolutions: this.resolutionStore.load(this.resolutionScope, this.connectingId) }); } end(reason: string): void { this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect(); @@ -310,6 +317,12 @@ export class MobileController { if (!this.state.ready || !this.state.consent) return; const display = this.resolutions.display(this.state.selectedDisplay); if (!display || display.display_id !== displayId || display.display_key !== displayKey) return; + if (!this.resolutions.pending && display.width === mode.width && display.height === mode.height && + display.modes.some((value: ResolutionMode) => value.width === mode.width && value.height === mode.height)) { + this.resolutionStore.remember(this.resolutionScope, this.connectingId, + { display_key: displayKey, width: mode.width, height: mode.height }); + return; + } const request = this.resolutions.begin(displayId, mode); if (!request) return; this.send({ op: 'displayResolution', displayId: request.display_id, displayKey: request.display_key, @@ -532,7 +545,10 @@ export class MobileController { if (!this.video.pending && this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.syncVideo(); return; } if (message.type === 16 && message.display_resolution && message.display_resolutions) { - this.resolutions.receive(message.display_resolution, message.display_resolutions); this.syncResolutions(); return; + const confirmed = this.resolutions.receive(message.display_resolution, message.display_resolutions); + if (confirmed) this.resolutionStore.remember(this.resolutionScope, this.connectingId, confirmed); + if (!this.resolutions.pending && this.resolutionTimeout >= 0) clearTimeout(this.resolutionTimeout); + this.syncResolutions(); return; } if (message.host_info) { const info = message.host_info; this.state.platform = info.platform; this.state.hostName = info.host_name; diff --git a/apps/harmony/entry/src/main/ets/mobile/RemoteDisplayResolutions.ets b/apps/harmony/entry/src/main/ets/mobile/RemoteDisplayResolutions.ets index dd4ba4d2..e14a3a45 100644 --- a/apps/harmony/entry/src/main/ets/mobile/RemoteDisplayResolutions.ets +++ b/apps/harmony/entry/src/main/ets/mobile/RemoteDisplayResolutions.ets @@ -1,7 +1,8 @@ import { tr } from './Localization'; export interface ResolutionMode { width: number; height: number; } -export interface ResolutionDisplay extends ResolutionMode { +export interface ResolutionPreference extends ResolutionMode { display_key: string; } +export interface ResolutionDisplay extends ResolutionPreference { display_id: number; display_key: string; width: number; height: number; modes: ResolutionMode[]; } export interface ResolutionRequest extends ResolutionMode { @@ -13,6 +14,51 @@ function validMode(mode: ResolutionMode): boolean { return !!mode && Number.isInteger(mode.width) && Number.isInteger(mode.height) && mode.width > 0 && mode.width <= 32768 && mode.height > 0 && mode.height <= 32768; } +export function validResolutionPreference(value: ResolutionPreference): boolean { + if (!validMode(value) || typeof value.display_key !== 'string' || !value.display_key.length || + value.display_key.length > 1024 || value.display_key.includes('\0')) return false; + const bytes = Array.from(value.display_key).reduce((n: number, c: string) => { + const point = c.codePointAt(0) ?? 0; + return n + (point < 128 ? 1 : point < 2048 ? 2 : point < 65536 ? 3 : 4); + }, 0); + return bytes <= 1024; +} +export function readResolutionPreferences(text: string): ResolutionPreference[] { + if (text.length > 65536) return []; + try { + const values = JSON.parse(text) as ResolutionPreference[]; + if (!Array.isArray(values) || values.length > 32) return []; + const keys: string[] = []; + const result: ResolutionPreference[] = []; + for (const value of values) { + if (!validResolutionPreference(value) || keys.includes(value.display_key)) return []; + keys.push(value.display_key); + result.push({ display_key: value.display_key, width: value.width, height: value.height }); + } + return result; + } catch { return []; } +} +export class RemoteResolutionStore { + private read: (key: string) => string; + private write: (key: string, value: string) => void; + constructor(read: (key: string) => string, write: (key: string, value: string) => void) { + this.read = read; this.write = write; + } + load(scope: string, id: string): ResolutionPreference[] { + if (!id) return []; + try { return readResolutionPreferences(this.read(`resolutions:${JSON.stringify([scope, id])}`)); } + catch { return []; } + } + remember(scope: string, id: string, preference: ResolutionPreference): void { + if (!id || !validResolutionPreference(preference)) return; + try { + const values = this.load(scope, id).filter((value: ResolutionPreference) => value.display_key !== preference.display_key); + if (values.length >= 32) values.shift(); + values.push({ display_key: preference.display_key, width: preference.width, height: preference.height }); + this.write(`resolutions:${JSON.stringify([scope, id])}`, JSON.stringify(values)); + } catch { console.warn('Could not save remote display resolution'); } + } +} export class RemoteDisplayResolutions { displays: ResolutionDisplay[] = []; requested?: ResolutionRequest; @@ -35,13 +81,7 @@ export class RemoteDisplayResolutions { const ids: number[] = []; for (const d of displays) { if (!d || !Number.isInteger(d.display_id) || d.display_id < 0 || d.display_id >= 32 || ids.includes(d.display_id) || - typeof d.display_key !== 'string' || !d.display_key.length || d.display_key.length > 1024 || d.display_key.includes('\0') || - !validMode(d) || !Array.isArray(d.modes) || d.modes.length > 256 || !d.modes.every((m: ResolutionMode) => validMode(m))) return false; - const bytes = Array.from(d.display_key).reduce((n: number, c: string) => { - const point = c.codePointAt(0) ?? 0; - return n + (point < 128 ? 1 : point < 2048 ? 2 : point < 65536 ? 3 : 4); - }, 0); - if (bytes > 1024) return false; + !validResolutionPreference(d) || !Array.isArray(d.modes) || d.modes.length > 256 || !d.modes.every((m: ResolutionMode) => validMode(m))) return false; ids.push(d.display_id); } return true; @@ -54,13 +94,14 @@ export class RemoteDisplayResolutions { d.display_id === request.display_id && d.display_key === request.display_key && d.modes.some((m: ResolutionMode) => sameMode(m, request)))) this.expire(request.request_id); } - receive(response: ResolutionRequest, displays: ResolutionDisplay[]): void { + receive(response: ResolutionRequest, displays: ResolutionDisplay[]): ResolutionPreference | undefined { const request = this.requested; if (!request || !response || response.request_id !== request.request_id || response.display_id !== request.display_id || - response.display_key !== request.display_key || !sameMode(response, request) || typeof response.accepted !== 'boolean' || !this.valid(displays)) return; + response.display_key !== request.display_key || !sameMode(response, request) || typeof response.accepted !== 'boolean' || !this.valid(displays)) return undefined; this.displays = displays; this.pending = false; this.failed = !response.accepted || !displays.some((d: ResolutionDisplay) => d.display_id === response.display_id && d.display_key === response.display_key && sameMode(d, response)); + return this.failed ? undefined : { display_key: response.display_key, width: response.width, height: response.height }; } feedback(): string { return this.pending ? tr("等待电脑端确认…") : this.failed ? tr("调整失败,请重试。") : ''; } } diff --git a/apps/harmony/native/mobile_session.cpp b/apps/harmony/native/mobile_session.cpp index ec590251..c3a3238b 100644 --- a/apps/harmony/native/mobile_session.cpp +++ b/apps/harmony/native/mobile_session.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -144,6 +145,7 @@ class MobileSession { s->connecting_ = false; s->connected_ = true; auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, true, s->version_, HostPlatform::HarmonyOS); info.i.supports_video_codec_status = true; + info.preferred_display_resolutions = s->preferred_display_resolutions_; s->Send(info); FreeRemoteAction(info); RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio); s->audio_.Start(); @@ -238,6 +240,7 @@ class MobileSession { CancelPreview(); if (controller_) { MiniRtcLeaveConnection(controller_,remote_.c_str()); MiniRtcDestroyPeer(&controller_); } files_.reset(); renderer_.Clear(); audio_.Stop(); + preferred_display_resolutions_.clear(); if (active) Event({{"type","ended"},{"reason",reason}},true); ++connection_; display_=0; frame_id_=0; } @@ -274,6 +277,7 @@ class MobileSession { else if (op=="connect") { if (!online_||login_.empty()) throw std::runtime_error("Not signed in"); Disconnect(""); connection_=v.at("connection").get(); + ReadDisplayResolutionPreferences(v.value("displayResolutions", json::array()), preferred_display_resolutions_); remote_=v.at("remote").get(); const auto password=v.at("password").get(); if (remote_.size()!=9||!std::all_of(remote_.begin(),remote_.end(),::isdigit)||password.size()>128||password.find('@')!=std::string::npos) throw std::runtime_error("Invalid remote credentials"); @@ -405,6 +409,7 @@ class MobileSession { std::unique_ptr identity_context_,control_context_; std::unique_ptr files_; std::string server_,logs_,login_,controller_login_,remote_,directory_,preview_directory_,preview_path_,version_; + std::vector preferred_display_resolutions_; int port_=9099,signal_status_=-1; uint64_t signal_generation_=0; std::set keys_; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index 047a342b..0200862d 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -180,7 +180,8 @@ typedef struct { - (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID NS_SWIFT_NAME(sendAnnouncementRequest(_:requestID:)); -- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password; +- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password + displayResolutions:(NSData *)displayResolutions; - (void)disconnect; // Close both session and signaling peers, including automatic reconnection. - (void)stopNetworking; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index 9f31d532..4280b95c 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -86,6 +86,7 @@ struct RTCState { std::string identity_base; std::string controller_login; std::string pending_remote_id; + std::vector preferred_display_resolutions; uint64_t pending_controller_generation = 0; std::string pending_password; std::string transmission_id; @@ -800,7 +801,14 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, }); } -- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password { +- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password + displayResolutions:(NSData *)displayResolutions { + std::vector preferences; + if (displayResolutions.length > 0 && displayResolutions.length <= 65536) { + const auto *bytes = static_cast(displayResolutions.bytes); + crossdesk::ParseDisplayResolutionPreferences( + std::string(bytes, displayResolutions.length), preferences); + } NSString *cleanID = [remoteID stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; NSString *cleanPassword = [password @@ -820,6 +828,7 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, dispatch_async(_rtcQueue, ^{ if (generation != self->_controllerGenerationCounter.load()) return; self->_state->pending_remote_id = identifier; + self->_state->preferred_display_resolutions = preferences; self->_state->pending_controller_generation = generation; self->_state->pending_password = password_value; [self destroyControllerPeer]; @@ -1458,6 +1467,7 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, : "iPhone-" + self->_state->identity_base; RemoteAction action{}; action.type = ControlType::host_infomation; + action.preferred_display_resolutions = self->_state->preferred_display_resolutions; action.i.platform = crossdesk::HostPlatform::IOS; action.i.supports_privacy_screen = true; action.i.supports_video_codec_status = true; diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index db205f67..744fdf32 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -370,6 +370,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe private var appIsActive = false private lazy var audioPlayer = RemoteAudioPlayer() private var activeRemoteID = "" + private var resolutionPreferenceKey = "" private var remoteAppVersion = "" private var remoteHostInfoReceived = false private var remoteVersionCheckAttempted = false @@ -610,6 +611,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe } private func beginRemoteConnection(_ identifier: String) { + guard let configuration = bridgeConfiguration else { return } AppOrientation.endSession() networkStatistics = RemoteNetworkStatistics() resetRemoteHostInfo() @@ -617,6 +619,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe resetVideoSettings() cancelPresenceProbe() activeRemoteID = identifier + resolutionPreferenceKey = RemoteDisplayResolutionStore.key(host: configuration.host, + port: configuration.signalPort, remoteID: identifier) activeDisplayName = "" displaySizes = [] connectionRecorded = false @@ -626,7 +630,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe sessionVisible = false resetRemoteCursorState() connectionStatus = NSLocalizedString("正在准备连接…", comment: "") - bridge.connect(toRemoteID: identifier, password: password) + bridge.connect(toRemoteID: identifier, password: password, + displayResolutions: RemoteDisplayResolutionStore.message(key: resolutionPreferenceKey)) } private func beginPresenceProbe(_ identifier: String) { @@ -1044,8 +1049,13 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func updateDisplayResolution(_ resolution: RemoteDisplayResolution, displayID: Int, displayKey: String) { guard hasNetworkConsent, isConnected, selectedDisplay == displayID, - displayResolutions.display(displayID)?.display_key == displayKey, - let request = displayResolutions.begin(displayID: selectedDisplay, resolution: resolution) else { return } + let display = displayResolutions.display(displayID), display.display_key == displayKey else { return } + if !displayResolutions.pending, display.current == resolution, display.modes.contains(resolution) { + RemoteDisplayResolutionStore.remember(RemoteDisplayResolutionPreference(display_key: displayKey, + width: resolution.width, height: resolution.height), key: resolutionPreferenceKey) + return + } + guard let request = displayResolutions.begin(displayID: selectedDisplay, resolution: resolution) else { return } displayResolutionTimeoutTask?.cancel() bridge.sendDisplayResolution(displayID: request.display_id, displayKey: request.display_key, width: request.width, height: request.height, requestID: request.request_id) @@ -1059,7 +1069,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe // Host information can arrive before the connection-state callback. // The bridge already rejects callbacks from previous sessions. guard hasNetworkConsent else { return } - displayResolutions.receive(message) + if let confirmed = displayResolutions.receive(message) { + RemoteDisplayResolutionStore.remember(confirmed, key: resolutionPreferenceKey) + } if !displayResolutions.pending { displayResolutionTimeoutTask?.cancel(); displayResolutionTimeoutTask = nil } diff --git a/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift b/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift index d5d11dee..52f0bb68 100644 --- a/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift +++ b/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift @@ -77,6 +77,42 @@ struct RemoteDisplayResolution: Codable, Equatable, Hashable { var valid: Bool { (1...32768).contains(width) && (1...32768).contains(height) } } +struct RemoteDisplayResolutionPreference: Codable, Equatable { + let display_key: String + let width: Int + let height: Int + var valid: Bool { + !display_key.isEmpty && display_key.utf8.count <= 1024 && !display_key.contains("\0") && + RemoteDisplayResolution(width: width, height: height).valid + } +} + +enum RemoteDisplayResolutionStore { + static func key(host: String, port: Int, remoteID: String) -> String { + let scope = [host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), String(port), remoteID] + guard !remoteID.isEmpty, let data = try? JSONEncoder().encode(scope) else { return "" } + return "crossdesk.remoteDisplayResolutions.v1." + data.base64EncodedString() + } + static func load(key: String, defaults: UserDefaults = .standard) -> [RemoteDisplayResolutionPreference] { + guard !key.isEmpty, let data = defaults.data(forKey: key), data.count <= 65536, + let values = try? JSONDecoder().decode([RemoteDisplayResolutionPreference].self, from: data), + values.count <= 32, values.allSatisfy(\.valid), + Set(values.map(\.display_key)).count == values.count else { return [] } + return values + } + static func remember(_ preference: RemoteDisplayResolutionPreference, key: String, + defaults: UserDefaults = .standard) { + guard !key.isEmpty, preference.valid else { return } + var values = load(key: key, defaults: defaults).filter { $0.display_key != preference.display_key } + if values.count >= 32 { values.removeFirst() } + values.append(preference) + if let data = try? JSONEncoder().encode(values) { defaults.set(data, forKey: key) } + } + static func message(key: String, defaults: UserDefaults = .standard) -> Data { + (try? JSONEncoder().encode(load(key: key, defaults: defaults))) ?? Data("[]".utf8) + } +} + struct RemoteDisplayResolutionInfo: Decodable { let display_id: Int let display_key: String @@ -128,7 +164,8 @@ struct RemoteDisplayResolutionState { guard pending, requested?.request_id == requestID else { return } pending = false; failed = true } - mutating func receive(_ data: Data) { + @discardableResult + mutating func receive(_ data: Data) -> RemoteDisplayResolutionPreference? { struct Host: Decodable { let display_resolutions: [RemoteDisplayResolutionInfo]? } struct Message: Decodable { let type: Int @@ -136,16 +173,16 @@ struct RemoteDisplayResolutionState { let display_resolution: RemoteDisplayResolutionRequest? let display_resolutions: [RemoteDisplayResolutionInfo]? } - guard let message = try? JSONDecoder().decode(Message.self, from: data) else { return } + guard let message = try? JSONDecoder().decode(Message.self, from: data) else { return nil } let actual: [RemoteDisplayResolutionInfo] if message.type == 3, let host = message.host_info { actual = host.display_resolutions ?? [] } else if message.type == 16, let response = message.display_resolution, let requested, response.matches(requested), let values = message.display_resolutions { actual = values - } else { return } + } else { return nil } guard actual.count <= 32, actual.allSatisfy(\.valid), - Set(actual.map(\.display_id)).count == actual.count else { return } + Set(actual.map(\.display_id)).count == actual.count else { return nil } displays = actual if message.type == 16, let response = message.display_resolution { pending = false @@ -153,11 +190,16 @@ struct RemoteDisplayResolutionState { $0.display_id == response.display_id && $0.display_key == response.display_key && $0.current == response.resolution } + if !failed { + return RemoteDisplayResolutionPreference(display_key: response.display_key, + width: response.width, height: response.height) + } } else if pending, let requested, !actual.contains(where: { $0.display_id == requested.display_id && $0.display_key == requested.display_key && $0.modes.contains(requested.resolution) }) { expire(requestID: requested.request_id) } + return nil } } diff --git a/libs/wire/include/display_resolution.h b/libs/wire/include/display_resolution.h index 272039af..bd5562d4 100644 --- a/libs/wire/include/display_resolution.h +++ b/libs/wire/include/display_resolution.h @@ -58,6 +58,10 @@ struct DisplayResolutionPreference { DisplayResolution resolution; }; +// JSON-free boundary for native bridges that do not expose the JSON library. +bool ParseDisplayResolutionPreferences( + const std::string &text, std::vector &result); + inline bool ValidDisplayResolutionPreference( const DisplayResolutionPreference &preference) { return !preference.display_key.empty() && diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index 7256c485..759ba8c6 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -138,6 +138,12 @@ char* DuplicateString(const std::string& value) { } // namespace +bool ParseDisplayResolutionPreferences( + const std::string& text, std::vector& result) { + return text.size() <= 65536 && + ReadDisplayResolutionPreferences(json::parse(text, nullptr, false), result); +} + const char* HostPlatformName(HostPlatform platform) { switch (platform) { case HostPlatform::Windows: return "windows";