From b8f704abb95b739ac3d55083caece8f0398fddee Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sun, 4 Oct 2026 01:38:08 +0800 Subject: [PATCH] [feat] add mobile update badges and iOS simulator support --- apps/android/README.md | 6 + .../java/cn/crossdesk/mobile/AboutPages.java | 30 ++- .../cn/crossdesk/mobile/AppUpdateChecker.java | 63 ++++++ .../cn/crossdesk/mobile/MainActivity.java | 51 ++++- .../cn/crossdesk/mobile/NativeSession.java | 1 + .../crossdesk/mobile/RemoteVersionCheck.java | 2 +- apps/android/native/rtc_bridge.cpp | 12 ++ .../mobile/AppUpdateCheckerTest.java | 180 ++++++++++++++++++ apps/desktop/tests/version_checker_test.cpp | 28 +++ .../CrossDeskMobile.xcodeproj/project.pbxproj | 16 +- .../CrossDeskMobile/AppUpdateChecker.swift | 78 ++++++++ .../Bridge/CrossDeskRTCBridge.h | 4 + .../Bridge/CrossDeskRTCBridge.mm | 10 + apps/ios/CrossDeskMobile/ContentView.swift | 34 +++- .../CrossDeskMobile/RemoteSessionModel.swift | 9 + .../ThirdPartyLicensesView.swift | 70 +++++-- apps/ios/README.md | 43 ++++- apps/ios/scripts/build_minirtc_ios.sh | 30 ++- .../scripts/generate_third_party_licenses.py | 2 +- apps/ios/scripts/package_openssl_ios.py | 7 +- apps/ios/tests/app_update_checker_test.py | 93 +++++++++ apps/ios/tests/build_minirtc_ios_test.py | 18 ++ apps/ios/tests/third_party_licenses_test.py | 15 ++ libs/wire/include/app_version.h | 11 +- libs/wire/src/app_version.cpp | 14 ++ 25 files changed, 766 insertions(+), 61 deletions(-) create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/AppUpdateChecker.java create mode 100644 apps/android/test/android/java/cn/crossdesk/mobile/AppUpdateCheckerTest.java create mode 100644 apps/ios/CrossDeskMobile/AppUpdateChecker.swift create mode 100644 apps/ios/tests/app_update_checker_test.py diff --git a/apps/android/README.md b/apps/android/README.md index fb0e8c43..5e5af91e 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -23,6 +23,12 @@ 当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。 +## 本机更新提示 + +完成联网授权后,每次启动或从后台返回时检查一次公共版本文件,前台远程连接结束后再检查一次;同时触发时复用正在进行的请求。不进行周期性检查或自动重试,失败时保留已发现的新版本提示,等待下一次触发或手动检查;进入后台或撤回授权时取消请求并忽略过期回复。 + +本机只读取 `downloads.android-arm64`,复用 PC 端的版本号及补丁比较规则;仅构建日期变化不提示升级,平台版本字段缺失按检查失败处理。发现新版本后,首页设置按钮右上角显示红底白色感叹号角标,大小和位置与通知角标一致,设置页“关于”显示“新版本”文字和红点;“关于”展示新版本号、官网更新入口,并提供“检查更新”。该功能独立于被控电脑的版本升级提示。 + ## 构建 在 macOS 或 Linux 安装: diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/AboutPages.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/AboutPages.java index a221b976..bef2bbdb 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/AboutPages.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/AboutPages.java @@ -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); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/AppUpdateChecker.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/AppUpdateChecker.java new file mode 100644 index 00000000..0f40cfd5 --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/AppUpdateChecker.java @@ -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 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 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(); + }); + } +} diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index f309da34..c9d434c6 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -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();} } diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java index d10a0894..00606894 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java @@ -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); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVersionCheck.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVersionCheck.java index d68c59c3..e1b8589f 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVersionCheck.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVersionCheck.java @@ -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 completion; diff --git a/apps/android/native/rtc_bridge.cpp b/apps/android/native/rtc_bridge.cpp index 3f9a6c65..c7764a09 100644 --- a/apps/android/native/rtc_bridge.cpp +++ b/apps/android/native/rtc_bridge.cpp @@ -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(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) { diff --git a/apps/android/test/android/java/cn/crossdesk/mobile/AppUpdateCheckerTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/AppUpdateCheckerTest.java new file mode 100644 index 00000000..a62c6428 --- /dev/null +++ b/apps/android/test/android/java/cn/crossdesk/mobile/AppUpdateCheckerTest.java @@ -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> 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 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 scenario = ActivityScenario.launch(MainActivity.class)) { + scenario.onActivity(activity -> { + ((Dialog)field(activity, "privacyDialog")).dismiss(); + List> 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 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; + } +} diff --git a/apps/desktop/tests/version_checker_test.cpp b/apps/desktop/tests/version_checker_test.cpp index 9a44653f..31cda60e 100644 --- a/apps/desktop/tests/version_checker_test.cpp +++ b/apps/desktop/tests/version_checker_test.cpp @@ -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; } diff --git a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj index c6949e22..836cd3ea 100644 --- a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj +++ b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj @@ -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 = ""; }; 20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = ""; }; 20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = ""; }; + 20000000000000000000001C /* AppUpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateChecker.swift; sourceTree = ""; }; /* 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; diff --git a/apps/ios/CrossDeskMobile/AppUpdateChecker.swift b/apps/ios/CrossDeskMobile/AppUpdateChecker.swift new file mode 100644 index 00000000..070bf048 --- /dev/null +++ b/apps/ios/CrossDeskMobile/AppUpdateChecker.swift @@ -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() } + } +} diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index 9830c4b8..1bcf1961 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -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; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index f020fcee..f735da49 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -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(releaseJSON.bytes), releaseJSON.length), + "ios-arm64"); + return result ? [NSString stringWithUTF8String:result->c_str()] : nil; +} + - (instancetype)init { self = [super init]; if (self) { diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index 8497141e..af4efb41 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -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("设置") diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index 0039f783..6e43d01d 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -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() { diff --git a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift index 6ad623e5..9268849c 100644 --- a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift +++ b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift @@ -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) } } diff --git a/apps/ios/README.md b/apps/ios/README.md index 9b0ace82..7fa404c4 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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-` 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 diff --git a/apps/ios/scripts/build_minirtc_ios.sh b/apps/ios/scripts/build_minirtc_ios.sh index 82612541..0b64db23 100755 --- a/apps/ios/scripts/build_minirtc_ios.sh +++ b/apps/ios/scripts/build_minirtc_ios.sh @@ -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}" diff --git a/apps/ios/scripts/generate_third_party_licenses.py b/apps/ios/scripts/generate_third_party_licenses.py index 25df8935..3e48d752 100644 --- a/apps/ios/scripts/generate_third_party_licenses.py +++ b/apps/ios/scripts/generate_third_party_licenses.py @@ -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") diff --git a/apps/ios/scripts/package_openssl_ios.py b/apps/ios/scripts/package_openssl_ios.py index 609f44ac..358ca340 100644 --- a/apps/ios/scripts/package_openssl_ios.py +++ b/apps/ios/scripts/package_openssl_ios.py @@ -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__": diff --git a/apps/ios/tests/app_update_checker_test.py b/apps/ios/tests/app_update_checker_test.py new file mode 100644 index 00000000..99794b91 --- /dev/null +++ b/apps/ios/tests/app_update_checker_test.py @@ -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() diff --git a/apps/ios/tests/build_minirtc_ios_test.py b/apps/ios/tests/build_minirtc_ios_test.py index 5a1f047d..9b577342 100644 --- a/apps/ios/tests/build_minirtc_ios_test.py +++ b/apps/ios/tests/build_minirtc_ios_test.py @@ -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") diff --git a/apps/ios/tests/third_party_licenses_test.py b/apps/ios/tests/third_party_licenses_test.py index 6e31c7e6..c480e4d3 100644 --- a/apps/ios/tests/third_party_licenses_test.py +++ b/apps/ios/tests/third_party_licenses_test.py @@ -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"): diff --git a/libs/wire/include/app_version.h b/libs/wire/include/app_version.h index 397ce61c..716f494b 100644 --- a/libs/wire/include/app_version.h +++ b/libs/wire/include/app_version.h @@ -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 ParseVersionInfoJSON(std::string_view json); bool IsValidAppVersion(const std::string& version); @@ -38,6 +38,13 @@ bool IsNewerVersionWithMetadata(const std::string& current, std::optional 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 CheckPlatformAppUpdate(const std::string& current, + std::string_view json, + const std::string& platform); + } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/libs/wire/src/app_version.cpp b/libs/wire/src/app_version.cpp index 69ab7419..b4e882c6 100644 --- a/libs/wire/src/app_version.cpp +++ b/libs/wire/src/app_version.cpp @@ -348,6 +348,20 @@ bool IsValidAppVersion(const std::string& version) { return ParseVersionInfo(nlohmann::json{{"version", version}}).has_value(); } +std::optional 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 AvailableAppUpdate(const std::string& current, const VersionInfo& latest) { if (!IsValidAppVersion(latest.version) ||