From 7dc2948bebc1c8a128ce20f0ca03bbc454f126ba Mon Sep 17 00:00:00 2001 From: des1re <1481770375@qq.com> Date: Mon, 12 Oct 2026 18:53:22 +0800 Subject: [PATCH 1/2] feat(android): install mobile updates in app --- mobile/README.md | 2 +- .../codexbridge/ArtifactDownload.java | 45 +++++++++++++------ .../try2love/codexbridge/MainActivity.java | 30 +++++++++---- .../try2love/codexbridge/MobileRelease.java | 8 +++- .../try2love/codexbridge/MobileStrings.java | 11 ++++- mobile/tests/MobileReleaseTests.java | 4 ++ scripts/test-mobile-downloads.py | 1 + 7 files changed, 76 insertions(+), 25 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 2e4567ff..840a9517 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -10,7 +10,7 @@ - **iPhone / iPad**:Release 提供未签名 IPA 和 Xcode 源码包,需使用自己的 Apple 账号签名;不能直接点击 IPA 安装,暂无 App Store / TestFlight 分发。免费签名可能需要定期重新安装。 - 使用 Release 中的 `SHA256SUMS.txt` 校验下载。编译通过不代表已验证所有机型、系统版本和网络环境。 -在「手机设置 → 应用更新」查看当前版本并检查更新。正式版只检查正式发布,预览版也可检查后续预览;不会自动下载或安装。检查访问公开 GitHub Release,不携带网关凭据或 Cookie。Android 下载 APK 后覆盖安装,iOS 打开新版与签名安装指引。 +在「手机设置 → 应用更新」查看当前版本并检查更新。正式版只检查正式发布,预览版也可检查后续预览;不会在后台自动下载或安装。确认更新后,Android 会在 App 内下载 APK 并打开系统安装器,iOS 打开新版与签名安装指引。检查访问公开 GitHub Release,不携带网关凭据或 Cookie。 ### 使用 Xcode 安装到 iPhone diff --git a/mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java b/mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java index 95446dea..50e6f308 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java +++ b/mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java @@ -4,7 +4,7 @@ import java.net.*; import java.util.regex.*; -/** Gateway downloads only. Credentials never follow a redirect. */ +/** Gateway attachments and allowlisted release packages. Credentials never follow a redirect. */ public final class ArtifactDownload { public static final long CHUNK=1024L*1024; public static boolean accepts(String url,String origin){ @@ -34,15 +34,18 @@ private static final class Failure extends IOException { /** One resumable task, independent of the current WebView or chat. */ public static final class Task { public final String url,origin;private final File directory;private final long limit; + private final long expectedSize;private final String expectedSha256;private final String fallbackName;private final boolean update; public volatile State state=State.READY; public volatile long downloaded,total=-1;public volatile double bytesPerSecond; public volatile String name="download",error="";public volatile boolean restartRequired; private volatile HttpURLConnection connection;private volatile boolean pause,cancelled; private File file;private String etag;private boolean running;private Observer observer; private long lastSample,lastBytes; - public Task(String url,String origin,File directory)throws IOException { - if(!accepts(url,origin))throw new IOException("下载地址不是当前电脑的附件"); - this.url=url;this.origin=origin;this.directory=directory;limit=9007199254740991L; // Policy is enforced by the authenticated gateway; stream to disk. + public Task(String url,String origin,File directory)throws IOException {this(url,origin,directory,-1,null,null);} + public Task(String url,String origin,File directory,long expectedSize,String expectedSha256,String fallbackName)throws IOException { + update=MobileRelease.isDownload(url); + if(!accepts(url,origin)&&!update)throw new IOException("下载地址不是当前电脑的附件"); + this.url=url;this.origin=origin;this.directory=directory;this.expectedSize=expectedSize;this.expectedSha256=expectedSha256;this.fallbackName=fallbackName==null||fallbackName.isEmpty()?"download":fallbackName;name=this.fallbackName;limit=update?512L*1024*1024:9007199254740991L; // Gateway policy is enforced by the authenticated relay; update packages have a local size ceiling. } public synchronized boolean resumable(){return !restartRequired&&(downloaded==0||etag!=null);} public void pause(){HttpURLConnection active;synchronized(this){if(state!=State.RUNNING)return;pause=true;state=State.PAUSING;active=connection;}if(active!=null)active.disconnect();} @@ -54,10 +57,15 @@ public Task(String url,String origin,File directory)throws IOException { public void run(Cookies cookies,Observer observer){ synchronized(this){if(running||cancelled||state==State.COMPLETE)return;if(!resumable()){state=State.FAILED;return;}running=true;pause=false;this.observer=observer;state=State.RUNNING;error="";bytesPerSecond=0;lastSample=System.nanoTime();lastBytes=downloaded;} update(true); - try{while(total<0||downloaded=0&&(downloaded!=expectedSize||total!=expectedSize))throw new Failure("更新文件大小与发布信息不一致,请重新下载。",true); + if(expectedSha256!=null){try{java.security.MessageDigest digest=java.security.MessageDigest.getInstance("SHA-256");try(InputStream input=new FileInputStream(file)){byte[] buffer=new byte[16384];int count;while((count=input.read(buffer))!=-1){stopped();digest.update(buffer,0,count);}}StringBuilder actual=new StringBuilder();for(byte value:digest.digest())actual.append(String.format(java.util.Locale.ROOT,"%02x",value));if(!expectedSha256.equalsIgnoreCase(actual.toString()))throw new Failure("更新文件校验失败,请重新下载。",true);}catch(java.security.NoSuchAlgorithmException impossible){throw new IOException(impossible);}} + } private boolean downloadChanged(HttpURLConnection active)throws IOException { // Relay errors have a small JSON body. Do not mistake another 409 (e.g. an // offline SSH desktop) for a changed file or buffer an unbounded error body. @@ -69,15 +77,25 @@ private boolean downloadChanged(HttpURLConnection active)throws IOException { } private void part(Cookies cookies)throws IOException { long start=downloaded,end=Math.min(start+CHUNK-1,limit-1);if(total>=0)end=Math.min(end,total-1); - HttpURLConnection active=(HttpURLConnection)new URL(url).openConnection(); - active.setConnectTimeout(15000);active.setReadTimeout(30000);active.setInstanceFollowRedirects(false); - active.setRequestProperty("Range","bytes="+start+"-"+end);active.setRequestProperty("Accept-Encoding","identity");active.setRequestProperty("User-Agent","BridgeMobile/0.1-Android"); - String cookie=cookies.read();if(cookie!=null&&!cookie.isEmpty())active.setRequestProperty("Cookie",cookie);if(etag!=null)active.setRequestProperty("If-Range",etag); - synchronized(this){stopped();connection=active;} + HttpURLConnection active=null;String target=url; + try{ + for(int redirect=0;;redirect++){ + active=(HttpURLConnection)new URL(target).openConnection(); + active.setConnectTimeout(15000);active.setReadTimeout(30000);active.setInstanceFollowRedirects(false); + active.setRequestProperty("Range","bytes="+start+"-"+end);active.setRequestProperty("Accept-Encoding","identity");active.setRequestProperty("User-Agent","BridgeMobile/0.1-Android"); + if(!update){String cookie=cookies.read();if(cookie!=null&&!cookie.isEmpty())active.setRequestProperty("Cookie",cookie);}if(etag!=null)active.setRequestProperty("If-Range",etag); + synchronized(this){stopped();connection=active;} + int redirectStatus=active.getResponseCode(); + // GitHub release assets redirect to their CDN. Only this credential-free + // release path follows the fixed GitHub host allowlist; gateway redirects + // still fail below and never carry cookies to another origin. + if(update&&redirectStatus>=300&&redirectStatus<400){String location=active.getHeaderField("Location");active.disconnect();active=null;if(redirect>=3||location==null||!MobileRelease.redirectAllowed(location))throw new Failure("更新下载地址发生跳转,已取消。",true);target=location;continue;} + break; + } try{ int status=active.getResponseCode();stopped(); if(status==409){boolean changed=downloadChanged(active);throw new Failure(changed?"文件已发生变化,请重新下载":"下载失败,请检查网络后重试",changed);} - if(status==401||status==403)throw new Failure("登录已失效,请重新连接电脑后继续下载",false); + if(status==401||status==403)throw new Failure(update?"更新下载被拒绝,请稍后重试":"登录已失效,请重新连接电脑后继续下载",false); if(status>=300&&status<400)throw new Failure("下载地址发生跳转,已取消。请重新打开电脑附件。",true); String tag=active.getHeaderField("ETag");boolean strong=tag!=null&&tag.matches("\"[^\"\\r\\n]+\""); if((status==200||status==206)&&etag!=null&&!etag.equals(tag))throw new Failure("文件已发生变化,请重新下载",true); @@ -96,7 +114,7 @@ else if(status==206){ if(total>limit)throw new Failure("下载响应格式不正确,请重新下载",true); long length=active.getContentLengthLong();if(status==206&&length>=0&&length!=expected)throw new Failure("网关返回的续传信息无效,请重新下载",true); if(file==null)file=File.createTempFile("bridge-download-",".part",directory); - name=filename(active.getHeaderField("Content-Disposition"));update(true); + String suggested=filename(active.getHeaderField("Content-Disposition"));if(!suggested.equals("download")||fallbackName.equals("download"))name=suggested;update(true); if(expected==0)return; if(file.length()!=downloaded)throw new Failure("下载文件大小与响应不一致,请重新下载",true); long received=0; @@ -106,7 +124,8 @@ else if(status==206){ } stopped();if(expected>=0&&received!=expected)throw new IOException("incomplete response"); if(status==200&&total<0)total=downloaded; - }finally{synchronized(this){if(connection==active)connection=null;}active.disconnect();} + }finally{synchronized(this){if(connection==active)connection=null;}if(active!=null)active.disconnect();} + }catch(IOException error){if(active!=null)active.disconnect();throw error;} } } /** Compatibility helper for callers that need a single, blocking transfer. */ diff --git a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java index 38a803c9..602ae797 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java @@ -27,7 +27,7 @@ public final class MainActivity extends Activity { private final Runnable homeRefresh=()->checkComputers(); private long homeBackAt;private Toast exitToast; private final ExecutorService downloads=Executors.newSingleThreadExecutor(); - private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private boolean savingDownload,openingDownload; + private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private MobileRelease pendingUpdate;private boolean savingDownload,openingDownload,installAfterPermission; private ServiceConnection monitor;private ValueCallback files;private WebView fileView;private int fileGeneration;private int generation; String L(String value){return MobileStrings.text(this,value);} int dp(int n){return (int)(getResources().getDisplayMetrics().density*n);} @@ -179,19 +179,21 @@ void startDownload(String target,String base){ try{downloadTask=new ArtifactDownload.Task(target,base,getCacheDir());downloadCard.showTask();renderDownload();runDownload(downloadTask);}catch(java.io.IOException e){message(e.getMessage());} } void runDownload(ArtifactDownload.Task task){ - task.state=ArtifactDownload.State.READY;renderDownload();downloads.execute(()->task.run(()->CookieManager.getInstance().getCookie(task.url),value->runOnUiThread(()->{if(downloadTask==value&&!isFinishing()&&!isDestroyed())renderDownload();}))); + task.state=ArtifactDownload.State.READY;renderDownload();downloads.execute(()->task.run(()->CookieManager.getInstance().getCookie(task.url),value->runOnUiThread(()->{if(downloadTask==value&&!isFinishing()&&!isDestroyed()){renderDownload();if(pendingUpdate!=null&&value.state==ArtifactDownload.State.COMPLETE&&!openingDownload)installDownloadedUpdate();}}))); } void downloadAction(){ ArtifactDownload.Task task=downloadTask;if(task==null||savingDownload||choosingDownload!=null||openingDownload)return; if(task.state==ArtifactDownload.State.RUNNING){task.pause();renderDownload();} else if(task.state==ArtifactDownload.State.PAUSED||task.state==ArtifactDownload.State.FAILED){ - if(task.resumable())runDownload(task);else{String target=task.url,base=task.origin;cancelDownload();startDownload(target,base);} + if(task.resumable())runDownload(task);else{MobileRelease release=pendingUpdate;String target=task.url,base=task.origin;cancelDownload();if(release!=null)startUpdateDownload(release,null);else startDownload(target,base);} }else if(task.state==ArtifactDownload.State.COMPLETE){ + if(pendingUpdate!=null){installDownloadedUpdate();return;} choosingDownload=task;renderDownload();Intent save=new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("application/octet-stream").putExtra(Intent.EXTRA_TITLE,task.name); try{startActivityForResult(save,32);}catch(Exception e){choosingDownload=null;renderDownload();message(L("没有可用的文件保存器"));} } } void openDownload(){ + if(pendingUpdate!=null){installDownloadedUpdate();return;} ArtifactDownload.Task task=downloadTask;if(task==null||task.state!=ArtifactDownload.State.COMPLETE||savingDownload||choosingDownload!=null||openingDownload)return; openingDownload=true;renderDownload();downloads.execute(()->{ Uri uri=null;try{uri=DownloadProvider.prepare(this,task.result());}catch(java.io.IOException ignored){} @@ -204,7 +206,7 @@ void openDownload(){ }); }); } - void cancelDownload(){if(downloadTask!=null){downloadTask.cancel();downloadTask=null;}renderDownload();} + void cancelDownload(){if(downloadTask!=null){downloadTask.cancel();downloadTask=null;}pendingUpdate=null;installAfterPermission=false;renderDownload();} void saveDownload(int result,Intent data){ ArtifactDownload.Task task=choosingDownload;choosingDownload=null; if(result!=RESULT_OK||data==null||data.getData()==null){renderDownload();return;} @@ -256,7 +258,7 @@ void settings(){ LinearLayout notifications=card();Button toggle=button(prefs.getBoolean("alerts",false)?L("关闭任务通知"):L("开启任务通知"),()->{dialog.dismiss();toggleNotifications();});add(toggle,notifications,8);add(text(L("App 打开时提醒已连接电脑的新任务消息。离开 App 或锁屏后不保证通知;可在电脑端配置 Bark 或 ntfy。"),14),notifications,12); add(button(L("测试本机通知"),()->{dialog.dismiss();testNotification();}),notifications,4);add(text(L("10 秒后显示,用于检查手机的通知权限。"),13),notifications,0);add(notifications,content,22); add(text(L("当前电脑"),19),content,12);LinearLayout computer=card();if(origin.isEmpty()){add(text(L("尚未选择电脑"),17),computer,8);add(text(L("返回首页扫码或输入网关地址。"),14),computer,0);}else{add(text(computerName(origin),17),computer,8);add(text(origin,13),computer,12);Button remove=button(L("移除这台电脑"),()->{dialog.dismiss();confirmRemove();});remove.setTextColor(0xffb44235);add(remove,computer,0);}add(computer,content,24); - LinearLayout updates=card();add(text(L("应用更新"),19),updates,8);add(text("Codex Mobile Bridge · "+appVersion(),14),updates,8);add(text(L("手动检查更新,不会自动安装"),13),updates,8); + LinearLayout updates=card();add(text(L("应用更新"),19),updates,8);add(text("Codex Mobile Bridge · "+appVersion(),14),updates,8);add(text(L("手动检查更新,确认后下载并安装"),13),updates,8); TextView updateStatus=text("",13);Button check=button(L("检查更新"),()->{});check.setOnClickListener(v->checkUpdate(dialog,check,updateStatus));add(check,updates,6);add(updateStatus,updates,0);add(updates,content,20); TextView version=text("Codex Mobile Bridge",13);version.setTextColor(0xff777b80);version.setGravity(Gravity.CENTER);add(version,content,0); } @@ -275,7 +277,7 @@ void checkUpdate(Dialog dialog,Button check,TextView updateStatus){ JSONArray rows=new JSONArray(bytes.toString("UTF-8")); for(int i=0;i0){String notes=row.optString("body");candidate=new MobileRelease(version,notes.substring(0,Math.min(notes.length(),12000)));} + String version=tag.substring(1);if(candidate==null||MobileRelease.compare(version,candidate.version)>0){String notes=row.optString("body");String digest=asset.optString("digest",null);candidate=new MobileRelease(version,notes.substring(0,Math.min(notes.length(),12000)),asset.optLong("size",-1),digest);} } } }catch(Exception e){problem=L("检查失败,请检查网络后重试。也可以打开版本页面。");}finally{if(connection!=null)connection.disconnect();} @@ -284,10 +286,22 @@ void checkUpdate(Dialog dialog,Button check,TextView updateStatus){ if(error!=null){updateStatus.setText(error);new AlertDialog.Builder(this).setMessage(error).setNegativeButton(L("关闭"),null).setPositiveButton(L("版本页面"),(d,w)->openUpdateUrl(MobileRelease.REPO+"/releases")).show();return;} if(result==null){updateStatus.setText(L("当前已是此通道最新版本。"));return;} updateStatus.setText(L("发现新版本")+" · "+result.version); - new AlertDialog.Builder(this).setTitle(L("发现新版本")+" · "+result.version).setMessage(L("下载后按系统提示覆盖安装,不要先卸载。已保存的电脑与登录状态会保留。")+"\n\n"+result.notes).setNegativeButton(L("稍后"),null).setNeutralButton(L("更新说明"),(d,w)->openUpdateUrl(result.page())).setPositiveButton(L("下载 APK"),(d,w)->openUpdateUrl(result.download)).show(); + new AlertDialog.Builder(this).setTitle(L("发现新版本")+" · "+result.version).setMessage(L("App 会在本机下载并打开系统安装器,已保存的电脑与登录状态会保留。")+"\n\n"+result.notes).setNegativeButton(L("稍后"),null).setNeutralButton(L("更新说明"),(d,w)->openUpdateUrl(result.page())).setPositiveButton(L("下载并安装"),(d,w)->startUpdateDownload(result,updateStatus)).show(); }); }); } + void startUpdateDownload(MobileRelease release,TextView updateStatus){ + if(downloadTask!=null){message(L("已有下载任务,请先完成或取消"));return;} + try{pendingUpdate=release;downloadTask=new ArtifactDownload.Task(release.download,MobileRelease.REPO,getCacheDir(),release.size,release.sha256,MobileRelease.asset(release.version));downloadCard.showTask();renderDownload();if(updateStatus!=null)updateStatus.setText(L("正在下载更新…"));runDownload(downloadTask);}catch(java.io.IOException e){pendingUpdate=null;downloadTask=null;message(e.getMessage());} + } + void installDownloadedUpdate(){ + final ArtifactDownload.Task task=downloadTask;if(task==null||pendingUpdate==null||task.state!=ArtifactDownload.State.COMPLETE||openingDownload)return; + if(Build.VERSION.SDK_INT>=26&&!getPackageManager().canRequestPackageInstalls()){installAfterPermission=true;try{startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,Uri.parse("package:"+getPackageName())));}catch(Exception e){installAfterPermission=false;message(L("无法打开安装权限设置,请在系统设置中允许此应用安装未知来源应用。"));}return;} + openingDownload=true;renderDownload();downloads.execute(()->{ + Uri uri=null;try{uri=DownloadProvider.prepare(this,task.result());}catch(java.io.IOException ignored){} + final Uri target=uri;runOnUiThread(()->{openingDownload=false;if(isFinishing()||isDestroyed())return;renderDownload();if(task!=downloadTask)return;if(target==null){message(L("安装包准备失败,请重新下载"));return;}Intent install=new Intent(Intent.ACTION_VIEW).setDataAndType(target,"application/vnd.android.package-archive").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION|Intent.FLAG_ACTIVITY_NEW_TASK);install.setClipData(ClipData.newRawUri("",target));try{startActivity(install);}catch(ActivityNotFoundException e){message(L("系统没有可用的安装器,请保存 APK 后手动安装"));}catch(Exception e){message(L("打开安装器失败,请重试"));}}); + }); + } void enableNativePush(){ if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},24);return;} try{Class provider=Class.forName("io.github.try2love.codexbridge.FirebaseBootstrap");prefs.edit().putBoolean("nativePushEnabled",true).apply();provider.getMethod("enable",MainActivity.class).invoke(null,this);}catch(Exception e){prefs.edit().putBoolean("nativePushEnabled",false).apply();message(L("此预览尚未配置系统推送项目。当前仅同步收件箱;配置 Firebase 或厂商推送后才能启用后台任务提醒。"));} @@ -323,7 +337,7 @@ void selectedFiles(int result,Intent data){ }); } @Override protected void onActivityResult(int code,int result,Intent data){super.onActivityResult(code,result,data);if(code==32){saveDownload(result,data);return;}if(code==20&&result==RESULT_OK&&data!=null)choose(data.getStringExtra("code"));if(code==31)selectedFiles(result,data);} - @Override protected void onResume(){super.onResume();resumed=true;ComputerNotifications.onChange=()->runOnUiThread(this::renderComputerNotifications);checkComputers();MonitorService.setVisible(true);if(web!=null)web.onResume();registerPushToken();} + @Override protected void onResume(){super.onResume();resumed=true;ComputerNotifications.onChange=()->runOnUiThread(this::renderComputerNotifications);checkComputers();MonitorService.setVisible(true);if(web!=null)web.onResume();registerPushToken();if(installAfterPermission){installAfterPermission=false;if(Build.VERSION.SDK_INT<26||getPackageManager().canRequestPackageInstalls())installDownloadedUpdate();else message(L("请在系统设置中允许此应用安装未知来源应用。"));}} // Keep the live WebView and its drafts/tabs when rotating or resizing the window. @Override public void onConfigurationChanged(android.content.res.Configuration config){super.onConfigurationChanged(config);screen.requestApplyInsets();downloadCard.post(downloadCard::place);if(web!=null){web.requestLayout();web.invalidate();}} @Override protected void onPause(){if(routeDiscovery!=null){routeDiscovery.cancel();routeDiscovery=null;}routeAttempted=false;ComputerNotifications.onChange=null;resumed=false;stopComputerChecks();resetExitGesture();MonitorService.setVisible(false);if(web!=null)web.onPause();CookieManager.getInstance().flush();super.onPause();} diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java b/mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java index 0e34fac0..cbb4d2dd 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java @@ -8,10 +8,14 @@ public final class MobileRelease { public static final String REPO="https://github.com/try2love/codex-mobile-bridge"; public static final String API="https://api.github.com/repos/try2love/codex-mobile-bridge/releases?per_page=100"; private static final Pattern VERSION=Pattern.compile("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(alpha|beta|preview|rc)\\.(0|[1-9][0-9]*))?$"); - public final String version,notes,download; - public MobileRelease(String version,String notes){this.version=version;this.notes=notes;download=REPO+"/releases/download/v"+version+"/"+asset(version);} + public final String version,notes,download;public final long size;public final String sha256; + public MobileRelease(String version,String notes){this(version,notes,-1,null);} + public MobileRelease(String version,String notes,long size,String sha256){this.version=version;this.notes=notes;this.size=size;this.sha256=normalizeDigest(sha256);download=REPO+"/releases/download/v"+version+"/"+asset(version);} public static String asset(String version){return "Codex-Mobile-Bridge-"+version+"-Android.apk";} public String page(){return REPO+"/releases/tag/v"+version;} + private static String normalizeDigest(String value){if(value==null)return null;String digest=value.startsWith("sha256:")?value.substring(7):value;return digest.matches("[a-fA-F0-9]{64}")?digest.toLowerCase():null;} + public static boolean isDownload(String url){try{String prefix=REPO+"/releases/download/v";if(!url.startsWith(prefix))return false;int slash=url.lastIndexOf('/');String version=url.substring(prefix.length(),slash);parts(version);return url.equals(new MobileRelease(version,"").download);}catch(Exception e){return false;}} + public static boolean redirectAllowed(String url){try{java.net.URI value=new java.net.URI(url);String host=value.getHost();return "https".equalsIgnoreCase(value.getScheme())&&value.getRawUserInfo()==null&&host!=null&&(host.equalsIgnoreCase("github.com")||host.equalsIgnoreCase("objects.githubusercontent.com")||host.equalsIgnoreCase("release-assets.githubusercontent.com")||host.equalsIgnoreCase("github-releases.githubusercontent.com"));}catch(Exception e){return false;}} private static long[] parts(String value){ Matcher m=VERSION.matcher(value);if(!m.matches())throw new IllegalArgumentException("Invalid version"); int stage=m.group(4)==null?4:java.util.Arrays.asList("alpha","beta","preview","rc").indexOf(m.group(4)); diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java index b53ee2a5..37848754 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java @@ -43,7 +43,7 @@ final class MobileStrings { values.put("应用更新","App updates"); -values.put("手动检查更新,不会自动安装","Manual update checks, no automatic installation"); +values.put("手动检查更新,确认后下载并安装","Check manually, then download and install after confirmation"); values.put("检查更新","Check for updates"); values.put("正在检查更新…","Checking for updates\u2026"); values.put("没有可用的浏览器,请在电脑上打开 GitHub Release。","No browser is available. Open GitHub Releases on your computer."); @@ -55,6 +55,15 @@ final class MobileStrings { values.put("稍后","Later"); values.put("更新说明","Release notes"); values.put("下载 APK","Download APK"); +values.put("下载并安装","Download and install"); +values.put("正在下载更新…","Downloading update…"); +values.put("更新下载被拒绝,请稍后重试","The update download was refused. Try again later."); +values.put("App 会在本机下载并打开系统安装器,已保存的电脑与登录状态会保留。","The app will download the APK here and open the system installer. Saved computers and sign-ins will be retained."); +values.put("无法打开安装权限设置,请在系统设置中允许此应用安装未知来源应用。","Could not open install permissions. Allow this app to install unknown apps in system settings."); +values.put("请在系统设置中允许此应用安装未知来源应用。","Allow this app to install unknown apps in system settings."); +values.put("安装包准备失败,请重新下载","Could not prepare the installer. Download the update again."); +values.put("系统没有可用的安装器,请保存 APK 后手动安装","No system installer is available. Save the APK and install it manually."); +values.put("打开安装器失败,请重试","Could not open the installer. Try again."); values.put("查看新版与安装指引","View update and installation guide"); values.put("iOS App 需要使用自己的 Apple 账号重新签名安装,暂不支持 App 内直接覆盖更新。","The iOS app requires signing the new build with your own Apple account. Direct in-app installation is not available yet."); diff --git a/mobile/tests/MobileReleaseTests.java b/mobile/tests/MobileReleaseTests.java index 4229cbc7..b01fc89f 100644 --- a/mobile/tests/MobileReleaseTests.java +++ b/mobile/tests/MobileReleaseTests.java @@ -14,6 +14,10 @@ public static void main(String[] args){ check(MobileRelease.compare("2.0.0-preview.10","2.0.0-preview.9")>0); check(MobileRelease.compare("2.0.0","2.0.0-preview.99")>0); check(MobileRelease.compare("2.1.0-preview.1","2.0.0")>0); + check(MobileRelease.isDownload(url)); + check(!MobileRelease.isDownload("https://evil.example/releases/download/v"+v+"/"+name)); + check(MobileRelease.redirectAllowed("https://objects.githubusercontent.com/github-production-release-asset-1/example")); + check(!MobileRelease.redirectAllowed("https://evil.example/update.apk")); System.out.println("Java: mobile release channel, version and asset checks passed"); } } diff --git a/scripts/test-mobile-downloads.py b/scripts/test-mobile-downloads.py index 67198d56..6cfd0b97 100644 --- a/scripts/test-mobile-downloads.py +++ b/scripts/test-mobile-downloads.py @@ -16,6 +16,7 @@ def java_tool(name): return str(Path(java_home) / 'bin' / name) if java_home else shutil.which(name) subprocess.run([java_tool('javac'), '-encoding', 'UTF-8', '-d', str(work), str(root / 'mobile/android/src/io/github/try2love/codexbridge/GatewayURL.java'), + str(root / 'mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java'), str(root / 'mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java'), str(root / 'mobile/tests/ArtifactDownloadTests.java'), str(root / 'mobile/tests/ArtifactRangeTests.java')], check=True) From 88a0360e7de70b584a3a0f81329354ddb06e81e5 Mon Sep 17 00:00:00 2001 From: try2love <1929706911@qq.com> Date: Mon, 12 Oct 2026 18:58:27 +0800 Subject: [PATCH 2/2] fix(android): adapt PR32 updates and gateway-assisted downloads Preserve the original PR32 commit by SevenPwZ. Add verified install recovery, explicit phone/computer download selection, authenticated gateway assistance, and an external-browser APK fallback with the reviewed native UI. Include focused regressions and decision notes. The user accepted the local test app; this is a source-only integration with no version or release change. --- .../2026-10-12-android-update-handoff.md | 31 +++ ...26-10-12-android-update-download-choice.md | 30 +++ ...6-10-12-gateway-assisted-android-update.md | 33 +++ .github/workflows/tests.yml | 1 + ARCHITECTURE.md | 8 + bridge/api/httpd.py | 32 +++ bridge/features/network/shared_relay.py | 1 + bridge/features/updates/mobile.py | 252 ++++++++++++++++++ mobile/README.md | 6 +- mobile/android/res/drawable/ic_phone.xml | 1 + .../codexbridge/ArtifactDownload.java | 30 ++- .../try2love/codexbridge/DownloadCard.java | 9 +- .../codexbridge/DownloadProvider.java | 11 +- .../try2love/codexbridge/GatewayUpdate.java | 68 +++++ .../try2love/codexbridge/MainActivity.java | 142 ++++++++-- .../try2love/codexbridge/MobileRelease.java | 1 + .../try2love/codexbridge/MobileStrings.java | 41 +++ .../codexbridge/UpdateInstallSession.java | 42 +++ mobile/tests/GatewayUpdateDownloadTests.java | 37 +++ mobile/tests/UpdateDownloadTests.java | 44 +++ mobile/tests/UpdateInstallSessionTests.java | 48 ++++ relay/protocol.py | 3 + scripts/test-mobile-downloads.py | 6 +- tests/test_mobile_updates.py | 193 ++++++++++++++ tests/test_shared_relay.py | 36 ++- 25 files changed, 1068 insertions(+), 38 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-10-12-android-update-handoff.md create mode 100644 .agents/notes/implemented/feature/2026-10-12-android-update-download-choice.md create mode 100644 .agents/notes/implemented/feature/2026-10-12-gateway-assisted-android-update.md create mode 100644 bridge/features/updates/mobile.py create mode 100644 mobile/android/res/drawable/ic_phone.xml create mode 100644 mobile/android/src/io/github/try2love/codexbridge/GatewayUpdate.java create mode 100644 mobile/android/src/io/github/try2love/codexbridge/UpdateInstallSession.java create mode 100644 mobile/tests/GatewayUpdateDownloadTests.java create mode 100644 mobile/tests/UpdateDownloadTests.java create mode 100644 mobile/tests/UpdateInstallSessionTests.java create mode 100644 tests/test_mobile_updates.py diff --git a/.agents/notes/implemented/bug-fix/2026-10-12-android-update-handoff.md b/.agents/notes/implemented/bug-fix/2026-10-12-android-update-handoff.md new file mode 100644 index 00000000..1f316d7c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-12-android-update-handoff.md @@ -0,0 +1,31 @@ +# Agent Note: Android 更新下载与安装交接 + +Status: implemented + +## Problem + +PR #32 把 Android 更新从浏览器下载改为 App 内下载并调用系统安装器。下载完成、离开 Activity 授予安装权限、返回 App、取消安装和保存 APK 之间需要保留同一个已校验文件,避免重复打开安装器或丢失任务。GitHub 不可达时,用户还需要明确的重试和版本页入口。 + +## Decision + +- 下载仍复用 `ArtifactDownload.Task`。直接访问 GitHub 的更新请求只接受本项目固定 Release 资产地址与 GitHub HTTPS 重定向白名单,且不携带网关 Cookie。大小和 SHA-256 必须有效,完整校验通过后才能安装。后续增加的网关来源遵循 [网关协助更新记录](../feature/2026-10-12-gateway-assisted-android-update.md),安装交接约束继续适用。 +- `UpdateInstallSession` 把已完成 APK 和安装权限交接状态保存在 App 私有目录。恢复及安装前重新校验文件;生命周期中断不代表文件损坏。下载完成事件只自动交接一次,用户取消安装后由「安装」按钮重试。 +- `DownloadProvider` 完整复制后原子发布只读副本;发现旧副本长度不符时重新复制。系统保存器的返回结果等待原任务恢复,防止 Activity 重建后失去保存对象。 +- 「保存」导出 APK,「安装」调用系统安装器。检查失败时提供 GitHub 版本页入口;下载失败卡片的「GitHub ↗」入口则在外部浏览器打开已检测到版本的 APK 直链,详见 [下载方式选择记录](../feature/2026-10-12-android-update-download-choice.md)。这些入口不能绕过网络对 GitHub 的限制。 +- 重复更新演练使用 `.tmp/` 内的源码副本和独立 App 标识。局域网元数据、测试版本、同版本重装只存在于该副本;正式源码的版本选择仍严格要求更新版本。 + +## Alternatives considered + +- **保留浏览器下载作为主流程**:实现简单,也方便用户使用浏览器的网络环境。但不能满足此次 App 内下载并直接安装的目标,因此仅作为失败兜底。 +- **仅在 Activity 内保留安装对象**:没有落盘格式或恢复校验成本。但系统权限页和文件选择器可能触发 Activity 重建,导致文件已下载却无法继续操作,因此只将已完成文件及必要交接状态持久化。 +- **正式源码提供任意更新服务器或强制更新开关**:测试方便,但会增加生产更新的信任入口。本次只在忽略提交的源码副本中替换测试输入。 + +## Testing + +`scripts/test-mobile-downloads.py` 覆盖更新元数据缺失、摘要和大小不一致、重定向白名单、凭据隔离、重复交接、权限状态恢复、文件篡改及恢复被中断后的重试。原有普通附件下载和续传用例继续运行。Android 构建及隔离模拟器验证记录保存在 `.tmp/pr32-local/`;用户已报告实测通过,并授权仅合并源码。 + +## Consequences + +已完成更新可以跨 Activity/进程恢复,用户仍控制系统安装。代价是维护少量私有状态并在恢复、安装时重新读取 APK 校验;下载中的跨进程续传不属于本次范围。版本已安装或用户关闭任务时清理待安装文件。 + +历史决策检索未发现已有 `.agents/notes/`、`docs/adr/` 或 `docs/decisions/` 对此交接的记录;手机安装说明位于 [mobile/README.md](../../../../mobile/README.md),无既有决定需要替代。 diff --git a/.agents/notes/implemented/feature/2026-10-12-android-update-download-choice.md b/.agents/notes/implemented/feature/2026-10-12-android-update-download-choice.md new file mode 100644 index 00000000..f4ed4e0c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-12-android-update-download-choice.md @@ -0,0 +1,30 @@ +# Agent Note: Android 更新下载方式由用户选择 + +Status: implemented + +## Problem + +用户实测时,点击「下载并安装」后自动进入电脑下载,无法明确决定使用哪一端的网络。用户可能已知手机无法访问 GitHub,也可能希望仅使用手机网络,不应由一次请求失败代替其选择。 + +## Decision + +点击「下载并安装」后显示两个操作:手机直接从 GitHub 下载、电脑从 GitHub 下载后传到手机。用户点选后才启动相应下载;取消不发起 APK 下载或网关准备。每次重新下载都可选择,不引入持久默认选项。 + +手机直下载失败后不自动使用电脑;下载卡片提供「重新选择」以及「GitHub ↗」入口。后者用外部浏览器打开当前已检测到版本的官方 APK 直链,由浏览器负责下载;即使原任务来自电脑,也不向浏览器传递网关地址或凭据。重选同一路径时可复用可续传任务,换路径时建立对应的新任务。暂停后的继续仍沿用已选路径。选择电脑时使用当前保存的电脑连接,继续核对登录、版本、大小和摘要。 + +版本查询仍允许直接查询失败后由电脑查询,避免手机无法发现更新。查询成功与哪一端负责下载是两个独立决定。网关来源限制、缓存、认证、中继及安装恢复沿用 [网关协助更新记录](2026-10-12-gateway-assisted-android-update.md) 和 [安装交接记录](../bug-fix/2026-10-12-android-update-handoff.md)。 + +## Alternatives considered + +- **保留自动回退**:操作步骤少,网络正常时用户不必关心来源。但不能满足用户明确选择网络路径的要求,也会让已知手机网络不通的用户先等一次失败。 +- **选择手机后仍自动回退到电脑**:失败时能省一次操作,但所选路径的含义不清晰。失败后由用户重新选择,保留重试与浏览器入口。 + +## Testing + +用独立局域网测试包检查选择前无 APK 请求、取消无下载、手机失败不触发电脑 APK 请求、电脑选择直接请求网关,以及下载失败后的重新选择。补充验证下载失败卡片的「GitHub ↗」由外部 Chrome 打开 APK 直链,确认后下载的文件摘要与测试包一致;该局域网结果不代表真实 GitHub 网络已验证。安装包和模拟器验证证据存于 `.tmp/pr32-gateway/`。用户已报告实测通过,授权合并源码;本次不编译 App 或发布新版本。 + +## Consequences + +用户可以直接选择可用网络,代价是下载前增加一次选择。电脑下载仍要求当前网关支持该功能且已登录;两端都不能访问 GitHub 时仍可能失败。此改动限于 Android 下载交互,不变更网关接口。 + +历史检索命中两篇相关记录,均为部分重叠:网关记录的下载自动回退部分被替代并互链,其来源、认证和缓存约束保留;安装交接记录的文件校验与恢复约束继续适用。无完全吸收或过时提案需要归档。 diff --git a/.agents/notes/implemented/feature/2026-10-12-gateway-assisted-android-update.md b/.agents/notes/implemented/feature/2026-10-12-gateway-assisted-android-update.md new file mode 100644 index 00000000..7d5b1a3a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-12-gateway-assisted-android-update.md @@ -0,0 +1,33 @@ +# Agent Note: 网关协助 Android 查询和下载安装包 + +Status: implemented + +## Problem + +手机无法访问 GitHub 时,应用内检查版本或下载 APK 都可能失败;浏览器入口不保证改变网络可达性。电脑网关可能有可用网络,手机也已建立对该电脑的认证连接。 + +## Decision + +Android 查询版本保留直接访问 GitHub、失败后通过当前已连接电脑重试的路径。初版下载也采用此顺序,理由是 API 不通不代表 APK 下载地址不通;这一下载自动回退决定已由 [下载方式选择记录](2026-10-12-android-update-download-choice.md) 部分取代,查询备用路径和下面的网关契约继续适用。手机选中的版本、大小、摘要必须与网关从固定官方仓库读取的资产记录相同;网关只接受版本与资产身份,不接受任意 URL。安装交接继续使用 [Android 更新交接记录](../bug-fix/2026-10-12-android-update-handoff.md) 的已完成文件恢复机制。 + +网关先完整下载到私有缓存并校验,再通过已认证下载接口传给手机。准备请求使用 CSRF,进度查询不占用长请求。下载缓存只保留于当前运行周期,闲置超过 24 小时按需清理,最多两个任务/缓存,每次一个后台下载,每包最多 512 MiB。手机取消等待保留电脑后台任务,其他已登录手机可复用;手机端没有跨进程保留未完成下载的承诺。 + +现有附件传输的范围协商和中继流式协议继续复用。中继、连接器和网关都限制准确的更新路径与方法,网关认证信息只发往选定地址,GitHub 请求不携带这些信息。手机再次核对完整 APK 的大小和摘要,最终交给系统安装器确认。SHA-256 用于检测损坏,不被描述为独立的发布者签名。 + +## Alternatives considered + +- **只保留手机直连和浏览器兜底**:单端实现、维护简单,但手机网络完全不可达时,两条路径都可能失败,不能利用已连接电脑的网络。 +- **只代理 APK 文件**:新增接口较少,但手机可能无法获取版本信息,根本到不了下载阶段,因此查询也需要网关备用路径。 +- **电脑边下载边直接转发一个长响应**:可以减少首次传输等待,但下载和手机断线耦合,重试与长时间无数据返回的处理更复杂。先缓存使完整性、进度和重试边界明确。 + +## Testing + +`tests/test_mobile_updates.py` 覆盖发布通道、固定来源、资产身份、并发复用、失败重试、完整校验、过期、认证、CSRF 和范围响应;`tests/test_shared_relay.py` 通过真实本机连接器验证新接口。`GatewayUpdateDownloadTests.java` 验证带设备前缀的认证下载、摘要、禁止重定向和缓存失效后的重新准备。构建与模拟器证据保存在忽略提交的 `.tmp/pr32-gateway/`。 + +独立测试包使用局域网固定夹具模拟手机查询和下载均失败、仅下载失败;两种场景均经打包后的 macOS 网关完成 Android 模拟器内安装。安装权限页面期间终止 App 后台进程后可恢复安装;同版本测试更新可再次触发并完成。最终从模拟器取出的已安装 APK 摘要与提供的目标包一致。这些模拟结果不代表真实 GitHub 网络已验证;用户随后报告实测通过,并授权仅合并源码。 + +## Consequences + +手机可以利用电脑的网络完成更新,电脑也可以复用已下载包。代价是网关存储、传输流量和跨端协议维护;共享中继必须同时升级白名单。电脑同样无法访问 GitHub 且没有缓存时,功能不能解决网络问题。旧网关不影响手机原有直连路径。 + +历史检索只命中前述安装交接记录,属于部分重叠:原先 GitHub 下载及安装恢复约束继续适用,本篇增加经网关传输的来源类型与认证契约,无其他提案需要归档。 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4b76f7a..916a8b5d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,7 @@ jobs: distribution: temurin java-version: '17' - run: python3 scripts/test-mobile-releases.py + - run: python3 scripts/test-mobile-downloads.py test: strategy: fail-fast: false diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4cee6f99..83d840c4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -151,6 +151,14 @@ Electron 主进程使用 qrcode 本地生成 PNG,渲染器接收图片、授 `bridge/features/updates/gateway.py` 负责原安装目录旁的准备、替换与恢复。准备阶段拒绝路径穿越和危险链接,核对包版本、平台、macOS 签名和内置运行时。独立复制的旧运行时等控制面板退出、协作停止原网关后替换目录,再等待新面板确认加载并恢复此前运行的网关。失败时尝试回滚;配置目录保持独立。发布签名身份和事务恢复操作见 [桌面更新文档](docs/desktop-updates.md)。 +## Android 更新协助 + +Android 优先直接检查本项目 GitHub Release,查询失败时可通过当前已登录网关查询版本。APK 下载由用户在「下载并安装」后选择手机直连或电脑下载再传送;取消选择不启动下载,下载失败不自动切换路径。下载卡片允许重新选择,暂停后的继续仍沿用原路径。交互取舍见 [下载方式选择记录](.agents/notes/implemented/feature/2026-10-12-android-update-download-choice.md)。`bridge/features/updates/mobile.py` 只接受固定仓库、版本对应的 APK、有效大小和 SHA-256。手机准备请求的版本、大小和摘要须与网关从官方源取得的记录一致;不接受手机指定外部 URL 或磁盘路径。 + +`GET /api/mobile/update?current=<版本>` 查询版本;带登录和 CSRF 的 `POST /api/mobile/update/prepare` 接受 `{version,size,sha256}`,启动或复用一个后台下载;`GET /api/mobile/update/status/` 返回进度;`GET /api/mobile/update/packages/` 只传送已完成校验的缓存包,并支持 Range/ETag。所有入口要求密码保护的登录会话。共享中继与连接器只放行这些准确的方法和路径,共用同一网关缓存。 + +网关单次最多下载 512 MiB,只并行下载一个包,最多保留两个任务/缓存;闲置超过 24 小时的缓存按需清理。缓存只属于当前网关运行周期。手机取消准备仅停止等待,电脑任务仍可供已登录手机复用。手机和网关各自校验完整 APK;手机安装恢复复用私有已校验文件,不依赖网关持续在线。网关 Cookie 只发往选定网关,不跟随重定向或发送给 GitHub。详细取舍见 [决策记录](.agents/notes/implemented/feature/2026-10-12-gateway-assisted-android-update.md)。 + ## PR8 验收反馈调整 diff --git a/bridge/api/httpd.py b/bridge/api/httpd.py index 8e3ac82f..f3c0599e 100644 --- a/bridge/api/httpd.py +++ b/bridge/api/httpd.py @@ -29,6 +29,7 @@ from bridge.clients.deepseek.legacy_proxy import proxy as harness_proxy from bridge.features.auth.pairing import Pairing from bridge.features.auth.auth import Auth, LoginRejected +from bridge.features.updates.mobile import MobileUpdates from bridge.clients.codex.ipc import IPCError from bridge.features.sessions.goal import GoalError from bridge.clients.codex.catalog import CatalogError @@ -55,6 +56,8 @@ class GatewayServer(ThreadingHTTPServer): def __init__(self, address, bridge, config, web_dir, data_dir=None, shared=None): self.transfer_directory = data_dir if shared is None else shared.transfer_directory + self.mobile_updates_owner = shared is None and data_dir is not None + self.mobile_updates = MobileUpdates(data_dir) if self.mobile_updates_owner else getattr(shared, 'mobile_updates', None) self.local_access = config.get('localAccess', True) if shared is None: self.bridge = bridge @@ -93,6 +96,11 @@ def ui_language(self): except (OSError, ValueError, AttributeError): return 'zh' + def server_close(self): + if self.mobile_updates_owner: + self.mobile_updates.close() + super().server_close() + def server_bind(self): # HTTPServer resolves the listening IP with getfqdn(), which can stall # startup on machines without working reverse DNS. Origins are explicit. @@ -322,6 +330,30 @@ def handle_method(self, write): auth = self.authorized(write) if not auth: return + mobile_update = re.fullmatch(r'/api/mobile/update(?:/(prepare|status/([a-f0-9]{64})|packages/([a-f0-9]{64})))?', path) + if mobile_update: + if self.server.auth.config.get('mode') == 'none': + raise PermissionError('请启用密码保护后使用电脑协助更新') + updates = self.server.mobile_updates + if updates is None: + return self.output(503, {'error': '此网关暂不支持协助更新'}) + operation, identifier, package = mobile_update.groups() + if write: + if operation != 'prepare' or query: + return self.output(405, {'error': '请求方式无效'}) + return self.output(200, updates.prepare(self.read_json())) + if operation is None: + if set(query) != {'current'} or len(query['current']) != 1: + raise ValueError('更新请求无效') + return self.output(200, updates.check(query['current'][0])) + if query: + raise ValueError('更新请求无效') + if identifier: + return self.output(200, updates.status(identifier)) + if package: + with updates.download(package, **self.download_request()) as (metadata, stream): + return self.download_response(metadata, stream, 'application/vnd.android.package-archive') + return self.output(405, {'error': '请求方式无效'}) if path == '/api/clients/claude/reconnect': if not write: return self.output(405, {'error': '请求方式无效'}) diff --git a/bridge/features/network/shared_relay.py b/bridge/features/network/shared_relay.py index e228c6fb..08fa5023 100644 --- a/bridge/features/network/shared_relay.py +++ b/bridge/features/network/shared_relay.py @@ -121,6 +121,7 @@ def __init__(self, directory, gateway, test_http=False): self.server = GatewayServer(('127.0.0.1', 0), gateway.bridge, config, gateway.web_dir) # Share the live file policy while keeping relay authentication private. self.server.transfer_directory = getattr(gateway, 'transfer_directory', None) + self.server.mobile_updates = getattr(gateway, 'mobile_updates', None) self.server.notifications = gateway.notifications self.server.desktop_sessions = getattr(gateway, 'desktop_sessions', None) self.local_origin = 'http://127.0.0.1:' + str(self.server.server_port) diff --git a/bridge/features/updates/mobile.py b/bridge/features/updates/mobile.py new file mode 100644 index 00000000..4f0d37a7 --- /dev/null +++ b/bridge/features/updates/mobile.py @@ -0,0 +1,252 @@ +"""Authenticated Android update assistance, restricted to this project's releases.""" +from contextlib import contextmanager +import hashlib +from http.client import HTTPException +import json +import os +from pathlib import Path +import re +import threading +import time +from urllib.parse import urlsplit +from urllib.request import Request, build_opener, HTTPRedirectHandler, HTTPSHandler + +from bridge.features.auth.tls import client_context +from bridge.features.workspace.workspace import download_range + +REPO = 'https://github.com/try2love/codex-mobile-bridge' +API = 'https://api.github.com/repos/try2love/codex-mobile-bridge/releases?per_page=100' +MAX_SIZE = 512 * 1024 * 1024 +MAX_METADATA = 4 * 1024 * 1024 +CACHE_SECONDS = 24 * 60 * 60 + + +def version_key(value): + match = re.fullmatch(r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|beta|preview|rc)\.(0|[1-9]\d*))?', value if isinstance(value, str) and len(value) <= 80 else '') + if not match: + raise ValueError('更新版本无效') + return tuple(int(match[i]) for i in (1, 2, 3)) + (('alpha', 'beta', 'preview', 'rc').index(match[4]) if match[4] else 4, int(match[5] or 0)) + + +def asset_name(version): + version_key(version) + return 'Codex-Mobile-Bridge-' + version + '-Android.apk' + + +def asset_url(version): + return REPO + '/releases/download/v' + version + '/' + asset_name(version) + + +def redirect_allowed(url): + try: + value = urlsplit(url) + return (value.scheme == 'https' and value.username is None and value.password is None + and value.port in (None, 443) and value.hostname in ( + 'github.com', 'objects.githubusercontent.com', + 'release-assets.githubusercontent.com', 'github-releases.githubusercontent.com')) + except ValueError: + return False + + +class ReleaseRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + # Metadata is never redirected; APK redirects never carry gateway credentials. + if req.full_url == API or not redirect_allowed(newurl): + raise ValueError('更新下载地址发生不允许的跳转') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +class MobileUpdates: + def __init__(self, directory): + self.directory = Path(directory) / 'mobile-updates' + self.lock = threading.RLock() + self.catalog_lock = threading.Lock() + self.catalog = None + self.catalog_time = 0 + self.jobs = {} + self.closed = threading.Event() + + def _open(self, url): + opener = build_opener(ReleaseRedirect(), HTTPSHandler(context=client_context())) + return opener.open(Request(url, headers={'User-Agent': 'Codex-Mobile-Bridge', + 'Accept': 'application/vnd.github+json' if url == API else 'application/octet-stream', + 'Accept-Encoding': 'identity'}), timeout=15) + + def releases(self): + with self.catalog_lock: + if self.catalog is not None and time.monotonic() - self.catalog_time < 300: + return self.catalog + try: + with self._open(API) as response: + if response.status != 200: + raise ValueError('GitHub 版本信息不可用') + raw = response.read(MAX_METADATA + 1) + if len(raw) > MAX_METADATA: + raise ValueError('GitHub 版本信息过大') + rows = json.loads(raw) + if not isinstance(rows, list): + raise ValueError('GitHub 版本信息无效') + releases = [] + for row in rows: + if not isinstance(row, dict) or row.get('draft') is not False: + continue + tag = row.get('tag_name', '') + if not isinstance(tag, str) or not tag.startswith('v'): + continue + version = tag[1:] + try: + version_key(version) + except ValueError: + continue + assets = row.get('assets') + for asset in assets if isinstance(assets, list) else []: + if not isinstance(asset, dict) or asset.get('name') != asset_name(version) or asset.get('browser_download_url') != asset_url(version): + continue + size, digest = asset.get('size'), asset.get('digest', '') + valid_size = type(size) is int and 0 < size <= MAX_SIZE + valid_digest = isinstance(digest, str) and re.fullmatch(r'sha256:[a-fA-F0-9]{64}', digest) + notes = row.get('body') or '' + releases.append({'version': version, 'size': size if valid_size else -1, 'sha256': digest[7:].lower() if valid_digest else None, + 'notes': notes[:12000] if isinstance(notes, str) else '', + 'prerelease': row.get('prerelease') is not False or '-' in version}) + self.catalog, self.catalog_time = releases, time.monotonic() + return releases + except (OSError, ValueError, HTTPException) as exc: + raise ValueError('电脑无法获取 GitHub 版本信息,请检查电脑网络后重试') from exc + + def check(self, current): + key = version_key(current) + candidates = [r for r in self.releases() if version_key(r['version']) > key + and ('-' in current or not r['prerelease'])] + selected = max(candidates, key=lambda r: version_key(r['version'])) if candidates else None + if selected and (selected['size'] <= 0 or selected['sha256'] is None): + raise ValueError('发布信息已变化或缺少校验信息,请重新检查更新') + return {'supported': True, 'release': selected} + + def _cleanup(self): + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + now = time.time() + for identifier, job in list(self.jobs.items()): + if job['state'] != 'downloading' and not job['readers'] and now - job['used'] > CACHE_SECONDS: + del self.jobs[identifier] + retained = {key + suffix for key in self.jobs for suffix in ('.apk', '.part')} + for file in self.directory.iterdir(): + if re.fullmatch(r'[a-f0-9]{64}\.(apk|part)', file.name) and file.name not in retained: + file.unlink(missing_ok=True) + + @staticmethod + def _status(job): + return {key: job[key] for key in ('id', 'state', 'downloaded', 'size', 'error')} + + def prepare(self, value): + if not isinstance(value, dict) or set(value) != {'version', 'size', 'sha256'}: + raise ValueError('更新请求无效') + version_key(value['version']) + if type(value['size']) is not int or not isinstance(value['sha256'], str): + raise ValueError('更新请求无效') + release = next((r for r in self.releases() if r['size'] > 0 and r['sha256'] is not None and all(r[k] == value[k] for k in value)), None) + if release is None: + raise ValueError('发布信息已变化或缺少校验信息,请重新检查更新') + identifier = hashlib.sha256((release['version'] + ':' + release['sha256']).encode()).hexdigest() + with self.lock: + if self.closed.is_set(): + raise ValueError('网关正在关闭') + self._cleanup() + old = self.jobs.get(identifier) + if old and old['state'] != 'failed': + old['used'] = time.time() + return self._status(old) + if any(j['state'] == 'downloading' for j in self.jobs.values()): + raise ValueError('电脑正在下载其他更新,请稍后重试') + if old: + del self.jobs[identifier] + while len(self.jobs) >= 2: + idle = [j for j in self.jobs.values() if not j['readers']] + if not idle: + raise ValueError('电脑正在传送更新,请稍后重试') + oldest = min(idle, key=lambda j: j['used']) + del self.jobs[oldest['id']] + self._cleanup() + job = {**release, 'id': identifier, 'state': 'downloading', 'downloaded': 0, + 'error': '', 'used': time.time(), 'readers': 0} + self.jobs[identifier] = job + threading.Thread(target=self._download, args=(job,), daemon=True, name='mobile-update').start() + return self._status(job) + + def _download(self, job): + staging = self.directory / (job['id'] + '.part') + target = self.directory / (job['id'] + '.apk') + error = '' + try: + digest, count, deadline = hashlib.sha256(), 0, time.monotonic() + 600 + with self._open(asset_url(job['version'])) as response: + if response.status != 200 or response.headers.get('Content-Encoding', 'identity') != 'identity': + raise ValueError('安装包下载响应无效') + length = response.headers.get('Content-Length') + if length is not None and int(length) != job['size']: + raise ValueError('安装包大小与发布信息不一致') + with os.fdopen(os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as output: + while True: + if self.closed.is_set() or time.monotonic() > deadline: + raise ValueError('安装包下载超时或网关已关闭') + block = response.read(65536) + if not block: + break + count += len(block) + if count > job['size']: + raise ValueError('安装包大小与发布信息不一致') + output.write(block) + digest.update(block) + with self.lock: + job['downloaded'] = count + output.flush() + os.fsync(output.fileno()) + if count != job['size'] or digest.hexdigest() != job['sha256']: + raise ValueError('安装包校验失败,请重新检查更新') + staging.replace(target) + except (OSError, ValueError, HTTPException) as exc: + error = str(exc) if isinstance(exc, ValueError) else '电脑下载失败,请检查电脑网络和存储后重试' + finally: + # Keep the job non-retryable until its old staging file is gone. + # Otherwise an old worker can unlink the replacement attempt's file. + try: + staging.unlink(missing_ok=True) + except OSError: + error = '电脑下载失败,请检查电脑网络和存储后重试' + with self.lock: + job['state'], job['error'], job['used'] = 'failed' if error else 'ready', error, time.time() + + def status(self, identifier): + with self.lock: + job = self.jobs.get(identifier) + if job is None or time.time() - job['used'] > CACHE_SECONDS: + raise ValueError('更新缓存已过期,请重新下载') + job['used'] = time.time() + return self._status(job) + + @contextmanager + def download(self, identifier, range_header='', if_range=''): + with self.lock: + self.status(identifier) + job = self.jobs[identifier] + if job['state'] != 'ready': + raise ValueError('电脑尚未完成更新下载') + path = self.directory / (identifier + '.apk') + if path.is_symlink(): + raise ValueError('更新缓存无效') + stream = path.open('rb') + if os.fstat(stream.fileno()).st_size != job['size']: + stream.close() + raise ValueError('更新缓存已变化,请重新下载') + job['readers'] += 1 + selection = download_range(job['size'], '"sha256-' + job['sha256'] + '"', range_header, if_range) + try: + stream.seek(selection['offset']) + yield {**selection, 'name': asset_name(job['version'])}, stream + finally: + stream.close() + with self.lock: + job['readers'] -= 1 + + def close(self): + self.closed.set() diff --git a/mobile/README.md b/mobile/README.md index 840a9517..4d4240e2 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -10,7 +10,11 @@ - **iPhone / iPad**:Release 提供未签名 IPA 和 Xcode 源码包,需使用自己的 Apple 账号签名;不能直接点击 IPA 安装,暂无 App Store / TestFlight 分发。免费签名可能需要定期重新安装。 - 使用 Release 中的 `SHA256SUMS.txt` 校验下载。编译通过不代表已验证所有机型、系统版本和网络环境。 -在「手机设置 → 应用更新」查看当前版本并检查更新。正式版只检查正式发布,预览版也可检查后续预览;不会在后台自动下载或安装。确认更新后,Android 会在 App 内下载 APK 并打开系统安装器,iOS 打开新版与签名安装指引。检查访问公开 GitHub Release,不携带网关凭据或 Cookie。 +在「手机设置 → 应用更新」查看当前版本并检查更新。正式版只检查正式发布,预览版也可检查后续预览;不会在后台自动下载或安装。确认更新后,Android 会在 App 内下载 APK 并打开系统安装器,iOS 打开新版与签名安装指引。访问公开 GitHub Release 的请求不携带网关凭据或 Cookie。 + +Android 在安装前核对发布信息中的大小和 SHA-256;缺少校验信息时提供版本页下载入口。首次安装更新需按系统提示允许此 App 安装应用。已完成的更新可在重新打开 App 后继续安装,也可选择「保存」导出 APK。 + +Android 手机无法查询 GitHub 时,会尝试通过当前已连接并登录的电脑查询。点击「下载并安装」后,由用户选择「手机下载」或「电脑下载」;选择电脑后直接使用网关,无须等待手机下载失败。下载失败不会自动切换方式,下载卡片提供「重新选择」和「GitHub ↗」入口;后者在外部浏览器打开已检测到的新版 APK 直链,由浏览器下载,无须进入版本页寻找安装包。网关完成下载和校验后传送到手机,手机重新校验并打开系统安装器。电脑需要能访问 GitHub,且手机 App 与网关都需支持此功能;共享中继部署还需更新中继服务。旧网关仍可配合手机直连更新。网关缓存仅在当前运行周期内复用,最多保留两个包,超过 24 小时的闲置缓存会在下次准备更新时清理。手机取消等待不会取消其他手机可复用的电脑下载。 ### 使用 Xcode 安装到 iPhone diff --git a/mobile/android/res/drawable/ic_phone.xml b/mobile/android/res/drawable/ic_phone.xml new file mode 100644 index 00000000..0731bca3 --- /dev/null +++ b/mobile/android/res/drawable/ic_phone.xml @@ -0,0 +1 @@ + diff --git a/mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java b/mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java index 50e6f308..f96f54e4 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java +++ b/mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java @@ -35,6 +35,7 @@ private static final class Failure extends IOException { public static final class Task { public final String url,origin;private final File directory;private final long limit; private final long expectedSize;private final String expectedSha256;private final String fallbackName;private final boolean update; + public final boolean gatewayUpdate; public volatile State state=State.READY; public volatile long downloaded,total=-1;public volatile double bytesPerSecond; public volatile String name="download",error="";public volatile boolean restartRequired; @@ -43,15 +44,32 @@ public static final class Task { private long lastSample,lastBytes; public Task(String url,String origin,File directory)throws IOException {this(url,origin,directory,-1,null,null);} public Task(String url,String origin,File directory,long expectedSize,String expectedSha256,String fallbackName)throws IOException { - update=MobileRelease.isDownload(url); + this(url,origin,directory,expectedSize,expectedSha256,fallbackName,false); + } + public static Task gatewayUpdate(String base,String identifier,MobileRelease release,File directory)throws IOException { + try{if(!GatewayURL.origin(base).equals(base)||identifier==null||!identifier.matches("[a-f0-9]{64}"))throw new IOException("电脑更新地址无效");} + catch(Exception e){throw new IOException("电脑更新地址无效",e);} + return new Task(base+"/api/mobile/update/packages/"+identifier,base,directory,release.size,release.sha256,MobileRelease.asset(release.version),true); + } + private Task(String url,String origin,File directory,long expectedSize,String expectedSha256,String fallbackName,boolean gatewayUpdate)throws IOException { + this.gatewayUpdate=gatewayUpdate;update=gatewayUpdate||MobileRelease.isDownload(url); if(!accepts(url,origin)&&!update)throw new IOException("下载地址不是当前电脑的附件"); - this.url=url;this.origin=origin;this.directory=directory;this.expectedSize=expectedSize;this.expectedSha256=expectedSha256;this.fallbackName=fallbackName==null||fallbackName.isEmpty()?"download":fallbackName;name=this.fallbackName;limit=update?512L*1024*1024:9007199254740991L; // Gateway policy is enforced by the authenticated relay; update packages have a local size ceiling. + if(update&&(expectedSize<=0||expectedSize>MobileRelease.MAX_SIZE||expectedSha256==null||!expectedSha256.matches("[a-fA-F0-9]{64}")))throw new IOException("更新缺少有效的大小或 SHA-256 校验信息,请打开版本页面下载。"); + this.url=url;this.origin=origin;this.directory=directory;this.expectedSize=expectedSize;this.expectedSha256=expectedSha256;this.fallbackName=fallbackName==null||fallbackName.isEmpty()?"download":fallbackName;name=this.fallbackName;limit=update?MobileRelease.MAX_SIZE:9007199254740991L; // Gateway policy is enforced by the authenticated relay; update packages have a local size ceiling. } public synchronized boolean resumable(){return !restartRequired&&(downloaded==0||etag!=null);} public void pause(){HttpURLConnection active;synchronized(this){if(state!=State.RUNNING)return;pause=true;state=State.PAUSING;active=connection;}if(active!=null)active.disconnect();} public void cancel(){HttpURLConnection active;synchronized(this){cancelled=true;state=State.CANCELLED;active=connection;if(!running)removeFile();}if(active!=null)active.disconnect();} private void removeFile(){if(file!=null){file.delete();file=null;}} public synchronized Result result()throws IOException {if(state!=State.COMPLETE||file==null)throw new IOException("下载尚未完成");return new Result(file,name);} + public static Task restoreUpdate(MobileRelease release,File directory,String filename)throws IOException { + if(filename==null||!filename.matches("bridge-download-[a-zA-Z0-9-]+\\.part"))throw new IOException("Invalid pending update path"); + File file=new File(directory,filename); + if(java.nio.file.Files.isSymbolicLink(file.toPath())||!directory.getCanonicalFile().equals(file.getCanonicalFile().getParentFile())||!file.isFile())throw new IOException("Missing pending update"); + Task task=new Task(release.download,MobileRelease.REPO,directory,release.size,release.sha256,MobileRelease.asset(release.version)); + task.file=file;task.downloaded=task.total=file.length();task.verifyUpdate();task.state=State.COMPLETE;return task; + } + public void verifyCompletedUpdate()throws IOException {if(!update||state!=State.COMPLETE||file==null)throw new IOException("Update not ready");verifyUpdate();} private void stopped()throws InterruptedIOException {if(pause||cancelled||Thread.currentThread().isInterrupted())throw new InterruptedIOException();} private void update(boolean force){long now=System.nanoTime();if(force||now-lastSample>=250_000_000L){double seconds=(now-lastSample)/1e9;if(seconds>0&&downloaded>lastBytes){double sample=(downloaded-lastBytes)/seconds;bytesPerSecond=bytesPerSecond==0?sample:bytesPerSecond*.5+sample*.5;}lastSample=now;lastBytes=downloaded;if(observer!=null)observer.changed(this);}} public void run(Cookies cookies,Observer observer){ @@ -63,7 +81,7 @@ public void run(Cookies cookies,Observer observer){ } private void verifyUpdate()throws IOException { if(!update)return; - if(expectedSize>=0&&(downloaded!=expectedSize||total!=expectedSize))throw new Failure("更新文件大小与发布信息不一致,请重新下载。",true); + if(file==null||file.length()!=expectedSize||downloaded!=expectedSize||total!=expectedSize)throw new Failure("更新文件大小与发布信息不一致,请重新下载。",true); if(expectedSha256!=null){try{java.security.MessageDigest digest=java.security.MessageDigest.getInstance("SHA-256");try(InputStream input=new FileInputStream(file)){byte[] buffer=new byte[16384];int count;while((count=input.read(buffer))!=-1){stopped();digest.update(buffer,0,count);}}StringBuilder actual=new StringBuilder();for(byte value:digest.digest())actual.append(String.format(java.util.Locale.ROOT,"%02x",value));if(!expectedSha256.equalsIgnoreCase(actual.toString()))throw new Failure("更新文件校验失败,请重新下载。",true);}catch(java.security.NoSuchAlgorithmException impossible){throw new IOException(impossible);}} } private boolean downloadChanged(HttpURLConnection active)throws IOException { @@ -83,17 +101,18 @@ private void part(Cookies cookies)throws IOException { active=(HttpURLConnection)new URL(target).openConnection(); active.setConnectTimeout(15000);active.setReadTimeout(30000);active.setInstanceFollowRedirects(false); active.setRequestProperty("Range","bytes="+start+"-"+end);active.setRequestProperty("Accept-Encoding","identity");active.setRequestProperty("User-Agent","BridgeMobile/0.1-Android"); - if(!update){String cookie=cookies.read();if(cookie!=null&&!cookie.isEmpty())active.setRequestProperty("Cookie",cookie);}if(etag!=null)active.setRequestProperty("If-Range",etag); + if(!update||gatewayUpdate){String cookie=cookies.read();if(cookie!=null&&!cookie.isEmpty())active.setRequestProperty("Cookie",cookie);}if(etag!=null)active.setRequestProperty("If-Range",etag); synchronized(this){stopped();connection=active;} int redirectStatus=active.getResponseCode(); // GitHub release assets redirect to their CDN. Only this credential-free // release path follows the fixed GitHub host allowlist; gateway redirects // still fail below and never carry cookies to another origin. - if(update&&redirectStatus>=300&&redirectStatus<400){String location=active.getHeaderField("Location");active.disconnect();active=null;if(redirect>=3||location==null||!MobileRelease.redirectAllowed(location))throw new Failure("更新下载地址发生跳转,已取消。",true);target=location;continue;} + if(update&&!gatewayUpdate&&redirectStatus>=300&&redirectStatus<400){String location=active.getHeaderField("Location");active.disconnect();active=null;if(redirect>=3||location==null||!MobileRelease.redirectAllowed(location))throw new Failure("更新下载地址发生跳转,已取消。",true);target=location;continue;} break; } try{ int status=active.getResponseCode();stopped(); + if(gatewayUpdate&&(status==400||status==404))throw new Failure("电脑更新缓存已过期,请重新下载",true); if(status==409){boolean changed=downloadChanged(active);throw new Failure(changed?"文件已发生变化,请重新下载":"下载失败,请检查网络后重试",changed);} if(status==401||status==403)throw new Failure(update?"更新下载被拒绝,请稍后重试":"登录已失效,请重新连接电脑后继续下载",false); if(status>=300&&status<400)throw new Failure("下载地址发生跳转,已取消。请重新打开电脑附件。",true); @@ -112,6 +131,7 @@ else if(status==206){ total=active.getContentLengthLong();expected=total;etag=strong?tag:null; }else throw new Failure("下载失败,请检查网络后重试",false); if(total>limit)throw new Failure("下载响应格式不正确,请重新下载",true); + if(update&&total>=0&&total!=expectedSize)throw new Failure("更新文件大小与发布信息不一致,请重新下载。",true); long length=active.getContentLengthLong();if(status==206&&length>=0&&length!=expected)throw new Failure("网关返回的续传信息无效,请重新下载",true); if(file==null)file=File.createTempFile("bridge-download-",".part",directory); String suggested=filename(active.getHeaderField("Content-Disposition"));if(!suggested.equals("download")||fallbackName.equals("download"))name=suggested;update(true); diff --git a/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java b/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java index 3719701a..19df1e98 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java +++ b/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java @@ -50,12 +50,14 @@ void place(){ float bottom=Math.max(top,parent.getHeight()-parent.getPaddingBottom()-getHeight()-dp(88)); setX(dragged?Math.max(left,Math.min(right,getX())):right);setY(dragged?Math.max(top,Math.min(bottom,getY())):bottom); } - void render(ArtifactDownload.Task task,boolean saving,boolean opening){ + void render(ArtifactDownload.Task task,boolean saving,boolean opening,boolean update){ if(task==null){setVisibility(GONE);return;} if(getVisibility()!=VISIBLE)showTask(); ArtifactDownload.State state=task.state;boolean active=state==ArtifactDownload.State.RUNNING||state==ArtifactDownload.State.READY||state==ArtifactDownload.State.PAUSING; String size=bytes(task.downloaded)+(task.total>=0?" / "+bytes(task.total):""); String phase=state==ArtifactDownload.State.COMPLETE?L("下载完成,可打开或保存"):state==ArtifactDownload.State.PAUSED?L("已暂停"):state==ArtifactDownload.State.FAILED?L("下载中断"):state==ArtifactDownload.State.PAUSING?L("正在暂停…"):L("正在下载…"); + if(update&&state==ArtifactDownload.State.COMPLETE)phase=L("更新已就绪,可安装或保存 APK"); + if(task.gatewayUpdate&&(state==ArtifactDownload.State.RUNNING||state==ArtifactDownload.State.READY))phase=L("正在从电脑传送到手机…"); if(opening)phase=L("正在打开…"); boolean busy=saving||opening; if(saving)phase=L("正在保存…"); @@ -64,9 +66,10 @@ void render(ArtifactDownload.Task task,boolean saving,boolean opening){ title.setContentDescription(task.name+" · "+phase+" · "+size); progress.setIndeterminate(active&&task.total<0);progress.setProgress(task.total>0?(int)Math.min(1000,task.downloaded*1000/task.total):state==ArtifactDownload.State.COMPLETE?1000:0); details.setText(phase+" · "+size+(state==ArtifactDownload.State.RUNNING?" · "+bytes((long)task.bytesPerSecond)+"/s":"")+(state==ArtifactDownload.State.FAILED&&!task.error.isEmpty()?"\n"+L(task.error):"")); - primary.setText(L(state==ArtifactDownload.State.COMPLETE?"保存":active?"暂停":task.resumable()?"继续":"重新下载"));primary.setEnabled(!busy&&state!=ArtifactDownload.State.PAUSING&&state!=ArtifactDownload.State.READY); + primary.setText(L(update&&state==ArtifactDownload.State.FAILED?"重新选择":state==ArtifactDownload.State.COMPLETE?"保存":active?"暂停":task.resumable()?"继续":"重新下载"));primary.setEnabled(!busy&&state!=ArtifactDownload.State.PAUSING&&state!=ArtifactDownload.State.READY); cancel.setText(L(state==ArtifactDownload.State.COMPLETE?"关闭":"取消"));cancel.setEnabled(!busy); - open.setText(L("打开"));open.setVisibility(state==ArtifactDownload.State.COMPLETE?VISIBLE:GONE);open.setEnabled(!busy); + boolean fallback=update&&state==ArtifactDownload.State.FAILED; + open.setText(fallback?"GitHub ↗":L(update?"安装":"打开"));open.setContentDescription(L(fallback?"在浏览器中下载 APK":update?"安装":"打开"));open.setVisibility(state==ArtifactDownload.State.COMPLETE||fallback?VISIBLE:GONE);open.setEnabled(!busy); setCollapsed(collapsed); } private static String bytes(long value){if(value<1024)return value+" B";if(value<1024*1024)return String.format(Locale.ROOT,"%.1f KB",value/1024d);return String.format(Locale.ROOT,"%.1f MB",value/(1024d*1024));} diff --git a/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java b/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java index 9c55ae3f..bd0fbdda 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java +++ b/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java @@ -9,6 +9,7 @@ import android.webkit.MimeTypeMap; import java.io.*; import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.*; /** Read-only copies keep external viewers independent of the active download. */ @@ -26,7 +27,15 @@ static Uri prepare(Context context,ArtifactDownload.Result result)throws IOExcep String key=UUID.nameUUIDFromBytes(result.file.getAbsolutePath().getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString(); File folder=new File(root,key);if(!folder.isDirectory()&&!folder.mkdir())throw new IOException(); File copy=new File(folder,result.name); - try{if(!copy.isFile())Files.copy(result.file.toPath(),copy.toPath());folder.setLastModified(System.currentTimeMillis());}catch(IOException e){copy.delete();folder.delete();throw e;} + File staging=null; + try{ + if(!copy.isFile()||copy.length()!=result.file.length()){ + staging=File.createTempFile("bridge-open-",".part",folder); + Files.copy(result.file.toPath(),staging.toPath(),StandardCopyOption.REPLACE_EXISTING); + Files.move(staging.toPath(),copy.toPath(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE); + } + folder.setLastModified(System.currentTimeMillis()); + }finally{if(staging!=null)staging.delete();} return new Uri.Builder().scheme("content").authority(context.getPackageName()+".downloads").appendPath(folder.getName()).appendPath(copy.getName()).build(); } private File file(Uri uri)throws FileNotFoundException { diff --git a/mobile/android/src/io/github/try2love/codexbridge/GatewayUpdate.java b/mobile/android/src/io/github/try2love/codexbridge/GatewayUpdate.java new file mode 100644 index 00000000..18cac6ec --- /dev/null +++ b/mobile/android/src/io/github/try2love/codexbridge/GatewayUpdate.java @@ -0,0 +1,68 @@ +package io.github.try2love.codexbridge; + +import java.io.*; +import java.net.*; +import org.json.*; + +/** One explicitly selected gateway. No redirects, arbitrary destinations or stored credentials. */ +final class GatewayUpdate { + interface Progress {void changed(long downloaded,long size);} + final String base;private final String cookie;private final ArtifactDownload.Cookies cookies; + private volatile boolean cancelled;private volatile HttpURLConnection active; + GatewayUpdate(String base,ArtifactDownload.Cookies cookies)throws IOException { + try{if(!GatewayURL.origin(base).equals(base))throw new IOException();}catch(Exception e){throw new IOException("请先连接并登录电脑网关",e);} + this.base=base;this.cookies=cookies;cookie=cookies.read();if(cookie==null||cookie.isEmpty())throw new IOException("请先连接并登录电脑网关"); + } + void cancel(){cancelled=true;HttpURLConnection c=active;if(c!=null)c.disconnect();} + private void stopped()throws IOException {if(cancelled||Thread.currentThread().isInterrupted())throw new InterruptedIOException();if(!cookie.equals(cookies.read()))throw new IOException("登录已变化,请重新连接电脑后检查更新");} + private JSONObject request(String path,JSONObject body)throws IOException { + stopped();HttpURLConnection connection=(HttpURLConnection)new URL(base+path).openConnection();active=connection; + try{ + connection.setConnectTimeout(15000);connection.setReadTimeout(20000);connection.setInstanceFollowRedirects(false);connection.setRequestProperty("Cookie",cookie);connection.setRequestProperty("Origin",GatewayURL.httpOrigin(base));connection.setRequestProperty("Accept","application/json"); + if(body!=null){ + // Fetch the current CSRF token on the same authenticated gateway before a write. + JSONObject auth=request("/api/auth",null);active=connection;stopped(); + if(!auth.optBoolean("authenticated")||auth.optString("csrf").isEmpty())throw new IOException("请先连接并登录电脑网关"); + byte[] data=body.toString().getBytes("UTF-8");connection.setRequestMethod("POST");connection.setRequestProperty("Content-Type","application/json");connection.setRequestProperty("X-CSRF-Token",auth.optString("csrf"));connection.setDoOutput(true);connection.setFixedLengthStreamingMode(data.length);try(OutputStream out=connection.getOutputStream()){out.write(data);} + } + int status=connection.getResponseCode();stopped(); + if(status==404||status==405)throw new IOException("此网关暂不支持协助更新,请先更新电脑网关"); + if(status==401||status==403)throw new IOException("电脑登录已失效,请重新连接电脑后检查更新"); + if(status>=300&&status<400)throw new IOException("电脑更新地址发生跳转,请重新连接电脑"); + InputStream stream=status==200?connection.getInputStream():connection.getErrorStream(); + try(InputStream in=stream;ByteArrayOutputStream bytes=new ByteArrayOutputStream()){ + if(in==null)throw new IOException("电脑协助更新失败,请重试");byte[] block=new byte[8192];int count; + while((count=in.read(block))!=-1){stopped();if(bytes.size()+count>262144)throw new IOException("电脑更新响应过大");bytes.write(block,0,count);} + JSONObject result=new JSONObject(bytes.toString("UTF-8"));if(status!=200){String error=result.optString("error");throw new IOException(error.isEmpty()||error.length()>300?"电脑协助更新失败,请重试":error);}return result; + } + }catch(JSONException e){throw new IOException("电脑更新响应无效",e);}finally{connection.disconnect();if(active==connection)active=null;} + } + MobileRelease check(String current)throws IOException { + JSONObject value=request("/api/mobile/update?current="+URLEncoder.encode(current,"UTF-8"),null); + if(!value.optBoolean("supported")||!value.has("release"))throw new IOException("此网关暂不支持协助更新,请先更新电脑网关"); + if(value.isNull("release"))return null; + try{ + JSONObject row=value.getJSONObject("release");MobileRelease release=new MobileRelease(row.getString("version"),row.optString("notes"),row.getLong("size"),row.getString("sha256")); + if(!MobileRelease.eligible(current,"v"+release.version,false,row.optBoolean("prerelease",true),MobileRelease.asset(release.version),release.download)||release.size<=0||release.size>MobileRelease.MAX_SIZE||release.sha256==null)throw new IOException("电脑返回的更新信息无效");return release; + }catch(JSONException|RuntimeException e){throw new IOException("电脑返回的更新信息无效",e);} + } + String prepare(MobileRelease release,Progress progress)throws IOException { + try{ + JSONObject body=new JSONObject().put("version",release.version).put("size",release.size).put("sha256",release.sha256); + JSONObject value=request("/api/mobile/update/prepare",body);String identifier=value.optString("id"); + if(!identifier.matches("[a-f0-9]{64}"))throw new IOException("电脑更新响应无效"); + long deadline=System.nanoTime()+660_000_000_000L; + while(true){ + stopped();String state=value.optString("state");long size=value.optLong("size",-1),downloaded=value.optLong("downloaded",-1); + if(!identifier.equals(value.optString("id"))||size!=release.size||downloaded<0||downloaded>size)throw new IOException("电脑更新响应无效"); + progress.changed(downloaded,size); + if(state.equals("ready")){if(downloaded!=size)throw new IOException("电脑更新响应无效");return identifier;} + if(state.equals("failed")){String error=value.optString("error");throw new IOException(error.isEmpty()||error.length()>300?"电脑下载失败,请重试":error);} + if(!state.equals("downloading"))throw new IOException("电脑更新响应无效"); + if(System.nanoTime()>deadline)throw new IOException("电脑下载超时,请稍后重试"); + try{Thread.sleep(1000);}catch(InterruptedException e){Thread.currentThread().interrupt();throw new InterruptedIOException();} + value=request("/api/mobile/update/status/"+identifier,null); + } + }catch(JSONException e){throw new IOException("更新信息无效",e);} + } +} diff --git a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java index 602ae797..1a2aa7a0 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java @@ -27,7 +27,9 @@ public final class MainActivity extends Activity { private final Runnable homeRefresh=()->checkComputers(); private long homeBackAt;private Toast exitToast; private final ExecutorService downloads=Executors.newSingleThreadExecutor(); - private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private MobileRelease pendingUpdate;private boolean savingDownload,openingDownload,installAfterPermission; + private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private UpdateInstallSession updateSession;private boolean savingDownload,openingDownload,restoringUpdate,autoInstallUpdate; + private String choosingUpdateFile;private Bundle pendingSaveResult; + private GatewayUpdate preparingUpdate; private ServiceConnection monitor;private ValueCallback files;private WebView fileView;private int fileGeneration;private int generation; String L(String value){return MobileStrings.text(this,value);} int dp(int n){return (int)(getResources().getDisplayMetrics().density*n);} @@ -72,13 +74,14 @@ void recoverConnection(String requested){ void message(String value){resetExitGesture();new AlertDialog.Builder(this).setMessage(L(value)).setPositiveButton(L("好"),null).show();} Set saved(){return new LinkedHashSet<>(prefs.getStringSet("origins",new HashSet<>()));} @Override public void onCreate(Bundle state){super.onCreate(state);prefs=getSharedPreferences("bridge",0);root=new LinearLayout(this);root.setOrientation(1);root.setBackgroundColor(0xfff5f5f3);screen=new FrameLayout(this);screen.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen);systemBars=new SystemBars(this,screen,()->{if(downloadCard!=null)downloadCard.post(downloadCard::place);}); + if(state!=null){choosingUpdateFile=state.getString("choosingUpdateFile");pendingSaveResult=state.getBundle("pendingSaveResult");} if(Build.VERSION.SDK_INT>=33)getOnBackInvokedDispatcher().registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT,this::handleBack); bar=new LinearLayout(this);bar.setGravity(Gravity.CENTER_VERTICAL);bar.setPadding(dp(20),dp(4),dp(12),dp(4));root.addView(bar);header(false); downloadCard=new DownloadCard(this,screen,this::downloadAction,this::openDownload,this::cancelDownload); status=text("",12);status.setTextColor(0xff6b7075);status.setGravity(Gravity.CENTER);root.addView(status); - if(!openIntent(getIntent())){String active=prefs.getString("active","");if(saved().contains(active))connect(active+"/");else home();}monitor();root.post(this::requestStartupNotifications); + if(!openIntent(getIntent())){String active=prefs.getString("active","");if(saved().contains(active))connect(active+"/");else home();}monitor();root.post(this::requestStartupNotifications);restoreUpdate(); } - void clear(){pendingStorage=null;if(routeDiscovery!=null){routeDiscovery.cancel();routeDiscovery=null;}if(files!=null){files.onReceiveValue(null);files=null;fileView=null;}stopComputerChecks();homeStates.clear();homeNotifications.clear();resetExitGesture();generation++;if(web!=null){web.stopLoading();web.destroy();web=null;}systemBars.update(0xfff5f5f3,0xfff5f5f3);while(root.getChildCount()>2)root.removeViewAt(2);} + void clear(){if(preparingUpdate!=null){preparingUpdate.cancel();preparingUpdate=null;}pendingStorage=null;if(routeDiscovery!=null){routeDiscovery.cancel();routeDiscovery=null;}if(files!=null){files.onReceiveValue(null);files=null;fileView=null;}stopComputerChecks();homeStates.clear();homeNotifications.clear();resetExitGesture();generation++;if(web!=null){web.stopLoading();web.destroy();web=null;}systemBars.update(0xfff5f5f3,0xfff5f5f3);while(root.getChildCount()>2)root.removeViewAt(2);} void home(){clear();header(false);status.setVisibility(View.GONE);ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);LinearLayout content=column();content.setPadding(dp(20),dp(16),dp(20),dp(24));scroll.addView(content);root.addView(scroll,new LinearLayout.LayoutParams(-1,0,1)); ImageView icon=new ImageView(this);icon.setImageResource(getResources().getIdentifier("ic_computer","drawable",getPackageName()));icon.setScaleType(ImageView.ScaleType.FIT_CENTER);LinearLayout.LayoutParams ip=new LinearLayout.LayoutParams(dp(64),dp(58));ip.gravity=Gravity.CENTER;ip.bottomMargin=dp(24);content.addView(icon,ip); TextView headline=text(L("电脑上的工作,\n带在身边。"),30);headline.setTypeface(null,android.graphics.Typeface.BOLD);headline.setGravity(Gravity.CENTER);add(headline,content,10); @@ -169,31 +172,41 @@ public boolean onShowFileChooser(WebView view,ValueCallback callback,File web.setDownloadListener((target,agent,disposition,mime,length)->downloadArtifact(target)); web.loadUrl(url);monitor(); }catch(Exception e){message(e.getMessage());}} - void renderDownload(){downloadCard.render(downloadTask,savingDownload||choosingDownload!=null,openingDownload);} + void renderDownload(){downloadCard.render(downloadTask,savingDownload||choosingDownload!=null,openingDownload,updateSession!=null);} void downloadArtifact(String target){ + if(preparingUpdate!=null){message(L("电脑正在准备更新,请稍候"));return;} if(downloadTask!=null){downloadCard.showTask();renderDownload();message(L("已有下载任务,请先完成或取消"));return;} if(web==null||!GatewayURL.sameOrigin(web.getUrl(),origin)||!ArtifactDownload.accepts(target,origin)){message(L("此下载不是当前电脑的附件,请在浏览器中打开"));return;} startDownload(target,origin); } void startDownload(String target,String base){ + if(restoringUpdate){message(L("正在恢复更新,请稍候"));return;} try{downloadTask=new ArtifactDownload.Task(target,base,getCacheDir());downloadCard.showTask();renderDownload();runDownload(downloadTask);}catch(java.io.IOException e){message(e.getMessage());} } void runDownload(ArtifactDownload.Task task){ - task.state=ArtifactDownload.State.READY;renderDownload();downloads.execute(()->task.run(()->CookieManager.getInstance().getCookie(task.url),value->runOnUiThread(()->{if(downloadTask==value&&!isFinishing()&&!isDestroyed()){renderDownload();if(pendingUpdate!=null&&value.state==ArtifactDownload.State.COMPLETE&&!openingDownload)installDownloadedUpdate();}}))); + final UpdateInstallSession session=updateSession;task.state=ArtifactDownload.State.READY;renderDownload(); + downloads.execute(()->task.run(()->CookieManager.getInstance().getCookie(task.url),value->{ + // Snapshot the event before posting: queued progress callbacks must not become completion events. + boolean complete=value.state==ArtifactDownload.State.COMPLETE; + if(complete&&session!=null)try{session.save();}catch(java.io.IOException e){complete=false;if(value.state!=ArtifactDownload.State.CANCELLED){value.state=ArtifactDownload.State.FAILED;value.restartRequired=true;value.error="无法保存待安装更新,请重新下载";}} + final boolean finished=complete; + runOnUiThread(()->{if(downloadTask!=value||isFinishing()||isDestroyed())return;renderDownload();if(finished&&session!=null&&updateSession==session&&session.completed()){autoInstallUpdate=true;resumeUpdateInstall();} + }); + })); } void downloadAction(){ ArtifactDownload.Task task=downloadTask;if(task==null||savingDownload||choosingDownload!=null||openingDownload)return; if(task.state==ArtifactDownload.State.RUNNING){task.pause();renderDownload();} + else if(task.state==ArtifactDownload.State.FAILED&&updateSession!=null){chooseUpdateDownload(updateSession.release,null);} else if(task.state==ArtifactDownload.State.PAUSED||task.state==ArtifactDownload.State.FAILED){ - if(task.resumable())runDownload(task);else{MobileRelease release=pendingUpdate;String target=task.url,base=task.origin;cancelDownload();if(release!=null)startUpdateDownload(release,null);else startDownload(target,base);} + if(task.resumable()){runDownload(task);}else{MobileRelease release=updateSession==null?null:updateSession.release;String target=task.url,base=task.origin;boolean assisted=task.gatewayUpdate;cancelDownload();if(release!=null){if(assisted)startGatewayUpdate(release,base);else startUpdateDownload(release);}else startDownload(target,base);} }else if(task.state==ArtifactDownload.State.COMPLETE){ - if(pendingUpdate!=null){installDownloadedUpdate();return;} - choosingDownload=task;renderDownload();Intent save=new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("application/octet-stream").putExtra(Intent.EXTRA_TITLE,task.name); - try{startActivityForResult(save,32);}catch(Exception e){choosingDownload=null;renderDownload();message(L("没有可用的文件保存器"));} + Intent save=new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("application/octet-stream").putExtra(Intent.EXTRA_TITLE,task.name); + try{choosingUpdateFile=updateSession==null?null:task.result().file.getName();choosingDownload=task;renderDownload();startActivityForResult(save,32);}catch(Exception e){choosingDownload=null;choosingUpdateFile=null;renderDownload();message(L("没有可用的文件保存器"));} } } void openDownload(){ - if(pendingUpdate!=null){installDownloadedUpdate();return;} + if(updateSession!=null){if(downloadTask!=null&&downloadTask.state==ArtifactDownload.State.FAILED)openUpdateUrl(updateSession.release.download);else installDownloadedUpdate();return;} ArtifactDownload.Task task=downloadTask;if(task==null||task.state!=ArtifactDownload.State.COMPLETE||savingDownload||choosingDownload!=null||openingDownload)return; openingDownload=true;renderDownload();downloads.execute(()->{ Uri uri=null;try{uri=DownloadProvider.prepare(this,task.result());}catch(java.io.IOException ignored){} @@ -206,10 +219,12 @@ void openDownload(){ }); }); } - void cancelDownload(){if(downloadTask!=null){downloadTask.cancel();downloadTask=null;}pendingUpdate=null;installAfterPermission=false;renderDownload();} + void cancelDownload(){if(preparingUpdate!=null){preparingUpdate.cancel();preparingUpdate=null;}if(updateSession!=null)updateSession.cancel();else if(downloadTask!=null)downloadTask.cancel();downloadTask=null;updateSession=null;autoInstallUpdate=false;renderDownload();} void saveDownload(int result,Intent data){ - ArtifactDownload.Task task=choosingDownload;choosingDownload=null; + if(restoringUpdate&&choosingUpdateFile!=null){pendingSaveResult=new Bundle();pendingSaveResult.putInt("result",result);pendingSaveResult.putParcelable("data",data);return;} + ArtifactDownload.Task task=choosingDownload;choosingDownload=null;choosingUpdateFile=null; if(result!=RESULT_OK||data==null||data.getData()==null){renderDownload();return;} + if(task==null){renderDownload();message(L("保存失败,请重试"));return;} Uri target=data.getData();savingDownload=true;renderDownload();downloads.execute(()->{ boolean ok=false; try{if(task==null||task.state!=ArtifactDownload.State.COMPLETE)throw new java.io.IOException();ArtifactDownload.Result download=task.result(); @@ -265,7 +280,7 @@ void settings(){ String appVersion(){try{return getPackageManager().getPackageInfo(getPackageName(),0).versionName;}catch(Exception e){return "2.0.0";}} void openUpdateUrl(String url){try{startActivity(new Intent(Intent.ACTION_VIEW,Uri.parse(url)));}catch(ActivityNotFoundException e){message(L("没有可用的浏览器,请在电脑上打开 GitHub Release。"));}} void checkUpdate(Dialog dialog,Button check,TextView updateStatus){ - check.setEnabled(false);updateStatus.setText(L("正在检查更新…"));final String current=appVersion(); + check.setEnabled(false);updateStatus.setText(L("正在检查更新…"));final String current=appVersion(),gateway=selectedUpdateGateway();final int ticket=generation; worker.execute(()->{ MobileRelease candidate=null;String problem=null; java.net.HttpURLConnection connection=null; @@ -281,25 +296,101 @@ void checkUpdate(Dialog dialog,Button check,TextView updateStatus){ } } }catch(Exception e){problem=L("检查失败,请检查网络后重试。也可以打开版本页面。");}finally{if(connection!=null)connection.disconnect();} + if(problem!=null&&!gateway.isEmpty()){ + runOnUiThread(()->{if(dialog.isShowing())updateStatus.setText(L("正在通过电脑检查更新…"));}); + try{candidate=new GatewayUpdate(gateway,()->CookieManager.getInstance().getCookie(gateway+"/")).check(current);problem=null;}catch(Exception e){problem=L("手机无法连接 GitHub,电脑协助更新也未成功。")+"\n"+L(e.getMessage()==null?"请检查网络后重试":e.getMessage());} + } final MobileRelease result=candidate;final String error=problem; - runOnUiThread(()->{if(isFinishing()||isDestroyed()||!dialog.isShowing())return;check.setEnabled(true); - if(error!=null){updateStatus.setText(error);new AlertDialog.Builder(this).setMessage(error).setNegativeButton(L("关闭"),null).setPositiveButton(L("版本页面"),(d,w)->openUpdateUrl(MobileRelease.REPO+"/releases")).show();return;} + runOnUiThread(()->{if(isFinishing()||isDestroyed()||!dialog.isShowing())return;check.setEnabled(true);if(ticket!=generation){updateStatus.setText(L("电脑连接已变化,请重新检查更新"));return;} + if(error!=null){updateStatus.setText(error);new AlertDialog.Builder(this).setMessage(error).setNegativeButton(L("关闭"),null).setPositiveButton(L("打开 GitHub 版本页"),(d,w)->openUpdateUrl(MobileRelease.REPO+"/releases")).show();return;} if(result==null){updateStatus.setText(L("当前已是此通道最新版本。"));return;} updateStatus.setText(L("发现新版本")+" · "+result.version); - new AlertDialog.Builder(this).setTitle(L("发现新版本")+" · "+result.version).setMessage(L("App 会在本机下载并打开系统安装器,已保存的电脑与登录状态会保留。")+"\n\n"+result.notes).setNegativeButton(L("稍后"),null).setNeutralButton(L("更新说明"),(d,w)->openUpdateUrl(result.page())).setPositiveButton(L("下载并安装"),(d,w)->startUpdateDownload(result,updateStatus)).show(); + new AlertDialog.Builder(this).setTitle(L("发现新版本")+" · "+result.version).setMessage(L("App 会在本机下载并打开系统安装器,已保存的电脑与登录状态会保留。")+"\n\n"+result.notes).setNegativeButton(L("稍后"),null).setNeutralButton(L("打开 GitHub 版本页"),(d,w)->openUpdateUrl(result.page())).setPositiveButton(L("下载并安装"),(d,w)->chooseUpdateDownload(result,dialog)).show(); }); }); } - void startUpdateDownload(MobileRelease release,TextView updateStatus){ - if(downloadTask!=null){message(L("已有下载任务,请先完成或取消"));return;} - try{pendingUpdate=release;downloadTask=new ArtifactDownload.Task(release.download,MobileRelease.REPO,getCacheDir(),release.size,release.sha256,MobileRelease.asset(release.version));downloadCard.showTask();renderDownload();if(updateStatus!=null)updateStatus.setText(L("正在下载更新…"));runDownload(downloadTask);}catch(java.io.IOException e){pendingUpdate=null;downloadTask=null;message(e.getMessage());} + java.io.File updateDirectory(){return new java.io.File(getFilesDir(),"pending-update");} + void updateProblem(String text,MobileRelease release){new AlertDialog.Builder(this).setMessage(L(text)).setNegativeButton(L("关闭"),null).setPositiveButton(L("打开 GitHub 版本页"),(d,w)->openUpdateUrl(release.page())).show();} + void chooseUpdateDownload(MobileRelease release,Dialog settingsDialog){ + Dialog dialog=new Dialog(this);dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout content=column();content.setPadding(dp(22),dp(22),dp(22),dp(16));content.setBackground(background(0xffffffff,26)); + TextView title=text(L("选择下载方式"),22);title.setTypeface(null,android.graphics.Typeface.BOLD);title.setPadding(0,0,0,0);add(title,content,22); + add(updateDownloadOption("ic_phone","手机下载","从 GitHub 直接下载到手机",()->{if(startUpdateDownload(release)){dialog.dismiss();if(settingsDialog!=null)settingsDialog.dismiss();}}),content,10); + add(updateDownloadOption("ic_computer","电脑下载","电脑从 GitHub 下载,再传到手机",()->{if(startGatewayUpdate(release,selectedUpdateGateway())){dialog.dismiss();if(settingsDialog!=null)settingsDialog.dismiss();}}),content,14); + Button cancel=button(L("取消"),dialog::dismiss);cancel.setTextColor(0xff727782);cancel.setBackground(new android.graphics.drawable.RippleDrawable(android.content.res.ColorStateList.valueOf(0x10202124),background(0xffffffff,12),null));add(cancel,content,0); + ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);scroll.addView(content);dialog.setContentView(scroll);dialog.setCanceledOnTouchOutside(true);dialog.show(); + Window window=dialog.getWindow();window.setBackgroundDrawableResource(android.R.color.transparent);window.setDimAmount(.34f);window.setLayout(Math.min(dp(420),getResources().getDisplayMetrics().widthPixels-dp(32)),WindowManager.LayoutParams.WRAP_CONTENT); + } + LinearLayout updateDownloadOption(String drawable,String title,String detail,Runnable action){ + LinearLayout row=new LinearLayout(this);row.setGravity(Gravity.CENTER_VERTICAL);row.setPadding(dp(14),dp(17),dp(12),dp(17));row.setMinimumHeight(dp(88));row.setBackground(new android.graphics.drawable.RippleDrawable(android.content.res.ColorStateList.valueOf(0x14202124),background(0xfff5f6f8,17),null)); + ImageView image=new ImageView(this);image.setImageResource(getResources().getIdentifier(drawable,"drawable",getPackageName()));image.setScaleType(ImageView.ScaleType.FIT_CENTER);image.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);row.addView(image,new LinearLayout.LayoutParams(dp(30),dp(30))); + LinearLayout labels=column();LinearLayout.LayoutParams labelParams=new LinearLayout.LayoutParams(0,-2,1);labelParams.setMarginStart(dp(13));labelParams.setMarginEnd(dp(8));row.addView(labels,labelParams); + TextView heading=text(L(title),16);heading.setTypeface(null,android.graphics.Typeface.BOLD);heading.setPadding(0,0,0,dp(5));heading.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);labels.addView(heading); + TextView description=text(L(detail),13);description.setTextColor(0xff747983);description.setPadding(0,0,0,0);description.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);labels.addView(description); + TextView arrow=text("›",24);arrow.setTextColor(0xffa3a7ae);arrow.setPadding(0,0,0,0);arrow.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);row.addView(arrow); + row.setContentDescription(L(title)+","+L(detail));row.setFocusable(true);row.setOnClickListener(v->action.run());return row; + } + String selectedUpdateGateway(){return !origin.isEmpty()&&saved().contains(origin)?origin:"";} + boolean startUpdateDownload(MobileRelease release){ + if(restoringUpdate){message(L("正在恢复更新,请稍候"));return false;} + if(preparingUpdate!=null||(downloadTask!=null&&(updateSession==null||downloadTask.state!=ArtifactDownload.State.FAILED))){message(L("已有下载任务,请先完成或取消"));return false;} + if(downloadTask!=null){ + if(!downloadTask.gatewayUpdate&&updateSession.release==release&&downloadTask.resumable()){runDownload(downloadTask);return true;} + cancelDownload(); + } + try{java.io.File directory=updateDirectory();if(!directory.isDirectory()&&!directory.mkdirs())throw new java.io.IOException("无法保存待安装更新,请重新下载"); + downloadTask=new ArtifactDownload.Task(release.download,MobileRelease.REPO,directory,release.size,release.sha256,MobileRelease.asset(release.version));updateSession=new UpdateInstallSession(release,downloadTask,directory);downloadCard.showTask();renderDownload();runDownload(downloadTask);return true; + }catch(java.io.IOException e){updateSession=null;downloadTask=null;updateProblem(e.getMessage(),release);return false;} + } + boolean startGatewayUpdate(MobileRelease release,String gateway){ + if(restoringUpdate||preparingUpdate!=null||(downloadTask!=null&&(updateSession==null||downloadTask.state!=ArtifactDownload.State.FAILED))){message(L("已有下载任务,请先完成或取消"));return false;} + if(gateway.isEmpty()||!gateway.equals(selectedUpdateGateway())){updateProblem("请先连接并登录电脑网关",release);return false;} + if(downloadTask!=null&&downloadTask.gatewayUpdate&&updateSession.release==release&&gateway.equals(downloadTask.origin)&&downloadTask.resumable()){runDownload(downloadTask);return true;} + final GatewayUpdate helper;try{helper=new GatewayUpdate(gateway,()->CookieManager.getInstance().getCookie(gateway+"/"));}catch(java.io.IOException e){updateProblem(e.getMessage(),release);return false;} + preparingUpdate=helper;final int ticket=generation;LinearLayout content=column();content.setPadding(dp(24),dp(12),dp(24),dp(12)); + TextView detail=text(L("电脑正在下载更新…"),15);add(detail,content,12);ProgressBar progress=new ProgressBar(this,null,android.R.attr.progressBarStyleHorizontal);progress.setMax(1000);progress.setIndeterminate(true);add(progress,content,8); + AlertDialog dialog=new AlertDialog.Builder(this).setTitle(L("通过电脑更新")+" · "+release.version).setView(content).setNegativeButton(L("取消"),(d,w)->{helper.cancel();if(preparingUpdate==helper)preparingUpdate=null;}).create();dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->{helper.cancel();if(preparingUpdate==helper)preparingUpdate=null;});dialog.show(); + downloads.execute(()->{ + ArtifactDownload.Task ready=null;String error=null; + try{String identifier=helper.prepare(release,(downloaded,size)->runOnUiThread(()->{if(preparingUpdate!=helper||isDestroyed())return;progress.setIndeterminate(false);progress.setProgress((int)(downloaded*1000/size));detail.setText(L("电脑正在下载更新…")+" "+(downloaded*100/size)+"%");})); + java.io.File directory=updateDirectory();if(!directory.isDirectory()&&!directory.mkdirs())throw new java.io.IOException("无法保存待安装更新,请重新下载");ready=ArtifactDownload.Task.gatewayUpdate(gateway,identifier,release,directory); + }catch(Exception e){error=e.getMessage()==null?"电脑协助更新失败,请重试":e.getMessage();} + final ArtifactDownload.Task task=ready;final String problem=error; + runOnUiThread(()->{ + if(isDestroyed()||isFinishing()||preparingUpdate!=helper||ticket!=generation||!gateway.equals(selectedUpdateGateway())){if(task!=null)task.cancel();if(!isDestroyed())dialog.dismiss();return;} + preparingUpdate=null;dialog.dismiss(); + if(problem!=null){updateProblem(problem,release);return;} + if(updateSession!=null)updateSession.cancel();else if(downloadTask!=null)downloadTask.cancel(); + downloadTask=task;updateSession=new UpdateInstallSession(release,task,updateDirectory());downloadCard.showTask();renderDownload();runDownload(task); + }); + });return true; + } + void restoreUpdate(){ + restoringUpdate=true;final String current=appVersion();downloads.execute(()->{ + UpdateInstallSession saved=null;boolean invalid=false;try{saved=UpdateInstallSession.restore(updateDirectory(),current);}catch(java.io.InterruptedIOException interrupted){return;}catch(java.io.IOException e){if(Thread.currentThread().isInterrupted())return;invalid=true;UpdateInstallSession.discard(updateDirectory());} + final UpdateInstallSession recovered=saved;final boolean failed=invalid;runOnUiThread(()->{ + if(isFinishing()||isDestroyed())return;restoringUpdate=false; + if(recovered!=null){updateSession=recovered;downloadTask=recovered.task;try{if(downloadTask.result().file.getName().equals(choosingUpdateFile))choosingDownload=downloadTask;}catch(java.io.IOException ignored){}downloadCard.showTask();renderDownload();} + else if(failed)message(L("待安装更新已失效,请重新检查更新")); + if(pendingSaveResult!=null){Bundle result=pendingSaveResult;pendingSaveResult=null;saveDownload(result.getInt("result"),result.getParcelable("data"));} + resumeUpdateInstall(); + }); + }); + } + void resumeUpdateInstall(){ + if(!resumed||updateSession==null||savingDownload||choosingDownload!=null)return; + if(updateSession.waitingPermission()){ + try{updateSession.permissionReturned();}catch(java.io.IOException e){updateProblem("无法保存待安装更新,请重新下载",updateSession.release);return;} + if(Build.VERSION.SDK_INT>=26&&!getPackageManager().canRequestPackageInstalls()){updateProblem("请在系统设置中允许此应用安装未知来源应用。",updateSession.release);return;} + installDownloadedUpdate(); + }else if(autoInstallUpdate){autoInstallUpdate=false;installDownloadedUpdate();} } void installDownloadedUpdate(){ - final ArtifactDownload.Task task=downloadTask;if(task==null||pendingUpdate==null||task.state!=ArtifactDownload.State.COMPLETE||openingDownload)return; - if(Build.VERSION.SDK_INT>=26&&!getPackageManager().canRequestPackageInstalls()){installAfterPermission=true;try{startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,Uri.parse("package:"+getPackageName())));}catch(Exception e){installAfterPermission=false;message(L("无法打开安装权限设置,请在系统设置中允许此应用安装未知来源应用。"));}return;} + final ArtifactDownload.Task task=downloadTask;final UpdateInstallSession session=updateSession;if(!resumed||task==null||session==null||task.state!=ArtifactDownload.State.COMPLETE||openingDownload||savingDownload||choosingDownload!=null||!session.beginInstall())return; + if(Build.VERSION.SDK_INT>=26&&!getPackageManager().canRequestPackageInstalls()){try{session.awaitPermission();startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,Uri.parse("package:"+getPackageName())));}catch(Exception e){try{session.permissionReturned();}catch(java.io.IOException ignored){}session.finishInstall();updateProblem("无法打开安装权限设置,请在系统设置中允许此应用安装未知来源应用。",session.release);}return;} openingDownload=true;renderDownload();downloads.execute(()->{ - Uri uri=null;try{uri=DownloadProvider.prepare(this,task.result());}catch(java.io.IOException ignored){} - final Uri target=uri;runOnUiThread(()->{openingDownload=false;if(isFinishing()||isDestroyed())return;renderDownload();if(task!=downloadTask)return;if(target==null){message(L("安装包准备失败,请重新下载"));return;}Intent install=new Intent(Intent.ACTION_VIEW).setDataAndType(target,"application/vnd.android.package-archive").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION|Intent.FLAG_ACTIVITY_NEW_TASK);install.setClipData(ClipData.newRawUri("",target));try{startActivity(install);}catch(ActivityNotFoundException e){message(L("系统没有可用的安装器,请保存 APK 后手动安装"));}catch(Exception e){message(L("打开安装器失败,请重试"));}}); + Uri uri=null;try{task.verifyCompletedUpdate();uri=DownloadProvider.prepare(this,task.result());}catch(java.io.IOException ignored){} + final Uri target=uri;runOnUiThread(()->{openingDownload=false;session.finishInstall();if(isFinishing()||isDestroyed()||task!=downloadTask)return;renderDownload();if(target==null){updateProblem("安装包准备失败,请重新下载",session.release);return;}if(!resumed)return;Intent install=new Intent(Intent.ACTION_VIEW).setDataAndType(target,"application/vnd.android.package-archive").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);install.setClipData(ClipData.newRawUri("",target));try{startActivity(install);}catch(ActivityNotFoundException e){updateProblem("系统没有可用的安装器,请保存 APK 后手动安装",session.release);}catch(Exception e){updateProblem("打开安装器失败,请重试",session.release);}}); }); } void enableNativePush(){ @@ -337,12 +428,13 @@ void selectedFiles(int result,Intent data){ }); } @Override protected void onActivityResult(int code,int result,Intent data){super.onActivityResult(code,result,data);if(code==32){saveDownload(result,data);return;}if(code==20&&result==RESULT_OK&&data!=null)choose(data.getStringExtra("code"));if(code==31)selectedFiles(result,data);} - @Override protected void onResume(){super.onResume();resumed=true;ComputerNotifications.onChange=()->runOnUiThread(this::renderComputerNotifications);checkComputers();MonitorService.setVisible(true);if(web!=null)web.onResume();registerPushToken();if(installAfterPermission){installAfterPermission=false;if(Build.VERSION.SDK_INT<26||getPackageManager().canRequestPackageInstalls())installDownloadedUpdate();else message(L("请在系统设置中允许此应用安装未知来源应用。"));}} + @Override protected void onSaveInstanceState(Bundle state){state.putString("choosingUpdateFile",choosingUpdateFile);if(pendingSaveResult!=null)state.putBundle("pendingSaveResult",pendingSaveResult);super.onSaveInstanceState(state);} + @Override protected void onResume(){super.onResume();resumed=true;ComputerNotifications.onChange=()->runOnUiThread(this::renderComputerNotifications);checkComputers();MonitorService.setVisible(true);if(web!=null)web.onResume();registerPushToken();resumeUpdateInstall();} // Keep the live WebView and its drafts/tabs when rotating or resizing the window. @Override public void onConfigurationChanged(android.content.res.Configuration config){super.onConfigurationChanged(config);screen.requestApplyInsets();downloadCard.post(downloadCard::place);if(web!=null){web.requestLayout();web.invalidate();}} @Override protected void onPause(){if(routeDiscovery!=null){routeDiscovery.cancel();routeDiscovery=null;}routeAttempted=false;ComputerNotifications.onChange=null;resumed=false;stopComputerChecks();resetExitGesture();MonitorService.setVisible(false);if(web!=null)web.onPause();CookieManager.getInstance().flush();super.onPause();} void resetExitGesture(){homeBackAt=0;if(exitToast!=null){exitToast.cancel();exitToast=null;}} @Override public void onBackPressed(){handleBack();} void handleBack(){if(web==null){long now=SystemClock.elapsedRealtime();if(homeBackAt!=0&&now-homeBackAt<=2000){resetExitGesture();finishAndRemoveTask();}else{homeBackAt=now;if(exitToast!=null)exitToast.cancel();exitToast=Toast.makeText(this,L("再按一次返回退出 App"),Toast.LENGTH_SHORT);exitToast.show();}return;}resetExitGesture();if(!GatewayURL.sameOrigin(web.getUrl(),origin)){home();return;}final int ticket=generation;web.evaluateJavascript("(()=>{const dialog=document.querySelector('dialog[open]');if(dialog){dialog.close();return true;}if(typeof window.BridgeNavigation?.back==='function')return window.BridgeNavigation.back();if(window.BridgeWorkbench?.back())return true;if(document.getElementById('app')?.classList.contains('chat-open')){document.getElementById('back')?.click();return true;}return false;})()",result->{if(ticket==generation&&!"true".equals(result))home();});} - @Override public void onDestroy(){if(downloadTask!=null)downloadTask.cancel();downloads.shutdownNow();clear();probes.shutdownNow();worker.shutdownNow();if(monitor!=null)getApplicationContext().unbindService(monitor);super.onDestroy();} + @Override public void onDestroy(){if(downloadTask!=null&&(updateSession==null||downloadTask.state!=ArtifactDownload.State.COMPLETE))downloadTask.cancel();downloads.shutdownNow();clear();probes.shutdownNow();worker.shutdownNow();if(monitor!=null)getApplicationContext().unbindService(monitor);super.onDestroy();} } diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java b/mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java index cbb4d2dd..5cc0ab60 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java @@ -7,6 +7,7 @@ public final class MobileRelease { public static final String REPO="https://github.com/try2love/codex-mobile-bridge"; public static final String API="https://api.github.com/repos/try2love/codex-mobile-bridge/releases?per_page=100"; + public static final long MAX_SIZE=512L*1024*1024; private static final Pattern VERSION=Pattern.compile("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(alpha|beta|preview|rc)\\.(0|[1-9][0-9]*))?$"); public final String version,notes,download;public final long size;public final String sha256; public MobileRelease(String version,String notes){this(version,notes,-1,null);} diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java index 37848754..2321811a 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java @@ -56,6 +56,23 @@ final class MobileStrings { values.put("更新说明","Release notes"); values.put("下载 APK","Download APK"); values.put("下载并安装","Download and install"); +values.put("选择下载方式","Choose download method"); +values.put("手机下载","Download on phone"); +values.put("电脑下载","Download on computer"); +values.put("从 GitHub 直接下载到手机","Download directly from GitHub"); +values.put("电脑从 GitHub 下载,再传到手机","Download from GitHub, then transfer to this phone"); +values.put("重新选择","Choose again"); +values.put("安装","Install"); +values.put("打开 GitHub 版本页","Open GitHub releases"); +values.put("在浏览器中下载 APK","Download APK in browser"); +values.put("更新已就绪,可安装或保存 APK","Update ready. Install or save the APK."); +values.put("正在恢复更新,请稍候","Restoring the update. Please wait."); +values.put("待安装更新已失效,请重新检查更新","The pending update is no longer valid. Check for updates again."); +values.put("无法保存待安装更新,请重新下载","Could not keep the pending update. Download it again."); +values.put("更新缺少有效的大小或 SHA-256 校验信息,请打开版本页面下载。","Update size or SHA-256 information is missing or invalid. Open the releases page to download it."); +values.put("更新文件大小与发布信息不一致,请重新下载。","The update size does not match the release. Download it again."); +values.put("更新文件校验失败,请重新下载。","Update verification failed. Download it again."); +values.put("更新下载地址发生跳转,已取消。","The update redirected to an unsupported address. Download cancelled."); values.put("正在下载更新…","Downloading update…"); values.put("更新下载被拒绝,请稍后重试","The update download was refused. Try again later."); values.put("App 会在本机下载并打开系统安装器,已保存的电脑与登录状态会保留。","The app will download the APK here and open the system installer. Saved computers and sign-ins will be retained."); @@ -190,6 +207,30 @@ final class MobileStrings { values.put("下载地址发生跳转,已取消。请重新打开电脑附件。","Download redirect blocked. Reopen the attachment from your computer."); values.put("下载失败,请检查网络后重试","Download failed. Check your network and try again."); values.put("请先连接并登录电脑网关","Connect and sign in to your computer’s gateway first"); + values.put("电脑更新地址无效","Invalid computer update address"); + values.put("电脑更新缓存已过期,请重新下载","The computer's update cache expired. Download again."); + values.put("登录已变化,请重新连接电脑后检查更新","Sign-in changed. Reconnect to the computer and check again."); + values.put("此网关暂不支持协助更新,请先更新电脑网关","Update the computer gateway to enable assisted updates."); + values.put("电脑登录已失效,请重新连接电脑后检查更新","Computer sign-in expired. Reconnect and check again."); + values.put("电脑更新地址发生跳转,请重新连接电脑","The update address redirected. Reconnect to the computer."); + values.put("电脑协助更新失败,请重试","Computer-assisted update failed. Try again."); + values.put("电脑更新响应过大","The computer's update response is too large."); + values.put("电脑更新响应无效","Invalid update response from the computer."); + values.put("电脑返回的更新信息无效","Invalid release information from the computer."); + values.put("电脑下载失败,请重试","The computer download failed. Try again."); + values.put("电脑下载超时,请稍后重试","The computer download timed out. Try again later."); + values.put("更新信息无效","Invalid update information."); + values.put("电脑正在准备更新,请稍候","The computer is preparing the update. Please wait."); + values.put("正在通过电脑检查更新…","Checking for updates through your computer…"); + values.put("手机无法连接 GitHub,电脑协助更新也未成功。","Your phone could not reach GitHub, and the computer could not complete the update check."); + values.put("电脑连接已变化,请重新检查更新","The computer connection changed. Check for updates again."); + values.put("电脑正在下载更新…","Your computer is downloading the update…"); + values.put("通过电脑更新","Update through computer"); + values.put("正在从电脑传送到手机…","Transferring from computer to phone…"); + values.put("电脑无法获取 GitHub 版本信息,请检查电脑网络后重试","Your computer could not fetch GitHub releases. Check its network and try again."); + values.put("电脑下载失败,请检查电脑网络和存储后重试","The computer download failed. Check its network and storage, then try again."); + values.put("电脑正在下载其他更新,请稍后重试","Your computer is downloading another update. Try again later."); + values.put("发布信息已变化或缺少校验信息,请重新检查更新","Release details changed or lack verification data. Check for updates again."); values.put("登录已失效,请重新连接电脑","Sign-in expired. Reconnect to the computer."); values.put("请使用配套电脑 Preview,并检查连接","Use the matching desktop Preview and check the connection"); values.put("查看聊天","View chat"); diff --git a/mobile/android/src/io/github/try2love/codexbridge/UpdateInstallSession.java b/mobile/android/src/io/github/try2love/codexbridge/UpdateInstallSession.java new file mode 100644 index 00000000..3c992c13 --- /dev/null +++ b/mobile/android/src/io/github/try2love/codexbridge/UpdateInstallSession.java @@ -0,0 +1,42 @@ +package io.github.try2love.codexbridge; + +import java.io.*; +import java.nio.file.*; +import java.util.Properties; + +/** One verified package and its installation handoff, independent of Activity recreation. */ +public final class UpdateInstallSession { + public final MobileRelease release;public final ArtifactDownload.Task task; + private final File directory; + private boolean completionHandled,opening,permissionPending,cancelled; + public UpdateInstallSession(MobileRelease release,ArtifactDownload.Task task,File directory){this.release=release;this.task=task;this.directory=directory;} + public synchronized boolean completed(){if(cancelled||completionHandled||task.state!=ArtifactDownload.State.COMPLETE)return false;completionHandled=true;return true;} + public synchronized boolean beginInstall(){if(cancelled||opening||permissionPending||task.state!=ArtifactDownload.State.COMPLETE)return false;opening=true;return true;} + public synchronized void finishInstall(){opening=false;} + public synchronized boolean waitingPermission(){return permissionPending;} + public synchronized void awaitPermission()throws IOException {permissionPending=true;opening=false;try{save();}catch(IOException e){permissionPending=false;throw e;}} + public synchronized boolean permissionReturned()throws IOException {if(!permissionPending)return false;permissionPending=false;save();return true;} + public synchronized void save()throws IOException { + if(cancelled||task.state!=ArtifactDownload.State.COMPLETE)throw new IOException("Update not ready"); + ArtifactDownload.Result result=task.result(); + if(!directory.getCanonicalFile().equals(result.file.getCanonicalFile().getParentFile()))throw new IOException("Invalid update directory"); + Properties p=new Properties();p.setProperty("version",release.version);p.setProperty("size",Long.toString(release.size));p.setProperty("sha256",release.sha256);p.setProperty("file",result.file.getName());p.setProperty("permission",Boolean.toString(permissionPending)); + File staging=new File(directory,"pending.properties.tmp"); + try(FileOutputStream out=new FileOutputStream(staging)){p.store(out,null);out.getFD().sync();} + Files.move(staging.toPath(),new File(directory,"pending.properties").toPath(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE); + } + public synchronized void cancel(){cancelled=true;permissionPending=false;opening=false;new File(directory,"pending.properties").delete();new File(directory,"pending.properties.tmp").delete();task.cancel();} + public static UpdateInstallSession restore(File directory,String currentVersion)throws IOException { + File record=new File(directory,"pending.properties");if(!record.isFile()){discard(directory);return null;} + Properties p=new Properties();try(InputStream in=new FileInputStream(record)){p.load(in);} + try{ + MobileRelease release=new MobileRelease(p.getProperty("version"),"",Long.parseLong(p.getProperty("size")),p.getProperty("sha256")); + if(MobileRelease.compare(release.version,currentVersion)<=0){discard(directory);return null;} + ArtifactDownload.Task task=ArtifactDownload.Task.restoreUpdate(release,directory,p.getProperty("file")); + File[] orphans=directory.listFiles();if(orphans!=null)for(File file:orphans)if(file.getName().matches("bridge-download-[a-zA-Z0-9-]+\\.part")&&!file.getName().equals(p.getProperty("file")))file.delete(); + UpdateInstallSession session=new UpdateInstallSession(release,task,directory);session.completionHandled=true;session.permissionPending=Boolean.parseBoolean(p.getProperty("permission"));return session; + }catch(RuntimeException e){throw new IOException("Invalid pending update",e);} + } + /** Only this private update directory is eligible for cleanup. */ + public static void discard(File directory){File[] files=directory.listFiles();if(files!=null)for(File file:files)if(file.getName().matches("bridge-download-[a-zA-Z0-9-]+\\.part|pending\\.properties(?:\\.tmp)?"))file.delete();} +} diff --git a/mobile/tests/GatewayUpdateDownloadTests.java b/mobile/tests/GatewayUpdateDownloadTests.java new file mode 100644 index 00000000..d04d447a --- /dev/null +++ b/mobile/tests/GatewayUpdateDownloadTests.java @@ -0,0 +1,37 @@ +import io.github.try2love.codexbridge.*; +import com.sun.net.httpserver.*; +import java.io.*; +import java.net.*; +import java.security.*; +import java.util.*; + +public class GatewayUpdateDownloadTests { + static int checks,requests,redirects;static boolean corrupt,redirect,expired; + static byte[] payload=new byte[1024*1024+27]; + static void check(boolean value,String message){checks++;if(!value)throw new AssertionError(message);} + public static void main(String[] args)throws Exception { + new Random(4).nextBytes(payload);StringBuilder digest=new StringBuilder();for(byte b:MessageDigest.getInstance("SHA-256").digest(payload))digest.append(String.format("%02x",b)); + File dir=new File(args[0]);dir.mkdirs();MobileRelease release=new MobileRelease("2.0.3","",payload.length,digest.toString());String identifier=String.join("",Collections.nCopies(64,"a")); + HttpServer server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);String base="http://127.0.0.1:"+server.getAddress().getPort()+"/d/"+String.join("",Collections.nCopies(32,"b")); + server.createContext("/redirect",exchange->{redirects++;exchange.sendResponseHeaders(500,-1);exchange.close();}); + server.createContext("/d/",exchange->{try{ + requests++;check("fixture=session".equals(exchange.getRequestHeaders().getFirst("Cookie")),"gateway request authenticated"); + check(exchange.getRequestURI().getPath().equals(new URI(base).getPath()+"/api/mobile/update/packages/"+identifier),"relay device prefix retained"); + if(redirect){exchange.getResponseHeaders().set("Location","/redirect");exchange.sendResponseHeaders(302,-1);return;} + if(expired){exchange.sendResponseHeaders(400,-1);return;} + String[] range=exchange.getRequestHeaders().getFirst("Range").substring(6).split("-");int first=Integer.parseInt(range[0]),last=Math.min(payload.length-1,Integer.parseInt(range[1])); + byte[] bytes=Arrays.copyOfRange(payload,first,last+1);if(corrupt)bytes[0]^=1; + exchange.getResponseHeaders().set("ETag","\"fixture\"");exchange.getResponseHeaders().set("Content-Range","bytes "+first+"-"+last+"/"+payload.length);exchange.sendResponseHeaders(206,bytes.length);exchange.getResponseBody().write(bytes); + }catch(Exception e){throw new RuntimeException(e);}finally{exchange.close();}});server.start(); + try{ + ArtifactDownload.Task task=ArtifactDownload.Task.gatewayUpdate(base,identifier,release,dir);task.run(()->"fixture=session",null);check(task.state==ArtifactDownload.State.COMPLETE,"gateway update completes");check(requests==2,"range chunks used");task.verifyCompletedUpdate();task.cancel(); + for(String id:new String[]{"../private","x","a?url=http://localhost"})try{ArtifactDownload.Task.gatewayUpdate(base,id,release,dir);throw new AssertionError("bad identifier");}catch(IOException expected){checks++;} + check(!ArtifactDownload.accepts(base+"/api/mobile/update/packages/"+identifier,base),"update is not a generic attachment"); + try{new ArtifactDownload.Task(base+"/api/mobile/update/packages/"+identifier,base,dir);throw new AssertionError("unverified update accepted");}catch(IOException expected){checks++;} + corrupt=true;task=ArtifactDownload.Task.gatewayUpdate(base,identifier,release,dir);task.run(()->"fixture=session",null);check(task.state==ArtifactDownload.State.FAILED&&task.restartRequired,"gateway package digest checked");task.cancel();corrupt=false; + redirect=true;task=ArtifactDownload.Task.gatewayUpdate(base,identifier,release,dir);task.run(()->"fixture=session",null);check(task.state==ArtifactDownload.State.FAILED&&redirects==0,"gateway redirects rejected without forwarding credentials");task.cancel();redirect=false; + expired=true;task=ArtifactDownload.Task.gatewayUpdate(base,identifier,release,dir);task.run(()->"fixture=session",null);check(task.state==ArtifactDownload.State.FAILED&&task.restartRequired,"expired cache requires new prepare");task.cancel(); + check(dir.list().length==0,"temporary downloads cleaned");System.out.println(checks+" gateway update download checks passed"); + }finally{server.stop(0);} + } +} diff --git a/mobile/tests/UpdateDownloadTests.java b/mobile/tests/UpdateDownloadTests.java new file mode 100644 index 00000000..e92a4bca --- /dev/null +++ b/mobile/tests/UpdateDownloadTests.java @@ -0,0 +1,44 @@ +import io.github.try2love.codexbridge.*; +import java.io.*; +import java.net.*; +import java.nio.file.*; +import java.security.*; +import java.util.*; + +/** Exercises the actual updater with synthetic responses, without external traffic. */ +public class UpdateDownloadTests { + static byte[] payload="signed-package-fixture".getBytes(java.nio.charset.StandardCharsets.UTF_8); + static String redirect;static long length=payload.length;static int cookies,checks; + static class Response extends HttpURLConnection { + Response(URL url){super(url);} + public void connect(){}public void disconnect(){}public boolean usingProxy(){return false;} + public int getResponseCode(){return redirect!=null&&url.getHost().equals("github.com")?302:200;} + public String getHeaderField(String key){return key.equalsIgnoreCase("Location")?redirect:null;} + public long getContentLengthLong(){return length;} + public InputStream getInputStream(){return new ByteArrayInputStream(payload);} + public void setRequestProperty(String key,String value){check(!key.equalsIgnoreCase("Cookie"),"release request must not carry cookies");} + } + public static void main(String[] args)throws Exception { + URL.setURLStreamHandlerFactory(protocol->protocol.equals("https")?new URLStreamHandler(){protected URLConnection openConnection(URL url){return new Response(url);}}:null); + File dir=new File(args[0]);dir.mkdirs();StringBuilder digest=new StringBuilder();for(byte b:MessageDigest.getInstance("SHA-256").digest(payload))digest.append(String.format("%02x",b)); + for(String missing:new String[]{null,"sha256:invalid"}){ + MobileRelease release=new MobileRelease("2.0.3","",payload.length,missing); + try{task(release,dir);throw new AssertionError("missing or malformed digest accepted");}catch(IOException expected){checks++;} + } + MobileRelease valid=new MobileRelease("2.0.3","",payload.length,"sha256:"+digest); + run(valid,dir,ArtifactDownload.State.COMPLETE); + run(new MobileRelease("2.0.3","",payload.length,String.join("",Collections.nCopies(64,"0"))),dir,ArtifactDownload.State.FAILED); + run(new MobileRelease("2.0.3","",payload.length+1,digest.toString()),dir,ArtifactDownload.State.FAILED); + for(long size:new long[]{-1,0,512L*1024*1024+1})try{task(new MobileRelease("2.0.3","",size,digest.toString()),dir);throw new AssertionError("invalid release size");}catch(IOException expected){checks++;} + redirect="https://release-assets.githubusercontent.com/fixture.apk";run(valid,dir,ArtifactDownload.State.COMPLETE); + redirect="https://evil.example/fixture.apk";run(valid,dir,ArtifactDownload.State.FAILED); + redirect="http://release-assets.githubusercontent.com/fixture.apk";run(valid,dir,ArtifactDownload.State.FAILED); + redirect=null;length=512L*1024*1024+1;run(valid,dir,ArtifactDownload.State.FAILED); + check(cookies==0,"gateway cookies never read"); + check(dir.list().length==0,"temporary packages cleaned"); + System.out.println(checks+" update download checks passed"); + } + static ArtifactDownload.Task task(MobileRelease r,File dir)throws IOException{return new ArtifactDownload.Task(r.download,MobileRelease.REPO,dir,r.size,r.sha256,MobileRelease.asset(r.version));} + static void run(MobileRelease r,File dir,ArtifactDownload.State expected)throws Exception{ArtifactDownload.Task t=task(r,dir);t.run(()->{cookies++;return "fixture=private";},null);check(t.state==expected,"state "+expected+", got "+t.state);t.cancel();} + static void check(boolean ok,String message){checks++;if(!ok)throw new AssertionError(message);} +} diff --git a/mobile/tests/UpdateInstallSessionTests.java b/mobile/tests/UpdateInstallSessionTests.java new file mode 100644 index 00000000..1ae02e0a --- /dev/null +++ b/mobile/tests/UpdateInstallSessionTests.java @@ -0,0 +1,48 @@ +import io.github.try2love.codexbridge.*; +import java.io.*; +import java.nio.file.*; +import java.security.*; +import java.util.*; + +public class UpdateInstallSessionTests { + static int checks; + static byte[] bytes="verified update fixture".getBytes(java.nio.charset.StandardCharsets.UTF_8); + static MobileRelease release; + static File directory; + public static void main(String[] args)throws Exception { + directory=new File(args[0]);directory.mkdirs();StringBuilder digest=new StringBuilder();for(byte b:MessageDigest.getInstance("SHA-256").digest(bytes))digest.append(String.format("%02x",b)); + release=new MobileRelease("2.0.3","",bytes.length,digest.toString()); + File orphan=File.createTempFile("bridge-download-",".part",directory);Files.write(orphan.toPath(),bytes); + check(UpdateInstallSession.restore(directory,"2.0.2")==null&&!orphan.exists(),"process-death partial APK reclaimed without a record"); + UpdateInstallSession session=create();session.save(); + orphan=File.createTempFile("bridge-download-",".part",directory);Files.write(orphan.toPath(),bytes); + check(UpdateInstallSession.restore(directory,"2.0.2")!=null&&!orphan.exists(),"unreferenced APK removed while recorded package preserved"); + check(session.completed(),"one automatic handoff");check(!session.completed(),"queued completion ignored"); + check(session.beginInstall(),"first installer request");check(!session.beginInstall(),"duplicate installer blocked"); + session.awaitPermission();check(!session.beginInstall(),"permission request not repeated"); + UpdateInstallSession restored=UpdateInstallSession.restore(directory,"2.0.2"); + check(restored!=null&&restored.waitingPermission(),"permission survives recreation"); + check(!restored.completed(),"restore does not replay completion"); + check(restored.permissionReturned(),"permission return consumed");check(!restored.permissionReturned(),"return consumed once"); + check(restored.beginInstall(),"resume after permission");restored.finishInstall(); + check(!restored.completed(),"cancelled installer not automatically reopened"); + check(restored.beginInstall(),"explicit retry allowed");restored.finishInstall(); + check(Arrays.equals(bytes,Files.readAllBytes(restored.task.result().file.toPath())),"saved APK remains readable"); + Thread.currentThread().interrupt(); + try{UpdateInstallSession.restore(directory,"2.0.2");throw new AssertionError("interrupted restore continued");}catch(InterruptedIOException expected){checks++;}finally{Thread.interrupted();} + check(UpdateInstallSession.restore(directory,"2.0.2")!=null,"interrupted verification preserves recoverable APK"); + restored.cancel();check(UpdateInstallSession.restore(directory,"2.0.2")==null,"cancel clears recovery"); + check(directory.list().length==0,"cancel removes APK"); + session=create();session.save();check(UpdateInstallSession.restore(directory,"2.0.3")==null,"installed version clears pending package");check(directory.list().length==0,"successful upgrade cleanup"); + session=create();session.save();Files.write(session.task.result().file.toPath(),new byte[bytes.length]); + try{UpdateInstallSession.restore(directory,"2.0.2");throw new AssertionError("tampered APK accepted");}catch(IOException expected){checks++;} + try{session.task.verifyCompletedUpdate();throw new AssertionError("tampered install accepted");}catch(IOException expected){checks++;}session.cancel(); + session=create();session.save();Files.write(session.task.result().file.toPath(),new byte[]{1}); + try{UpdateInstallSession.restore(directory,"2.0.2");throw new AssertionError("truncated APK accepted");}catch(IOException expected){checks++;}session.cancel(); + session=create();session.save();File record=new File(directory,"pending.properties");Properties p=new Properties();try(InputStream in=new FileInputStream(record)){p.load(in);}p.setProperty("file","../other.apk");try(OutputStream out=new FileOutputStream(record)){p.store(out,null);} + try{UpdateInstallSession.restore(directory,"2.0.2");throw new AssertionError("escaped path accepted");}catch(IOException expected){checks++;}session.cancel(); + check(directory.list().length==0,"fixtures cleaned");System.out.println(checks+" update install/recovery checks passed"); + } + static UpdateInstallSession create()throws Exception {File apk=File.createTempFile("bridge-download-",".part",directory);Files.write(apk.toPath(),bytes);ArtifactDownload.Task task=ArtifactDownload.Task.restoreUpdate(release,directory,apk.getName());return new UpdateInstallSession(release,task,directory);} + static void check(boolean ok,String label){checks++;if(!ok)throw new AssertionError(label);} +} diff --git a/relay/protocol.py b/relay/protocol.py index 87dce464..573ea8dc 100644 --- a/relay/protocol.py +++ b/relay/protocol.py @@ -43,6 +43,9 @@ def validate(method, path, size=0): raise ValueError('Unsupported request or body too large') if method == 'GET' and size: raise ValueError('GET body is not allowed') + if ((method == 'GET' and (p.path == '/api/mobile/update' or re.fullmatch(r'/api/mobile/update/(?:status|packages)/[a-f0-9]{64}', p.path))) + or (method == 'POST' and p.path == '/api/mobile/update/prepare')): + return if p.path == '/api/sessions' or (p.path in ('/api/projects', '/api/mobile/events') and method == 'GET') or (p.path in ('/api/activity', '/api/mobile/events/read') and method == 'POST'): return # /api/clients permits explicit disable, native quit, and confirmed Windows diff --git a/scripts/test-mobile-downloads.py b/scripts/test-mobile-downloads.py index 6cfd0b97..56a57cf8 100644 --- a/scripts/test-mobile-downloads.py +++ b/scripts/test-mobile-downloads.py @@ -18,7 +18,11 @@ def java_tool(name): str(root / 'mobile/android/src/io/github/try2love/codexbridge/GatewayURL.java'), str(root / 'mobile/android/src/io/github/try2love/codexbridge/MobileRelease.java'), str(root / 'mobile/android/src/io/github/try2love/codexbridge/ArtifactDownload.java'), + str(root / 'mobile/android/src/io/github/try2love/codexbridge/UpdateInstallSession.java'), + str(root / 'mobile/tests/UpdateDownloadTests.java'), + str(root / 'mobile/tests/UpdateInstallSessionTests.java'), + str(root / 'mobile/tests/GatewayUpdateDownloadTests.java'), str(root / 'mobile/tests/ArtifactDownloadTests.java'), str(root / 'mobile/tests/ArtifactRangeTests.java')], check=True) -for test in ('ArtifactDownloadTests', 'ArtifactRangeTests'): +for test in ('ArtifactDownloadTests', 'ArtifactRangeTests', 'UpdateDownloadTests', 'UpdateInstallSessionTests', 'GatewayUpdateDownloadTests'): subprocess.run([java_tool('java'), '-cp', str(work), test, str(work / test)], check=True) diff --git a/tests/test_mobile_updates.py b/tests/test_mobile_updates.py new file mode 100644 index 00000000..3de025a7 --- /dev/null +++ b/tests/test_mobile_updates.py @@ -0,0 +1,193 @@ +import hashlib +import http.client +import io +import json +from pathlib import Path +import tempfile +import threading +import time +import unittest +from unittest.mock import Mock, patch + +from bridge.features.updates.mobile import MobileUpdates, API, asset_name, asset_url, redirect_allowed, ReleaseRedirect +from urllib.request import Request +import test_bridge as support + +PAYLOAD = b'Android fixture package' * 6000 +SHA = hashlib.sha256(PAYLOAD).hexdigest() +VALUE = {'version': '2.0.3', 'size': len(PAYLOAD), 'sha256': SHA} + + +def row(version='2.0.3', **extra): + return {'tag_name': 'v' + version, 'draft': False, 'prerelease': '-' in version, 'body': 'Fixture', + 'assets': [{'name': asset_name(version), 'browser_download_url': asset_url(version), + 'size': len(PAYLOAD), 'digest': 'sha256:' + SHA}], **extra} + + +class Response(io.BytesIO): + status = 200 + def __init__(self, data): + super().__init__(data) + self.headers = {'Content-Length': str(len(data))} + + +def fixture_service(directory): + service = MobileUpdates(directory) + service._open = Mock(side_effect=lambda url: Response(json.dumps([row()]).encode() if url == API else PAYLOAD)) + return service + + +def completed(service, job): + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + state = service.status(job['id']) + if state['state'] != 'downloading': + return state + time.sleep(.01) + raise AssertionError('download did not terminate') + + +class MobileUpdateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=support.ROOT / '.tmp') + self.service = fixture_service(self.temp.name) + + def tearDown(self): + self.service.close() + self.temp.cleanup() + + def test_channel_selection_and_metadata_cache(self): + self.service._open = Mock(return_value=Response(json.dumps([row('2.0.4-beta.1'), row(), row('2.0.5', draft=True)]).encode())) + self.assertEqual(self.service.check('2.0.2')['release']['version'], '2.0.3') + self.assertIsNone(self.service.check('2.0.3')['release']) + self.assertEqual(self.service.check('2.0.3-beta.1')['release']['version'], '2.0.4-beta.1') + self.service._open.assert_called_once_with(API) + with self.assertRaises(ValueError): self.service.check('../etc') + + def test_no_arbitrary_destination_and_exact_release_identity(self): + for value in ({**VALUE, 'url': 'http://127.0.0.1/private'}, {**VALUE, 'version': '../x'}, + {**VALUE, 'sha256': '0' * 64}, {**VALUE, 'size': True}, {**VALUE, 'version': '2.0.4'}): + with self.assertRaises(ValueError): self.service.prepare(value) + self.assertTrue(all(call.args[0] == API for call in self.service._open.call_args_list)) + + def test_unverifiable_new_release_is_not_reported_as_up_to_date(self): + missing = row() + missing['assets'][0]['digest'] = None + self.service._open = Mock(return_value=Response(json.dumps([missing, row('2.0.1')]).encode())) + with self.assertRaisesRegex(ValueError, '校验信息'): self.service.check('2.0.2') + self.assertIsNone(self.service.check('2.0.4')['release']) + + def test_cache_range_resume_and_concurrent_reuse(self): + job = self.service.prepare(VALUE) + self.assertEqual(completed(self.service, job)['state'], 'ready') + self.assertEqual(self.service.prepare(VALUE)['id'], job['id']) + self.assertEqual(sum(c.args[0] == asset_url('2.0.3') for c in self.service._open.call_args_list), 1) + with self.service.download(job['id'], 'bytes=10-99') as (meta, stream): + self.assertEqual(meta['status'], 206) + self.assertEqual(stream.read(meta['length']), PAYLOAD[10:100]) + with self.service.download(job['id'], 'bytes=100-199', meta['etag']) as (next_meta, stream): + self.assertEqual(next_meta['status'], 206) + self.assertEqual(stream.read(next_meta['length']), PAYLOAD[100:200]) + with self.service.download(job['id'], 'bytes=100-199', '"stale"') as (meta, stream): + self.assertEqual(meta['status'], 200) + with self.service.download(job['id'], 'bytes=999999-') as (meta, stream): + self.assertEqual(meta['status'], 416) + self.service.jobs[job['id']]['used'] = 0 + with self.assertRaises(ValueError): self.service.status(job['id']) + self.service._cleanup() + self.assertEqual(list(self.service.directory.iterdir()), []) + + def test_only_verified_package_is_servable_and_failed_task_can_retry(self): + self.service._open.side_effect = lambda url: Response(json.dumps([row()]).encode() if url == API else b'x' * len(PAYLOAD)) + job = self.service.prepare(VALUE) + self.assertEqual(completed(self.service, job)['state'], 'failed') + with self.assertRaises(ValueError): + with self.service.download(job['id']): pass + self.assertFalse((self.service.directory / (job['id'] + '.apk')).exists()) + self.service._open.side_effect = lambda url: Response(PAYLOAD) + retry = self.service.prepare(VALUE) + self.assertEqual(completed(self.service, retry)['state'], 'ready') + + def test_download_is_shared_and_does_not_hold_http_request(self): + started, resume = threading.Event(), threading.Event() + service = self.service + def read(url): + if url == API: return Response(json.dumps([row()]).encode()) + started.set();resume.wait(2);return Response(PAYLOAD) + service._open.side_effect = read + job = service.prepare(VALUE) + self.assertEqual(job['state'], 'downloading') + self.assertTrue(started.wait(1)) + self.assertEqual(service.prepare(VALUE)['id'], job['id']) + resume.set() + self.assertEqual(completed(service, job)['state'], 'ready') + + def test_incomplete_http_body_finishes_as_failure(self): + self.service.releases() + self.service._open.side_effect = http.client.IncompleteRead(b'partial') + self.assertEqual(completed(self.service, self.service.prepare(VALUE))['state'], 'failed') + + def test_retry_cannot_start_before_old_staging_cleanup(self): + entered, resume = threading.Event(), threading.Event() + unlink = Path.unlink + def slow_unlink(path, *args, **kwargs): + if threading.current_thread().name == 'mobile-update' and path.suffix == '.part': + entered.set();resume.wait(2) + return unlink(path, *args, **kwargs) + self.service._open.side_effect = lambda url: Response(json.dumps([row()]).encode() if url == API else b'x' * len(PAYLOAD)) + with patch.object(Path, 'unlink', slow_unlink): + job = self.service.prepare(VALUE) + self.assertTrue(entered.wait(1)) + retry = self.service.prepare(VALUE) + self.assertEqual(retry['state'], 'downloading') + self.assertEqual(sum(c.args[0] == asset_url('2.0.3') for c in self.service._open.call_args_list), 1) + resume.set() + self.assertEqual(completed(self.service, job)['state'], 'failed') + self.service._open.side_effect = lambda url: Response(PAYLOAD) + self.assertEqual(completed(self.service, self.service.prepare(VALUE))['state'], 'ready') + + def test_release_redirect_rules(self): + for url in ('http://github.com/x', 'https://github.com.evil.test/x', 'https://user@github.com/x', 'https://github.com:8443/x'): + self.assertFalse(redirect_allowed(url)) + self.assertTrue(redirect_allowed('https://release-assets.githubusercontent.com/fixture')) + with self.assertRaises(ValueError): + ReleaseRedirect().redirect_request(Request(API), None, 302, '', {}, 'https://github.com/x') + + +class MobileUpdateHttpTests(unittest.TestCase): + setUpClass = classmethod(support.HttpTests.setUpClass.__func__) + request = support.HttpTests.request + login = support.HttpTests.login + + def setUp(self): + support.HttpTests.setUp(self) + self.temp = tempfile.TemporaryDirectory(dir=support.ROOT / '.tmp') + self.server.mobile_updates = fixture_service(self.temp.name) + + def tearDown(self): + support.HttpTests.tearDown(self) + self.server.mobile_updates.close() + self.temp.cleanup() + + def test_auth_csrf_methods_and_update_download(self): + check, prepare = '/api/mobile/update?current=2.0.2', '/api/mobile/update/prepare' + self.assertEqual(self.request('GET', check)[0], 401) + self.assertEqual(self.request('POST', prepare, VALUE)[0], 401) + headers = self.login() + self.assertEqual(self.request('POST', prepare, VALUE, {'Cookie': headers['Cookie']})[0], 403) + self.assertEqual(self.request('GET', prepare, headers=headers)[0], 405) + self.assertEqual(self.request('GET', check + '&url=http://localhost', headers=headers)[0], 400) + self.assertEqual(self.request('GET', check, headers=headers)[2]['release']['version'], '2.0.3') + job = self.request('POST', prepare, VALUE, headers)[2] + self.assertEqual(completed(self.server.mobile_updates, job)['state'], 'ready') + path = '/api/mobile/update/packages/' + job['id'] + self.assertEqual(self.request('GET', path)[0], 401) + conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=3) + conn.request('GET', path, headers={**headers, 'Range': 'bytes=10-99'}) + response = conn.getresponse() + self.assertEqual(response.status, 206) + self.assertEqual(response.getheader('Content-Type'), 'application/vnd.android.package-archive') + self.assertEqual(response.read(), PAYLOAD[10:100]) + conn.close() + self.server.auth.config['mode'] = 'none' + self.assertEqual(self.request('GET', check, headers=headers)[0], 403) diff --git a/tests/test_shared_relay.py b/tests/test_shared_relay.py index e1604f33..7e391e92 100644 --- a/tests/test_shared_relay.py +++ b/tests/test_shared_relay.py @@ -81,6 +81,13 @@ def test_desktop_account_access_and_attachments_keep_relay_boundaries(self): validate(method, root+action) def test_protocol_v2_routes_and_management_boundary(self): + for method, path in [('GET', '/api/mobile/update?current=2.0.2'), ('POST', '/api/mobile/update/prepare'), + ('GET', '/api/mobile/update/status/' + 'a' * 64), ('GET', '/api/mobile/update/packages/' + 'a' * 64)]: + validate(method, path) + for method, path in [('GET', '/api/mobile/update/prepare'), ('POST', '/api/mobile/update'), + ('POST', '/api/mobile/update/packages/' + 'a' * 64), ('GET', '/api/mobile/update/packages/../private'), + ('POST', '/api/update-apply')]: + with self.assertRaises(PermissionError): validate(method, path) for method, action in [('GET','workspace/download?path=a.txt'), ('POST','workspace/upload?path=a.txt'), ('POST','terminal'), ('POST','side-chat'), ('GET','catalog?kind=skills'), ('POST','permissions')]: validate(method, '/api/sessions/'+THREAD+'/'+action) for path in ['/api/accounts', '/api/account/reset', '/api/login', '/api/pair', 'https://example.com/api/sessions', '/api/sessions/'+THREAD+'/events', '/api/%73essions', '/api/../admin']: @@ -169,7 +176,7 @@ async def socket(self, device): self.sockets.append(ws) return ws - async def real_connector(self, device, desktop_sessions=None): + async def real_connector(self, device, desktop_sessions=None, mobile_updates=None): directory = Path(self.temp.name)/device['deviceId'] save_config(directory, {**device, 'url': self.url, 'enabled': True}) bridge = SimpleNamespace(host_errors=[], list=lambda **kw: [{'title': device['deviceName'], 'id': THREAD}]) @@ -177,7 +184,7 @@ async def real_connector(self, device, desktop_sessions=None): bridge.for_host = lambda host: bridge bridge.terminal = lambda thread, owner, action, body: {'owner': owner, 'action': action} gateway = SimpleNamespace(bridge=bridge, web_dir=ROOT/'web', notifications=None, desktop_sessions=desktop_sessions, - transfer_directory=directory/'gateway-settings') + transfer_directory=directory/'gateway-settings', mobile_updates=mobile_updates) connector = Connector(directory, gateway, test_http=True) connector.start(); self.connectors.append(connector) for _ in range(100): @@ -186,6 +193,31 @@ async def real_connector(self, device, desktop_sessions=None): await asyncio.sleep(.02) self.fail('Connector did not come online') + async def test_mobile_update_assistance_through_authenticated_connector(self): + from test_mobile_updates import fixture_service, VALUE, PAYLOAD, completed + service = fixture_service(Path(self.temp.name) / 'updates') + device = await self.device('Updater') + connector = await self.real_connector(device, mobile_updates=service) + self.assertIs(connector.server.mobile_updates, service) + phone = await self.phone(device) + check = await self.client.get('/api/mobile/update?current=2.0.2', headers=phone) + self.assertEqual(check.status, 200) + self.assertEqual((await check.json())['release']['version'], '2.0.3') + denied = await self.client.post('/api/mobile/update/prepare', json=VALUE, headers={'Cookie': phone['Cookie'], 'Origin': self.url}) + self.assertIn(denied.status, (401, 403)) + prepared = await self.client.post('/api/mobile/update/prepare', json=VALUE, headers=phone) + self.assertEqual(prepared.status, 200) + job = await prepared.json() + self.assertEqual((await asyncio.to_thread(completed, service, job))['state'], 'ready') + path = '/api/mobile/update/packages/' + job['id'] + response = await self.client.get(path, headers={**phone, 'Range': 'bytes=10-99'}) + self.assertEqual(response.status, 206) + self.assertEqual(await response.read(), PAYLOAD[10:100]) + self.assertEqual(response.headers['Content-Range'], 'bytes 10-99/' + str(len(PAYLOAD))) + await asyncio.to_thread(connector.close) + self.assertFalse(service.closed.is_set(), 'Stopping relay must not close shared gateway update service') + service.close() + async def test_two_real_connectors_route_independently_and_phone_owners_are_separate(self): a, b = await self.device('Alice'), await self.device('Bob') await self.real_connector(a); await self.real_connector(b)