[feat] remember remote display resolutions on mobile clients

This commit is contained in:
dijunkun
2026-11-11 15:26:04 +08:00
parent 23f48dc761
commit 25fb2a3d7f
15 changed files with 253 additions and 33 deletions
@@ -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");
@@ -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;
}
@@ -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);
@@ -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<Display> actual=new ArrayList<>();
for(int i=0;i<values.length();i++){
Display display=new Display(values.getJSONObject(i));
for(Display previous:actual)if(previous.id==display.id)return;
for(Display previous:actual)if(previous.id==display.id)return null;
actual.add(display);
}
displays=actual;
if(response!=null){
Display display=display(requested.displayId);pending=false;
failed=!response.getBoolean("accepted")||display==null||!display.key.equals(requested.displayKey)||!display.current.same(requested.mode);
if(!failed)return requested;
}else if(pending){
Display display=display(requested.displayId);
if(display==null||!display.key.equals(requested.displayKey)||!display.supports(requested.mode))expire(requested.id);
}
}catch(Exception ignored){ /* Ignore malformed or unrelated control messages. */ }
return null;
}
String feedback(){return pending?L10n.tr("等待电脑端确认…"):failed?L10n.tr("调整失败,请重试。"):"";}
private static Mode mode(JSONObject value)throws Exception{return new Mode((int)integer(value,"width",1,32768),(int)integer(value,"height",1,32768));}
@@ -0,0 +1,62 @@
package cn.crossdesk.mobile;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Locale;
/** Successful selections, scoped to the signaling server, device and native screen. */
final class RemoteResolutionStore {
private final SharedPreferences preferences;
RemoteResolutionStore(Context context) {
preferences=context.getSharedPreferences("remote_display_resolutions",Context.MODE_PRIVATE);
}
private String key(String host,int port,String remote) {
return new JSONArray().put(host.trim().toLowerCase(Locale.ROOT)).put(port).put(remote).toString();
}
private static boolean validKey(String key) {
return !key.isEmpty()&&key.getBytes(StandardCharsets.UTF_8).length<=1024&&key.indexOf('\0')<0;
}
private static int dimension(JSONObject value,String key)throws Exception {
Object raw=value.get(key);
if(!(raw instanceof Integer)&&!(raw instanceof Long))throw new IllegalArgumentException();
long number=((Number)raw).longValue();
if(number<1||number>32768)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<String> keys=new HashSet<>();
JSONArray result=new JSONArray();
for(int i=0;i<values.length();i++) {
JSONObject value=values.getJSONObject(i);
Object identity=value.get("display_key");
if(!(identity instanceof String)||!validKey((String)identity)||!keys.add((String)identity))return new JSONArray();
result.put(new JSONObject().put("display_key",identity).put("width",dimension(value,"width")).put("height",dimension(value,"height")));
}
return result;
} catch(Exception ignored) { return new JSONArray(); }
}
synchronized void remember(String host,int port,String remote,String displayKey,RemoteDisplayResolutions.Mode mode) {
if(remote.isEmpty()||!validKey(displayKey)||mode==null||mode.width<1||mode.width>32768||mode.height<1||mode.height>32768)return;
try {
JSONArray saved=load(host,port,remote),updated=new JSONArray();
for(int i=0;i<saved.length();i++) {
JSONObject value=saved.getJSONObject(i);
if(!displayKey.equals(value.getString("display_key")))updated.put(value);
}
if(updated.length()>=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); }
}
}
+7 -1
View File
@@ -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<jbyte*>(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)(
@@ -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;
@@ -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("调整失败,请重试。") : ''; }
}
+5
View File
@@ -7,6 +7,7 @@
#include <mobile_file_transfer_session.h>
#include <mobile_video_timing.h>
#include <display_stream_id.h>
#include <display_resolution_preferences.h>
#include <libyuv.h>
#include <algorithm>
#include <cmath>
@@ -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<uint64_t>();
ReadDisplayResolutionPreferences(v.value("displayResolutions", json::array()), preferred_display_resolutions_);
remote_=v.at("remote").get<std::string>(); const auto password=v.at("password").get<std::string>();
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<Context> identity_context_,control_context_;
std::unique_ptr<mobile_controller::FileTransferSession> files_;
std::string server_,logs_,login_,controller_login_,remote_,directory_,preview_directory_,preview_path_,version_;
std::vector<DisplayResolutionPreference> preferred_display_resolutions_;
int port_=9099,signal_status_=-1;
uint64_t signal_generation_=0;
std::set<int> keys_;
@@ -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;
@@ -86,6 +86,7 @@ struct RTCState {
std::string identity_base;
std::string controller_login;
std::string pending_remote_id;
std::vector<crossdesk::DisplayResolutionPreference> 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<crossdesk::DisplayResolutionPreference> preferences;
if (displayResolutions.length > 0 && displayResolutions.length <= 65536) {
const auto *bytes = static_cast<const char *>(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;
@@ -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
}
@@ -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
}
}