mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 17:10:20 +08:00
[feat] add mobile update badges and iOS simulator support
This commit is contained in:
@@ -23,6 +23,12 @@
|
||||
|
||||
当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
|
||||
|
||||
## 本机更新提示
|
||||
|
||||
完成联网授权后,每次启动或从后台返回时检查一次公共版本文件,前台远程连接结束后再检查一次;同时触发时复用正在进行的请求。不进行周期性检查或自动重试,失败时保留已发现的新版本提示,等待下一次触发或手动检查;进入后台或撤回授权时取消请求并忽略过期回复。
|
||||
|
||||
本机只读取 `downloads.android-arm64`,复用 PC 端的版本号及补丁比较规则;仅构建日期变化不提示升级,平台版本字段缺失按检查失败处理。发现新版本后,首页设置按钮右上角显示红底白色感叹号角标,大小和位置与通知角标一致,设置页“关于”显示“新版本”文字和红点;“关于”展示新版本号、官网更新入口,并提供“检查更新”。该功能独立于被控电脑的版本升级提示。
|
||||
|
||||
## 构建
|
||||
|
||||
在 macOS 或 Linux 安装:
|
||||
|
||||
@@ -17,15 +17,22 @@ final class AboutPages {
|
||||
private final MobileUi ui;
|
||||
private final Runnable settings;
|
||||
private final LicenseCatalog catalog;
|
||||
private final AppUpdateChecker updates;
|
||||
private TextView updateStatus, updateLink, checkButton;
|
||||
private LinearLayout updateRow;
|
||||
|
||||
AboutPages(MainActivity activity,MobileUi ui,Runnable settings){
|
||||
this.activity=activity;this.ui=ui;this.settings=settings;
|
||||
AboutPages(MainActivity activity,MobileUi ui,Runnable settings,AppUpdateChecker updates){
|
||||
this.activity=activity;this.ui=ui;this.settings=settings;this.updates=updates;
|
||||
LicenseCatalog loaded;try{loaded=new LicenseCatalog(activity);}catch(Exception error){loaded=null;}catalog=loaded;
|
||||
}
|
||||
void show(){
|
||||
LinearLayout content=activity.formPage("about","关于 CrossDesk",settings);
|
||||
LinearLayout version=activity.section(content,"");value(version,"版本",applicationVersion());
|
||||
updateRow=ui.row();updateRow.setPadding(ui.dp(16),0,ui.dp(16),0);updateRow.addView(activity.updateDot(),ui.size(8,8));
|
||||
updateLink=ui.action("",BLUE,()->open("https://crossdesk.cn"));updateLink.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);updateLink.setPadding(ui.dp(8),ui.dp(12),0,ui.dp(12));updateRow.addView(updateLink,new LinearLayout.LayoutParams(0,-2,1));version.addView(updateRow);
|
||||
updateStatus=ui.text("",13,false);updateStatus.setPadding(ui.dp(16),ui.dp(8),ui.dp(16),ui.dp(8));version.addView(updateStatus);
|
||||
checkButton=ui.action("检查更新",BLUE,activity::checkAppUpdates);version.addView(checkButton);renderUpdates();ui.gap(content,28);
|
||||
if(catalog==null){unavailable(content);return;}
|
||||
LinearLayout version=activity.section(content,"");value(version,"版本",applicationVersion());ui.gap(content,28);
|
||||
LinearLayout links=activity.section(content,"");
|
||||
activity.link(links,"软件许可","",()->component(catalog.find("crossdesk"),this::show));ui.divider(links);
|
||||
activity.link(links,"开源组件","",this::components);ui.divider(links);
|
||||
@@ -33,7 +40,22 @@ final class AboutPages {
|
||||
activity.footer(content,"许可与版权声明可离线查阅。");
|
||||
}
|
||||
private String applicationVersion(){
|
||||
try{return activity.getPackageManager().getPackageInfo(activity.getPackageName(),0).versionName;}catch(android.content.pm.PackageManager.NameNotFoundException error){return "—";}
|
||||
return activity.applicationVersion();
|
||||
}
|
||||
void renderUpdates(){
|
||||
if(updateStatus==null)return;
|
||||
updateRow.setVisibility(updates.updateAvailable()?View.VISIBLE:View.GONE);
|
||||
updateLink.setText("新版本可用:v"+updates.availableVersion);
|
||||
String status=!activity.hasNetworkConsent()?"完成隐私授权后可检查更新":switch(updates.status){
|
||||
case CHECKING -> "正在检查更新…";
|
||||
case UP_TO_DATE -> "当前已是最新版本";
|
||||
case FAILED -> "检查失败,请稍后重试";
|
||||
default -> "";
|
||||
};
|
||||
updateStatus.setText(status);updateStatus.setVisibility(status.isEmpty()?View.GONE:View.VISIBLE);
|
||||
updateStatus.setTextColor(updates.status==AppUpdateChecker.Status.FAILED?RED:SECONDARY);
|
||||
checkButton.setEnabled(updates.status!=AppUpdateChecker.Status.CHECKING);
|
||||
checkButton.setAlpha(checkButton.isEnabled()?1f:.5f);
|
||||
}
|
||||
private void components(){
|
||||
LinearLayout content=activity.formPage("open-source","开源组件",this::show);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/** Main-thread local-app checks triggered by visits, session endings and manual requests. */
|
||||
final class AppUpdateChecker {
|
||||
enum Status { IDLE, CHECKING, UP_TO_DATE, AVAILABLE, FAILED }
|
||||
private final String currentVersion;
|
||||
private final RemoteVersionCheck.Fetcher fetcher;
|
||||
private final BiFunction<String, byte[], String> compare;
|
||||
private final Runnable changed;
|
||||
private RemoteVersionCheck.Cancellation request;
|
||||
private boolean enabled;
|
||||
private long generation;
|
||||
Status status = Status.IDLE;
|
||||
String availableVersion = "";
|
||||
|
||||
AppUpdateChecker(String currentVersion, Runnable changed) {
|
||||
this(currentVersion, RemoteVersionCheck.ReleaseRequest::start,
|
||||
NativeSession::nCheckMobileUpdate, changed);
|
||||
}
|
||||
|
||||
AppUpdateChecker(String currentVersion, RemoteVersionCheck.Fetcher fetcher,
|
||||
BiFunction<String, byte[], String> compare, Runnable changed) {
|
||||
this.currentVersion = currentVersion; this.fetcher = fetcher;
|
||||
this.compare = compare;
|
||||
this.changed = changed;
|
||||
}
|
||||
|
||||
boolean updateAvailable() { return !availableVersion.isEmpty(); }
|
||||
|
||||
void setEnabled(boolean value) {
|
||||
if (enabled == value) return;
|
||||
enabled = value;
|
||||
if (enabled) { checkNow(); return; }
|
||||
++generation;
|
||||
if (request != null) request.cancel();
|
||||
request = null;
|
||||
if (status == Status.CHECKING) {
|
||||
status = updateAvailable() ? Status.AVAILABLE : Status.IDLE;
|
||||
}
|
||||
changed.run();
|
||||
}
|
||||
|
||||
void checkNow() {
|
||||
if (!enabled || status == Status.CHECKING) return;
|
||||
status = Status.CHECKING;
|
||||
changed.run();
|
||||
long token = ++generation;
|
||||
request = fetcher.fetch(data -> {
|
||||
if (!enabled || token != generation) return;
|
||||
request = null;
|
||||
String result = data == null ? null : compare.apply(currentVersion, data);
|
||||
if (result == null) {
|
||||
status = Status.FAILED;
|
||||
} else {
|
||||
availableVersion = result;
|
||||
status = updateAvailable() ? Status.AVAILABLE : Status.UP_TO_DATE;
|
||||
}
|
||||
changed.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private MobileUi ui;
|
||||
private PageHost pageHost;
|
||||
private AboutPages aboutPages;
|
||||
private AppUpdateChecker appUpdates;
|
||||
private View settingsUpdateDot, aboutUpdateDot, settingsUpdateButton, aboutUpdateRow;
|
||||
private TextView aboutUpdateLabel;
|
||||
private boolean navigatingBack;
|
||||
private FrameLayout canvas;
|
||||
private LinearLayout root;
|
||||
@@ -84,6 +87,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
recentPresence.sender=ids->{if(signaling!=null&&!signaling.isClosed())signaling.presenceRequest(ids);};
|
||||
recentPresence.changed=this::renderRecentConnections;
|
||||
remoteVersionCheck=new RemoteVersionCheck(this::showRemoteUpdate);
|
||||
appUpdates=new AppUpdateChecker(applicationVersion(),this::renderAppUpdates);
|
||||
previews=RemotePreviewStore.get(this);previews.addListener(previewChanged);previews.enforceConsent();
|
||||
announcementInbox=new AnnouncementInbox(new java.io.File(getNoBackupFilesDir(),"AnnouncementReads"));
|
||||
announcementInbox.sender=(request,id)->{if(signaling!=null&&!signaling.isClosed())signaling.announcementRequest(request,id);else announcementInbox.failed(id);};
|
||||
@@ -107,6 +111,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
if(root!=null)hideKeyboard();page=nextPage;
|
||||
signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null;
|
||||
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
|
||||
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
|
||||
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
|
||||
root=column();canvas.addView(root,new FrameLayout.LayoutParams(-1,-1));
|
||||
@@ -144,10 +149,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
LinearLayout card=column();ui.card(card,radius);content.addView(card);return card;
|
||||
}
|
||||
void footer(LinearLayout content,String title){TextView v=text(title,12,false);v.setTextColor(SECONDARY);v.setLineSpacing(dp(3),1);v.setPadding(dp(16),dp(8),dp(16),dp(16));content.addView(v);}
|
||||
void link(LinearLayout parent,String title,String detail,Runnable action){
|
||||
LinearLayout link(LinearLayout parent,String title,String detail,Runnable action){
|
||||
LinearLayout item=row();item.setPadding(dp(16),dp(14),dp(12),dp(14));item.setMinimumHeight(dp(48));
|
||||
item.addView(text(title,16,false),new LinearLayout.LayoutParams(0,-2,1));TextView value=text(detail,15,false);value.setTextColor(SECONDARY);item.addView(value);
|
||||
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;
|
||||
}
|
||||
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Match the iOS portrait home; sessions support both orientations.
|
||||
private void home(String message){
|
||||
@@ -159,10 +165,14 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
else if(signalState==1)toast("已连接 "+serverKey());
|
||||
else {stopSignaling();ensureSignaling();}
|
||||
});renderSignaling();toolbar.addView(signalBadge,ui.size(-2,34));toolbar.addView(new View(this),new LinearLayout.LayoutParams(0,1,1));
|
||||
FrameLayout bell=(FrameLayout)ui.iconButton("bell","通知公告",this::announcements);bell.setClipChildren(false);toolbar.setClipChildren(false);
|
||||
FrameLayout bell=(FrameLayout)ui.iconButton("bell","通知公告",this::announcements);bell.setClipChildren(false);toolbar.setClipChildren(false);toolbar.setClipToPadding(false);
|
||||
announcementBadge=text("",10,true);announcementBadge.setTextColor(Color.WHITE);announcementBadge.setGravity(Gravity.CENTER);announcementBadge.setMinWidth(dp(16));announcementBadge.setPadding(dp(4),0,dp(4),0);announcementBadge.setBackground(ui.background(RED,12));announcementBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
FrameLayout.LayoutParams badgePosition=new FrameLayout.LayoutParams(-2,dp(16),Gravity.TOP|Gravity.END);badgePosition.topMargin=-dp(3);badgePosition.setMarginEnd(-dp(5));bell.addView(announcementBadge,badgePosition);renderAnnouncements();
|
||||
toolbar.addView(bell,ui.size(40,40));View spacer=new View(this);toolbar.addView(spacer,ui.size(12,1));toolbar.addView(ui.iconButton("settings","设置",this::settings),ui.size(40,40));
|
||||
toolbar.addView(bell,ui.size(40,40));View spacer=new View(this);toolbar.addView(spacer,ui.size(12,1));
|
||||
FrameLayout settingsButton=(FrameLayout)ui.iconButton("settings","设置",this::settings);settingsButton.setClipChildren(false);settingsUpdateButton=settingsButton;
|
||||
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);
|
||||
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));
|
||||
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);
|
||||
@@ -223,7 +233,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private void consent(Runnable accepted){
|
||||
if(privacyDialog!=null&&privacyDialog.isShowing())return;
|
||||
Dialog dialog=new PrivacyConsentDialog(this,ui,()->{
|
||||
preferences.edit().putBoolean("networkConsent",true).apply();renderSignaling();ensureSignaling();accepted.run();
|
||||
preferences.edit().putBoolean("networkConsent",true).apply();appUpdates.setEnabled(foreground);renderSignaling();ensureSignaling();accepted.run();
|
||||
});
|
||||
privacyDialog=dialog;dialog.setOnDismissListener(d->{if(privacyDialog==dialog)privacyDialog=null;});dialog.show();
|
||||
}
|
||||
@@ -273,6 +283,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
resetVideoSettings();
|
||||
previewCapture=null;previewCopying=false;
|
||||
if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}if(session!=null){releaseKeys();session.close();session=null;}stopSignaling();pendingPassword="";remoteClipboard="";hostPlatform="";displays=new JSONArray();ready=false;video=null;controls=null;getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);home(reason);
|
||||
appUpdates.checkNow();
|
||||
}
|
||||
private void settings(){
|
||||
LinearLayout content=formPage("settings","设置",()->home(""));
|
||||
@@ -294,7 +305,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
previewSwitch.setOnCheckedChangeListener((button,checked)->{if(updatingPreviewSwitch)return;if(checked){renderPreviews();confirmPreviewSaving();}else previews.setEnabled(false);});
|
||||
ui.divider(privacy);clearPreviewsButton=ui.action("清除预览图",RED,this::confirmClearPreviews);clearPreviewsButton.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);clearPreviewsButton.setTypeface(Typeface.DEFAULT);clearPreviewsButton.setPadding(dp(16),0,dp(16),0);privacy.addView(clearPreviewsButton,ui.size(-1,48));
|
||||
previewCleanupError=text("",12,false);previewCleanupError.setTextColor(RED);previewCleanupError.setPadding(dp(16),dp(8),dp(16),dp(12));privacy.addView(previewCleanupError);renderPreviews();
|
||||
footer(content,"画面预览默认关闭。开启后,从下一次连接起保存一张远程画面到本机,显示在最近连接中,不上传且不纳入设备备份。关闭开关会清除已有预览;连接记录和密码不受影响。");LinearLayout about=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);link(about,"关于","",this::about);
|
||||
footer(content,"画面预览默认关闭。开启后,从下一次连接起保存一张远程画面到本机,显示在最近连接中,不上传且不纳入设备备份。关闭开关会清除已有预览;连接记录和密码不受影响。");LinearLayout about=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout aboutLink=link(about,"关于","新版本",this::about);aboutUpdateRow=aboutLink;
|
||||
aboutUpdateLabel=(TextView)aboutLink.getChildAt(1);aboutUpdateLabel.setPadding(0,0,dp(8),0);
|
||||
aboutUpdateDot=updateDot();LinearLayout.LayoutParams dotPosition=new LinearLayout.LayoutParams(dp(8),dp(8));dotPosition.setMarginEnd(dp(8));aboutLink.addView(aboutUpdateDot,2,dotPosition);renderAppUpdates();
|
||||
}
|
||||
private void confirmPreviewSaving(){
|
||||
previewDialog=new AlertDialog.Builder(this).setTitle("保存远程画面预览?")
|
||||
@@ -363,14 +376,32 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private void privacyPage(){
|
||||
LinearLayout content=formPage("privacy","隐私与授权",this::settings);LinearLayout card=section(content,"");boolean consent=preferences.getBoolean("networkConsent",false);link(card,"授权状态",consent?"已授权":"未授权",()->{});ui.divider(card);link(card,"隐私政策","",this::privacyPolicyPage);
|
||||
footer(content,"设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用会结束当前会话。");
|
||||
if(consent)content.addView(ui.action("撤回联网授权",RED,()->new AlertDialog.Builder(this).setTitle("撤回联网授权?").setMessage("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。").setNegativeButton("取消",null).setPositiveButton("撤回",(d,w)->{preferences.edit().putBoolean("networkConsent",false).apply();previews.setEnabled(false);stopSignaling();announcementInbox.reset();privacyPage();}).show()));else content.addView(ui.primary("阅读并授权",()->consent(this::privacyPage)));
|
||||
if(consent)content.addView(ui.action("撤回联网授权",RED,()->new AlertDialog.Builder(this).setTitle("撤回联网授权?").setMessage("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。").setNegativeButton("取消",null).setPositiveButton("撤回",(d,w)->{preferences.edit().putBoolean("networkConsent",false).apply();appUpdates.setEnabled(false);previews.setEnabled(false);stopSignaling();announcementInbox.reset();privacyPage();}).show()));else content.addView(ui.primary("阅读并授权",()->consent(this::privacyPage)));
|
||||
}
|
||||
private void privacyPolicyPage(){
|
||||
installRoot("privacy-policy",false);navigation("隐私政策",this::privacyPage);
|
||||
root.addView(new PrivacyPolicyDocument(this).view(ui),new LinearLayout.LayoutParams(-1,0,1));
|
||||
}
|
||||
String applicationVersion(){
|
||||
try{return getPackageManager().getPackageInfo(getPackageName(),0).versionName;}catch(android.content.pm.PackageManager.NameNotFoundException error){return "";}
|
||||
}
|
||||
View updateDot(){View dot=new View(this);dot.setBackground(ui.background(RED,4));dot.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);return dot;}
|
||||
boolean hasNetworkConsent(){return preferences.getBoolean("networkConsent",false);}
|
||||
void checkAppUpdates(){
|
||||
if(hasNetworkConsent())appUpdates.checkNow();
|
||||
else consent(appUpdates::checkNow);
|
||||
}
|
||||
private void renderAppUpdates(){
|
||||
boolean available=appUpdates.updateAvailable();
|
||||
if(settingsUpdateDot!=null)settingsUpdateDot.setVisibility(available?View.VISIBLE:View.GONE);
|
||||
if(aboutUpdateDot!=null)aboutUpdateDot.setVisibility(available?View.VISIBLE:View.GONE);
|
||||
if(aboutUpdateLabel!=null)aboutUpdateLabel.setVisibility(available?View.VISIBLE:View.GONE);
|
||||
if(settingsUpdateButton!=null)settingsUpdateButton.setContentDescription(available?"设置,有新版本":"设置");
|
||||
if(aboutUpdateRow!=null)aboutUpdateRow.setContentDescription(available?"关于,有新版本":"关于");
|
||||
if(aboutPages!=null&&page.equals("about"))aboutPages.renderUpdates();
|
||||
}
|
||||
private void about(){
|
||||
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings);aboutPages.show();
|
||||
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings,appUpdates);aboutPages.show();
|
||||
}
|
||||
private void sendText(String value){if(session==null)return;if(value.length()>2048){toast("单次最多 2048 个字符");return;}for(char ch:value.toCharArray())if(KeyMap.ascii(ch)==0){toast("中文等文本请通过剪贴板发送,再点击粘贴");return;}for(char ch:value.toCharArray()){int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;if(shift)session.key(0x10,true);tapKey(code&0xFF);if(shift)session.key(0x10,false);}}
|
||||
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
|
||||
@@ -510,7 +541,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
@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);}
|
||||
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
||||
@Override protected void onStart(){super.onStart();foreground=true;if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
|
||||
@Override protected void onStop(){foreground=false;if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
|
||||
@Override protected void onDestroy(){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();if(session!=null)session.close();stopSignaling();super.onDestroy();}
|
||||
@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 onDestroy(){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();if(session!=null)session.close();stopSignaling();super.onDestroy();}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ final class NativeSession implements AutoCloseable {
|
||||
}
|
||||
static native boolean nValidAppVersion(String version);
|
||||
static native boolean nHasAppUpdate(String version, byte[] releaseJson);
|
||||
static native String nCheckMobileUpdate(String version, byte[] releaseJson);
|
||||
private static native long nCreate(NativeSession owner, String host, int port, String identity, String logs, String roots);
|
||||
private static native boolean nConnect(long handle, String identity, String remote, String password);
|
||||
private static native void nReady(long handle);
|
||||
|
||||
@@ -61,7 +61,7 @@ final class RemoteVersionCheck {
|
||||
request = null;
|
||||
}
|
||||
|
||||
private static final class ReleaseRequest implements Cancellation, Runnable {
|
||||
static final class ReleaseRequest implements Cancellation, Runnable {
|
||||
private static final int MAX_BYTES = 256 * 1024;
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final Consumer<byte[]> completion;
|
||||
|
||||
@@ -298,6 +298,18 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nHasAppUpdate)(
|
||||
const auto latest = crossdesk::ParseVersionInfoJSON(data);
|
||||
return latest && crossdesk::AvailableAppUpdate(String(env, version), *latest).has_value();
|
||||
}
|
||||
extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nCheckMobileUpdate)(
|
||||
JNIEnv* env, jclass, jstring version, jbyteArray release_json) {
|
||||
if (!version || !release_json) return nullptr;
|
||||
const auto size = env->GetArrayLength(release_json);
|
||||
if (size <= 0 || size > 256 * 1024) return nullptr;
|
||||
std::string data(size, '\0');
|
||||
env->GetByteArrayRegion(release_json, 0, size, reinterpret_cast<jbyte*>(data.data()));
|
||||
if (env->ExceptionCheck()) return nullptr;
|
||||
const auto result = crossdesk::CheckPlatformAppUpdate(
|
||||
String(env, version), data, "android-arm64");
|
||||
return result ? env->NewStringUTF(result->c_str()) : nullptr;
|
||||
}
|
||||
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nCreate)(
|
||||
JNIEnv* env, jclass, jobject owner, jstring host, jint port, jstring identity,
|
||||
jstring log_path, jstring certificates) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class AppUpdateCheckerTest {
|
||||
private static final class Fixture implements RemoteVersionCheck.Fetcher {
|
||||
int cancelled;
|
||||
final List<Consumer<byte[]>> replies = new ArrayList<>();
|
||||
final AppUpdateChecker checker = new AppUpdateChecker("1.6.0", this,
|
||||
(version, data) -> new String(data, StandardCharsets.UTF_8), () -> {});
|
||||
public RemoteVersionCheck.Cancellation fetch(Consumer<byte[]> reply) {
|
||||
replies.add(reply); return () -> cancelled++;
|
||||
}
|
||||
void reply(String result) { replies.get(replies.size()-1).accept(result == null ? null : result.getBytes(StandardCharsets.UTF_8)); }
|
||||
}
|
||||
|
||||
@Test public void everyVisitChecksOnceAndConcurrentTriggersDeduplicate() {
|
||||
Fixture f = new Fixture();
|
||||
f.checker.checkNow(); assertTrue(f.replies.isEmpty());
|
||||
f.checker.setEnabled(true); f.checker.checkNow(); f.checker.setEnabled(true);
|
||||
assertEquals(1, f.replies.size());
|
||||
f.reply("1.7.0"); assertTrue(f.checker.updateAvailable());
|
||||
f.checker.setEnabled(true); assertEquals(1, f.replies.size());
|
||||
f.checker.setEnabled(false); f.checker.setEnabled(true);
|
||||
assertEquals(2, f.replies.size());
|
||||
f.reply(""); assertFalse(f.checker.updateAvailable());
|
||||
assertEquals(AppUpdateChecker.Status.UP_TO_DATE, f.checker.status);
|
||||
f.checker.checkNow(); assertEquals(3, f.replies.size());
|
||||
}
|
||||
|
||||
@Test public void failedChecksRetainBadgeUntilAnotherExplicitCheck() {
|
||||
Fixture f = new Fixture(); f.checker.setEnabled(true); f.reply("1.7.0");
|
||||
f.checker.checkNow(); f.reply(null);
|
||||
assertEquals(AppUpdateChecker.Status.FAILED, f.checker.status);
|
||||
assertTrue(f.checker.updateAvailable());
|
||||
f.checker.setEnabled(true); assertEquals(2, f.replies.size());
|
||||
f.checker.checkNow(); assertEquals(3, f.replies.size());
|
||||
f.reply(""); assertFalse(f.checker.updateAvailable());
|
||||
}
|
||||
|
||||
@Test public void backgroundAndRevocationCancelAndIgnoreStaleReplies() {
|
||||
Fixture f = new Fixture(); f.checker.setEnabled(true); f.checker.setEnabled(false);
|
||||
assertEquals(1, f.cancelled);
|
||||
f.reply("9.0.0"); assertFalse(f.checker.updateAvailable());
|
||||
f.checker.setEnabled(true); assertEquals(2, f.replies.size());
|
||||
f.replies.get(0).accept("9.0.0".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals(AppUpdateChecker.Status.CHECKING, f.checker.status);
|
||||
f.reply("1.7.0"); f.checker.setEnabled(false); f.checker.checkNow();
|
||||
assertEquals(2, f.replies.size()); f.checker.setEnabled(true);
|
||||
assertEquals(3, f.replies.size()); assertTrue(f.checker.updateAvailable());
|
||||
}
|
||||
|
||||
@Test public void nativeParserSeparatesMobileFromDesktopAndInvalidMetadata() {
|
||||
byte[] manifest = "{\"latest_version\":\"99.0.0\",\"patch\":99,\"downloads\":{\"ios-arm64\":{\"version\":\"0.0.2\"},\"android-arm64\":{\"version\":\"1.6.0-2-20261002\"}}}".getBytes(StandardCharsets.UTF_8);
|
||||
assertEquals("1.6.0-2", NativeSession.nCheckMobileUpdate("1.6.0", manifest));
|
||||
assertEquals("", NativeSession.nCheckMobileUpdate("1.6.0-2", manifest));
|
||||
assertEquals("", NativeSession.nCheckMobileUpdate("1.7.0", manifest));
|
||||
assertNull(NativeSession.nCheckMobileUpdate("", manifest));
|
||||
assertNull(NativeSession.nCheckMobileUpdate("1.6.0", "{\"version\":\"99.0.0\"}".getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
@Test public void settingsAndAboutReflectUpdatesWithoutReopeningThePage() {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
var preferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE);
|
||||
boolean hadConsent = preferences.contains("networkConsent");
|
||||
boolean consent = preferences.getBoolean("networkConsent", false);
|
||||
preferences.edit().putBoolean("networkConsent", false).commit();
|
||||
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
|
||||
scenario.onActivity(activity -> {
|
||||
((Dialog)field(activity, "privacyDialog")).dismiss();
|
||||
List<Consumer<byte[]>> replies = new ArrayList<>();
|
||||
AppUpdateChecker checker = new AppUpdateChecker("1.6.0",
|
||||
reply -> { replies.add(reply); return () -> {}; },
|
||||
(version, data) -> new String(data, StandardCharsets.UTF_8),
|
||||
() -> invoke(activity, "renderAppUpdates"));
|
||||
try {
|
||||
var field = MainActivity.class.getDeclaredField("appUpdates");
|
||||
field.setAccessible(true); field.set(activity, checker);
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
assertEquals(View.GONE, ((View)field(activity, "settingsUpdateDot")).getVisibility());
|
||||
checker.setEnabled(true);
|
||||
replies.get(0).accept("1.7.0".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals(View.VISIBLE, ((View)field(activity, "settingsUpdateDot")).getVisibility());
|
||||
assertEquals("设置,有新版本", ((View)field(activity, "settingsUpdateButton")).getContentDescription());
|
||||
((View)field(activity, "settingsUpdateButton")).performClick();
|
||||
assertEquals(View.VISIBLE, ((View)field(activity, "aboutUpdateDot")).getVisibility());
|
||||
((View)field(activity, "aboutUpdateRow")).performClick();
|
||||
assertNotNull(findText(activity.getWindow().getDecorView(), "新版本可用:v1.7.0"));
|
||||
checker.checkNow(); replies.get(1).accept(null);
|
||||
assertNotNull(findText(activity.getWindow().getDecorView(), "新版本可用:v1.7.0"));
|
||||
checker.checkNow(); replies.get(2).accept(new byte[0]);
|
||||
invoke(activity, "settings");
|
||||
assertEquals(View.GONE, ((View)field(activity, "aboutUpdateDot")).getVisibility());
|
||||
checker.setEnabled(false);
|
||||
});
|
||||
} finally {
|
||||
if (hadConsent) preferences.edit().putBoolean("networkConsent", consent).commit();
|
||||
else preferences.edit().remove("networkConsent").commit();
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void activityVisitsAndConnectionTeardownTriggerChecks() {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
var preferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE);
|
||||
boolean hadConsent = preferences.contains("networkConsent");
|
||||
boolean consent = preferences.getBoolean("networkConsent", false);
|
||||
preferences.edit().putBoolean("networkConsent", false).commit();
|
||||
Fixture f = new Fixture();
|
||||
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
|
||||
scenario.onActivity(activity -> {
|
||||
((Dialog)field(activity, "privacyDialog")).dismiss();
|
||||
try {
|
||||
var field = MainActivity.class.getDeclaredField("appUpdates");
|
||||
field.setAccessible(true); field.set(activity, f.checker);
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
});
|
||||
scenario.moveToState(Lifecycle.State.CREATED);
|
||||
preferences.edit().putBoolean("networkConsent", true).commit();
|
||||
scenario.moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity -> {
|
||||
assertEquals(1, f.replies.size()); f.reply("1.7.0");
|
||||
endConnection(activity);
|
||||
assertEquals(2, f.replies.size()); f.reply(null);
|
||||
assertTrue(f.checker.updateAvailable());
|
||||
endConnection(activity);
|
||||
assertEquals(3, f.replies.size()); f.reply("");
|
||||
assertFalse(f.checker.updateAvailable());
|
||||
});
|
||||
scenario.moveToState(Lifecycle.State.CREATED);
|
||||
scenario.onActivity(activity -> {
|
||||
endConnection(activity); assertEquals(3, f.replies.size());
|
||||
});
|
||||
scenario.moveToState(Lifecycle.State.RESUMED);
|
||||
scenario.onActivity(activity -> assertEquals(4, f.replies.size()));
|
||||
} finally {
|
||||
if (hadConsent) preferences.edit().putBoolean("networkConsent", consent).commit();
|
||||
else preferences.edit().remove("networkConsent").commit();
|
||||
}
|
||||
}
|
||||
|
||||
private static void endConnection(MainActivity activity) {
|
||||
try {
|
||||
var method = MainActivity.class.getDeclaredMethod("end", String.class);
|
||||
method.setAccessible(true); method.invoke(activity, "");
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
|
||||
private static Object field(MainActivity activity, String name) {
|
||||
try { var field = MainActivity.class.getDeclaredField(name); field.setAccessible(true); return field.get(activity); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
private static void invoke(MainActivity activity, String name) {
|
||||
try { var method = MainActivity.class.getDeclaredMethod(name); method.setAccessible(true); method.invoke(activity); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
private static TextView findText(View view, String text) {
|
||||
if (view instanceof TextView && ((TextView)view).getText().toString().equals(text)) return (TextView)view;
|
||||
if (view instanceof ViewGroup) for (int i = 0; i < ((ViewGroup)view).getChildCount(); i++) {
|
||||
TextView found = findText(((ViewGroup)view).getChildAt(i), text);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -129,5 +129,33 @@ int main() {
|
||||
crossdesk::ParseVersionInfoJSON(response).has_value(), false);
|
||||
}
|
||||
|
||||
const auto mobile_manifest = R"({"latest_version":"99.0.0","patch":99,
|
||||
"downloads":{"ios-arm64":{"version":"0.0.2-20261002"},
|
||||
"android-arm64":{"version":"1.6.0-2-20261002"}}})";
|
||||
ok &= ExpectEqual("iOS uses its own release", crossdesk::CheckPlatformAppUpdate(
|
||||
"0.0.1", mobile_manifest, "ios-arm64") == "0.0.2", true);
|
||||
ok &= ExpectEqual("Android uses its own release and inline patch",
|
||||
crossdesk::CheckPlatformAppUpdate("1.6.0", mobile_manifest, "android-arm64") == "1.6.0-2", true);
|
||||
for (const auto& version : {"1.6.0-2", "1.6.0-3", "1.7.0"}) {
|
||||
ok &= ExpectEqual("desktop patch cannot affect mobile comparison",
|
||||
crossdesk::CheckPlatformAppUpdate(version, mobile_manifest, "android-arm64") == "", true);
|
||||
}
|
||||
ok &= ExpectEqual("mobile build date does not count as update",
|
||||
crossdesk::CheckPlatformAppUpdate("0.0.2-20261001", mobile_manifest, "ios-arm64") == "", true);
|
||||
ok &= ExpectEqual("invalid installed version is a failed local check",
|
||||
crossdesk::CheckPlatformAppUpdate("", mobile_manifest, "ios-arm64").has_value(), false);
|
||||
for (const auto& response : {"not JSON", "[]", R"({"version":"99.0.0"})",
|
||||
R"({"downloads":[]})", R"({"downloads":{"ios-arm64":null}})",
|
||||
R"({"downloads":{"android-arm64":{"version":"99.0.0"}}})",
|
||||
R"({"downloads":{"ios-arm64":{"url":"https://example.com/app.zip"}}})",
|
||||
R"({"downloads":{"ios-arm64":{"version":"invalid"}}})"}) {
|
||||
ok &= ExpectEqual("missing mobile metadata never falls back to desktop",
|
||||
crossdesk::CheckPlatformAppUpdate("0.0.1", response, "ios-arm64").has_value(), false);
|
||||
}
|
||||
ok &= ExpectEqual("platform patch metadata follows desktop rules",
|
||||
crossdesk::CheckPlatformAppUpdate("1.6.0-2",
|
||||
R"({"downloads":{"android-arm64":{"version":"1.6.0","patch":3}}})",
|
||||
"android-arm64") == "1.6.0-3", true);
|
||||
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
100000000000000000000019 /* AnnouncementInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000019 /* AnnouncementInbox.swift */; };
|
||||
10000000000000000000001A /* AnnouncementText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001A /* AnnouncementText.swift */; };
|
||||
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001B /* AnnouncementInboxView.swift */; };
|
||||
10000000000000000000001C /* AppUpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001C /* AppUpdateChecker.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -60,6 +61,7 @@
|
||||
200000000000000000000019 /* AnnouncementInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInbox.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001C /* AppUpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateChecker.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -107,6 +109,7 @@
|
||||
200000000000000000000019 /* AnnouncementInbox.swift */,
|
||||
20000000000000000000001A /* AnnouncementText.swift */,
|
||||
20000000000000000000001B /* AnnouncementInboxView.swift */,
|
||||
20000000000000000000001C /* AppUpdateChecker.swift */,
|
||||
200000000000000000000018 /* RecentConnection.swift */,
|
||||
200000000000000000000016 /* RemoteVideoSettings.swift */,
|
||||
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
|
||||
@@ -252,7 +255,7 @@
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/OpenSSL",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/Info.plist",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/PrivacyInfo.xcprivacy",
|
||||
@@ -262,7 +265,7 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\"\n";
|
||||
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\" --platform \"${PLATFORM_NAME}\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
@@ -276,6 +279,7 @@
|
||||
100000000000000000000019 /* AnnouncementInbox.swift in Sources */,
|
||||
10000000000000000000001A /* AnnouncementText.swift in Sources */,
|
||||
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */,
|
||||
10000000000000000000001C /* AppUpdateChecker.swift in Sources */,
|
||||
100000000000000000000018 /* RecentConnection.swift in Sources */,
|
||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
|
||||
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
|
||||
@@ -357,7 +361,7 @@
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"-liconv",
|
||||
"-lresolv",
|
||||
"-framework",
|
||||
@@ -377,7 +381,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -407,7 +411,7 @@
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"-liconv",
|
||||
"-lresolv",
|
||||
"-framework",
|
||||
@@ -427,7 +431,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Main-thread local-app checks triggered by visits, session endings and manual requests.
|
||||
final class AppUpdateChecker: ObservableObject {
|
||||
enum Status { case idle, checking, upToDate, available, failed }
|
||||
// Fetchers complete asynchronously on the main thread.
|
||||
typealias Fetch = (@escaping (Data?) -> Void) -> (() -> Void)
|
||||
@Published private(set) var status: Status = .idle
|
||||
@Published private(set) var availableVersion = ""
|
||||
var updateAvailable: Bool { !availableVersion.isEmpty }
|
||||
let currentVersion: String
|
||||
|
||||
private let fetch: Fetch
|
||||
private let compare: (String, Data) -> String?
|
||||
private var enabled = false
|
||||
private var generation = UUID()
|
||||
private var cancelRequest: (() -> Void)?
|
||||
|
||||
init(currentVersion: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "",
|
||||
fetch: @escaping Fetch = AppUpdateChecker.fetchRelease,
|
||||
compare: @escaping (String, Data) -> String? = CrossDeskRTCBridge.checkMobileUpdate) {
|
||||
self.currentVersion = currentVersion
|
||||
self.fetch = fetch
|
||||
self.compare = compare
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancelRequest?()
|
||||
}
|
||||
|
||||
func setEnabled(_ value: Bool) {
|
||||
guard enabled != value else { return }
|
||||
enabled = value
|
||||
if enabled { checkNow(); return }
|
||||
generation = UUID()
|
||||
cancelRequest?()
|
||||
cancelRequest = nil
|
||||
if status == .checking {
|
||||
status = updateAvailable ? .available : .idle
|
||||
}
|
||||
}
|
||||
|
||||
func checkNow() {
|
||||
guard enabled, status != .checking else { return }
|
||||
status = .checking
|
||||
let token = UUID()
|
||||
generation = token
|
||||
cancelRequest = fetch { [weak self] data in
|
||||
guard let self, self.enabled, self.generation == token else { return }
|
||||
self.cancelRequest = nil
|
||||
if let data, let result = self.compare(self.currentVersion, data) {
|
||||
self.availableVersion = result
|
||||
self.status = self.updateAvailable ? .available : .upToDate
|
||||
} else {
|
||||
self.status = .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public metadata only: no identity, installed version or credentials sent.
|
||||
private static func fetchRelease(completion: @escaping (Data?) -> Void) -> (() -> Void) {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 10
|
||||
configuration.timeoutIntervalForResource = 15
|
||||
let session = URLSession(configuration: configuration)
|
||||
let request = URLRequest(url: URL(string: "https://version.crossdesk.cn/version.json")!,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
let valid = error == nil && (response as? HTTPURLResponse)?.statusCode == 200 &&
|
||||
(data?.count ?? 0) <= 256 * 1024
|
||||
DispatchQueue.main.async { completion(valid ? data : nil) }
|
||||
session.finishTasksAndInvalidate()
|
||||
}
|
||||
task.resume()
|
||||
return { task.cancel(); session.invalidateAndCancel() }
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,10 @@ typedef struct {
|
||||
releaseJSON:(NSData *)releaseJSON
|
||||
NS_SWIFT_NAME(availableUpdate(appVersion:releaseJSON:));
|
||||
|
||||
+ (nullable NSString *)checkMobileUpdateForAppVersion:(NSString *)appVersion
|
||||
releaseJSON:(NSData *)releaseJSON
|
||||
NS_SWIFT_NAME(checkMobileUpdate(appVersion:releaseJSON:));
|
||||
|
||||
- (void)configureWithSignalHost:(NSString *)host
|
||||
signalPort:(NSInteger)signalPort;
|
||||
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
|
||||
|
||||
@@ -508,6 +508,16 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
return update ? [NSString stringWithUTF8String:update->c_str()] : nil;
|
||||
}
|
||||
|
||||
+ (nullable NSString *)checkMobileUpdateForAppVersion:(NSString *)appVersion
|
||||
releaseJSON:(NSData *)releaseJSON {
|
||||
if (releaseJSON.length == 0 || releaseJSON.length > 256 * 1024) return nil;
|
||||
const auto result = crossdesk::CheckPlatformAppUpdate(
|
||||
appVersion.UTF8String ?: "", std::string_view(
|
||||
static_cast<const char *>(releaseJSON.bytes), releaseJSON.length),
|
||||
"ios-arm64");
|
||||
return result ? [NSString stringWithUTF8String:result->c_str()] : nil;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
|
||||
@@ -41,6 +41,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
|
||||
.environmentObject(session.appUpdates)
|
||||
.preferredColorScheme(session.sessionVisible ? .dark : .light)
|
||||
.sheet(isPresented: Binding(
|
||||
get: { session.privacyNoticeVisible },
|
||||
@@ -158,6 +159,7 @@ private struct KeyboardDismissTapView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
private struct ConnectionHomeView: View {
|
||||
@EnvironmentObject private var appUpdates: AppUpdateChecker
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@FocusState private var remoteIDFocused: Bool
|
||||
@State private var passwordPromptVisible = false
|
||||
@@ -187,6 +189,7 @@ private struct ConnectionHomeView: View {
|
||||
|
||||
private var signalHasError: Bool {
|
||||
session.signalStatus.contains("失败") ||
|
||||
session.signalStatus.contains("无法") ||
|
||||
session.signalStatus.contains("无效") ||
|
||||
session.signalStatus.contains("关闭") ||
|
||||
session.signalStatus.contains("未知")
|
||||
@@ -318,10 +321,21 @@ private struct ConnectionHomeView: View {
|
||||
.background(Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 10,
|
||||
style: .continuous))
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if appUpdates.updateAvailable {
|
||||
Text("!")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 16, height: 16)
|
||||
.background(.red, in: Circle())
|
||||
.offset(x: 5, y: -3)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.disabled(session.isConnecting)
|
||||
.accessibilityLabel("设置")
|
||||
.accessibilityLabel(appUpdates.updateAvailable ? "设置,有新版本" : "设置")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +351,8 @@ private struct ConnectionHomeView: View {
|
||||
}
|
||||
.foregroundStyle(signalTint)
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 34)
|
||||
// Leave room for both toolbar actions even when the status is an error.
|
||||
.frame(maxWidth: 160, minHeight: 34, maxHeight: 34, alignment: .leading)
|
||||
.background(signalTint.opacity(0.11), in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
@@ -752,6 +767,7 @@ private struct PasswordPromptView: View {
|
||||
}
|
||||
|
||||
private struct ServerSettingsView: View {
|
||||
@EnvironmentObject private var appUpdates: AppUpdateChecker
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@State private var serverConfigurationExpanded = false
|
||||
@State private var usesCustomServer: Bool
|
||||
@@ -855,9 +871,19 @@ private struct ServerSettingsView: View {
|
||||
}
|
||||
PrivacySettingsSection(session: session)
|
||||
Section {
|
||||
NavigationLink("关于") {
|
||||
ThirdPartyLicensesView()
|
||||
NavigationLink {
|
||||
ThirdPartyLicensesView(session: session)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("关于")
|
||||
Spacer()
|
||||
if appUpdates.updateAvailable {
|
||||
Text("新版本").foregroundStyle(.secondary)
|
||||
UpdateDot()
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(appUpdates.updateAvailable ? "关于,有新版本" : "关于")
|
||||
}
|
||||
}
|
||||
.navigationTitle("设置")
|
||||
|
||||
@@ -339,6 +339,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
|
||||
let bridge = CrossDeskRTCBridge()
|
||||
let announcements = AnnouncementInbox()
|
||||
let appUpdates = AppUpdateChecker()
|
||||
private var appIsActive = false
|
||||
private let audioPlayer = RemoteAudioPlayer()
|
||||
private var activeRemoteID = ""
|
||||
private var remoteAppVersion = ""
|
||||
@@ -403,6 +405,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
var privacyNoticeVisible: Bool { privacyNotice.isVisible }
|
||||
|
||||
func applicationDidBecomeActive() {
|
||||
appIsActive = true
|
||||
appUpdates.setEnabled(hasNetworkConsent)
|
||||
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
|
||||
refreshRecentConnectionPresenceAfterForeground()
|
||||
refreshAnnouncements()
|
||||
@@ -410,6 +414,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground() {
|
||||
appIsActive = false
|
||||
appUpdates.setEnabled(false)
|
||||
privacyNotice.didEnterBackground()
|
||||
suspendVideoForBackground()
|
||||
suspendPresenceMonitoring()
|
||||
@@ -426,6 +432,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
func acceptNetworkConsent() {
|
||||
privacyPreferences.setNetworkConsent(true)
|
||||
hasNetworkConsent = true
|
||||
appUpdates.setEnabled(appIsActive)
|
||||
privacyNotice.dismiss()
|
||||
configureBridge()
|
||||
}
|
||||
@@ -438,6 +445,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
func revokeNetworkConsent() {
|
||||
privacyPreferences.setNetworkConsent(false)
|
||||
hasNetworkConsent = false
|
||||
appUpdates.setEnabled(false)
|
||||
signalConnected = false
|
||||
stopPresenceMonitoring()
|
||||
invalidatePresence()
|
||||
@@ -669,6 +677,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
connectionStatus = "未连接"
|
||||
audioPlayer.setEnabled(false)
|
||||
AppOrientation.update(to: .portrait)
|
||||
appUpdates.checkNow()
|
||||
}
|
||||
|
||||
func retry() {
|
||||
|
||||
@@ -77,28 +77,52 @@ struct SourceMetadata: Decodable {
|
||||
}
|
||||
|
||||
struct ThirdPartyLicensesView: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@EnvironmentObject private var appUpdates: AppUpdateChecker
|
||||
var body: some View {
|
||||
Group {
|
||||
List {
|
||||
Section {
|
||||
LabeledContent("版本", value: appUpdates.currentVersion)
|
||||
if appUpdates.updateAvailable {
|
||||
Link(destination: URL(string: "https://crossdesk.cn")!) {
|
||||
HStack {
|
||||
UpdateDot()
|
||||
Text("新版本可用:v\(appUpdates.availableVersion)")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !session.hasNetworkConsent {
|
||||
Text("完成隐私授权后可检查更新").foregroundStyle(.secondary)
|
||||
} else if appUpdates.status == .checking {
|
||||
Text("正在检查更新…").foregroundStyle(.secondary)
|
||||
} else if appUpdates.status == .upToDate {
|
||||
Text("当前已是最新版本").foregroundStyle(.secondary)
|
||||
} else if appUpdates.status == .failed {
|
||||
Text("检查失败,请稍后重试").foregroundStyle(.red)
|
||||
}
|
||||
Button("检查更新") {
|
||||
if session.hasNetworkConsent { appUpdates.checkNow() }
|
||||
else { session.showPrivacyNotice() }
|
||||
}
|
||||
.disabled(appUpdates.status == .checking)
|
||||
}
|
||||
switch LicenseCatalog.bundled {
|
||||
case .success(let catalog):
|
||||
List {
|
||||
if let application = catalog.components.first(where: { $0.id == "crossdesk" }) {
|
||||
Section {
|
||||
LabeledContent("版本", value: application.displayVersion)
|
||||
if let application = catalog.components.first(where: { $0.id == "crossdesk" }) {
|
||||
Section {
|
||||
NavigationLink("软件许可") {
|
||||
ComponentLicenseView(component: application)
|
||||
}
|
||||
Section {
|
||||
NavigationLink("软件许可") {
|
||||
ComponentLicenseView(component: application)
|
||||
}
|
||||
NavigationLink("开源组件") {
|
||||
OpenSourceComponentsView(catalog: catalog)
|
||||
}
|
||||
NavigationLink("源码与构建说明") {
|
||||
SourceCodeView(application: application)
|
||||
}
|
||||
} footer: {
|
||||
Text("许可与版权声明可离线查阅。")
|
||||
NavigationLink("开源组件") {
|
||||
OpenSourceComponentsView(catalog: catalog)
|
||||
}
|
||||
NavigationLink("源码与构建说明") {
|
||||
SourceCodeView(application: application)
|
||||
}
|
||||
} footer: {
|
||||
Text("许可与版权声明可离线查阅。")
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
@@ -116,6 +140,18 @@ struct ThirdPartyLicensesView: View {
|
||||
}
|
||||
.navigationTitle("关于 CrossDesk")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: session.hasNetworkConsent) { allowed in
|
||||
if allowed { appUpdates.checkNow() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateDot: View {
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-3
@@ -8,7 +8,7 @@ There is no `WKWebView` or browser runtime.
|
||||
|
||||
- Xcode 16 or newer
|
||||
- Xmake 3.1.1 available on `PATH` (or selected with `XMAKE_BIN`)
|
||||
- A physical arm64 iPhone or iPad running iOS 16 or newer
|
||||
- A physical arm64 iPhone/iPad running iOS 16 or newer, or an iOS simulator on Apple Silicon
|
||||
|
||||
## Build
|
||||
|
||||
@@ -57,8 +57,8 @@ package downloads disabled.
|
||||
The first build phase builds MiniRTC and the CrossDesk wire library, downloads
|
||||
and builds their dependencies, then merges the iPhoneOS archives into a local library under
|
||||
`Vendor/`. OpenSSL is kept separate and packaged as a static framework with SDK
|
||||
privacy resources in the Xcode products directory. This build path targets physical devices; simulator builds are
|
||||
not supported. The exact tool versions used by CI are recorded in the
|
||||
privacy resources in the Xcode products directory. Device and simulator builds
|
||||
use separate native archives and package caches. The exact tool versions used by CI are recorded in the
|
||||
[iOS workflow](../../.github/workflows/build-ios.yml).
|
||||
|
||||
You can also build the target without code signing. The resulting app, like
|
||||
@@ -72,6 +72,25 @@ xcodebuild -project apps/ios/CrossDeskMobile.xcodeproj \
|
||||
CODE_SIGNING_ALLOWED=NO build
|
||||
```
|
||||
|
||||
On an Apple Silicon Mac, select an iOS simulator in Xcode and run the same
|
||||
scheme. To build from the command line:
|
||||
|
||||
```sh
|
||||
xcodebuild -project apps/ios/CrossDeskMobile.xcodeproj \
|
||||
-scheme CrossDeskMobile -configuration Debug \
|
||||
-sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
|
||||
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- build
|
||||
```
|
||||
|
||||
Keep signing enabled for simulator runs. Xcode uses a local ad-hoc signature
|
||||
and supplies the application identity required by Keychain. Disabling signing
|
||||
causes identity storage to fail with `-34018` before signaling can connect.
|
||||
|
||||
The first simulator build compiles its own MiniRTC and dependencies; later builds
|
||||
reuse `.xmake/packages-simulator` and `Vendor/iphonesimulator`. This runs the
|
||||
native application, including version checks and signaling. Validate hardware
|
||||
video behavior and performance separately on a physical device.
|
||||
|
||||
The first connection to a signaling server provisions and stores an identity
|
||||
for that server. Remote control sessions then log in as `C-<identity>` and use
|
||||
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
|
||||
@@ -128,6 +147,24 @@ 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
|
||||
so the runner can check out all corresponding sources.
|
||||
|
||||
## App updates
|
||||
|
||||
After network consent, the app checks the public version manifest once on each
|
||||
launch or return from the background, and after a remote connection ends while
|
||||
active. Concurrent triggers share the pending request. There are no periodic
|
||||
checks or automatic retries; failures retain any known update until the next
|
||||
trigger or manual check. Backgrounding or withdrawing consent cancels requests
|
||||
and ignores stale replies.
|
||||
|
||||
Local updates read only `downloads.ios-arm64`, using the same numeric version
|
||||
and patch comparison as desktop. Build dates alone do not indicate an update;
|
||||
missing platform metadata is a failed check. New releases show a red badge with
|
||||
a white exclamation mark at the top-right edge of the home Settings button,
|
||||
matching the announcement badge's size and position.
|
||||
Settings → About shows “新版本” and a red dot. About displays the new version,
|
||||
links to the official download page, and offers **检查更新** for manual checks.
|
||||
This is independent of the remote desktop's upgrade notice.
|
||||
|
||||
## Announcements
|
||||
|
||||
The home toolbar's bell opens **通知公告** and shows the unread count (capped
|
||||
|
||||
@@ -17,7 +17,17 @@ fi
|
||||
# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding
|
||||
# cross-project cache collisions, this guarantees every archive is compiled
|
||||
# with the iOS 16 deployment target instead of the active SDK version.
|
||||
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages"
|
||||
SDK_NAME="${PLATFORM_NAME:-iphoneos}"
|
||||
APPLE_DEVICE="iphone"
|
||||
CACHE_SUFFIX=""
|
||||
if [[ "${SDK_NAME}" == "iphonesimulator" ]]; then
|
||||
APPLE_DEVICE="simulator"
|
||||
CACHE_SUFFIX="-simulator"
|
||||
elif [[ "${SDK_NAME}" != "iphoneos" ]]; then
|
||||
print -u2 "Unsupported iOS SDK: ${SDK_NAME}"
|
||||
exit 64
|
||||
fi
|
||||
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages${CACHE_SUFFIX}"
|
||||
|
||||
if [[ "${MODE}" != "debug" && "${MODE}" != "release" ]]; then
|
||||
MODE="release"
|
||||
@@ -28,7 +38,7 @@ if [[ "${ARCH_NAME}" == "undefined_arch" ]]; then
|
||||
ARCH_NAME="arm64"
|
||||
fi
|
||||
if [[ "${ARCH_NAME}" != "arm64" ]]; then
|
||||
print -u2 "CrossDesk Mobile currently supports physical iOS arm64 builds only."
|
||||
print -u2 "CrossDesk Mobile supports arm64 devices and Apple Silicon simulators."
|
||||
exit 64
|
||||
fi
|
||||
|
||||
@@ -77,16 +87,16 @@ run_xmake() {
|
||||
"${XMAKE_BIN}" "$@"
|
||||
}
|
||||
|
||||
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
|
||||
OUTPUT_DIR="${IOS_DIR}/Vendor/${SDK_NAME}/${CONFIG_NAME}"
|
||||
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
|
||||
XMAKE_REPOSITORY_DIR="$(run_xmake lua -c 'import("core.base.global"); print(path.join(global.directory(), "repositories", "xmake-repo"))')"
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
print -r -- "${XMAKE_REPOSITORY_DIR}" > "${OUTPUT_DIR}/xmake-repository.txt"
|
||||
run_xmake --version > "${OUTPUT_DIR}/xmake-version.txt"
|
||||
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
|
||||
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build${CACHE_SUFFIX}"
|
||||
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
|
||||
WIRE_BUILD_DIR="${IOS_DIR}/.xmake/wire-build"
|
||||
WIRE_WORK_DIR="${IOS_DIR}/.xmake/wire-work"
|
||||
WIRE_BUILD_DIR="${IOS_DIR}/.xmake/wire-build${CACHE_SUFFIX}"
|
||||
WIRE_WORK_DIR="${IOS_DIR}/.xmake/wire-work${CACHE_SUFFIX}"
|
||||
WIRE_MANIFEST_FILE="${WIRE_WORK_DIR}/xmake.lua"
|
||||
WIRE_LIBRARY="${WIRE_BUILD_DIR}/iphoneos/arm64/${MODE}/libcrossdesk_wire.a"
|
||||
|
||||
@@ -99,11 +109,11 @@ fi
|
||||
# Still configure on every invocation: Xmake must see manifest edits and restore
|
||||
# the iOS configuration if MiniRTC was built standalone for another platform.
|
||||
CONFIG_SIGNATURE="$(print -r -- "${XMAKE_BIN}" "${XMAKE_DEVELOPER_DIR}" \
|
||||
"${MODE}" "${ARCH_NAME}" "16.0" "${XMAKE_PKG_INSTALLDIR}" "${MINIRTC_ENABLE_AOM}"
|
||||
"${MODE}" "${ARCH_NAME}" "${SDK_NAME}" "${APPLE_DEVICE}" "16.0" "${XMAKE_PKG_INSTALLDIR}" "${MINIRTC_ENABLE_AOM}"
|
||||
run_xmake --version | sed -n '1p'
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcodebuild -version
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk iphoneos --show-sdk-path
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk iphoneos --show-sdk-version)"
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk "${SDK_NAME}" --show-sdk-path
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk "${SDK_NAME}" --show-sdk-version)"
|
||||
|
||||
configure_xmake() {
|
||||
local project_dir="$1"
|
||||
@@ -115,7 +125,7 @@ configure_xmake() {
|
||||
clean_flags=(-c)
|
||||
fi
|
||||
run_xmake f -P "${project_dir}" "${clean_flags[@]}" -o "${build_dir}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" --target_minver=16.0 \
|
||||
-p iphoneos -a arm64 --appledev="${APPLE_DEVICE}" -m "${MODE}" --target_minver=16.0 \
|
||||
--policies=package.precompiled:n -y "$@"
|
||||
mkdir -p "${build_dir}"
|
||||
print -r -- "${CONFIG_SIGNATURE}" > "${stamp}"
|
||||
|
||||
@@ -168,7 +168,7 @@ def verify(lock, inputs, target_info, xmake_repository=None, xmake_version=None)
|
||||
seen = {}
|
||||
# Package names/versions come from resolved Xmake link and include paths,
|
||||
# never from an inventory of all packages left in the installation cache.
|
||||
pattern = r"/packages/[^/\s]+/([^/\s]+)/([^/\s]+)/[a-f0-9]+/"
|
||||
pattern = r"/packages(?:-simulator)?/[^/\s]+/([^/\s]+)/([^/\s]+)/[a-f0-9]+/"
|
||||
for name, version in re.findall(pattern, inputs.read_text() + target_info.read_text()):
|
||||
if name not in known or known[name] != version:
|
||||
raise ValueError(f"Unreviewed iOS dependency: {name} {version}; regenerate licenses from its source")
|
||||
|
||||
@@ -22,7 +22,7 @@ FORBIDDEN_APIS = {
|
||||
}
|
||||
|
||||
|
||||
def package(inputs, output):
|
||||
def package(inputs, output, platform="iphoneos"):
|
||||
catalog = json.loads((IOS / "CrossDeskMobile/Resources/ThirdPartyLicenses.json").read_text())
|
||||
component = next(c for c in catalog["components"] if c["id"] == "openssl3")
|
||||
archives = {}
|
||||
@@ -71,7 +71,7 @@ def package(inputs, output):
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleVersion": component["version"],
|
||||
"CFBundleShortVersionString": component["version"],
|
||||
"CFBundleSupportedPlatforms": ["iPhoneOS"],
|
||||
"CFBundleSupportedPlatforms": ["iPhoneSimulator" if platform == "iphonesimulator" else "iPhoneOS"],
|
||||
"MinimumOSVersion": "16.0",
|
||||
}),
|
||||
"LICENSE.txt": "\n\n".join(d["text"] for d in component["documents"]).encode(),
|
||||
@@ -94,8 +94,9 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--inputs", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--platform", choices=("iphoneos", "iphonesimulator"), default="iphoneos")
|
||||
args = parser.parse_args()
|
||||
package(args.inputs, args.output)
|
||||
package(args.inputs, args.output, args.platform)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Run the production event-driven update checker with a deterministic transport."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime")
|
||||
class AppUpdateCheckerTest(unittest.TestCase):
|
||||
def test_visit_session_end_failures_and_lifecycle(self):
|
||||
source = Path(__file__).resolve().parents[1] / "CrossDeskMobile/AppUpdateChecker.swift"
|
||||
with tempfile.TemporaryDirectory(prefix="crossdesk-app-update-tests-") as directory:
|
||||
harness = Path(directory) / "Tests.swift"
|
||||
harness.write_text(r'''
|
||||
import Foundation
|
||||
|
||||
// The actual shared C++ parser is covered by version_checker_test.cpp.
|
||||
enum CrossDeskRTCBridge {
|
||||
static func checkMobileUpdate(appVersion: String, releaseJSON: Data) -> String? {
|
||||
String(data: releaseJSON, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError(message) }
|
||||
}
|
||||
@MainActor
|
||||
final class Fixture {
|
||||
var replies: [(Data?) -> Void] = []
|
||||
var cancellations = 0
|
||||
lazy var checker = AppUpdateChecker(currentVersion: "0.0.1", fetch: { completion in
|
||||
self.replies.append(completion)
|
||||
return { self.cancellations += 1 }
|
||||
})
|
||||
func reply(_ value: String?) { replies.last!(value.map { Data($0.utf8) }) }
|
||||
}
|
||||
@main
|
||||
enum Tests {
|
||||
@MainActor static func main() {
|
||||
let f = Fixture()
|
||||
f.checker.checkNow()
|
||||
expect(f.replies.isEmpty, "No checks without consent and foreground")
|
||||
f.checker.setEnabled(true)
|
||||
f.checker.checkNow()
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == 1, "Deduplicate concurrent checks")
|
||||
f.reply("0.0.2")
|
||||
expect(f.checker.updateAvailable && f.checker.status == .available, "Show badge")
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == 1, "Duplicate active callbacks do not recheck after completion")
|
||||
f.checker.setEnabled(false)
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == 2, "Every new visit checks even immediately after success")
|
||||
f.reply(nil)
|
||||
expect(f.checker.status == .failed && f.checker.updateAvailable,
|
||||
"Failed checks retain discovered update")
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == 2, "A failure does not restart the check")
|
||||
// Session teardown and the About action both explicitly request a check.
|
||||
f.checker.checkNow()
|
||||
f.checker.checkNow()
|
||||
expect(f.replies.count == 3, "Explicit events check again without a cooldown and deduplicate")
|
||||
f.reply("")
|
||||
expect(!f.checker.updateAvailable && f.checker.status == .upToDate,
|
||||
"Successful current-version check clears badge")
|
||||
f.checker.checkNow()
|
||||
let stale = f.replies.last!
|
||||
f.checker.setEnabled(false)
|
||||
expect(f.cancellations == 1, "Background/revocation cancels request")
|
||||
stale(Data("9.0.0".utf8))
|
||||
expect(!f.checker.updateAvailable, "Ignore cancelled response")
|
||||
f.checker.setEnabled(true)
|
||||
stale(Data("9.0.0".utf8))
|
||||
expect(f.checker.status == .checking, "Ignore old response during new request")
|
||||
f.reply("0.0.2")
|
||||
f.checker.setEnabled(false)
|
||||
let beforeForeground = f.replies.count
|
||||
f.checker.checkNow()
|
||||
expect(f.replies.count == beforeForeground, "Session endings in background do not check")
|
||||
f.checker.setEnabled(true)
|
||||
expect(f.replies.count == beforeForeground + 1, "Check once on return from background")
|
||||
expect(f.checker.updateAvailable, "Retain badge while rechecking")
|
||||
f.checker.setEnabled(false)
|
||||
}
|
||||
}
|
||||
''')
|
||||
binary = Path(directory) / "app-update-tests"
|
||||
subprocess.run(["xcrun", "swiftc", str(source), str(harness), "-o", str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -133,6 +133,24 @@ elif args[0] == "show":
|
||||
self.run_build()
|
||||
self.assertTrue(all("-c" in args for args in self.config_calls()))
|
||||
|
||||
def test_simulator_uses_separate_archives_and_switches_back_to_device(self):
|
||||
self.run_build()
|
||||
device_archive = self.output.read_bytes()
|
||||
self.env["PLATFORM_NAME"] = "iphonesimulator"
|
||||
self.run_build()
|
||||
simulator = self.ios / "Vendor/iphonesimulator/Release/libCrossDeskMiniRTC.a"
|
||||
self.assertTrue(simulator.exists())
|
||||
self.assertTrue((self.ios / ".xmake/wire-work-simulator/xmake.lua").exists())
|
||||
signature = self.ios / ".xmake/minirtc-build-simulator/ios-config-signature"
|
||||
self.assertIn("packages-simulator", signature.read_text())
|
||||
for args in self.config_calls()[-2:]:
|
||||
self.assertIn("--appledev=simulator", args)
|
||||
self.assertIn("-simulator", args[args.index("-o") + 1])
|
||||
self.env["PLATFORM_NAME"] = "iphoneos"
|
||||
self.run_build()
|
||||
self.assertEqual(self.output.read_bytes(), device_archive)
|
||||
self.assertTrue(all("--appledev=iphone" in args for args in self.config_calls()[-2:]))
|
||||
|
||||
def test_aom_is_only_required_and_merged_when_enabled(self):
|
||||
self.run_build()
|
||||
manifest = Path(str(self.output) + ".inputs.sha256")
|
||||
|
||||
@@ -129,6 +129,21 @@ class LicenseValidationTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "License/build dependency mismatch; missing:.*asio"):
|
||||
self.verify()
|
||||
|
||||
def test_simulator_cache_keeps_dependency_and_archive_validation(self):
|
||||
(self.root / "packages").rename(self.root / "packages-simulator")
|
||||
self.archive = Path(str(self.archive).replace("/packages/", "/packages-simulator/"))
|
||||
self.inputs.write_text(self.inputs.read_text().replace("/packages/", "/packages-simulator/"))
|
||||
self.target.write_text(self.target.read_text().replace("/packages/", "/packages-simulator/"))
|
||||
self.verify()
|
||||
valid_target = self.target.read_text()
|
||||
self.target.write_text(valid_target.replace("/asio/1.0/", "/asio/2.0/"))
|
||||
with self.assertRaisesRegex(ValueError, "Unreviewed iOS dependency: asio 2.0"):
|
||||
self.verify()
|
||||
self.target.write_text(valid_target)
|
||||
self.archive.write_bytes(b"changed simulator archive")
|
||||
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"):
|
||||
self.verify()
|
||||
|
||||
def test_changed_native_archive_is_rejected(self):
|
||||
self.archive.write_bytes(b"changed archive")
|
||||
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"):
|
||||
|
||||
@@ -21,7 +21,7 @@ struct VersionInfo {
|
||||
int patch = -1;
|
||||
};
|
||||
|
||||
// Shared desktop release parsing and comparison for desktop and iOS controllers.
|
||||
// Shared release parsing and comparison for desktop and mobile controllers.
|
||||
std::optional<VersionInfo> ParseVersionInfoJSON(std::string_view json);
|
||||
bool IsValidAppVersion(const std::string& version);
|
||||
|
||||
@@ -38,6 +38,13 @@ bool IsNewerVersionWithMetadata(const std::string& current,
|
||||
std::optional<std::string> AvailableAppUpdate(const std::string& current,
|
||||
const VersionInfo& latest);
|
||||
|
||||
// Local mobile updates use downloads[platform], never the desktop release at
|
||||
// the root. nullopt means invalid/unavailable metadata; an empty string means
|
||||
// up to date, otherwise the result is the newer release's display version.
|
||||
std::optional<std::string> CheckPlatformAppUpdate(const std::string& current,
|
||||
std::string_view json,
|
||||
const std::string& platform);
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -348,6 +348,20 @@ bool IsValidAppVersion(const std::string& version) {
|
||||
return ParseVersionInfo(nlohmann::json{{"version", version}}).has_value();
|
||||
}
|
||||
|
||||
std::optional<std::string> CheckPlatformAppUpdate(const std::string& current,
|
||||
std::string_view json,
|
||||
const std::string& platform) {
|
||||
if (!IsValidAppVersion(current)) return std::nullopt;
|
||||
try {
|
||||
const auto manifest = nlohmann::json::parse(json);
|
||||
const auto latest = ParseVersionInfo(manifest.at("downloads").at(platform));
|
||||
if (!latest) return std::nullopt;
|
||||
return AvailableAppUpdate(current, *latest).value_or("");
|
||||
} catch (const nlohmann::json::exception&) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::string> AvailableAppUpdate(const std::string& current,
|
||||
const VersionInfo& latest) {
|
||||
if (!IsValidAppVersion(latest.version) ||
|
||||
|
||||
Reference in New Issue
Block a user