[feat] add macOS and mobile resolution controls with disconnect restore

This commit is contained in:
dijunkun
2026-11-11 11:02:41 +08:00
parent a3159b198e
commit 34b59de15d
50 changed files with 1086 additions and 36 deletions
@@ -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); }
@@ -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() {}
}
@@ -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<Integer> 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<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?L10n.tr("显示器"):display.optString("name",L10n.tr("显示器")));}new AlertDialog.Builder(this).setTitle(L10n.tr("显示器")).setItems(labels,(d,w)->{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<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?L10n.tr("显示器"):display.optString("name",L10n.tr("显示器")));}new AlertDialog.Builder(this).setTitle(L10n.tr("显示器")).setItems(labels,(d,w)->{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);
@@ -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);
@@ -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<Mode> 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<options.length();i++){
Mode item=mode(options.getJSONObject(i));
if(!supports(item))modes.add(item);
}
}
boolean supports(Mode value){for(Mode item:modes)if(item.same(value))return true;return false;}
}
static final class Request {
final int displayId;
final String displayKey;
final Mode mode;
final long id;
Request(Display display,Mode mode,long id){this.displayId=display.id;this.displayKey=display.key;this.mode=mode;this.id=id;}
boolean matches(JSONObject value)throws Exception{
return integer(value,"request_id",1,0xffffffffL)==id&&integer(value,"display_id",0,31)==displayId
&&displayKey.equals(value.get("display_key"))&&mode.same(mode(value));
}
}
private List<Display> 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<Display> actual=new ArrayList<>();
for(int i=0;i<values.length();i++){
Display display=new Display(values.getJSONObject(i));
for(Display previous:actual)if(previous.id==display.id)return;
actual.add(display);
}
displays=actual;
if(response!=null){
Display display=display(requested.displayId);pending=false;
failed=!response.getBoolean("accepted")||display==null||!display.key.equals(requested.displayKey)||!display.current.same(requested.mode);
}else if(pending){
Display display=display(requested.displayId);
if(display==null||!display.key.equals(requested.displayKey)||!display.supports(requested.mode))expire(requested.id);
}
}catch(Exception ignored){ /* Ignore malformed or unrelated control messages. */ }
}
String feedback(){return pending?L10n.tr("等待电脑端确认…"):failed?L10n.tr("调整失败,请重试。"):"";}
private static Mode mode(JSONObject value)throws Exception{return new Mode((int)integer(value,"width",1,32768),(int)integer(value,"height",1,32768));}
private static long integer(JSONObject value,String key,long min,long max)throws Exception{
Object raw=value.get(key);
if(!(raw instanceof Integer)&&!(raw instanceof Long))throw new IllegalArgumentException();
long number=((Number)raw).longValue();if(number<min||number>max)throw new IllegalArgumentException();return number;
}
}
@@ -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<labels.length;i++){labels[i]=display.modes.get(i).label();if(display.current.same(display.modes.get(i)))selected=i;}
resolutionDialog=new android.app.AlertDialog.Builder(getContext()).setTitle(L10n.tr("被控端分辨率"))
.setSingleChoiceItems(labels,selected,(dialog,index)->{
dialog.dismiss();resolutionDialog=null;
RemoteDisplayResolutions.Display current=resolutions.display(resolutionDisplay);
if(current!=null&&current.id==display.id&&current.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();
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">",已保存密码"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">",接收 "</string>
<string name="text_42237b5ec70c26f3" formatted="false">",点击重试"</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"被控端分辨率"</string>
<string name="text_36e0073276c0e39f" formatted="false">"设备不支持"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"等待电脑端确认…"</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">",已儲存密碼"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">",接收 "</string>
<string name="text_42237b5ec70c26f3" formatted="false">",點選重試"</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"被控端解析度"</string>
<string name="text_36e0073276c0e39f" formatted="false">"裝置不支援"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"等待電腦端確認…"</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">", Passwort gespeichert"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", empfangen "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", klicken Sie auf Wiederholen"</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"Auflösung des entfernten Bildschirms"</string>
<string name="text_36e0073276c0e39f" formatted="false">"Nicht unterstützt"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"Warten auf Bestätigung vom Computer..."</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">", contraseña guardada"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", recibir "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", haga clic en Reintentar"</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"Resolución de la pantalla remota"</string>
<string name="text_36e0073276c0e39f" formatted="false">"No compatible"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"Esperando confirmación del ordenador..."</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">", mot de passe enregistré"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", recevoir "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", cliquez sur Réessayer"</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"Résolution de l’écran distant"</string>
<string name="text_36e0073276c0e39f" formatted="false">"Non pris en charge"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"En attente de confirmation de l\'ordinateur..."</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">"、パスワードが保存されました"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">"、受け取ります "</string>
<string name="text_42237b5ec70c26f3" formatted="false">"、「再試行」をクリックします"</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"接続先の画面解像度"</string>
<string name="text_36e0073276c0e39f" formatted="false">"サポートされていません"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"コンピューターからの確認を待っています..."</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">", 비밀번호가 저장되었습니다"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", 수신 "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", 재시도를 클릭하세요."</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"원격 화면 해상도"</string>
<string name="text_36e0073276c0e39f" formatted="false">"지원되지 않음"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"컴퓨터의 확인을 기다리는 중..."</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">", senha salva"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", receba "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", clique em Tentar novamente"</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"Resolução da tela remota"</string>
<string name="text_36e0073276c0e39f" formatted="false">"Não suportado"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"Aguardando confirmação do computador..."</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">", пароль сохранен"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", получить "</string>
<string name="text_42237b5ec70c26f3" formatted="false">"нажмите «Повторить»."</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"Разрешение удалённого экрана"</string>
<string name="text_36e0073276c0e39f" formatted="false">"Не поддерживается"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"Ожидание подтверждения от компьютера..."</string>
</resources>
@@ -305,4 +305,7 @@
<string name="text_ba9a792d063a8c86" formatted="false">", password saved"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", receive "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", click Retry"</string>
<string name="text_59c9dca5568a6cd3" formatted="false">"Remote display resolution"</string>
<string name="text_36e0073276c0e39f" formatted="false">"Unsupported"</string>
<string name="text_7b3440ad0f81b811" formatted="false">"Waiting for confirmation from the computer..."</string>
</resources>
+10
View File
@@ -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<uint32_t>(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;
@@ -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
@@ -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 <functional>
#include <map>
#include <string>
#include <utility>
#include <display_resolution.h>
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<bool()>;
template <typename Capture, typename Change>
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<std::string, Restore> originals_;
};
} // namespace crossdesk
#endif
@@ -2,15 +2,19 @@
#include <SDL3/SDL_timer.h>
#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<DisplayResolutionInfo> 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<DisplayResolutionInfo> 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<PendingDisplayResolution> 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;
@@ -82,6 +82,7 @@ GuiRuntime::~GuiRuntime() {
lan_.reset();
transfers_.Shutdown();
WaitForSessionCleanup();
RestoreHostDisplayResolutions(true);
}
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
@@ -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<RemoteSession> props);
void CloseAllRemoteSessions();
@@ -256,11 +258,18 @@ class GuiRuntime : protected gui_detail::GuiState {
std::string remote_id;
std::weak_ptr<PeerEventHandler> source;
DisplayResolutionChange change;
uint64_t epoch;
};
std::mutex display_resolution_commands_mutex_;
std::deque<PendingDisplayResolution> display_resolution_commands_;
std::vector<DisplayResolutionInfo> 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<uint64_t> 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;
@@ -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={}",
@@ -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 <ColorSync/ColorSync.h>
#include <CoreGraphics/CoreGraphics.h>
#include <IOKit/graphics/IOGraphicsTypes.h>
#include <cmath>
#include <functional>
#include <map>
#include <memory>
#include <tuple>
#include "display_info.h"
#include <display_resolution.h>
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<CGDirectDisplayID>(
reinterpret_cast<uintptr_t>(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<CGDisplayModeRef> Choices() const {
std::vector<CGDisplayModeRef> 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<std::pair<int, int>, CGDisplayModeRef> unique;
for (CFIndex index = 0; index < CFArrayGetCount(modes_); ++index) {
auto mode = static_cast<CGDisplayModeRef>(
const_cast<void *>(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<DisplayResolutionInfo>
QueryMacDisplayResolutions(const std::vector<DisplayInfo> &displays) {
std::vector<DisplayResolutionInfo> 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<DisplayInfo> &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<bool()>
CaptureMacDisplayResolution(const std::vector<DisplayInfo> &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<CGDisplayMode> 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
@@ -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<std::mutex> 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<std::mutex> 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
@@ -11,6 +11,7 @@
#include <algorithm>
#include <cmath>
#include <functional>
#include <map>
#include <string>
#include <vector>
@@ -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<bool()> CaptureWindowsDisplayResolution(
const std::vector<DisplayInfo> &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, &current)) 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, &current) &&
SameWindowsResolutionMode(current, original);
};
}
inline std::vector<DisplayResolutionInfo>
@@ -187,4 +241,4 @@ ApplyWindowsDisplayResolution(const std::vector<DisplayInfo> &displays,
} // namespace crossdesk
#endif
#endif
+1 -1
View File
@@ -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")
@@ -240,4 +240,6 @@ export const translations: Record<string, string[]> = {
"鼠标右键,按住拖动": ["鼠标右键,按住拖动", "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"],
};
@@ -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);
@@ -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,
@@ -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("调整失败,请重试。") : ''; }
}
+9
View File
@@ -330,6 +330,15 @@ class MobileSession {
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
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<int64_t>();
if (request <= 0 || request > UINT32_MAX) return;
const auto bytes = mobile_controller::DisplayResolutionMessage(
v.at("displayId").get<int>(), v.at("displayKey").get<std::string>(),
v.at("width").get<int>(), v.at("height").get<int>(), 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<int>(), v.at("blockLocalInput").get<bool>());
@@ -100,6 +100,10 @@ typedef struct {
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)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<NSString *> *)remoteIDs
subscribe:(BOOL)subscribe
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
@@ -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<CrossDeskRTCBridgeDelegate> 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<CrossDeskRTCBridgeDelegate> 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;
@@ -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<Void, Never>?
private var videoSettingsTimeoutTask: Task<Void, Never>?
private var displayResolutionTimeoutTask: Task<Void, Never>?
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
@@ -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 },
@@ -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)
}
}
}
@@ -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...";
@@ -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...";
@@ -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...";
@@ -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...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "現在 App Store に利用可能なアップデートはありません";
"暂时无法在当前地区的 App Store 查询到此应用" = "現在の地域の App Store でこのアプリが見つかりませんでした";
"新版本需要 iOS %1$@ 或更高版本" = "新しいバージョンには iOS %1$@ 以降が必要です";
"被控端分辨率" = "接続先の画面解像度";
"设备不支持" = "サポートされていません";
"等待电脑端确认…" = "コンピューターからの確認を待っています...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "현재 App Store에 사용 가능한 업데이트가 없습니다";
"暂时无法在当前地区的 App Store 查询到此应用" = "현재 지역의 App Store에서 이 앱을 찾을 수 없습니다";
"新版本需要 iOS %1$@ 或更高版本" = "새 버전에는 iOS %1$@ 이상이 필요합니다";
"被控端分辨率" = "원격 화면 해상도";
"设备不支持" = "지원되지 않음";
"等待电脑端确认…" = "컴퓨터의 확인을 기다리는 중...";
@@ -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...";
@@ -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...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "В App Store пока нет доступных обновлений";
"暂时无法在当前地区的 App Store 查询到此应用" = "Не удалось найти приложение в App Store для текущего региона";
"新版本需要 iOS %1$@ 或更高版本" = "Для новой версии требуется iOS %1$@ или новее";
"被控端分辨率" = "Разрешение удалённого экрана";
"设备不支持" = "Не поддерживается";
"等待电脑端确认…" = "Ожидание подтверждения от компьютера...";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "App Store 暂无可用更新";
"暂时无法在当前地区的 App Store 查询到此应用" = "暂时无法在当前地区的 App Store 查询到此应用";
"新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或更高版本";
"被控端分辨率" = "被控端分辨率";
"设备不支持" = "设备不支持";
"等待电脑端确认…" = "等待电脑端确认…";
@@ -285,3 +285,6 @@
"App Store 暂无可用更新" = "App Store 暫無可用更新";
"暂时无法在当前地区的 App Store 查询到此应用" = "暫時無法在目前地區的 App Store 查詢到此 App";
"新版本需要 iOS %1$@ 或更高版本" = "新版本需要 iOS %1$@ 或以上版本";
"被控端分辨率" = "被控端解析度";
"设备不支持" = "裝置不支援";
"等待电脑端确认…" = "等待電腦端確認…";