From 34b59de15de2d422a6e671525a532e1a2496ce20 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 11 Nov 2026 11:02:41 +0800 Subject: [PATCH] [feat] add macOS and mobile resolution controls with disconnect restore --- .../crossdesk/mobile/BackgroundSession.java | 3 + .../cn/crossdesk/mobile/LocalizationKeys.java | 3 + .../cn/crossdesk/mobile/MainActivity.java | 36 ++- .../cn/crossdesk/mobile/NativeSession.java | 5 + .../mobile/RemoteDisplayResolutions.java | 90 +++++++ .../cn/crossdesk/mobile/SessionControls.java | 32 ++- .../src/main/res/values-b+zh+Hans/strings.xml | 3 + .../src/main/res/values-b+zh+Hant/strings.xml | 3 + .../app/src/main/res/values-de/strings.xml | 3 + .../app/src/main/res/values-es/strings.xml | 3 + .../app/src/main/res/values-fr/strings.xml | 3 + .../app/src/main/res/values-ja/strings.xml | 3 + .../app/src/main/res/values-ko/strings.xml | 3 + .../app/src/main/res/values-pt/strings.xml | 3 + .../app/src/main/res/values-ru/strings.xml | 3 + .../app/src/main/res/values/strings.xml | 3 + apps/android/native/rtc_bridge.cpp | 10 + .../src/gui/runtime/connection_runtime.cpp | 4 + .../runtime/display_resolution_restoration.h | 61 +++++ .../runtime/display_resolution_runtime.cpp | 94 ++++++- apps/desktop/src/gui/runtime/gui_runtime.cpp | 1 + apps/desktop/src/gui/runtime/gui_runtime.h | 9 + apps/desktop/src/gui/runtime/lan_runtime.cpp | 2 + .../screen_capturer/display_resolution_mac.h | 231 ++++++++++++++++++ .../screen_capturer_sck_impl.mm | 14 +- .../screen_capturer/display_resolution_win.h | 72 +++++- apps/desktop/xmake/platform.lua | 2 +- .../src/main/ets/mobile/LocalizationData.ets | 2 + .../src/main/ets/mobile/MobileController.ets | 48 +++- .../src/main/ets/mobile/MobileRemoteView.ets | 17 ++ .../ets/mobile/RemoteDisplayResolutions.ets | 66 +++++ apps/harmony/native/mobile_session.cpp | 9 + .../Bridge/CrossDeskRTCBridge.h | 10 + .../Bridge/CrossDeskRTCBridge.mm | 41 ++++ .../CrossDeskMobile/RemoteSessionModel.swift | 35 +++ .../CrossDeskMobile/RemoteSessionView.swift | 33 +++ .../CrossDeskMobile/RemoteVideoSettings.swift | 95 +++++++ .../Resources/de.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 3 + .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/fr.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 3 + .../Resources/ko.lproj/Localizable.strings | 3 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/pt.lproj/Localizable.strings | 3 + .../Resources/ru.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 3 + .../include/mobile_controller_protocol.h | 8 + localization/catalog.json | 26 +- 50 files changed, 1086 insertions(+), 36 deletions(-) create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteDisplayResolutions.java create mode 100644 apps/desktop/src/gui/runtime/display_resolution_restoration.h create mode 100644 apps/desktop/src/platform/macos/screen_capturer/display_resolution_mac.h create mode 100644 apps/harmony/entry/src/main/ets/mobile/RemoteDisplayResolutions.ets 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 618147bc..8bfbcd61 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 @@ -17,6 +17,7 @@ final class BackgroundSession implements NativeSession.Listener { String identity = "", platform = "", clipboard = "", fileStatus = "", endReason = ""; JSONArray displays = new JSONArray(); RemoteVideoSettings videoSettings; + RemoteDisplayResolutions displayResolutions=new RemoteDisplayResolutions(); int signalState, selectedDisplay, width, height; boolean muted, remoteServiceAvailable, background, ended; boolean autoLandscape; @@ -55,6 +56,7 @@ final class BackgroundSession implements NativeSession.Listener { } @Override public void data(JSONObject message) { if (view != null) { view.data(message); return; } + displayResolutions.receive(message); if (message.optInt("type", -1) == 12 && videoSettings != null) videoSettings.receive(message); if (message.optInt("type", -1) == 5) { JSONObject service = message.optJSONObject("service_status"); @@ -80,6 +82,7 @@ final class BackgroundSession implements NativeSession.Listener { if (view != null) view.fileTransfer(name, progress, sending, received); } @Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); } + @Override public void displayResolutionFailed(long requestId) { if(view!=null)view.displayResolutionFailed(requestId);else displayResolutions.expire(requestId); } @Override public void privacyChanged() { if (view != null) view.privacyChanged(); } @Override public void presence(JSONObject message) { if (view != null) view.presence(message); } @Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); } diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/LocalizationKeys.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/LocalizationKeys.java index 3f7f3bb7..4e432470 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/LocalizationKeys.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/LocalizationKeys.java @@ -311,6 +311,9 @@ final class LocalizationKeys { IDS.put(",已保存密码", R.string.text_ba9a792d063a8c86); IDS.put(",接收 ", R.string.text_f4c1f9d7896760a7); IDS.put(",点击重试", R.string.text_42237b5ec70c26f3); + IDS.put("被控端分辨率", R.string.text_59c9dca5568a6cd3); + IDS.put("设备不支持", R.string.text_36e0073276c0e39f); + IDS.put("等待电脑端确认…", R.string.text_7b3440ad0f81b811); } private LocalizationKeys() {} } 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 2a399905..ba94949a 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 @@ -93,6 +93,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1); private final android.os.Handler mainHandler=new android.os.Handler(android.os.Looper.getMainLooper()); private Runnable videoSettingsTimeout; + private RemoteDisplayResolutions displayResolutions=new RemoteDisplayResolutions(); + private Runnable displayResolutionTimeout; private final Set pressed=new HashSet<>(); private Runnable backAction; @@ -349,8 +351,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);} public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);} public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);} + public void displayResolution(RemoteDisplayResolutions.Mode mode){updateDisplayResolution(mode);} public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));} - });controls.setHostPlatform(hostPlatform);controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + });controls.setHostPlatform(hostPlatform);controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);controls.displayResolutions(displayResolutions,selectedDisplay);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput)); privacyChanged(); video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();}; @@ -506,7 +509,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}} private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}} private void selectDisplay(){ - if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast(L10n.tr("等待远端屏幕信息"));return;}String[] labels=new String[count];for(int i=0;i{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show(); + if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast(L10n.tr("等待远端屏幕信息"));return;}String[] labels=new String[count];for(int i=0;i{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay);if(controls!=null)controls.resetVideoStatistics();}}).show(); } private void secureAttention(){ if(!ready||session==null||!hostPlatform.equals("windows"))return; @@ -645,7 +648,22 @@ public final class MainActivity extends Activity implements NativeSession.Listen remoteVersionCheck.reset(); if(remoteUpdateDialog!=null){remoteUpdateDialog.dismiss();remoteUpdateDialog=null;} } + private void cancelDisplayResolutionTimeout(){if(displayResolutionTimeout!=null)mainHandler.removeCallbacks(displayResolutionTimeout);displayResolutionTimeout=null;} + private void updateDisplayResolution(RemoteDisplayResolutions.Mode mode){ + if(!ready||session==null||!hasNetworkConsent())return; + RemoteDisplayResolutions.Request request=displayResolutions.begin(selectedDisplay,mode);if(request==null)return; + cancelDisplayResolutionTimeout();session.displayResolution(request); + if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay); + displayResolutionTimeout=()->{displayResolutions.expire(request.id);if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay);}; + mainHandler.postDelayed(displayResolutionTimeout,10000); + } + @Override public void displayResolutionFailed(long requestId){ + displayResolutions.expire(requestId); + if(!displayResolutions.pending)cancelDisplayResolutionTimeout(); + if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay); + } private void resetVideoSettings(){ + cancelDisplayResolutionTimeout();displayResolutions=new RemoteDisplayResolutions(); cancelVideoSettingsTimeout(); android.util.DisplayMetrics metrics=getResources().getDisplayMetrics(); videoSettings=new RemoteVideoSettings(preferences.getInt("videoPreference",1),Math.max(metrics.widthPixels,metrics.heightPixels)); @@ -658,6 +676,11 @@ 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(); + if(controls!=null)controls.displayResolutions(displayResolutions,selectedDisplay); + if(message.optInt("type",-1)==16)return; + } if(message.optInt("type",-1)==5){JSONObject service=message.optJSONObject("service_status");remoteServiceAvailable=service!=null&&service.optBoolean("available",false);return;} if(message.optInt("type",-1)==12){if(!ready)return;videoSettings.receive(message);if(!videoSettings.pending)cancelVideoSettingsTimeout();if(controls!=null)controls.videoSettings(videoSettings);return;} JSONObject info=message.optJSONObject("host_info"); @@ -746,7 +769,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen BackgroundSession retained=backgroundSession;if(retained==null)return; retained.identity=localIdentity;retained.signalState=signalState;retained.platform=hostPlatform;retained.displays=displays; retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable; - retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile; + retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.displayResolutions=displayResolutions;retained.receivedFile=receivedFile; retained.width=videoWidth;retained.height=videoHeight; retained.autoLandscape=remoteOrientation.autoLandscape(); } @@ -783,10 +806,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen connectedRemote=retained.remote;localIdentity=retained.identity;signalState=retained.signalState;signalError=""; hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay; remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile; - videoSettings=retained.videoSettings;ready=true;retained.attach(this); + videoSettings=retained.videoSettings;displayResolutions=retained.displayResolutions;ready=true;retained.attach(this); if(rebuild)sessionScreen(retained.autoLandscape); if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height); - if(controls!=null){controls.setHostPlatform(hostPlatform);controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);} + if(controls!=null){controls.setHostPlatform(hostPlatform);controls.setMuted(muted);controls.videoSettings(videoSettings);controls.displayResolutions(displayResolutions,selectedDisplay);controls.fileStatus(retained.fileStatus,receivedFile!=null);} retained.setBackground(false);signaling(signalState,localIdentity); return true; } @@ -844,11 +867,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen if(!isFinishing()&&!enteredPictureInPicture&&ready&&(backgroundSession!=null||documentSession==null)&&prepareBackgroundSession(false)){ captureBackgroundSession();backgroundSession.detach(this);backgroundSession.setBackground(true); mainHandler.removeCallbacks(presenceTick);recentPresence.setConnected(false,android.os.SystemClock.elapsedRealtime());announcementInbox.setConnected(false); + cancelDisplayResolutionTimeout();if(displayResolutions.pending)displayResolutions.expire(displayResolutions.requested.id); cancelVideoSettingsTimeout();if(videoSettings.pending)videoSettings.expire(videoSettings.requestId); resetRemoteVersionCheck();if(disconnectDialog!=null)disconnectDialog.dismiss(); }else if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":L10n.tr("远程窗口已关闭,或后台保活未能启动,会话已结束"));stopSignaling();} super.onStop();} - @Override protected void onDestroy(){dismissPrivacyUnlockDialog();mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss(); + @Override protected void onDestroy(){dismissPrivacyUnlockDialog();mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();cancelDisplayResolutionTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss(); if(connectionDialog!=null)connectionDialog.dismiss(); boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession; if(backgroundSession!=null)backgroundSession.detach(this); 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 8003c9e0..57f1191f 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 @@ -31,6 +31,7 @@ final class NativeSession implements AutoCloseable { default void fileTransfer(String name,double progress,boolean sending,File received) { } default void controlFeedback(String message) { } default void privacyChanged() { } + default void displayResolutionFailed(long requestId) { } default void signaling(int state, String deviceId) { } default void presence(JSONObject message) { } default void announcement(JSONObject message) { } @@ -243,6 +244,9 @@ final class NativeSession implements AutoCloseable { if(!settings.valid()||requestId<0||requestId>0xffffffffL)return; execute(()->{if(connected&&!nVideoSettings(handle,settings.quality,settings.frameRate,settings.preference,requestId))ui(()->listener.data(settings.status(requestId,false)));}); } + 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 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));}); @@ -344,6 +348,7 @@ final class NativeSession implements AutoCloseable { private static native void nKey(long handle, int code, boolean down); private static native void nText(long handle,byte[] text); private static native void nControl(long handle, int type, int value); + private static native boolean nDisplayResolution(long handle,int displayId,String displayKey,int width,int height,long requestId); private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId); private static native boolean nAnnouncementRequest(long handle,byte[] data); private static native boolean nPresenceRequest(long handle,byte[] data); 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 new file mode 100644 index 00000000..819db15f --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteDisplayResolutions.java @@ -0,0 +1,90 @@ +package cn.crossdesk.mobile; + +import org.json.JSONArray; +import org.json.JSONObject; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** Host-confirmed desktop modes. Requests are scoped to the native display identity. */ +final class RemoteDisplayResolutions { + static final class Mode { + final int width, height; + Mode(int width,int height){this.width=width;this.height=height;} + boolean same(Mode other){return other!=null&&width==other.width&&height==other.height;} + String label(){return width+" × "+height;} + } + static final class Display { + final int id; + final String key; + final Mode current; + final List modes=new ArrayList<>(); + Display(JSONObject value)throws Exception{ + id=(int)integer(value,"display_id",0,31); + Object identity=value.get("display_key"); + if(!(identity instanceof String))throw new IllegalArgumentException(); + key=(String)identity; + if(key.isEmpty()||key.getBytes(StandardCharsets.UTF_8).length>1024||key.indexOf('\0')>=0)throw new IllegalArgumentException(); + current=mode(value); + JSONArray options=value.getJSONArray("modes"); + if(options.length()>256)throw new IllegalArgumentException(); + for(int i=0;i displays=new ArrayList<>(); + Request requested; + boolean pending,failed; + Display display(int id){for(Display display:displays)if(display.id==id)return display;return null;} + Request begin(int displayId,Mode mode){ + Display display=display(displayId); + if(pending||display==null||!display.supports(mode)||display.current.same(mode))return null; + long next=requested==null?1:(requested.id+1)&0xffffffffL; + 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){ + 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; + List actual=new ArrayList<>(); + for(int i=0;imax)throw new IllegalArgumentException();return number; + } +} diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java index 3b2d5197..51f9019f 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java @@ -14,6 +14,7 @@ final class SessionControls extends FrameLayout { void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse(); default boolean textEdit(int deletions,String value){return false;} void videoSettings(int field,int value); + default void displayResolution(RemoteDisplayResolutions.Mode mode) { } void sendFile(); void saveFile(); void secureAttention(); default void togglePrivacy() { } } @@ -36,7 +37,10 @@ final class SessionControls extends FrameLayout { int height=Math.max(0,getRootView().getHeight()-visible.bottom); keyboardInsets(height,height>ui.dp(80),0); } - private TextView videoFeedback; + private TextView videoFeedback,resolutionButton,resolutionFeedback; + private RemoteDisplayResolutions resolutions=new RemoteDisplayResolutions(); + private int resolutionDisplay; + private android.app.AlertDialog resolutionDialog; private RemotePrivacyScreen privacy=new RemotePrivacyScreen(); private View privacyButton; private MobileUi.Icon privacyIcon; @@ -111,13 +115,14 @@ final class SessionControls extends FrameLayout { void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt,network.remoteEncoder,network.localDecoder));} void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();} void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();} + void displayResolutions(RemoteDisplayResolutions value,int display){resolutions=value;resolutionDisplay=display;renderResolutions();} void privacyScreen(RemotePrivacyScreen value){privacy=value;renderPrivacy();} void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();} private void renderFileStatus(){ if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);} if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE); } - private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;privacyIcon=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription(L10n.tr("展开远程控制菜单"));positionMouse();} + private void closeMenu(){if(resolutionDialog!=null){resolutionDialog.dismiss();resolutionDialog=null;}resolutionButton=resolutionFeedback=null;if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;privacyIcon=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription(L10n.tr("展开远程控制菜单"));positionMouse();} private void openMenu(String page){ cancelMouseInput(); closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1)); @@ -153,6 +158,9 @@ final class SessionControls extends FrameLayout { }else if(page.equals("network")){ networkPage(body); }else{ + TextView resolutionTitle=ui.text(L10n.tr("被控端分辨率"),12,false);resolutionTitle.setTextColor(SECONDARY);body.addView(resolutionTitle);ui.gap(body,5); + resolutionButton=ui.action("",INK,this::chooseResolution);resolutionButton.setTextSize(13);resolutionButton.setBackground(ui.background(0xFFF0F0F2,7));body.addView(resolutionButton,ui.size(-1,34)); + resolutionFeedback=ui.text("",12,false);resolutionFeedback.setTextColor(SECONDARY);resolutionFeedback.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(resolutionFeedback);ui.gap(body,12);renderResolutions(); settingRows.add(new SettingRow(body,0,L10n.tr("画面质量"),new String[]{L10n.tr("低"),L10n.tr("中"),L10n.tr("高")},new int[]{0,1,2}));ui.gap(body,12); settingRows.add(new SettingRow(body,1,L10n.tr("画面采集帧率"),new String[]{"30 fps","60 fps"},new int[]{30,60}));ui.gap(body,12); settingRows.add(new SettingRow(body,2,L10n.tr("画面偏好"),RemoteVideoSettings.preferences(),new int[]{0,1,2}));renderVideoSettings(); @@ -192,6 +200,26 @@ final class SessionControls extends FrameLayout { } void render(){group.setSelection(selectionIndex());group.setOptionsEnabled(settings.supported);} } + private void renderResolutions(){ + if(resolutionButton==null)return; + RemoteDisplayResolutions.Display display=resolutions.display(resolutionDisplay); + boolean enabled=display!=null&&!display.modes.isEmpty()&&!resolutions.pending; + resolutionButton.setText(display==null?L10n.tr("设备不支持"):display.current.label()+" ▾"); + resolutionButton.setEnabled(enabled);resolutionButton.setAlpha(enabled?1f:.45f); + String feedback=resolutions.feedback();resolutionFeedback.setText(feedback);resolutionFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE); + } + private void chooseResolution(){ + RemoteDisplayResolutions.Display display=resolutions.display(resolutionDisplay); + if(display==null||resolutions.pending||display.modes.isEmpty())return; + String[] labels=new String[display.modes.size()];int selected=-1; + for(int i=0;i{ + dialog.dismiss();resolutionDialog=null; + RemoteDisplayResolutions.Display current=resolutions.display(resolutionDisplay); + if(current!=null&¤t.id==display.id&¤t.key.equals(display.key))actions.displayResolution(display.modes.get(index)); + }).setNegativeButton(L10n.tr("取消"),null).show(); + } private void renderVideoSettings(){ if(videoFeedback!=null){String feedback=settings.feedback();videoFeedback.setText(feedback);videoFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);} for(SettingRow row:settingRows)row.render(); diff --git a/apps/android/app/src/main/res/values-b+zh+Hans/strings.xml b/apps/android/app/src/main/res/values-b+zh+Hans/strings.xml index a56af42c..398a13c8 100644 --- a/apps/android/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/apps/android/app/src/main/res/values-b+zh+Hans/strings.xml @@ -305,4 +305,7 @@ ",已保存密码" ",接收 " ",点击重试" + "被控端分辨率" + "设备不支持" + "等待电脑端确认…" diff --git a/apps/android/app/src/main/res/values-b+zh+Hant/strings.xml b/apps/android/app/src/main/res/values-b+zh+Hant/strings.xml index c78dada6..6651d738 100644 --- a/apps/android/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/apps/android/app/src/main/res/values-b+zh+Hant/strings.xml @@ -305,4 +305,7 @@ ",已儲存密碼" ",接收 " ",點選重試" + "被控端解析度" + "裝置不支援" + "等待電腦端確認…" diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 5c879a5a..b426fd42 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -305,4 +305,7 @@ ", Passwort gespeichert" ", empfangen " ", klicken Sie auf Wiederholen" + "Auflösung des entfernten Bildschirms" + "Nicht unterstützt" + "Warten auf Bestätigung vom Computer..." diff --git a/apps/android/app/src/main/res/values-es/strings.xml b/apps/android/app/src/main/res/values-es/strings.xml index 0c98c2fd..c1d14e9d 100644 --- a/apps/android/app/src/main/res/values-es/strings.xml +++ b/apps/android/app/src/main/res/values-es/strings.xml @@ -305,4 +305,7 @@ ", contraseña guardada" ", recibir " ", haga clic en Reintentar" + "Resolución de la pantalla remota" + "No compatible" + "Esperando confirmación del ordenador..." diff --git a/apps/android/app/src/main/res/values-fr/strings.xml b/apps/android/app/src/main/res/values-fr/strings.xml index 37524a15..ca3c7372 100644 --- a/apps/android/app/src/main/res/values-fr/strings.xml +++ b/apps/android/app/src/main/res/values-fr/strings.xml @@ -305,4 +305,7 @@ ", mot de passe enregistré" ", recevoir " ", cliquez sur Réessayer" + "Résolution de l’écran distant" + "Non pris en charge" + "En attente de confirmation de l\'ordinateur..." diff --git a/apps/android/app/src/main/res/values-ja/strings.xml b/apps/android/app/src/main/res/values-ja/strings.xml index f766d11e..19f2ecb6 100644 --- a/apps/android/app/src/main/res/values-ja/strings.xml +++ b/apps/android/app/src/main/res/values-ja/strings.xml @@ -305,4 +305,7 @@ "、パスワードが保存されました" "、受け取ります " "、「再試行」をクリックします" + "接続先の画面解像度" + "サポートされていません" + "コンピューターからの確認を待っています..." diff --git a/apps/android/app/src/main/res/values-ko/strings.xml b/apps/android/app/src/main/res/values-ko/strings.xml index 439b874a..413b0bbc 100644 --- a/apps/android/app/src/main/res/values-ko/strings.xml +++ b/apps/android/app/src/main/res/values-ko/strings.xml @@ -305,4 +305,7 @@ ", 비밀번호가 저장되었습니다" ", 수신 " ", 재시도를 클릭하세요." + "원격 화면 해상도" + "지원되지 않음" + "컴퓨터의 확인을 기다리는 중..." diff --git a/apps/android/app/src/main/res/values-pt/strings.xml b/apps/android/app/src/main/res/values-pt/strings.xml index 432c15db..0cfc69ce 100644 --- a/apps/android/app/src/main/res/values-pt/strings.xml +++ b/apps/android/app/src/main/res/values-pt/strings.xml @@ -305,4 +305,7 @@ ", senha salva" ", receba " ", clique em Tentar novamente" + "Resolução da tela remota" + "Não suportado" + "Aguardando confirmação do computador..." diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 02655d1d..1be6ed77 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -305,4 +305,7 @@ ", пароль сохранен" ", получить " "нажмите «Повторить»." + "Разрешение удалённого экрана" + "Не поддерживается" + "Ожидание подтверждения от компьютера..." diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index c34c6c41..caf92b08 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -305,4 +305,7 @@ ", password saved" ", receive " ", click Retry" + "Remote display resolution" + "Unsupported" + "Waiting for confirmation from the computer..." diff --git a/apps/android/native/rtc_bridge.cpp b/apps/android/native/rtc_bridge.cpp index e34eb239..7b234dd8 100644 --- a/apps/android/native/rtc_bridge.cpp +++ b/apps/android/native/rtc_bridge.cpp @@ -422,6 +422,16 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nVideoSettings)( if (data.empty()) return false; return MiniRtcSendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0; } +extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nDisplayResolution)( + JNIEnv* env, jclass, jlong handle, jint display_id, jstring display_key, + jint width, jint height, jlong request_id) { + auto* s = From(handle); + if (!s || !s->controller || s->stopping || request_id <= 0 || request_id > UINT32_MAX) return false; + const auto data = mobile_controller::DisplayResolutionMessage( + display_id, String(env, display_key), width, height, static_cast(request_id)); + return !data.empty() && MiniRtcSendReliableDataFrame( + s->controller, data.data(), data.size(), kControlStream) == 0; +} extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jclass, jlong handle) { auto* s = From(handle); if (!s || !s->controller || s->stopping) return false; diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index f31160ef..0fc1b44f 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -220,6 +220,7 @@ void GuiRuntime::HandleServerControllerDisconnected( windows_rdp_handoff_connected_->store(has_connected_controller, std::memory_order_relaxed); #endif + if (!has_connected_controller) ++display_resolution_epoch_; } clipboard_.ForgetWeb(remote_id); if (has_connected_controller) { @@ -269,6 +270,9 @@ void GuiRuntime::HandleScreenLock() { std::unique_lock lock(connection_status_mutex_); if (!disconnect_lock_.TakeRequest(config_center_->IsLockOnDisconnect())) return; + // Restore while the interactive desktop is still available. This forced + // path does not reacquire connection_status_mutex_. + RestoreHostDisplayResolutions(true); #if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__)) if (LockScreen()) LOG_INFO("Requested screen lock after last controller disconnected"); #endif diff --git a/apps/desktop/src/gui/runtime/display_resolution_restoration.h b/apps/desktop/src/gui/runtime/display_resolution_restoration.h new file mode 100644 index 00000000..888f714e --- /dev/null +++ b/apps/desktop/src/gui/runtime/display_resolution_restoration.h @@ -0,0 +1,61 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _DISPLAY_RESOLUTION_RESTORATION_H_ +#define _DISPLAY_RESOLUTION_RESTORATION_H_ + +#include +#include +#include +#include + +#include + +namespace crossdesk { + +// Application-thread state. Each callback owns the original native display +// mode, so restoration does not depend on capture still running or slot order. +class DisplayResolutionRestoration { +public: + using Restore = std::function; + + template + bool Apply(const std::string &key, Capture capture, Change change) { + if (originals_.count(key)) + return change(); + if (originals_.size() >= kMaxResolutionDisplays) + return false; + auto restore = capture(); + if (!restore) + return false; + // Save before changing anything, including a failed change whose native + // rollback might fail. Subsequent requests never replace this baseline. + originals_.emplace(key, std::move(restore)); + const bool accepted = change(); + if (!accepted && originals_.at(key)()) + originals_.erase(key); + return accepted; + } + + bool RestoreAll() { + for (auto it = originals_.begin(); it != originals_.end();) { + if (it->second()) + it = originals_.erase(it); + else + ++it; + } + return originals_.empty(); + } + + bool Empty() const { return originals_.empty(); } + +private: + std::map originals_; +}; + +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/gui/runtime/display_resolution_runtime.cpp b/apps/desktop/src/gui/runtime/display_resolution_runtime.cpp index fa7c091d..30d3c1aa 100644 --- a/apps/desktop/src/gui/runtime/display_resolution_runtime.cpp +++ b/apps/desktop/src/gui/runtime/display_resolution_runtime.cpp @@ -2,15 +2,19 @@ #include +#include "rd_log.h" + #ifdef _WIN32 #include "interactive_desktop.h" #include "platform/windows/screen_capturer/display_resolution_win.h" +#elif defined(__APPLE__) +#include "platform/macos/screen_capturer/display_resolution_mac.h" #endif namespace crossdesk { namespace { -// EnumDisplaySettings caches its enumeration. Serialize queries on transport -// threads with mode changes on the application thread. +// Serialize native queries on transport threads with mode changes on the +// application thread (EnumDisplaySettings also caches its enumeration). std::mutex native_display_resolution_mutex; } // namespace @@ -18,6 +22,9 @@ std::vector GuiRuntime::QueryHostDisplayResolutions() { #ifdef _WIN32 std::lock_guard lock(native_display_resolution_mutex); return QueryWindowsDisplayResolutions(devices_.display_info_list()); +#elif defined(__APPLE__) + std::lock_guard lock(native_display_resolution_mutex); + return QueryMacDisplayResolutions(devices_.display_info_list()); #else return {}; #endif @@ -26,24 +33,54 @@ std::vector GuiRuntime::QueryHostDisplayResolutions() { void GuiRuntime::QueueDisplayResolutionChange( const std::string &id, PeerEventHandler *source, const DisplayResolutionChange &change) { + const auto epoch = display_resolution_epoch_.load(); const auto events = ControllerEvents(id); if (events.get() != source || !IsAuthorizedController(id)) return; std::lock_guard lock(display_resolution_commands_mutex_); if (display_resolution_commands_.size() >= 32) return; - display_resolution_commands_.push_back({id, events, change}); + display_resolution_commands_.push_back({id, events, change, epoch}); +} + +void GuiRuntime::RestoreHostDisplayResolutions(bool force) { + const auto epoch = display_resolution_epoch_.load(); + const bool disconnected = epoch != handled_display_resolution_epoch_; + handled_display_resolution_epoch_ = epoch; + if (display_resolution_restoration_.Empty()) + return; + const auto now = SDL_GetTicks(); + // A disconnect boundary must survive a rapid reconnect. Otherwise retry + // failed restoration only while idle, without disrupting an active session. + if (!disconnected && !force && + (now < next_display_resolution_restore_ || + !ConnectedControllers().empty())) + return; + next_display_resolution_restore_ = now + 2000; + std::lock_guard lock(native_display_resolution_mutex); +#ifdef _WIN32 + if (!IsCurrentSessionUserDesktopActive()) + return; +#endif + if (display_resolution_restoration_.RestoreAll()) + LOG_INFO("Restored original display modes after remote control"); + else if (disconnected || force) + LOG_WARN("Could not restore every display mode; will retry while idle"); + next_display_resolution_poll_ = 0; } void GuiRuntime::HandleDisplayResolutionChanges() { + RestoreHostDisplayResolutions(); std::deque commands; { std::lock_guard lock(display_resolution_commands_mutex_); commands.swap(display_resolution_commands_); } for (const auto &command : commands) { + RestoreHostDisplayResolutions(); const auto source = command.source.lock(); - if (!source || !source->IsActive() || + if (stopping_ || command.epoch != display_resolution_epoch_.load() || + !source || !source->IsActive() || source != ControllerEvents(command.remote_id) || !IsAuthorizedController(command.remote_id)) continue; @@ -51,20 +88,57 @@ void GuiRuntime::HandleDisplayResolutionChanges() { reply.type = ControlType::display_resolution_status; reply.resolution_change = command.change; reply.resolution_change.accepted = false; + const auto displays = devices_.display_info_list(); + const auto available = QueryHostDisplayResolutions(); + const auto selected = + std::find_if(available.begin(), available.end(), [&](const auto &info) { + return SupportsDisplayResolution(info, command.change); + }); + if (selected == available.end()) { + // Unsupported or stale requests must never create a restoration record. + } else if (selected->current == command.change.resolution) { + reply.resolution_change.accepted = true; + } else { #ifdef _WIN32 - { - std::lock_guard lock(native_display_resolution_mutex); - if (IsCurrentSessionUserDesktopActive()) - reply.resolution_change.accepted = ApplyWindowsDisplayResolution( - devices_.display_info_list(), command.change); - } + { + std::lock_guard lock(native_display_resolution_mutex); + if (IsCurrentSessionUserDesktopActive()) + reply.resolution_change.accepted = + display_resolution_restoration_.Apply( + command.change.display_key, + [&] { + return CaptureWindowsDisplayResolution(displays, + command.change); + }, + [&] { + return ApplyWindowsDisplayResolution(displays, + command.change); + }); + } +#elif defined(__APPLE__) + { + std::lock_guard lock(native_display_resolution_mutex); + reply.resolution_change.accepted = + display_resolution_restoration_.Apply( + command.change.display_key, + [&] { + return CaptureMacDisplayResolution(displays, command.change); + }, + [&] { + return ApplyMacDisplayResolution(displays, command.change); + }); + } #endif + } reply.display_resolutions = QueryHostDisplayResolutions(); const auto message = reply.to_json(); SendControllerData(command.remote_id, message.data(), message.size(), control_data_label_.c_str()); next_display_resolution_poll_ = 0; } + // The transport can disconnect while a synchronous native mode switch is + // in progress. Restore that change on this tick as well. + RestoreHostDisplayResolutions(); const auto now = SDL_GetTicks(); if (now < next_display_resolution_poll_) return; diff --git a/apps/desktop/src/gui/runtime/gui_runtime.cpp b/apps/desktop/src/gui/runtime/gui_runtime.cpp index e86438a5..13b32a2e 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.cpp +++ b/apps/desktop/src/gui/runtime/gui_runtime.cpp @@ -82,6 +82,7 @@ GuiRuntime::~GuiRuntime() { lan_.reset(); transfers_.Shutdown(); WaitForSessionCleanup(); + RestoreHostDisplayResolutions(true); } void GuiRuntime::SendRemoteInput(const RemoteAction& action) { diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index e6d2e6a2..6db35648 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -25,6 +25,7 @@ #include "privacy_controller.h" #include "runtime/gui_state.h" #include "runtime/disconnect_lock_policy.h" +#include "runtime/display_resolution_restoration.h" #include "runtime/announcement_inbox.h" #include "runtime/peer_event_handler.h" #include "runtime/peer_transport.h" @@ -157,6 +158,7 @@ class GuiRuntime : protected gui_detail::GuiState { PeerEventHandler* source, const DisplayResolutionChange& change); void HandleDisplayResolutionChanges(); + void RestoreHostDisplayResolutions(bool force = false); void CloseRemoteSession(std::shared_ptr props); void CloseAllRemoteSessions(); @@ -256,11 +258,18 @@ class GuiRuntime : protected gui_detail::GuiState { std::string remote_id; std::weak_ptr source; DisplayResolutionChange change; + uint64_t epoch; }; std::mutex display_resolution_commands_mutex_; std::deque display_resolution_commands_; std::vector last_display_resolutions_; uint64_t next_display_resolution_poll_ = 0; + // Incremented when the last incoming controller disconnects. Keep the + // boundary even if a new connection arrives before the application tick. + std::atomic display_resolution_epoch_{0}; + uint64_t handled_display_resolution_epoch_ = 0; + uint64_t next_display_resolution_restore_ = 0; + DisplayResolutionRestoration display_resolution_restoration_; bool privacy_settings_changed_ = false; uint32_t last_privacy_revision_ = 0; uint64_t last_privacy_status_tick_ = 0; diff --git a/apps/desktop/src/gui/runtime/lan_runtime.cpp b/apps/desktop/src/gui/runtime/lan_runtime.cpp index a6c160d2..44892d61 100644 --- a/apps/desktop/src/gui/runtime/lan_runtime.cpp +++ b/apps/desktop/src/gui/runtime/lan_runtime.cpp @@ -218,6 +218,7 @@ void GuiRuntime::BeginShutdown() { std::chrono::steady_clock::now() + std::chrono::seconds(5); if (lan_) lan_->Stop(); CloseAllRemoteSessions(); + RestoreHostDisplayResolutions(true); } bool GuiRuntime::ShutdownDrained() { TickLan(); @@ -225,6 +226,7 @@ bool GuiRuntime::ShutdownDrained() { const bool drained = (!lan_ || lan_->Quiescent()) && !connection_peer_cleanup_.valid() && session_cleanup_tasks_.empty() && controller_cleanup_tasks_.empty(); + if (drained) RestoreHostDisplayResolutions(true); if (!drained && std::chrono::steady_clock::now() >= shutdown_report_at_) { LOG_WARN( "Shutdown waiting: LAN [{}], cloud peer={}, viewers={}, controllers={}", diff --git a/apps/desktop/src/platform/macos/screen_capturer/display_resolution_mac.h b/apps/desktop/src/platform/macos/screen_capturer/display_resolution_mac.h new file mode 100644 index 00000000..2a8e5238 --- /dev/null +++ b/apps/desktop/src/platform/macos/screen_capturer/display_resolution_mac.h @@ -0,0 +1,231 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _DISPLAY_RESOLUTION_MAC_H_ +#define _DISPLAY_RESOLUTION_MAC_H_ + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "display_info.h" +#include + +namespace crossdesk { + +inline DisplayResolution MacDisplayResolution(CGDisplayModeRef mode) { + if (!mode) + return {}; + // Desktop dimensions, matching macOS scaled resolutions. Pixel dimensions + // can be twice these values on Retina displays and belong to capture only. + const auto width = CGDisplayModeGetWidth(mode); + const auto height = CGDisplayModeGetHeight(mode); + if (width > 32768 || height > 32768) + return {}; + return {int(width), int(height)}; +} + +inline bool ResolveMacResolutionDisplay(const DisplayInfo &display, + CGDirectDisplayID &id, + std::string &key) { + id = static_cast( + reinterpret_cast(display.handle)); + if (!id || !CGDisplayIsOnline(id) || !CGDisplayIsActive(id)) + return false; + CFUUIDRef uuid = CGDisplayCreateUUIDFromDisplayID(id); + if (!uuid) + return false; + CFStringRef uuid_string = CFUUIDCreateString(nullptr, uuid); + CFRelease(uuid); + if (!uuid_string) + return false; + char value[128]{}; + const bool valid = CFStringGetCString(uuid_string, value, sizeof(value), + kCFStringEncodingUTF8); + CFRelease(uuid_string); + if (!valid) + return false; + key = std::to_string(id) + "|" + value; + return true; +} + +// The array owns the borrowed mode references returned by Choices(). +class MacResolutionModes { +public: + explicit MacResolutionModes(CGDirectDisplayID display) + : current(CGDisplayCopyDisplayMode(display)), + modes_(CGDisplayCopyAllDisplayModes(display, nullptr)) {} + ~MacResolutionModes() { + if (modes_) + CFRelease(modes_); + if (current) + CGDisplayModeRelease(current); + } + MacResolutionModes(const MacResolutionModes &) = delete; + MacResolutionModes &operator=(const MacResolutionModes &) = delete; + + std::vector Choices() const { + std::vector result; + if (!current || !modes_ || + !ValidDisplayResolution(MacDisplayResolution(current))) + return result; + const double scale = double(CGDisplayModeGetPixelWidth(current)) / + CGDisplayModeGetWidth(current); + const double refresh = CGDisplayModeGetRefreshRate(current); + const auto rank = [&](CGDisplayModeRef mode) { + return std::make_tuple( + std::abs(double(CGDisplayModeGetPixelWidth(mode)) / + CGDisplayModeGetWidth(mode) - + scale), + std::abs(CGDisplayModeGetRefreshRate(mode) - refresh), + CGDisplayModeGetIODisplayModeID(mode) != + CGDisplayModeGetIODisplayModeID(current)); + }; + std::map, CGDisplayModeRef> unique; + for (CFIndex index = 0; index < CFArrayGetCount(modes_); ++index) { + auto mode = static_cast( + const_cast(CFArrayGetValueAtIndex(modes_, index))); + const auto size = MacDisplayResolution(mode); + const auto flags = CGDisplayModeGetIOFlags(mode); + if (!ValidDisplayResolution(size) || + !CGDisplayModeIsUsableForDesktopGUI(mode) || + (flags & (kDisplayModeInterlacedFlag | kDisplayModeStretchedFlag))) + continue; + const auto key = std::make_pair(size.width, size.height); + auto found = unique.find(key); + // Prefer the current Retina density and then the closest refresh rate. + if (found == unique.end() || rank(mode) < rank(found->second)) + unique[key] = mode; + } + for (auto it = unique.rbegin(); it != unique.rend(); ++it) { + result.push_back(it->second); + if (result.size() == kMaxDisplayResolutionModes) + break; + } + return result; + } + + CGDisplayModeRef current; + +private: + CFArrayRef modes_; +}; + +inline std::vector +QueryMacDisplayResolutions(const std::vector &displays) { + std::vector result; + for (int index = 0; + index < int(displays.size()) && index < kMaxResolutionDisplays; + ++index) { + DisplayResolutionInfo info; + info.display_id = index; + CGDirectDisplayID id = 0; + if (!ResolveMacResolutionDisplay(displays[index], id, info.display_key)) + continue; + MacResolutionModes modes(id); + info.current = MacDisplayResolution(modes.current); + if (!ValidDisplayResolution(info.current)) + continue; + for (auto mode : modes.Choices()) + info.modes.push_back(MacDisplayResolution(mode)); + result.push_back(std::move(info)); + } + return result; +} + +inline bool ApplyMacDisplayResolution(const std::vector &displays, + const DisplayResolutionChange &change) { + if (change.display_id < 0 || change.display_id >= int(displays.size()) || + !ValidDisplayResolution(change.resolution)) + return false; + CGDirectDisplayID id = 0; + std::string key; + if (!ResolveMacResolutionDisplay(displays[change.display_id], id, key) || + key != change.display_key) + return false; + MacResolutionModes modes(id); + for (auto mode : modes.Choices()) { + if (!(MacDisplayResolution(mode) == change.resolution)) + continue; + if (MacDisplayResolution(modes.current) == change.resolution) + return true; + // A synchronous, application-lifetime change; never persist remote choices + // in the user's permanent display configuration. + if (CGDisplaySetDisplayMode(id, mode, nullptr) != kCGErrorSuccess) + return false; + CGDisplayModeRef applied = CGDisplayCopyDisplayMode(id); + const bool accepted = MacDisplayResolution(applied) == change.resolution; + if (applied) + CGDisplayModeRelease(applied); + if (!accepted) + CGDisplaySetDisplayMode(id, modes.current, nullptr); + return accepted; + } + return false; +} + +inline bool SameMacResolutionMode(CGDisplayModeRef mode, + CGDisplayModeRef original) { + return mode && original && + MacDisplayResolution(mode) == MacDisplayResolution(original) && + CGDisplayModeGetPixelWidth(mode) == + CGDisplayModeGetPixelWidth(original) && + CGDisplayModeGetPixelHeight(mode) == + CGDisplayModeGetPixelHeight(original) && + CGDisplayModeGetRefreshRate(mode) == + CGDisplayModeGetRefreshRate(original); +} + +inline std::function +CaptureMacDisplayResolution(const std::vector &displays, + const DisplayResolutionChange &change) { + if (change.display_id < 0 || change.display_id >= int(displays.size())) + return {}; + const auto display = displays[change.display_id]; + CGDirectDisplayID id = 0; + std::string key; + if (!ResolveMacResolutionDisplay(display, id, key) || + key != change.display_key) + return {}; + // Retain the exact mode, including Retina pixel density and refresh rate. + std::shared_ptr original(CGDisplayCopyDisplayMode(id), + [](CGDisplayModeRef mode) { + if (mode) + CGDisplayModeRelease(mode); + }); + if (!original) + return {}; + return [display, key, original] { + CGDirectDisplayID id = 0; + std::string current_key; + if (!ResolveMacResolutionDisplay(display, id, current_key) || + current_key != key) + return false; + CGDisplayModeRef current = CGDisplayCopyDisplayMode(id); + const bool unchanged = SameMacResolutionMode(current, original.get()); + if (current) + CGDisplayModeRelease(current); + if (unchanged) + return true; + if (CGDisplaySetDisplayMode(id, original.get(), nullptr) != kCGErrorSuccess) + return false; + current = CGDisplayCopyDisplayMode(id); + const bool restored = SameMacResolutionMode(current, original.get()); + if (current) + CGDisplayModeRelease(current); + return restored; + }; +} + +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck_impl.mm b/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck_impl.mm index 678df779..fad3e98e 100644 --- a/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck_impl.mm +++ b/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck_impl.mm @@ -180,7 +180,7 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer { bool complete, SCStreamConfiguration* configuration, NSError* error); void RetryPrivacyFilter(); - void PrivacyCoversChanged() { StartOrReconfigureCapturer(); } + void CaptureConfigurationChanged() { StartOrReconfigureCapturer(); } void CaptureStopped(SCStream* source, NSError* error) { { std::lock_guard lock(lock_); @@ -914,13 +914,23 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() { DISPATCH_QUEUE_SERIAL); [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(privacyCoversChanged:) name:@"CrossDeskPrivacyCoversChanged" object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(screenParametersChanged:) + name:NSApplicationDidChangeScreenParametersNotification object:nil]; } return self; } - (void)privacyCoversChanged:(NSNotification*)notification { std::lock_guard lock(_capturer_lock); - if (_capturer) _capturer->PrivacyCoversChanged(); + if (_capturer) _capturer->CaptureConfigurationChanged(); +} +- (void)screenParametersChanged:(NSNotification*)notification { + // Defer until the mode switch has completed, then fetch fresh SCDisplay + // geometry and update the stream's pixel dimensions and input bounds. + dispatch_async(_completion_queue, ^{ + std::lock_guard lock(self->_capturer_lock); + if (self->_capturer) self->_capturer->CaptureConfigurationChanged(); + }); } - (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation revision:(uint64_t)revision complete:(BOOL)complete diff --git a/apps/desktop/src/platform/windows/screen_capturer/display_resolution_win.h b/apps/desktop/src/platform/windows/screen_capturer/display_resolution_win.h index ebd66c2a..bb69f90b 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/display_resolution_win.h +++ b/apps/desktop/src/platform/windows/screen_capturer/display_resolution_win.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -105,6 +106,18 @@ inline bool ApplyWindowsResolutionMode(const wchar_t *device, return false; } +inline bool WindowsResolutionDeviceKey(const std::wstring &device, + std::string &key) { + DISPLAY_DEVICEW target{}; + target.cb = sizeof(target); + if (!EnumDisplayDevicesW(device.c_str(), 0, &target, + EDD_GET_DEVICE_INTERFACE_NAME) || + !target.DeviceID[0]) + return false; + key = WideToUtf8(device) + "|" + WideToUtf8(target.DeviceID); + return true; +} + inline bool ResolveWindowsResolutionDisplay(const DisplayInfo &display, std::wstring &device, std::string &key) { @@ -135,14 +148,55 @@ inline bool ResolveWindowsResolutionDisplay(const DisplayInfo &display, if (device.empty()) return false; } - DISPLAY_DEVICEW target{}; - target.cb = sizeof(target); - if (!EnumDisplayDevicesW(device.c_str(), 0, &target, - EDD_GET_DEVICE_INTERFACE_NAME) || - !target.DeviceID[0]) - return false; - key = WideToUtf8(device) + "|" + WideToUtf8(target.DeviceID); - return true; + return WindowsResolutionDeviceKey(device, key); +} + +inline bool SameWindowsResolutionMode(const DEVMODEW &mode, + const DEVMODEW &original) { + return mode.dmPelsWidth == original.dmPelsWidth && + mode.dmPelsHeight == original.dmPelsHeight && + mode.dmBitsPerPel == original.dmBitsPerPel && + mode.dmDisplayFrequency == original.dmDisplayFrequency && + mode.dmDisplayFlags == original.dmDisplayFlags && + (!(original.dmFields & DM_DISPLAYORIENTATION) || + mode.dmDisplayOrientation == original.dmDisplayOrientation) && + (!(original.dmFields & DM_DISPLAYFIXEDOUTPUT) || + mode.dmDisplayFixedOutput == original.dmDisplayFixedOutput); +} + +inline std::function CaptureWindowsDisplayResolution( + const std::vector &displays, + const DisplayResolutionChange &change, + const WindowsResolutionApi &api = {}) { + if (change.display_id < 0 || change.display_id >= int(displays.size())) + return {}; + std::wstring device; + std::string key; + if (!ResolveWindowsResolutionDisplay(displays[change.display_id], device, key) || + key != change.display_key) + return {}; + DEVMODEW original{}; + original.dmSize = sizeof(original); + if (!api.enumerate(device.c_str(), ENUM_CURRENT_SETTINGS, &original)) return {}; + return [device, key, original, api] { + std::string current_key; + // Resolve the native output, not a capture slot or stale HMONITOR. Never + // apply a saved mode to a different monitor plugged into the same output. + if (!WindowsResolutionDeviceKey(device, current_key) || current_key != key) + return false; + DEVMODEW current{}; + current.dmSize = sizeof(current); + if (!api.enumerate(device.c_str(), ENUM_CURRENT_SETTINGS, ¤t)) return false; + if (SameWindowsResolutionMode(current, original)) return true; + auto mode = original; + if (api.change(device.c_str(), &mode, nullptr, CDS_TEST, nullptr) != + DISP_CHANGE_SUCCESSFUL || + api.change(device.c_str(), &mode, nullptr, 0, nullptr) != + DISP_CHANGE_SUCCESSFUL) + return false; + return api.enumerate(device.c_str(), ENUM_CURRENT_SETTINGS, ¤t) && + SameWindowsResolutionMode(current, original); + }; } inline std::vector @@ -187,4 +241,4 @@ ApplyWindowsDisplayResolution(const std::vector &displays, } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/xmake/platform.lua b/apps/desktop/xmake/platform.lua index 352e54ec..5356c412 100644 --- a/apps/desktop/xmake/platform.lua +++ b/apps/desktop/xmake/platform.lua @@ -106,7 +106,7 @@ function setup_platform_settings() add_cxflags("-Wno-unused-variable") elseif is_os("macosx") then add_cxflags("-Wno-unused-variable") - add_frameworks("Cocoa", "CoreGraphics", "Metal", "QuartzCore", "IOKit", + add_frameworks("Cocoa", "CoreGraphics", "ColorSync", "Metal", "QuartzCore", "IOKit", "IOSurface", "ScreenCaptureKit", "AVFoundation", "CoreMedia", "CoreVideo", "CoreAudio", "AudioToolbox") diff --git a/apps/harmony/entry/src/main/ets/mobile/LocalizationData.ets b/apps/harmony/entry/src/main/ets/mobile/LocalizationData.ets index c718d1df..4d80d75a 100644 --- a/apps/harmony/entry/src/main/ets/mobile/LocalizationData.ets +++ b/apps/harmony/entry/src/main/ets/mobile/LocalizationData.ets @@ -240,4 +240,6 @@ export const translations: Record = { "鼠标右键,按住拖动": ["鼠标右键,按住拖动", "Right mouse button, hold down and drag", "Правая кнопка мыши, удерживайте ее и перетащите", "滑鼠右鍵,按住拖動", "マウスの右ボタンを押したままドラッグします", "마우스 오른쪽 버튼을 누르고 드래그하세요.", "Rechte Maustaste, gedrückt halten und ziehen", "Bouton droit de la souris, maintenez enfoncé et faites glisser", "Botón derecho del ratón, mantén pulsado y arrastra", "Botão direito do mouse, mantenha pressionado e arraste"], "鼠标左键,按住拖动": ["鼠标左键,按住拖动", "Left mouse button, hold down and drag", "Левая кнопка мыши, удерживая ее, перетащите", "滑鼠左鍵,按住拖動", "マウスの左ボタンを押したままドラッグします", "마우스 왼쪽 버튼을 누른 채 드래그", "Linke Maustaste, gedrückt halten und ziehen", "Bouton gauche de la souris, maintenez enfoncé et faites glisser", "Botón izquierdo del ratón, mantén pulsado y arrastra", "Botão esquerdo do mouse, mantenha pressionado e arraste"], "鼠标控制": ["鼠标控制", "Mouse control", "Управление мышью", "滑鼠控制", "マウスコントロール", "마우스 제어", "Maussteuerung", "Contrôle de la souris", "Control del ratón", "Controle do mouse"], + "被控端分辨率": ["被控端分辨率", "Remote display resolution", "Разрешение удалённого экрана", "被控端解析度", "接続先の画面解像度", "원격 화면 해상도", "Auflösung des entfernten Bildschirms", "Résolution de l’écran distant", "Resolución de la pantalla remota", "Resolução da tela remota"], + "设备不支持": ["设备不支持", "Unsupported", "Не поддерживается", "裝置不支援", "サポートされていません", "지원되지 않음", "Nicht unterstützt", "Non pris en charge", "No compatible", "Não suportado"], }; diff --git a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets index a9e3176f..49253355 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets @@ -1,4 +1,5 @@ import { tr } from './Localization'; +import { RemoteDisplayResolutions, ResolutionDisplay, ResolutionMode, ResolutionRequest, resolutionLabel } from './RemoteDisplayResolutions'; import { RemotePrivacyScreen, PrivacyStatus } from './RemotePrivacyScreen'; import bridge from 'libcrossdesk.so'; import { common, bundleManager } from '@kit.AbilityKit'; @@ -11,6 +12,7 @@ import { AnnouncementInbox, AnnouncementItem, AnnouncementRequest, AnnouncementR import { VideoSettings, VideoValues, NetworkStatistics, NetworkReport, RecentPresence, PresenceMessage, asciiKey } from './ControllerModels'; interface Command { + 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; id?: string; x?: number; y?: number; flag?: number; scroll?: number; code?: number; down?: boolean; @@ -24,11 +26,11 @@ interface NativeEvent { sending?: boolean; path?: string; ok?: boolean; request?: number | string; latest?: string; } export interface RemoteDisplay { name: string; } -interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; } +interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; display_resolutions?: ResolutionDisplay[]; } interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; } interface ServiceStatus { available: boolean; interactive_stage?: string; } interface VideoCodecStatus { encoder: string; } -interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; video_codec?: VideoCodecStatus; } +interface ControlData { display_resolution?: ResolutionRequest; display_resolutions?: ResolutionDisplay[]; type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; video_codec?: VideoCodecStatus; } interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; } interface SignalType { type: string; } @@ -76,6 +78,11 @@ export class MobileState { videoSelectedPreference: number = 1; videoSupported: boolean = false; videoFeedback: string = ''; + resolutionModes: ResolutionMode[] = []; + resolutionKey: string = ''; + resolutionCurrent: string = tr("设备不支持"); + resolutionEnabled: boolean = false; + resolutionFeedback: string = ''; privacyStatus: string = ''; privacyButtonTitle: string = tr("隐私屏"); privacyFeedback: string = ''; @@ -97,6 +104,8 @@ export class MobileController { store: MobileStore; platform: MobilePlatform; video: VideoSettings = new VideoSettings(); + private resolutions: RemoteDisplayResolutions = new RemoteDisplayResolutions(); + private resolutionTimeout: number = -1; private privacyScreen: RemotePrivacyScreen = new RemotePrivacyScreen(); network: NetworkStatistics = new NetworkStatistics(); presence: RecentPresence; @@ -248,6 +257,7 @@ export class MobileController { this.network = new NetworkStatistics(); let edge = 1920; try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {} this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo(); + this.resetResolutions(); this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy(); this.store.put('lastRemote', this.state.remote); this.state.password = ''; this.ensureSignal(); this.joinIfReady(); @@ -259,6 +269,7 @@ export class MobileController { } end(reason: string): void { this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect(); + this.resetResolutions(); this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false; this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy(); this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = ''; @@ -285,7 +296,7 @@ export class MobileController { selectDisplay(index: number): void { if (index < 0 || index >= this.state.displays.length) return; this.release(); this.state.selectedDisplay = index; this.state.viewportReset++; this.state.hasFrame = false; this.network.reset(); - this.send({ op: 'display', index: index }); this.state.modal = ''; + this.send({ op: 'display', index: index }); this.state.modal = ''; this.syncResolutions(); } updateVideo(field: number, value: number): void { const old = this.video.selection; @@ -295,6 +306,31 @@ export class MobileController { if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.settingsTimeout = setTimeout(() => { this.video.expire(id); this.syncVideo(); }, 5000); this.syncVideo(); } + updateResolution(mode: ResolutionMode, displayId: number, displayKey: string): void { + 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; + const request = this.resolutions.begin(displayId, mode); + if (!request) return; + this.send({ op: 'displayResolution', displayId: request.display_id, displayKey: request.display_key, + width: request.width, height: request.height, request: request.request_id }); + if (this.resolutionTimeout >= 0) clearTimeout(this.resolutionTimeout); + this.resolutionTimeout = setTimeout(() => { this.resolutions.expire(request.request_id); this.syncResolutions(); }, 10000); + this.syncResolutions(); + } + private resetResolutions(): void { + if (this.resolutionTimeout >= 0) clearTimeout(this.resolutionTimeout); + this.resolutionTimeout = -1; this.resolutions = new RemoteDisplayResolutions(); this.syncResolutions(); + } + private syncResolutions(): void { + const display = this.resolutions.display(this.state.selectedDisplay); + this.state.resolutionModes = display?.modes ?? []; + this.state.resolutionKey = display?.display_key ?? ''; + this.state.resolutionCurrent = display ? resolutionLabel(display) : tr("设备不支持"); + this.state.resolutionEnabled = this.state.ready && !this.resolutions.pending && this.state.resolutionModes.length > 0; + this.state.resolutionFeedback = this.resolutions.feedback(); + if (!this.resolutions.pending && this.resolutionTimeout >= 0) { clearTimeout(this.resolutionTimeout); this.resolutionTimeout = -1; } + } setPreference(value: number): void { this.store.put('videoPreference', value); this.state.videoPreference = value; } setAutoLandscape(value: boolean): void { this.store.put('autoLandscape', value); this.state.autoLandscape = value; } setPreviews(value: boolean): void { @@ -441,6 +477,7 @@ export class MobileController { this.state.transfer = (e.progress ?? 0) < 0 ? tr("{0} 传输失败", [String(e.name)]) : `${e.sending ? '发送' : '接收'} ${e.name} · ${(e.progress ?? 0) >= 1 ? '已完成' : Math.floor((e.progress ?? 0) * 100) + '%'}`; if (e.path) { this.state.receivedPath = e.path; this.state.receivedName = e.name ?? 'received-file'; } } else if (e.type === 'privacyFailed') { this.privacyScreen.failed(Number(e.request)); this.syncPrivacy(); } + else if (e.type === 'resolutionFailed') { this.resolutions.expire(Number(e.request)); this.syncResolutions(); } else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); } else if (e.type === 'preview') { this.previewPending = false; @@ -477,6 +514,7 @@ export class MobileController { } }).catch(() => this.toast(tr("密码保存失败,本次连接仍可使用"))); this.platform.connected(this.state.autoLandscape); + this.syncResolutions(); if (this.video.supported && this.video.requestId === 0) this.updateVideo(0, this.video.selection.quality); if (this.receivedHostInfo) this.checkRemote(this.remoteVersion); } @@ -493,9 +531,13 @@ export class MobileController { this.video.receive({ quality: v.quality, frameRate: v.frame_rate, preference: v.preference }, v.request_id, v.accepted === true); 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; + } if (message.host_info) { const info = message.host_info; this.state.platform = info.platform; this.state.hostName = info.host_name; this.state.displays = Array.isArray(info.displays) ? info.displays.slice(0, 8) : []; + this.resolutions.host(info.display_resolutions === undefined ? [] : info.display_resolutions); this.syncResolutions(); this.store.metadata(this.scope(), this.connectingId, info.host_name, info.platform); const supported = this.video.supported; this.video.support(info.supports_video_settings === true); this.syncVideo(); if (this.state.ready && this.video.supported && !supported) this.updateVideo(0, this.video.selection.quality); diff --git a/apps/harmony/entry/src/main/ets/mobile/MobileRemoteView.ets b/apps/harmony/entry/src/main/ets/mobile/MobileRemoteView.ets index d9bd69b9..489bf918 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobileRemoteView.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobileRemoteView.ets @@ -1,4 +1,5 @@ import { tr } from './Localization'; +import { ResolutionMode, resolutionLabel } from './RemoteDisplayResolutions'; import { MobileController, MobileState } from './MobileController'; import { RemoteViewport, MouseInput, TouchInput, TouchPoint, clamp, NetworkStatistics, Traffic, asciiKey } from './ControllerModels'; import { MobileTheme, MobileSegment, MobileIcon, MobileIconButton } from './MobileWidgets'; @@ -192,6 +193,16 @@ export struct MobileRemoteView { this.keyboardX = clamp(this.keyboardOriginX + event.offsetX, 8, this.viewWidth - this.keyboardWidth() - 8); this.keyboardY = clamp(this.keyboardOriginY + event.offsetY, 8, Math.max(8, this.viewHeight - this.keyboardHeight - 8)); } + @Builder + ResolutionMenu(modes: ResolutionMode[], displayId: number, displayKey: string) { + Menu() { + ForEach(modes, (mode: ResolutionMode) => { + MenuItem({ content: resolutionLabel(mode) }) + .onClick(() => this.controller?.updateResolution(mode, displayId, displayKey)) + }, (mode: ResolutionMode) => resolutionLabel(mode)) + } + } + @Builder NetworkRow(title: string, traffic: Traffic | undefined) { Row({ space: 4 }) { @@ -252,6 +263,12 @@ export struct MobileRemoteView { if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') } if (this.state.receivedPath) { Button(tr("保存收到的文件"), { type: ButtonType.Normal }).fontSize(14).fontColor(MobileTheme.blue).backgroundColor(Color.White).height(42).width('100%').onClick(() => this.controller?.saveFile()) } } else if (this.menu === 'video') { + Text(tr("被控端分辨率")).fontSize(12).width('100%') + Button(`${this.state.resolutionCurrent} ▾`, { type: ButtonType.Normal }) + .fontSize(13).fontColor(MobileTheme.ink).backgroundColor('#F0F0F2').borderRadius(7) + .width('100%').height(34).enabled(this.state.resolutionEnabled) + .bindMenu(() => { this.ResolutionMenu(this.state.resolutionModes, this.state.selectedDisplay, this.state.resolutionKey) }) + if (this.state.resolutionFeedback) { Text(this.state.resolutionFeedback).fontSize(12).fontColor(MobileTheme.secondary).width('100%') } Text(this.state.videoFeedback).fontSize(12).fontColor(MobileTheme.secondary).width('100%') Text(tr("画面质量")).fontSize(12).width('100%') MobileSegment({ labels: [tr("低"), tr("中"), tr("高")], selection: this.state.videoQuality, diff --git a/apps/harmony/entry/src/main/ets/mobile/RemoteDisplayResolutions.ets b/apps/harmony/entry/src/main/ets/mobile/RemoteDisplayResolutions.ets new file mode 100644 index 00000000..dd4ba4d2 --- /dev/null +++ b/apps/harmony/entry/src/main/ets/mobile/RemoteDisplayResolutions.ets @@ -0,0 +1,66 @@ +import { tr } from './Localization'; + +export interface ResolutionMode { width: number; height: number; } +export interface ResolutionDisplay extends ResolutionMode { + display_id: number; display_key: string; width: number; height: number; modes: ResolutionMode[]; +} +export interface ResolutionRequest extends ResolutionMode { + display_id: number; display_key: string; width: number; height: number; request_id: number; accepted: boolean; +} +export function resolutionLabel(mode: ResolutionMode): string { return `${mode.width} × ${mode.height}`; } +function sameMode(a: ResolutionMode, b: ResolutionMode): boolean { return a.width === b.width && a.height === b.height; } +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 class RemoteDisplayResolutions { + displays: ResolutionDisplay[] = []; + requested?: ResolutionRequest; + pending: boolean = false; + failed: boolean = false; + display(id: number): ResolutionDisplay | undefined { return this.displays.find((d: ResolutionDisplay) => d.display_id === id); } + begin(id: number, mode: ResolutionMode): ResolutionRequest | undefined { + const display = this.display(id); + if (this.pending || !display || !validMode(mode) || sameMode(display, mode) || + !display.modes.some((m: ResolutionMode) => sameMode(m, mode))) return undefined; + const next = ((this.requested?.request_id ?? 0) + 1) >>> 0; + this.requested = { display_id: id, display_key: display.display_key, width: mode.width, + height: mode.height, request_id: next || 1, accepted: false }; + this.pending = true; this.failed = false; + return this.requested; + } + expire(id: number): void { if (this.pending && this.requested?.request_id === id) { this.pending = false; this.failed = true; } } + private valid(displays: ResolutionDisplay[]): boolean { + if (!Array.isArray(displays) || displays.length > 32) return false; + 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; + ids.push(d.display_id); + } + return true; + } + host(displays: ResolutionDisplay[]): void { + if (!this.valid(displays)) return; + this.displays = displays; + const request = this.requested; + if (this.pending && request && !displays.some((d: ResolutionDisplay) => + 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 { + 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; + 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)); + } + 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 92d649e2..ec590251 100644 --- a/apps/harmony/native/mobile_session.cpp +++ b/apps/harmony/native/mobile_session.cpp @@ -330,6 +330,15 @@ class MobileSession { auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get()); if(MiniRtcSendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0) Event({{"type","settingsFailed"},{"request",v.at("request")}},true); + } else if (op=="displayResolution") { + const auto request = v.at("request").get(); + if (request <= 0 || request > UINT32_MAX) return; + const auto bytes = mobile_controller::DisplayResolutionMessage( + v.at("displayId").get(), v.at("displayKey").get(), + v.at("width").get(), v.at("height").get(), uint32_t(request)); + if (bytes.empty() || MiniRtcSendReliableDataFrame( + controller_, bytes.data(), bytes.size(), kControlStream) != 0) + Event({{"type", "resolutionFailed"}, {"request", request}}, true); } else if (op=="privacy") { const auto bytes = mobile_controller::PrivacyMessage( v.at("flag").get(), v.at("blockLocalInput").get()); diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index 94119340..047a342b 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -100,6 +100,10 @@ typedef struct { - (void)rtcBridge:(CrossDeskRTCBridge *)bridge didReceivePresence:(NSDictionary *)presence snapshot:(BOOL)snapshot; +- (void)rtcBridge:(CrossDeskRTCBridge *)bridge + didReceiveDisplayResolutions:(NSData *)message; +- (void)rtcBridge:(CrossDeskRTCBridge *)bridge + didFailDisplayResolutionRequest:(uint32_t)requestID; - (void)rtcBridge:(CrossDeskRTCBridge *)bridge didReceiveAnnouncementMessage:(NSData *)message; - (void)rtcBridge:(CrossDeskRTCBridge *)bridge @@ -163,6 +167,12 @@ typedef struct { preference:(NSInteger)preference requestID:(uint32_t)requestID NS_SWIFT_NAME(sendVideoSettings(quality:frameRate:preference:requestID:)); +- (void)sendDisplayResolution:(NSInteger)displayID + displayKey:(NSString *)displayKey + width:(NSInteger)width + height:(NSInteger)height + requestID:(uint32_t)requestID + NS_SWIFT_NAME(sendDisplayResolution(displayID:displayKey:width:height:requestID:)); - (void)requestPresenceForRemoteIDs:(NSArray *)remoteIDs subscribe:(BOOL)subscribe NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:)); diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index 91bfb5ec..9f31d532 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -711,6 +711,35 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, }); } +- (void)sendDisplayResolution:(NSInteger)displayID + displayKey:(NSString *)displayKey + width:(NSInteger)width + height:(NSInteger)height + requestID:(uint32_t)requestID { + if (displayID < 0 || displayID >= crossdesk::kMaxResolutionDisplays || + width <= 0 || width > 32768 || height <= 0 || height > 32768 || !requestID) return; + NSString *key = [displayKey copy]; + const uint64_t generation = _activeControllerGeneration.load(); + dispatch_async(_rtcQueue, ^{ + if (![self isControllerGenerationActive:generation] || + !self->_state->controller_peer) return; + RemoteAction action{}; + action.type = ControlType::display_resolution; + action.resolution_change = {int(displayID), key.UTF8String ?: "", + {int(width), int(height)}, requestID, false}; + const auto message = action.to_json(); + if (message.empty() || MiniRtcSendReliableDataFrame(self->_state->controller_peer, + message.data(), message.size(), kControlStream) != 0) { + DispatchMain(^{ + if (![self isControllerGenerationActive:generation]) return; + id delegate = self.delegate; + if ([delegate respondsToSelector:@selector(rtcBridge:didFailDisplayResolutionRequest:)]) + [delegate rtcBridge:self didFailDisplayResolutionRequest:requestID]; + }); + } + }); +} + - (void)notifyVideoSettings:(crossdesk::VideoSettings)settings generation:(uint64_t)generation { DispatchMain(^{ @@ -1738,6 +1767,18 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, }); return; } + if (action.type == ControlType::display_resolution_status || + action.type == ControlType::host_infomation) { + const auto message = action.to_json(); + NSData *payload = [NSData dataWithBytes:message.data() length:message.size()]; + DispatchMain(^{ + if (![self isControllerGenerationActive:generation]) return; + id delegate = self.delegate; + if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveDisplayResolutions:)]) + [delegate rtcBridge:self didReceiveDisplayResolutions:payload]; + }); + if (action.type == ControlType::display_resolution_status) return; + } if (action.type == ControlType::video_settings_status) { [self notifyVideoSettings:action.vs generation:generation]; return; diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index e278cb58..db205f67 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -322,6 +322,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe didSet { UserDefaults.standard.set(autoLandscape, forKey: AppOrientation.autoLandscapeKey) } } @Published private(set) var videoSettings = RemoteVideoSettingsState() + @Published private(set) var displayResolutions = RemoteDisplayResolutionState() @Published var privacyScreen = RemotePrivacyScreen() @Published private(set) var privacyClock = ProcessInfo.processInfo.systemUptime private var privacyTimer: Timer? @@ -384,6 +385,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe private var videoWasBackgrounded = false private var videoRecoveryTask: Task? private var videoSettingsTimeoutTask: Task? + private var displayResolutionTimeoutTask: Task? private var pendingPresenceRemoteID: String? private var presenceProbeGeneration: UInt64 = 0 @Published private(set) var signalConnected = false @@ -1040,7 +1042,40 @@ 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 } + displayResolutionTimeoutTask?.cancel() + bridge.sendDisplayResolution(displayID: request.display_id, displayKey: request.display_key, + width: request.width, height: request.height, requestID: request.request_id) + displayResolutionTimeoutTask = Task { @MainActor [weak self] in + do { try await Task.sleep(nanoseconds: 10_000_000_000) } catch { return } + self?.displayResolutions.expire(requestID: request.request_id) + } + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveDisplayResolutions message: Data) { + // 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 !displayResolutions.pending { + displayResolutionTimeoutTask?.cancel(); displayResolutionTimeoutTask = nil + } + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, didFailDisplayResolutionRequest requestID: UInt32) { + guard hasNetworkConsent, isConnected else { return } + displayResolutions.expire(requestID: requestID) + if !displayResolutions.pending { + displayResolutionTimeoutTask?.cancel(); displayResolutionTimeoutTask = nil + } + } + private func resetVideoSettings() { + displayResolutionTimeoutTask?.cancel(); displayResolutionTimeoutTask = nil + displayResolutions = RemoteDisplayResolutionState() privacyTimer?.invalidate(); privacyTimer = nil privacyScreen = RemotePrivacyScreen() privacyClock = ProcessInfo.processInfo.systemUptime diff --git a/apps/ios/CrossDeskMobile/RemoteSessionView.swift b/apps/ios/CrossDeskMobile/RemoteSessionView.swift index d75585e6..04fe8c4d 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionView.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionView.swift @@ -1488,6 +1488,39 @@ private struct FloatingVideoSettings: View { var body: some View { VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 5) { + Text(NSLocalizedString("被控端分辨率", comment: "")) + .font(.caption).foregroundStyle(.secondary) + let display = session.displayResolutions.display(session.selectedDisplay) + Menu { + ForEach(Array(Set(display?.modes ?? [])).sorted { + $0.width == $1.width ? $0.height > $1.height : $0.width > $1.width + }, id: \.self) { mode in + Button { + if let display { + session.updateDisplayResolution(mode, displayID: display.display_id, displayKey: display.display_key) + } + } label: { + if mode == display?.current { Label(mode.label, systemImage: "checkmark") } + else { Text(mode.label) } + } + } + } label: { + HStack { + Text(display?.current.label ?? NSLocalizedString("设备不支持", comment: "")) + Spacer() + Image(systemName: "chevron.up.chevron.down") + } + .font(.subheadline).padding(8) + .background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 7)) + } + .disabled(!session.isConnected || session.displayResolutions.pending || (display?.modes.isEmpty ?? true)) + if session.displayResolutions.pending { + Text(NSLocalizedString("等待电脑端确认…", comment: "")).font(.caption).foregroundStyle(.secondary) + } else if session.displayResolutions.failed { + Text(NSLocalizedString("调整失败,请重试。", comment: "")).font(.caption).foregroundStyle(.secondary) + } + } settingRow(NSLocalizedString("画面质量", comment: ""), options: [NSLocalizedString("低", comment: ""), NSLocalizedString("中", comment: ""), NSLocalizedString("高", comment: "")], values: [0, 1, 2], selection: Binding( get: { session.videoSettings.selection.quality }, diff --git a/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift b/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift index 673702af..d5d11dee 100644 --- a/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift +++ b/apps/ios/CrossDeskMobile/RemoteVideoSettings.swift @@ -1,3 +1,5 @@ +import Foundation + // Values match crossdesk::VideoSettings on the reliable control stream. struct RemoteVideoSettings: Equatable { var quality = 2 @@ -66,3 +68,96 @@ struct RemoteVideoSettingsState { failed = true } } + + +struct RemoteDisplayResolution: Codable, Equatable, Hashable { + let width: Int + let height: Int + var label: String { "\(width) × \(height)" } + var valid: Bool { (1...32768).contains(width) && (1...32768).contains(height) } +} + +struct RemoteDisplayResolutionInfo: Decodable { + let display_id: Int + let display_key: String + let width: Int + let height: Int + let modes: [RemoteDisplayResolution] + var current: RemoteDisplayResolution { RemoteDisplayResolution(width: width, height: height) } + var valid: Bool { + (0..<32).contains(display_id) && !display_key.isEmpty && + display_key.utf8.count <= 1024 && !display_key.contains("\0") && + current.valid && modes.count <= 256 && modes.allSatisfy(\.valid) + } +} + +struct RemoteDisplayResolutionRequest: Decodable, Equatable { + let display_id: Int + let display_key: String + let width: Int + let height: Int + let request_id: UInt32 + var accepted = false + var resolution: RemoteDisplayResolution { RemoteDisplayResolution(width: width, height: height) } + func matches(_ other: Self) -> Bool { + display_id == other.display_id && display_key == other.display_key && + resolution == other.resolution && request_id == other.request_id + } +} + +struct RemoteDisplayResolutionState { + private(set) var displays: [RemoteDisplayResolutionInfo] = [] + private(set) var requested: RemoteDisplayResolutionRequest? + private(set) var pending = false + private(set) var failed = false + + func display(_ id: Int) -> RemoteDisplayResolutionInfo? { + displays.first { $0.display_id == id } + } + mutating func begin(displayID: Int, resolution: RemoteDisplayResolution) -> RemoteDisplayResolutionRequest? { + guard !pending, let display = display(displayID), display.modes.contains(resolution), + display.current != resolution else { return nil } + let next = (requested?.request_id ?? 0) &+ 1 + let request = RemoteDisplayResolutionRequest(display_id: displayID, + display_key: display.display_key, width: resolution.width, + height: resolution.height, request_id: next == 0 ? 1 : next) + requested = request; pending = true; failed = false + return request + } + mutating func expire(requestID: UInt32) { + guard pending, requested?.request_id == requestID else { return } + pending = false; failed = true + } + mutating func receive(_ data: Data) { + struct Host: Decodable { let display_resolutions: [RemoteDisplayResolutionInfo]? } + struct Message: Decodable { + let type: Int + let host_info: Host? + let display_resolution: RemoteDisplayResolutionRequest? + let display_resolutions: [RemoteDisplayResolutionInfo]? + } + guard let message = try? JSONDecoder().decode(Message.self, from: data) else { return } + 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 } + guard actual.count <= 32, actual.allSatisfy(\.valid), + Set(actual.map(\.display_id)).count == actual.count else { return } + displays = actual + if message.type == 16, let response = message.display_resolution { + pending = false + failed = !response.accepted || !actual.contains { + $0.display_id == response.display_id && $0.display_key == response.display_key && + $0.current == response.resolution + } + } 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) + } + } +} diff --git a/apps/ios/CrossDeskMobile/Resources/de.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/de.lproj/Localizable.strings index 6055f152..ac435264 100644 --- a/apps/ios/CrossDeskMobile/Resources/de.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/de.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "Im App Store ist derzeit kein Update verfügbar"; "暂时无法在当前地区的 App Store 查询到此应用" = "Diese App wurde im App Store der aktuellen Region nicht gefunden"; "新版本需要 iOS %1$@ 或更高版本" = "Die neue Version erfordert iOS %1$@ oder neuer"; +"被控端分辨率" = "Auflösung des entfernten Bildschirms"; +"设备不支持" = "Nicht unterstützt"; +"等待电脑端确认…" = "Warten auf Bestätigung vom Computer..."; diff --git a/apps/ios/CrossDeskMobile/Resources/en.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/en.lproj/Localizable.strings index 91edd115..79796d3d 100644 --- a/apps/ios/CrossDeskMobile/Resources/en.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/en.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "No update is currently available in the App Store"; "暂时无法在当前地区的 App Store 查询到此应用" = "This app could not be found in the App Store for the current region"; "新版本需要 iOS %1$@ 或更高版本" = "The new version requires iOS %1$@ or later"; +"被控端分辨率" = "Remote display resolution"; +"设备不支持" = "Unsupported"; +"等待电脑端确认…" = "Waiting for confirmation from the computer..."; diff --git a/apps/ios/CrossDeskMobile/Resources/es.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/es.lproj/Localizable.strings index abf9a753..1d213a6d 100644 --- a/apps/ios/CrossDeskMobile/Resources/es.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/es.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "No hay actualizaciones disponibles en el App Store por ahora"; "暂时无法在当前地区的 App Store 查询到此应用" = "No se encontró esta app en el App Store de la región actual"; "新版本需要 iOS %1$@ 或更高版本" = "La nueva versión requiere iOS %1$@ o posterior"; +"被控端分辨率" = "Resolución de la pantalla remota"; +"设备不支持" = "No compatible"; +"等待电脑端确认…" = "Esperando confirmación del ordenador..."; diff --git a/apps/ios/CrossDeskMobile/Resources/fr.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/fr.lproj/Localizable.strings index 6e8f3848..9a38a8b6 100644 --- a/apps/ios/CrossDeskMobile/Resources/fr.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/fr.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "Aucune mise à jour disponible dans l’App Store pour le moment"; "暂时无法在当前地区的 App Store 查询到此应用" = "Cette app est introuvable dans l’App Store de la région actuelle"; "新版本需要 iOS %1$@ 或更高版本" = "La nouvelle version nécessite iOS %1$@ ou une version ultérieure"; +"被控端分辨率" = "Résolution de l’écran distant"; +"设备不支持" = "Non pris en charge"; +"等待电脑端确认…" = "En attente de confirmation de l'ordinateur..."; diff --git a/apps/ios/CrossDeskMobile/Resources/ja.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/ja.lproj/Localizable.strings index 5c8b4c7e..3938da1e 100644 --- a/apps/ios/CrossDeskMobile/Resources/ja.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/ja.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "現在 App Store に利用可能なアップデートはありません"; "暂时无法在当前地区的 App Store 查询到此应用" = "現在の地域の App Store でこのアプリが見つかりませんでした"; "新版本需要 iOS %1$@ 或更高版本" = "新しいバージョンには iOS %1$@ 以降が必要です"; +"被控端分辨率" = "接続先の画面解像度"; +"设备不支持" = "サポートされていません"; +"等待电脑端确认…" = "コンピューターからの確認を待っています..."; diff --git a/apps/ios/CrossDeskMobile/Resources/ko.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/ko.lproj/Localizable.strings index 0d38f72e..907cb9da 100644 --- a/apps/ios/CrossDeskMobile/Resources/ko.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/ko.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "현재 App Store에 사용 가능한 업데이트가 없습니다"; "暂时无法在当前地区的 App Store 查询到此应用" = "현재 지역의 App Store에서 이 앱을 찾을 수 없습니다"; "新版本需要 iOS %1$@ 或更高版本" = "새 버전에는 iOS %1$@ 이상이 필요합니다"; +"被控端分辨率" = "원격 화면 해상도"; +"设备不支持" = "지원되지 않음"; +"等待电脑端确认…" = "컴퓨터의 확인을 기다리는 중..."; diff --git a/apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/Localizable.strings index d24274bb..03132bea 100644 --- a/apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "Nenhuma atualização disponível na App Store no momento"; "暂时无法在当前地区的 App Store 查询到此应用" = "Este app não foi encontrado na App Store da região atual"; "新版本需要 iOS %1$@ 或更高版本" = "A nova versão requer iOS %1$@ ou posterior"; +"被控端分辨率" = "Resolução da tela remota"; +"设备不支持" = "Não suportado"; +"等待电脑端确认…" = "Aguardando confirmação do computador..."; diff --git a/apps/ios/CrossDeskMobile/Resources/pt.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/pt.lproj/Localizable.strings index d24274bb..03132bea 100644 --- a/apps/ios/CrossDeskMobile/Resources/pt.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/pt.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "Nenhuma atualização disponível na App Store no momento"; "暂时无法在当前地区的 App Store 查询到此应用" = "Este app não foi encontrado na App Store da região atual"; "新版本需要 iOS %1$@ 或更高版本" = "A nova versão requer iOS %1$@ ou posterior"; +"被控端分辨率" = "Resolução da tela remota"; +"设备不支持" = "Não suportado"; +"等待电脑端确认…" = "Aguardando confirmação do computador..."; diff --git a/apps/ios/CrossDeskMobile/Resources/ru.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/ru.lproj/Localizable.strings index 140620e4..e6a3453a 100644 --- a/apps/ios/CrossDeskMobile/Resources/ru.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/ru.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "В App Store пока нет доступных обновлений"; "暂时无法在当前地区的 App Store 查询到此应用" = "Не удалось найти приложение в App Store для текущего региона"; "新版本需要 iOS %1$@ 或更高版本" = "Для новой версии требуется iOS %1$@ или новее"; +"被控端分辨率" = "Разрешение удалённого экрана"; +"设备不支持" = "Не поддерживается"; +"等待电脑端确认…" = "Ожидание подтверждения от компьютера..."; diff --git a/apps/ios/CrossDeskMobile/Resources/zh-Hans.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/zh-Hans.lproj/Localizable.strings index 748ee027..7a3fe982 100644 --- a/apps/ios/CrossDeskMobile/Resources/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/zh-Hans.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "App Store 暂无可用更新"; "暂时无法在当前地区的 App Store 查询到此应用" = "暂时无法在当前地区的 App Store 查询到此应用"; "新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或更高版本"; +"被控端分辨率" = "被控端分辨率"; +"设备不支持" = "设备不支持"; +"等待电脑端确认…" = "等待电脑端确认…"; diff --git a/apps/ios/CrossDeskMobile/Resources/zh-Hant.lproj/Localizable.strings b/apps/ios/CrossDeskMobile/Resources/zh-Hant.lproj/Localizable.strings index 440e4200..8eed55c4 100644 --- a/apps/ios/CrossDeskMobile/Resources/zh-Hant.lproj/Localizable.strings +++ b/apps/ios/CrossDeskMobile/Resources/zh-Hant.lproj/Localizable.strings @@ -285,3 +285,6 @@ "App Store 暂无可用更新" = "App Store 暫無可用更新"; "暂时无法在当前地区的 App Store 查询到此应用" = "暫時無法在目前地區的 App Store 查詢到此 App"; "新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或以上版本"; +"被控端分辨率" = "被控端解析度"; +"设备不支持" = "裝置不支援"; +"等待电脑端确认…" = "等待電腦端確認…"; diff --git a/libs/controller/include/mobile_controller_protocol.h b/libs/controller/include/mobile_controller_protocol.h index 0a49e4b2..0d7dd82a 100644 --- a/libs/controller/include/mobile_controller_protocol.h +++ b/libs/controller/include/mobile_controller_protocol.h @@ -34,6 +34,14 @@ inline std::string VideoSettingsMessage(int quality, int frame_rate, int prefere return action.to_json(); } +inline std::string DisplayResolutionMessage(int display_id, const std::string& display_key, + int width, int height, uint32_t request_id) { + RemoteAction action{}; + action.type = display_resolution; + action.resolution_change = {display_id, display_key, {width, height}, request_id, false}; + return action.to_json(); +} + inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, MiniRtcTraversalMode mode) { const auto traffic = [](const MiniRtcInboundStats& in, const MiniRtcOutboundStats& out) { return nlohmann::json{{"inbound", in.bitrate}, {"outbound", out.bitrate}, {"loss", in.loss_rate}}; diff --git a/localization/catalog.json b/localization/catalog.json index 4224cc0a..740e2899 100644 --- a/localization/catalog.json +++ b/localization/catalog.json @@ -11039,6 +11039,18 @@ "fr": "Joignez l’archive ZIP des journaux exportés à votre ticket GitHub pour nous aider à analyser le problème.", "es": "Adjunta el archivo ZIP de los registros exportados a tu issue de GitHub para ayudarnos a investigar el problema.", "pt-BR": "Anexe o arquivo ZIP dos logs exportados à sua issue do GitHub para nos ajudar a investigar o problema." + }, + "被控端分辨率": { + "zh-Hans": "被控端分辨率", + "en": "Remote display resolution", + "ru": "Разрешение удалённого экрана", + "zh-Hant": "被控端解析度", + "ja": "接続先の画面解像度", + "ko": "원격 화면 해상도", + "de": "Auflösung des entfernten Bildschirms", + "fr": "Résolution de l’écran distant", + "es": "Resolución de la pantalla remota", + "pt-BR": "Resolução da tela remota" } }, "desktop": { @@ -12398,7 +12410,10 @@ ",发送 ", ",已保存密码", ",接收 ", - ",点击重试" + ",点击重试", + "被控端分辨率", + "设备不支持", + "等待电脑端确认…" ], "ios": [ "%1$@ 传输失败", @@ -12686,7 +12701,10 @@ "前往 App Store 更新", "App Store 暂无可用更新", "暂时无法在当前地区的 App Store 查询到此应用", - "新版本需要 iOS %1$@ 或更高版本" + "新版本需要 iOS %1$@ 或更高版本", + "被控端分辨率", + "设备不支持", + "等待电脑端确认…" ], "harmony": [ "OpenFEC 告知", @@ -12928,7 +12946,9 @@ "默认关闭。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受影响。", "鼠标右键,按住拖动", "鼠标左键,按住拖动", - "鼠标控制" + "鼠标控制", + "被控端分辨率", + "设备不支持" ] } }