mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-11 19:07:33 +08:00
[feat] add macOS and mobile resolution controls with disconnect restore
This commit is contained in:
@@ -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&¤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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, ¤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<DisplayResolutionInfo>
|
||||
@@ -187,4 +241,4 @@ ApplyWindowsDisplayResolution(const std::vector<DisplayInfo> &displays,
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -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("调整失败,请重试。") : ''; }
|
||||
}
|
||||
@@ -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$@ 或以上版本";
|
||||
"被控端分辨率" = "被控端解析度";
|
||||
"设备不支持" = "裝置不支援";
|
||||
"等待电脑端确认…" = "等待電腦端確認…";
|
||||
|
||||
@@ -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}};
|
||||
|
||||
@@ -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 @@
|
||||
"默认关闭。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受影响。",
|
||||
"鼠标右键,按住拖动",
|
||||
"鼠标左键,按住拖动",
|
||||
"鼠标控制"
|
||||
"鼠标控制",
|
||||
"被控端分辨率",
|
||||
"设备不支持"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user