[feat] support adaptive tablet layouts on Android and iPad

This commit is contained in:
dijunkun
2026-10-05 14:10:00 +08:00
parent 9489ede03c
commit 76eddc9f02
13 changed files with 218 additions and 33 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。 - 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。 - 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码底部弹层、两列最近连接及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。 - 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
- 平板首页支持横竖屏和分屏,按当前可用宽度自动适配:900 dp 起连接与最近连接并排,较窄窗口纵向排列;最近连接随宽度显示 1–4 列,手机常规宽度仍为两列。首页最大宽度为 1200 dp,设置、公告及阅读页最大宽度为 720 dp,宽窗口中的密码和隐私弹窗居中限宽。旋转或调整分屏直接重排现有页面,保留输入、设置编辑和当前会话;手机全屏首页保持竖屏。
- 页面转场沿用 iOS 的交互方式:设置、隐私、关于及公告页面进入时从右滑入,返回时反向滑出,背景页面轻微跟随;进入和退出远程会话使用 0.2 秒淡入淡出。遵循 Android 系统动画开关及速度设置。 - 页面转场沿用 iOS 的交互方式:设置、隐私、关于及公告页面进入时从右滑入,返回时反向滑出,背景页面轻微跟随;进入和退出远程会话使用 0.2 秒淡入淡出。遵循 Android 系统动画开关及速度设置。
- 设置页的鼠标控制、画面偏好,以及悬浮控制栏的画质、帧率和画面偏好使用 0.2 秒的选中底块滑动动画;连续点击从当前位置转向,电脑端拒绝或超时后平滑回退,系统关闭动画时直接切换。 - 设置页的鼠标控制、画面偏好,以及悬浮控制栏的画质、帧率和画面偏好使用 0.2 秒的选中底块滑动动画;连续点击从当前位置转向,电脑端拒绝或超时后平滑回退,系统关闭动画时直接切换。
- 通知公告与 iOS 共用信令协议,支持未读角标、分页、下拉刷新、更新推送、详情和网页链接;已读及删除状态按服务器、本机身份和公告版本保存在本机。 - 通知公告与 iOS 共用信令协议,支持未读角标、分页、下拉刷新、更新推送、详情和网页链接;已读及删除状态按服务器、本机身份和公告版本保存在本机。
@@ -8,7 +8,8 @@
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:supportsRtl="true" android:usesCleartextTraffic="false"> android:supportsRtl="true" android:usesCleartextTraffic="false">
<activity android:name=".MainActivity" android:exported="true" <activity android:name=".MainActivity" android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:resizeableActivity="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -0,0 +1,46 @@
package cn.crossdesk.mobile;
import android.view.Gravity;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.ScrollView;
/** Reflows the existing panels, keeping text, focus and scroll state during window resizing. */
@android.annotation.SuppressLint("ViewConstructor")
final class HomeLayout extends ScrollView {
final LinearLayout panels;
private final MobileUi ui;
private int panelHeight;
HomeLayout(MobileUi ui){
super(ui.context);this.ui=ui;
setFillViewport(true);setClipChildren(false);setClipToPadding(false);
panels=new LinearLayout(ui.context){
@Override protected void onMeasure(int widthSpec,int heightSpec){
// ScrollView offers unbounded height even with an explicit LayoutParams height.
super.onMeasure(widthSpec,MeasureSpec.makeMeasureSpec(panelHeight,MeasureSpec.EXACTLY));
}
};panels.setGravity(Gravity.TOP);
panels.setPadding(ui.dp(20),ui.dp(24),ui.dp(20),ui.dp(12));
panels.setClipChildren(false);panels.setClipToPadding(false);
addView(panels,new LayoutParams(-1,-1));
}
@Override protected void onMeasure(int widthSpec,int heightSpec){
boolean wide=MeasureSpec.getSize(widthSpec)>=ui.dp(900);
panels.setOrientation(wide?LinearLayout.HORIZONTAL:LinearLayout.VERTICAL);
// A short split window or the IME can leave less room than the two panels need.
// Keep the recent list usable and let the outer viewport scroll in that case.
panelHeight=Math.max(ui.dp(wide?360:440),MeasureSpec.getSize(heightSpec));
if(panels.getChildCount()==2){
View connection=panels.getChildAt(0),recent=panels.getChildAt(1);
LinearLayout.LayoutParams first=(LinearLayout.LayoutParams)connection.getLayoutParams();
first.width=wide?ui.dp(360):-1;first.height=-2;first.weight=0;first.gravity=Gravity.TOP;
LinearLayout.LayoutParams second=(LinearLayout.LayoutParams)recent.getLayoutParams();
second.width=wide?0:-1;second.height=wide?-1:0;second.weight=1;
second.topMargin=wide?0:ui.dp(20);second.setMarginStart(wide?ui.dp(24):0);
second.resolveLayoutDirection(panels.getLayoutDirection());
}
super.onMeasure(widthSpec,heightSpec);
}
}
@@ -46,6 +46,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private RecentConnections history; private RecentConnections history;
private final RecentConnectionPresence recentPresence=new RecentConnectionPresence(); private final RecentConnectionPresence recentPresence=new RecentConnectionPresence();
private LinearLayout recentGrid; private LinearLayout recentGrid;
private int recentColumns=2;
private final Runnable presenceTick=new Runnable(){public void run(){ private final Runnable presenceTick=new Runnable(){public void run(){
if(!recentPresence.isConnected())return; if(!recentPresence.isConnected())return;
recentPresence.maintain(android.os.SystemClock.elapsedRealtime()); recentPresence.maintain(android.os.SystemClock.elapsedRealtime());
@@ -114,8 +115,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null; settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null; previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND); canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
root=column();canvas.addView(root,new FrameLayout.LayoutParams(-1,-1)); FrameLayout safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1));
root.setOnApplyWindowInsetsListener((view,insets)->{ root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720);
safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL));
safeArea.setOnApplyWindowInsetsListener((view,insets)->{
view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets; view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
}); });
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion); root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
@@ -155,9 +158,18 @@ public final class MainActivity extends Activity implements NativeSession.Listen
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item); TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
return item; return item;
} }
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Match the iOS portrait home; sessions support both orientations. @android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
private void updateHomeOrientation(){
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
if(android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
tablet|=Math.min(bounds.width(),bounds.height())>=dp(600);
}
int orientation=tablet||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation);
}
private void home(String message){ private void home(String message){
backAction=null;setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);installRoot("home",false); backAction=null;updateHomeOrientation();installRoot("home",false);
LinearLayout toolbar=row();toolbar.setPadding(dp(20),0,dp(20),0);root.addView(toolbar,ui.size(-1,56)); LinearLayout toolbar=row();toolbar.setPadding(dp(20),0,dp(20),0);root.addView(toolbar,ui.size(-1,56));
signalBadge=text("",12,true);signalBadge.setGravity(Gravity.CENTER);signalBadge.setPadding(dp(11),0,dp(11),0);signalBadge.setSingleLine(true); signalBadge=text("",12,true);signalBadge.setGravity(Gravity.CENTER);signalBadge.setPadding(dp(11),0,dp(11),0);signalBadge.setSingleLine(true);
signalBadge.setOnClickListener(v->{ signalBadge.setOnClickListener(v->{
@@ -173,7 +185,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
TextView updateBadge=text("!",10,true);updateBadge.setTextColor(Color.WHITE);updateBadge.setGravity(Gravity.CENTER);updateBadge.setIncludeFontPadding(false);updateBadge.setBackground(ui.background(RED,8));updateBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);settingsUpdateDot=updateBadge; TextView updateBadge=text("!",10,true);updateBadge.setTextColor(Color.WHITE);updateBadge.setGravity(Gravity.CENTER);updateBadge.setIncludeFontPadding(false);updateBadge.setBackground(ui.background(RED,8));updateBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);settingsUpdateDot=updateBadge;
FrameLayout.LayoutParams updatePosition=new FrameLayout.LayoutParams(dp(16),dp(16),Gravity.TOP|Gravity.END);updatePosition.topMargin=-dp(3);updatePosition.setMarginEnd(-dp(5));settingsButton.addView(settingsUpdateDot,updatePosition); FrameLayout.LayoutParams updatePosition=new FrameLayout.LayoutParams(dp(16),dp(16),Gravity.TOP|Gravity.END);updatePosition.topMargin=-dp(3);updatePosition.setMarginEnd(-dp(5));settingsButton.addView(settingsUpdateDot,updatePosition);
toolbar.addView(settingsButton,ui.size(40,40));renderAppUpdates(); toolbar.addView(settingsButton,ui.size(40,40));renderAppUpdates();
LinearLayout content=column();content.setPadding(dp(20),dp(24),dp(20),dp(12));content.setClipChildren(false);content.setClipToPadding(false);root.addView(content,new LinearLayout.LayoutParams(-1,0,1)); HomeLayout layout=new HomeLayout(ui);root.addView(layout,new LinearLayout.LayoutParams(-1,0,1));LinearLayout content=layout.panels;
LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));content.addView(ui.shadowCard(connection,20)); LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));content.addView(ui.shadowCard(connection,20));
connection.addView(text("远程桌面",20,true));ui.gap(connection,16); connection.addView(text("远程桌面",20,true));ui.gap(connection,16);
LinearLayout entry=row();connection.addView(entry,ui.size(-1,54)); LinearLayout entry=row();connection.addView(entry,ui.size(-1,54));
@@ -191,7 +203,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12); LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12);
TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint); TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint);
}else{ }else{
ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));recentGrid=column();scroll.addView(recentGrid);renderRecentConnections(); ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));recentGrid=column();scroll.addView(recentGrid);
recentGrid.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
int width=right-left;if(width<=0)return;
int columns=width<dp(300)?1:Math.max(2,Math.min(4,(width+dp(12))/dp(192)));
if(columns!=recentColumns){recentColumns=columns;view.post(()->{if(view==recentGrid)renderRecentConnections();});}
});renderRecentConnections();
} }
ensureSignaling(); ensureSignaling();
if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show(); if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show();
@@ -202,7 +219,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(recentGrid==null)return; if(recentGrid==null)return;
JSONArray records=recentPresence.ordered(history.list(serverKey()),android.os.SystemClock.elapsedRealtime()); JSONArray records=recentPresence.ordered(history.list(serverKey()),android.os.SystemClock.elapsedRealtime());
recentGrid.removeAllViews();previewCards.clear(); recentGrid.removeAllViews();previewCards.clear();
for(int i=0;i<records.length();i+=2){LinearLayout line=row();line.setGravity(Gravity.TOP);recentGrid.addView(line);for(int j=i;j<i+2;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)cell.leftMargin=dp(12);line.addView(card,cell);}ui.gap(recentGrid,12);} for(int i=0;i<records.length();i+=recentColumns){LinearLayout line=row();line.setGravity(Gravity.TOP);recentGrid.addView(line);for(int j=i;j<i+recentColumns;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)cell.setMarginStart(dp(12));line.addView(card,cell);}ui.gap(recentGrid,12);}
} }
private View recentCard(JSONObject item){ private View recentCard(JSONObject item){
if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform"); if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform");
@@ -249,9 +266,16 @@ public final class MainActivity extends Activity implements NativeSession.Listen
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;} String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret); rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48)); });connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
dialog.setContentView(sheet);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();if(window!=null){window.setLayout(-1,-2);window.setGravity(Gravity.BOTTOM);}password.requestFocus();if(!error.isEmpty())password.setError(error); ScrollView scroll=new ScrollView(this);scroll.addView(sheet);
dialog.setContentView(scroll);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();sizePasswordDialog();password.requestFocus();if(!error.isEmpty())password.setError(error);
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;}); dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
} }
private void sizePasswordDialog(){
if(passwordDialog==null||passwordDialog.getWindow()==null)return;
int width=getResources().getConfiguration().screenWidthDp;
Window window=passwordDialog.getWindow();window.setLayout(width>=600?dp(Math.min(520,width-48)):-1,-2);
window.setGravity(width>=600?Gravity.CENTER:Gravity.BOTTOM);
}
private void begin(String id,String secret){ private void begin(String id,String secret){
resetRemoteVersionCheck(); resetRemoteVersionCheck();
resetVideoSettings(); resetVideoSettings();
@@ -538,7 +562,14 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();} private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);} @Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();} @android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
@Override public void onConfigurationChanged(Configuration config){super.onConfigurationChanged(config);if(controls!=null)controls.post(controls::constrainPanels);} @Override public void onConfigurationChanged(Configuration config){
super.onConfigurationChanged(config);
releaseKeys();
if(!page.equals("session"))updateHomeOrientation();
sizePasswordDialog();
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
if(controls!=null)controls.post(controls::constrainPanels);
}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice. // onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();} @Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();} @Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
@@ -29,6 +29,16 @@ final class MobileUi {
return wrapper; return wrapper;
} }
LinearLayout column() { LinearLayout v=new LinearLayout(context);v.setOrientation(LinearLayout.VERTICAL);return v; } LinearLayout column() { LinearLayout v=new LinearLayout(context);v.setOrientation(LinearLayout.VERTICAL);return v; }
LinearLayout pageColumn(int maxWidth) {
LinearLayout v=new LinearLayout(context){
@Override protected void onMeasure(int widthSpec,int heightSpec){
if(maxWidth>0&&MeasureSpec.getSize(widthSpec)>dp(maxWidth))
widthSpec=MeasureSpec.makeMeasureSpec(dp(maxWidth),MeasureSpec.EXACTLY);
super.onMeasure(widthSpec,heightSpec);
}
};
v.setOrientation(LinearLayout.VERTICAL);return v;
}
LinearLayout row() { LinearLayout v=new LinearLayout(context);v.setGravity(Gravity.CENTER_VERTICAL);return v; } LinearLayout row() { LinearLayout v=new LinearLayout(context);v.setGravity(Gravity.CENTER_VERTICAL);return v; }
TextView text(String title,int size,boolean bold) { TextView text(String title,int size,boolean bold) {
TextView v=new TextView(context);v.setText(title);v.setTextSize(size);v.setTextColor(INK);v.setIncludeFontPadding(false); TextView v=new TextView(context);v.setText(title);v.setTextSize(size);v.setTextColor(INK);v.setIncludeFontPadding(false);
@@ -12,11 +12,13 @@ import static cn.crossdesk.mobile.MobileUi.*;
/** Explicit agreement is required; refusal dismisses only the current visit. */ /** Explicit agreement is required; refusal dismisses only the current visit. */
final class PrivacyConsentDialog extends Dialog { final class PrivacyConsentDialog extends Dialog {
private final Activity activity;
private final MobileUi ui;
PrivacyConsentDialog(Activity activity,MobileUi ui,Runnable accepted){ PrivacyConsentDialog(Activity activity,MobileUi ui,Runnable accepted){
this(activity,ui,new PrivacyPolicyDocument(activity),accepted); this(activity,ui,new PrivacyPolicyDocument(activity),accepted);
} }
PrivacyConsentDialog(Activity activity,MobileUi ui,PrivacyPolicyDocument policy,Runnable accepted){ PrivacyConsentDialog(Activity activity,MobileUi ui,PrivacyPolicyDocument policy,Runnable accepted){
super(activity);requestWindowFeature(Window.FEATURE_NO_TITLE);setCancelable(false);setCanceledOnTouchOutside(false); super(activity);this.activity=activity;this.ui=ui;requestWindowFeature(Window.FEATURE_NO_TITLE);setCancelable(false);setCanceledOnTouchOutside(false);
LinearLayout sheet=ui.column();sheet.setBackground(ui.background(android.graphics.Color.WHITE,20));sheet.setClipToOutline(true); LinearLayout sheet=ui.column();sheet.setBackground(ui.background(android.graphics.Color.WHITE,20));sheet.setClipToOutline(true);
TextView title=ui.text("隐私政策",17,true);title.setGravity(Gravity.CENTER);sheet.addView(title,ui.size(-1,56));ui.divider(sheet); TextView title=ui.text("隐私政策",17,true);title.setGravity(Gravity.CENTER);sheet.addView(title,ui.size(-1,56));ui.divider(sheet);
sheet.addView(policy.view(ui),new LinearLayout.LayoutParams(-1,0,1));ui.divider(sheet); sheet.addView(policy.view(ui),new LinearLayout.LayoutParams(-1,0,1));ui.divider(sheet);
@@ -31,6 +33,15 @@ final class PrivacyConsentDialog extends Dialog {
setContentView(sheet);Window window=getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setDimAmount(.22f);} setContentView(sheet);Window window=getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setDimAmount(.22f);}
} }
@Override public void show(){ @Override public void show(){
super.show();Window window=getWindow();if(window!=null){window.setLayout(-1,Math.round(getContext().getResources().getDisplayMetrics().heightPixels*.9f));window.setGravity(Gravity.BOTTOM);} super.show();updateSize();
}
void updateSize(){
android.content.res.Configuration configuration=activity.getResources().getConfiguration();
boolean wide=configuration.screenWidthDp>=600;
Window window=getWindow();if(window!=null){
window.setLayout(wide?ui.dp(Math.min(640,configuration.screenWidthDp-48)):-1,
Math.min(ui.dp(760),Math.round(ui.dp(configuration.screenHeightDp)*.9f)));
window.setGravity(wide?Gravity.CENTER:Gravity.BOTTOM);
}
} }
} }
@@ -20,6 +20,8 @@ final class SessionControls extends FrameLayout {
private final LinearLayout waiting; private final LinearLayout waiting;
private View shield; private View shield;
private LinearLayout menu,keyboard; private LinearLayout menu,keyboard;
private ScrollView keyboardKeys;
private int keyboardPreferredHeight;
private TextView networkLabel,videoFeedback; private TextView networkLabel,videoFeedback;
private String menuPage="controls"; private String menuPage="controls";
private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0); private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0);
@@ -136,14 +138,21 @@ final class SessionControls extends FrameLayout {
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));} private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));} private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));}
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));} private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;}} private int keyboardWidth(int width){return Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,width-ui.dp(16)));}
private int keyboardHeight(int height){return Math.min(keyboardPreferredHeight,Math.max(1,height-ui.dp(16)));}
private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}}
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;keyboardKeys=null;}}
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();} private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
private void showKeyboard(){ private void showKeyboard(){
float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10)); float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();int oldScroll=keyboardKeys==null?0:keyboardKeys.getScrollY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10));
LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{}); LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{});
// Keep the drag/close row fixed while short split windows can scroll every key into view.
keyboardKeys=new ScrollView(getContext());keyboard.addView(keyboardKeys,new LinearLayout.LayoutParams(-1,0,1));LinearLayout keys=ui.column();keyboardKeys.addView(keys);
String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}}; String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}};
for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keyboard.addView(line);ui.gap(keyboard,3);} for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keys.addView(line);ui.gap(keys,3);}
int width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));addView(keyboard,new LayoutParams(width,-2));keyboard.post(()->{if(keyboard!=null){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);constrainPanels();}}); keyboardPreferredHeight=ui.dp(5)*2+ui.dp(28)+rows.length*(ui.dp(31)+ui.dp(3));
addView(keyboard,new LayoutParams(keyboardWidth(getWidth()),keyboardHeight(getHeight())));LinearLayout shownKeyboard=keyboard;ScrollView shownKeys=keyboardKeys;
keyboard.post(()->{if(keyboard==shownKeyboard){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);shownKeys.scrollTo(0,oldScroll);constrainPanels();}});
} }
private void keyboardKey(String value){ private void keyboardKey(String value){
switch(value){ switch(value){
@@ -177,16 +186,22 @@ final class SessionControls extends FrameLayout {
} }
void constrainPanels(){ void constrainPanels(){
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8))); orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
if(menu!=null)requestLayout(); if(menu!=null||keyboard!=null)requestLayout();
if(keyboard!=null){ViewGroup.LayoutParams p=keyboard.getLayoutParams();p.width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));keyboard.setLayoutParams(p);keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-p.width-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));} positionKeyboard();
} }
@Override protected void onMeasure(int widthSpec,int heightSpec){ @Override protected void onMeasure(int widthSpec,int heightSpec){
super.onMeasure(widthSpec,heightSpec); super.onMeasure(widthSpec,heightSpec);
boolean resized=false;
if(menu!=null){ if(menu!=null){
// Use this traversal's viewport, including rotation/inset changes, before drawing. // Use this traversal's viewport, including rotation/inset changes, before drawing.
ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight()); ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight());
if(p.width!=width||p.height!=height){p.width=width;p.height=height;super.onMeasure(widthSpec,heightSpec);} if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
} }
if(keyboard!=null){
ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight());
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
}
if(resized)super.onMeasure(widthSpec,heightSpec);
} }
@Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){ @Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){
super.onLayout(changed,left,top,right,bottom); super.onLayout(changed,left,top,right,bottom);
@@ -194,6 +209,7 @@ final class SessionControls extends FrameLayout {
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8))); orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
// New/rebuilt menus have real dimensions now; position them in the same frame. // New/rebuilt menus have real dimensions now; position them in the same frame.
positionMenu(); positionMenu();
positionKeyboard();
} }
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);} @Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));} private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
@@ -108,6 +108,8 @@ struct AnnouncementInboxView: View {
Text("共 \(inbox.state.total) 条 · \(inbox.state.unread) 条未读") Text("共 \(inbox.state.total) 条 · \(inbox.state.unread) 条未读")
} }
} }
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("通知公告") .navigationTitle("通知公告")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -174,10 +176,12 @@ struct AnnouncementDetailView: View {
}) })
} }
.padding(20) .padding(20)
.readableContentWidth()
} else { } else {
Text("这条公告已更新或删除,请返回公告列表查看。") Text("这条公告已更新或删除,请返回公告列表查看。")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(20) .padding(20)
.readableContentWidth()
} }
} }
.navigationTitle("公告详情") .navigationTitle("公告详情")
+47 -11
View File
@@ -1,6 +1,19 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
enum MobileLayout {
static let twoColumnBreakpoint: CGFloat = 900
static let homeMaximumWidth: CGFloat = 1200
static let readableMaximumWidth: CGFloat = 720
}
extension View {
func readableContentWidth() -> some View {
frame(maxWidth: MobileLayout.readableMaximumWidth)
.frame(maxWidth: .infinity)
}
}
enum HomeDestination: Hashable { enum HomeDestination: Hashable {
case settings case settings
case announcements case announcements
@@ -207,8 +220,11 @@ private struct ConnectionHomeView: View {
return "arrow.triangle.2.circlepath" return "arrow.triangle.2.circlepath"
} }
private var recentConnectionColumns: [GridItem] { private func recentConnectionColumns(windowWidth: CGFloat) -> [GridItem] {
[ if windowWidth >= 600 {
return [GridItem(.adaptive(minimum: 180), spacing: 12, alignment: .top)]
}
return [
GridItem(.flexible(), spacing: 12, alignment: .top), GridItem(.flexible(), spacing: 12, alignment: .top),
GridItem(.flexible(), spacing: 12, alignment: .top) GridItem(.flexible(), spacing: 12, alignment: .top)
] ]
@@ -229,15 +245,33 @@ private struct ConnectionHomeView: View {
ZStack { ZStack {
Color(.systemGroupedBackground).ignoresSafeArea() Color(.systemGroupedBackground).ignoresSafeArea()
VStack(spacing: 0) { GeometryReader { proxy in
remoteConnectionPanel let isWide = proxy.size.width >= MobileLayout.twoColumnBreakpoint
// AnyLayout keeps the fields, focus and scroll view alive when
// rotation or iPad multitasking crosses the layout breakpoint.
let layout = isWide
? AnyLayout(HStackLayout(alignment: .top, spacing: 24))
: AnyLayout(VStackLayout(spacing: 20))
ScrollView(.vertical) {
layout {
remoteConnectionPanel
.frame(width: isWide ? 360 : nil)
recentConnectionsPanel(windowWidth: proxy.size.width)
.frame(maxHeight: .infinity)
}
.frame(maxWidth: MobileLayout.homeMaximumWidth)
// Short multitasking windows and the onscreen keyboard
// must not squeeze the recent list below a usable height.
.frame(height: max(isWide ? 260 : 420, proxy.size.height - 36),
alignment: .top)
.frame(maxWidth: .infinity)
.padding(.horizontal, 20)
.padding(.top, 24) .padding(.top, 24)
recentConnectionsPanel .padding(.bottom, 12)
.padding(.top, 20) }
.frame(maxHeight: .infinity) .scrollDismissesKeyboard(.interactively)
} }
.padding(.horizontal, 20)
.padding(.bottom, 12)
if session.isConnecting { if session.isConnecting {
connectionProgressOverlay connectionProgressOverlay
@@ -430,7 +464,7 @@ private struct ConnectionHomeView: View {
} }
@ViewBuilder @ViewBuilder
private var recentConnectionsPanel: some View { private func recentConnectionsPanel(windowWidth: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
Text("最近连接") Text("最近连接")
@@ -458,7 +492,7 @@ private struct ConnectionHomeView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
ScrollView(.vertical) { ScrollView(.vertical) {
LazyVGrid(columns: recentConnectionColumns, spacing: 12) { LazyVGrid(columns: recentConnectionColumns(windowWidth: windowWidth), spacing: 12) {
ForEach(orderedRecentConnections) { connection in ForEach(orderedRecentConnections) { connection in
RecentConnectionCard( RecentConnectionCard(
connection: connection, connection: connection,
@@ -872,6 +906,8 @@ private struct ServerSettingsView: View {
.accessibilityLabel(appUpdates.updateAvailable ? "关于,有新版本" : "关于") .accessibilityLabel(appUpdates.updateAvailable ? "关于,有新版本" : "关于")
} }
} }
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("设置") .navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
@@ -2,7 +2,8 @@ import SwiftUI
import UIKit import UIKit
final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate { final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
static var supportedOrientations: UIInterfaceOrientationMask = .portrait static var supportedOrientations: UIInterfaceOrientationMask =
UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
func application(_ application: UIApplication, func application(_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?) supportedInterfaceOrientationsFor window: UIWindow?)
@@ -14,6 +15,10 @@ final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
enum AppOrientation { enum AppOrientation {
static func update(to orientations: UIInterfaceOrientationMask) { static func update(to orientations: UIInterfaceOrientationMask) {
DispatchQueue.main.async { DispatchQueue.main.async {
// iPad windows can rotate and resize independently of the session.
// Keep all orientations available and let the window manager choose
// the geometry; the phone still locks the home screen to portrait.
guard UIDevice.current.userInterfaceIdiom != .pad else { return }
CrossDeskAppDelegate.supportedOrientations = orientations CrossDeskAppDelegate.supportedOrientations = orientations
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes { for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
+5 -1
View File
@@ -10,6 +10,7 @@ struct PrivacyConsentView: View {
PrivacyPolicyDocument() PrivacyPolicyDocument()
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 20) .padding(.vertical, 20)
.readableContentWidth()
} }
.background(Color(.systemBackground)) .background(Color(.systemBackground))
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
@@ -57,6 +58,7 @@ struct PrivacyConsentView: View {
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 12) .padding(.vertical, 12)
.readableContentWidth()
} }
.background(Color(.systemBackground)) .background(Color(.systemBackground))
} }
@@ -161,6 +163,8 @@ private struct PrivacyAuthorizationView: View {
} }
} }
} }
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("隐私与授权") .navigationTitle("隐私与授权")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.alert("是否撤回隐私授权?", isPresented: $confirmWithdrawal) { .alert("是否撤回隐私授权?", isPresented: $confirmWithdrawal) {
@@ -220,7 +224,7 @@ private struct PrivacyPolicyDocument: View {
struct PrivacyPolicyView: View { struct PrivacyPolicyView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
PrivacyPolicyDocument().padding() PrivacyPolicyDocument().padding().readableContentWidth()
} }
.navigationTitle("隐私政策") .navigationTitle("隐私政策")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -138,6 +138,8 @@ struct ThirdPartyLicensesView: View {
.padding() .padding()
} }
} }
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("关于 CrossDesk") .navigationTitle("关于 CrossDesk")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onChange(of: session.hasNetworkConsent) { allowed in .onChange(of: session.hasNetworkConsent) { allowed in
@@ -189,6 +191,8 @@ private struct OpenSourceComponentsView: View {
Text("各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。") Text("各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。")
} }
} }
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("开源组件") .navigationTitle("开源组件")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
@@ -217,6 +221,8 @@ private struct SourceCodeView: View {
} }
} }
} }
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("源码与构建说明") .navigationTitle("源码与构建说明")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
@@ -240,6 +246,7 @@ private struct ComponentLicenseView: View {
ForEach(component.documents) { document in ForEach(component.documents) { document in
NavigationLink { NavigationLink {
LicenseTextView(text: document.text) LicenseTextView(text: document.text)
.readableContentWidth()
.navigationTitle(documentTitle(document.title)) .navigationTitle(documentTitle(document.title))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} label: { } label: {
@@ -248,6 +255,8 @@ private struct ComponentLicenseView: View {
} }
} }
} }
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(component.name) .navigationTitle(component.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
+11
View File
@@ -148,6 +148,17 @@ No Apple signing secrets are needed. Sign the app yourself before installing
it on a device. Push any referenced MiniRTC commit before the parent repository it on a device. Push any referenced MiniRTC commit before the parent repository
so the runner can check out all corresponding sources. so the runner can check out all corresponding sources.
## iPad and adaptive layout
iPad supports portrait, upside-down portrait, landscape and resizable multitasking
windows on both the home screen and an active remote session. The home screen
uses two columns at a window width of 900 points or more, with the connection
form beside recent connections; smaller windows use a single column. Content is
centered at a maximum width of 1200 points, and recent-connection cards adapt to
the available space. Settings and reading pages are limited to 720 points.
Resizing preserves the connection fields and active session. iPhone retains its
portrait home screen and two-column recent-connection grid.
## App updates ## App updates
After network consent, the app checks the public version manifest once on each After network consent, the app checks the public version manifest once on each