[feat] add mobile update badges and iOS simulator support

This commit is contained in:
dijunkun
2026-10-04 01:38:08 +08:00
parent 115533cd62
commit b8f704abb9
25 changed files with 766 additions and 61 deletions
+6
View File
@@ -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;
+12
View File
@@ -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) {
+30 -4
View File
@@ -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
View File
@@ -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
+20 -10
View File
@@ -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")
+4 -3
View File
@@ -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__":
+93
View File
@@ -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()
+18
View File
@@ -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"):
+9 -2
View File
@@ -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
+14
View File
@@ -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) ||