[feat] complete mobile session controls and file transfer

This commit is contained in:
dijunkun
2026-10-09 12:57:23 +08:00
parent 080b9760e3
commit 41aed0984f
16 changed files with 641 additions and 76 deletions
+8 -1
View File
@@ -12,6 +12,7 @@
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
- 平板首页支持横竖屏和分屏,按当前可用宽度自动适配:900 dp 起连接与最近连接并排,较窄窗口纵向排列;最近连接随宽度显示 1–4 列,手机常规宽度仍为两列。首页最大宽度为 1200 dp,设置、公告及阅读页最大宽度为 720 dp,宽窗口中的密码和隐私弹窗居中限宽。旋转或调整分屏直接重排现有页面,保留输入、设置编辑和当前会话;手机全屏首页保持竖屏。
@@ -22,7 +23,7 @@
- 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。
- “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。
当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
当前不包含安卓被控端、隐私屏或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
## 本机更新提示
@@ -151,6 +152,10 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。浮动键盘切换“电脑”后的“复制 / 粘贴 / 全选”根据远端平台选择 Ctrl 或 macOS Command。
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
选取或保存文件期间保留会话,取消选择不会断开;文件选择器停留超过 5 分钟且应用仍不在前台时结束会话。其他进入后台的操作仍结束会话。发送副本和收到的文件暂存于应用私有缓存,结束会话后清除,需要保留的接收文件应在断开前导出;不申请全盘存储权限。
首页设置里的“画面偏好”保存在本机,作为下一次连接的默认偏好;会话内画面设置与 iOS 一样只影响当前连接。初始质量按屏幕长边选择低(不超过 1280 像素)或中,采集帧率为 30 fps,电脑端报告支持后自动下发;旧版电脑未报告支持时禁用控件。网络页的 FPS 统计实际提交给 Android Surface 的帧,画面延时使用 MiniRTC 校准后的采集时间至提交时刻的估计值,未完成校准时显示“—”;RTT 使用 0.25 权重平滑,3 秒未更新的数据会过期。切换屏幕清除旧画面的帧率、分辨率和画面延时,保留网络统计。
采集时间的有效性在接收时检查,本机解码和显示等待计入画面延时;真实延时超过 5 秒也会继续显示,不会当成无效时间戳过滤。显示线程复用解码器的 CPU NV12 缓冲区,只保留一帧待显示画面,切换显示器或 Surface 时清除旧帧,会话结束时先停止显示线程再销毁原生连接。
@@ -202,6 +207,8 @@ python3 test/run_native.py
UI 验证统一由人工完成,不维护页面导航、布局、动画、悬浮窗和截图采集的自动化用例。许可证和构建资料的完整性由 `test/verify_apk.py` 检查。
控制栏联调需在真实远程会话中验证文件选择及取消、收发文件内容一致性、接收后导出、传输中断线;Windows 远端分别检查系统服务开启与关闭时的 Ctrl+Alt+Del 行为。
## MediaCodec 编解码
实现参照 MiniRTC 的 NVCodec 分层:`src/media/mediacodec/` 负责硬件后端、能力查询和 JNI/Surface 资源;`src/media/video/encode/mediacodec/` 与 `src/media/video/decode/mediacodec/` 分别接入编码器、解码器接口,并负责同编码格式的软件回退。Android 工程直接编译这些源码。通用 API 仅需启用 `hardware_acceleration` 和 `native_video_output`。JavaVM 由 Android JNI 适配层在 `JNI_OnLoad` 中设置到后端私有实现;该桥接不安装为 SDK 头文件,也不导出为动态库符号。当前能力查询和 SurfaceTexture 创建依赖 JNI,缺少 JavaVM 时仍可使用软件编解码。独立输出线程在画面停止更新后仍会取出最后一帧,空闲时休眠。
@@ -4,6 +4,7 @@ import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.ClipData;
import android.content.Intent;
import android.content.ClipboardManager;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
@@ -75,6 +76,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private int port,selectedDisplay;
private JSONArray displays=new JSONArray();
private boolean muted,ready,rememberPassword;
private boolean remoteServiceAvailable;
private static final int PICK_FILE=41,SAVE_FILE=42;
private NativeSession documentSession;
private java.io.File receivedFile,documentToSave;
private final Runnable documentTimeout=()->{if(documentSession!=null&&!foreground)end("文件选择已超时,会话已结束");};
private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1);
private final android.os.Handler mainHandler=new android.os.Handler(android.os.Looper.getMainLooper());
private Runnable videoSettingsTimeout;
@@ -277,6 +283,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
window.setGravity(width>=600?Gravity.CENTER:Gravity.BOTTOM);
}
private void begin(String id,String secret){
remoteServiceAvailable=false;receivedFile=null;
resetRemoteVersionCheck();
resetVideoSettings();
previewCapture=previews.begin(serverKey(),id);previewCopying=false;previewAttempts=0;
@@ -294,6 +301,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
public void key(int code){tapKey(code);}
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);}
@@ -303,6 +311,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
private void end(String reason){
mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;receivedFile=null;remoteServiceAvailable=false;
resetRemoteVersionCheck();
resetVideoSettings();
previewCapture=null;previewCopying=false;
@@ -432,6 +441,44 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private void selectDisplay(){
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
}
private void secureAttention(){
if(!ready||session==null)return;
if(!remoteServiceAvailable){controlFeedback(hostPlatform.equals("windows")?"请在远端启用 CrossDesk 系统服务后重试":"Ctrl+Alt+Del 需要远端 Windows 系统服务支持");return;}
session.secureAttention();
}
private void chooseFile(){
if(!ready||session==null)return;
openDocument(new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"),PICK_FILE);
}
private void saveReceivedFile(){
if(!ready||session==null||receivedFile==null)return;
documentToSave=receivedFile;
openDocument(new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("application/octet-stream").putExtra(Intent.EXTRA_TITLE,receivedFile.getName()),SAVE_FILE);
}
private void openDocument(Intent intent,int request){
if(documentSession!=null)return;
documentSession=session;releaseKeys();
try{startActivityForResult(intent,request);mainHandler.postDelayed(documentTimeout,5*60*1000);}
catch(android.content.ActivityNotFoundException error){documentSession=null;documentToSave=null;controlFeedback("此设备没有可用的文件选择器");}
}
@Override protected void onActivityResult(int request,int result,Intent data){
super.onActivityResult(request,result,data);
if(request!=PICK_FILE&&request!=SAVE_FILE)return;
NativeSession source=documentSession;java.io.File saved=documentToSave;
documentSession=null;documentToSave=null;mainHandler.removeCallbacks(documentTimeout);
if(source==null||source!=session||!ready||result!=RESULT_OK||data==null||data.getData()==null)return;
if(request==PICK_FILE)source.sendFile(data.getData());else if(saved!=null)source.saveFile(saved,data.getData());
}
@Override public void fileTransfer(String name,double progress,boolean sending,java.io.File received){
if(!ready)return;
if(received!=null)receivedFile=received;
String fileStatus=progress<0?name+" 传输失败":(sending?"发送 ":"接收 ")+name+(progress>=1?" · 已完成":" · "+(int)(progress*100)+"%");
if(controls!=null)controls.fileStatus(fileStatus,receivedFile!=null);
if(progress<0||progress>=1)toast(fileStatus);
}
@Override public void controlFeedback(String message){
toast(message);
}
private void clipboard(){
if(!ready)return;
EditText input=field("要发送到远端的文本",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_FLAG_MULTI_LINE);input.setSingleLine(false);input.setMinLines(3);
@@ -540,6 +587,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
@Override public void data(JSONObject message){
if(session==null)return;
if(message.optInt("type",-1)==5){JSONObject service=message.optJSONObject("service_status");remoteServiceAvailable=service!=null&&service.optBoolean("available",false);return;}
if(message.optInt("type",-1)==12){if(!ready)return;videoSettings.receive(message);if(!videoSettings.pending)cancelVideoSettingsTimeout();if(controls!=null)controls.videoSettings(videoSettings);return;}
JSONObject info=message.optJSONObject("host_info");
if(info!=null){
@@ -572,6 +620,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
@Override protected void onDestroy(){appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();if(session!=null)session.close();stopSignaling();super.onDestroy();}
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
// The system document picker is part of this session's explicit file action.
if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();}
super.onStop();}
@Override protected void onDestroy(){mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();if(session!=null)session.close();stopSignaling();super.onDestroy();}
}
@@ -28,6 +28,8 @@ final class NativeSession implements AutoCloseable {
void videoSize(int width, int height);
void clipboard(String text);
void statistics(RemoteNetworkStatistics.Snapshot value);
default void fileTransfer(String name,double progress,boolean sending,File received) { }
default void controlFeedback(String message) { }
default void signaling(int state, String deviceId) { }
default void presence(JSONObject message) { }
default void announcement(JSONObject message) { }
@@ -52,6 +54,11 @@ final class NativeSession implements AutoCloseable {
private volatile boolean closed;
private Surface surface;
private volatile AudioPlayer audio;
private final SessionDocuments documents;
private boolean sendingFile;
private final Runnable fileTick=new Runnable(){public void run(){
execute(()->{boolean sending=connected&&handle!=0&&nPumpFiles(handle);if(!closed)main.postDelayed(this,sending?10:1000);});
}};
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
private final Runnable statisticsTick=new Runnable(){public void run(){
if(closed)return;
@@ -67,6 +74,7 @@ final class NativeSession implements AutoCloseable {
this.host = host; this.port = port; this.remote = remote; this.password = password;
scope = host.toLowerCase(java.util.Locale.ROOT) + ":" + port;
secrets = new SecretStore(this.context);
documents = new SessionDocuments(this.context);
}
void start() {
if (started || closed) return;
@@ -80,6 +88,7 @@ final class NativeSession implements AutoCloseable {
if (!logs.isDirectory() && !logs.mkdirs()) throw new IllegalStateException("Log directory unavailable");
handle = nCreate(this, host, port, identity, logs.getAbsolutePath(), certificates.getAbsolutePath());
if (handle == 0) { fail("原生连接初始化失败"); return; }
nFileDirectory(handle,new File(documents.directory,"received").getAbsolutePath());
if (surface != null && surface.isValid()) nSurface(handle, surface);
} catch (Exception error) { fail("安全存储或证书初始化失败,请重试或清除应用数据"); }
});
@@ -169,7 +178,7 @@ final class NativeSession implements AutoCloseable {
connected = true; main.removeCallbacks(timeout); nReady(handle);
audio = new AudioPlayer();
nControl(handle, 2, 1);
ui(() -> { listener.connected(); if(!closed)statisticsTick.run(); });
ui(() -> { listener.connected(); if(!closed){statisticsTick.run();fileTick.run();} });
} else if (status == 0 || status == 2) ui(() -> listener.status("正在建立安全连接…"));
else if (status == 6) ui(() -> { listener.passwordRejected(); close(); });
else {
@@ -186,6 +195,11 @@ final class NativeSession implements AutoCloseable {
else if (type == 9 && signalReady && presenceRequested) {
long generation=event.optLong("generation");
if(generation==signalGeneration)ui(()->{if(generation==signalGeneration)listener.presence(event);});
} else if(type==10){
String name=event.getString("name"),path=event.optString("path");
double progress=event.getDouble("progress");boolean sending=event.getBoolean("sending");
ui(()->{if(sending&&(progress<0||progress>=1))sendingFile=false;
listener.fileTransfer(name,progress,sending,path.isEmpty()?null:new File(path));});
}
} catch (Exception error) {
if (type == 3) fail("设备身份保存失败,请检查安全存储");
@@ -232,14 +246,36 @@ final class NativeSession implements AutoCloseable {
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
execute(() -> { if (connected) nClipboard(handle, bytes); });
}
void secureAttention(){execute(()->{
boolean sent=connected&&nSecureAttention(handle);
ui(()->listener.controlFeedback(sent?"已发送 Ctrl+Alt+Del":"指令发送失败,请重试"));
});}
void sendFile(android.net.Uri uri){
if(closed)return;
if(sendingFile){listener.controlFeedback("请等待当前文件发送完成");return;}
sendingFile=true;listener.fileTransfer("正在读取所选文件…",0,true,null);
documents.prepare(uri,new SessionDocuments.Prepared(){
public void ready(File file,String name){execute(()->{
if(!connected||!nSendFile(handle,file.getAbsolutePath(),name.getBytes(StandardCharsets.UTF_8))){
file.delete();ui(()->{sendingFile=false;listener.fileTransfer(name,-1,true,null);});
}
});}
public void failed(){ui(()->{sendingFile=false;listener.fileTransfer("所选文件(无法读取或空间不足)",-1,true,null);});}
});
}
void saveFile(File file,android.net.Uri destination){
if(closed)return;
documents.save(file,destination,success->ui(()->listener.controlFeedback(success?"文件已保存":"文件保存失败,请重试")));
}
@Override public void close() {
if (closed) return;
closed = true; main.removeCallbacks(timeout);main.removeCallbacks(statisticsTick);
closed = true; main.removeCallbacks(timeout);main.removeCallbacks(statisticsTick);main.removeCallbacks(fileTick);
RTC.execute(() -> {
main.removeCallbacks(timeout);
if (audio != null) { audio.close(); audio = null; }
password = "";
if (handle != 0) { nDestroy(handle); handle = 0; }
documents.close();
surface = null;
});
}
@@ -256,6 +292,10 @@ final class NativeSession implements AutoCloseable {
private static native boolean nAnnouncementRequest(long handle,byte[] data);
private static native boolean nPresenceRequest(long handle,byte[] data);
private static native void nClipboard(long handle, byte[] data);
private static native boolean nSecureAttention(long handle);
private static native void nFileDirectory(long handle,String directory);
private static native boolean nSendFile(long handle,String path,byte[] name);
private static native boolean nPumpFiles(long handle);
private static native void nSurface(long handle, Surface surface);
private static native void nDestroy(long handle);
}
@@ -54,6 +54,8 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
point(current); return true;
}
});
// Let the remote desktop detect double-clicks; the default listener consumes the second tap.
gestures.setOnDoubleTapListener(null);
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false;
@@ -13,6 +13,7 @@ final class SessionControls extends FrameLayout {
void disconnect(); void display(); void clipboard(); void key(int code);
void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse();
void videoSettings(int field,int value);
void sendFile(); void saveFile(); void secureAttention();
}
private final MobileUi ui;
private final Actions actions;
@@ -23,6 +24,9 @@ final class SessionControls extends FrameLayout {
private ScrollView keyboardKeys;
private int keyboardPreferredHeight;
private TextView networkLabel,videoFeedback;
private TextView transferLabel,saveFileButton;
private String transferStatus="";
private boolean hasReceivedFile;
private String menuPage="controls";
private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0);
private RemoteVideoSettings settings=new RemoteVideoSettings(1);
@@ -50,7 +54,12 @@ final class SessionControls extends FrameLayout {
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");}
void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();}
private void renderFileStatus(){
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");}
private void openMenu(String page){
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
menu=ui.column();menu.setPadding(ui.dp(12),ui.dp(12),ui.dp(12),ui.dp(12));ui.card(menu,18);menu.setElevation(ui.dp(12));
@@ -67,11 +76,12 @@ final class SessionControls extends FrameLayout {
if(page.equals("controls")){
String[] labels={"键盘","显示器","画面设置",muted?"静音":"声音",relative?"相对鼠标":"绝对鼠标","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
String[] icons={"keyboard","display","sliders","audio","mouse","folder","chart","lock","clipboard"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,()->{},()->openMenu("network"),()->{},actions::clipboard};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
for(int row=0;row<3;row++){LinearLayout line=ui.row();for(int col=0;col<3;col++){int index=row*3+col;LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
if(index==5||index==7){control.setEnabled(false);control.setAlpha(.38f);control.setContentDescription(labels[index]+",暂不支持");}
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
}body.addView(line);if(row<2)ui.gap(body,7);}
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
}else if(page.equals("network")){
networkPage(body);
}else{
@@ -0,0 +1,75 @@
package cn.crossdesk.mobile;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import java.io.*;
import java.nio.file.Files;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** Private session files; document-provider reads/writes never run on UI/RTC threads. */
final class SessionDocuments {
interface Prepared { void ready(File file,String name); void failed(); }
private final Context context;
private final ExecutorService io=Executors.newSingleThreadExecutor(r->new Thread(r,"CrossDesk documents"));
final File directory;
private volatile boolean closed;
SessionDocuments(Context context){
this.context=context.getApplicationContext();
directory=new File(context.getCacheDir(),"transfers/"+UUID.randomUUID());
}
void prepare(Uri uri,Prepared callback){
io.execute(()->{
File staged=null;
try{
if(closed)return;
String name="file";
try(Cursor cursor=context.getContentResolver().query(uri,new String[]{OpenableColumns.DISPLAY_NAME},null,null,null)){
if(cursor!=null&&cursor.moveToFirst())name=cursor.getString(0);
}
name=safeName(name);
Files.createDirectories(directory.toPath());
staged=File.createTempFile("sending-",".part",directory);
try(InputStream input=context.getContentResolver().openInputStream(uri);OutputStream output=new FileOutputStream(staged)){
if(input==null)throw new IOException("No input");
copy(input,output);
}
if(closed){Files.deleteIfExists(staged.toPath());return;}
callback.ready(staged,name);
}catch(Exception error){
if(staged!=null)staged.delete();
if(!closed)callback.failed();
}
});
}
void save(File file,Uri uri,java.util.function.Consumer<Boolean> completion){
io.execute(()->{
boolean success=false;
try{
if(closed)return;
try(InputStream input=new FileInputStream(file);OutputStream output=context.getContentResolver().openOutputStream(uri,"wt")){
if(output==null)throw new IOException("No output");
copy(input,output);
}
success=true;
}catch(Exception ignored){ }
if(!closed)completion.accept(success);
});
}
private void copy(InputStream input,OutputStream output)throws IOException{
byte[] bytes=new byte[64*1024];int count;
while((count=input.read(bytes))!=-1){if(closed)throw new IOException("Session closed");output.write(bytes,0,count);}
}
static String safeName(String value){
if(value==null)return "file";
value=value.replace('\\','/');value=value.substring(value.lastIndexOf('/')+1).replaceAll("[\\p{Cntrl}:]","_");
return value.isEmpty()||value.equals(".")||value.equals("..")||value.getBytes(StandardCharsets.UTF_8).length>240?"file":value;
}
// Called after native file handles have closed. Queue cleanup behind any provider I/O.
void close(){closed=true;io.execute(()->remove(directory));io.shutdown();}
private static void remove(File file){File[] children=file.listFiles();if(children!=null)for(File child:children)remove(child);file.delete();}
}
+7 -1
View File
@@ -12,6 +12,12 @@
#include <remote_action.h>
namespace crossdesk::android_controller {
inline std::string SecureAttentionMessage() {
RemoteAction action{};
action.type = service_command;
action.c.flag = send_sas;
return action.to_json();
}
inline std::string VideoSettingsMessage(int quality, int frame_rate, int preference,
uint32_t request_id) {
RemoteAction action{};
@@ -32,4 +38,4 @@ inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, Travers
}
} // namespace crossdesk::android_controller
#endif
#endif
+271
View File
@@ -0,0 +1,271 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _FILE_TRANSFER_SESSION_H_
#define _FILE_TRANSFER_SESSION_H_
#include <file_transfer_format.h>
#include <stream_names.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <mutex>
#include <optional>
namespace crossdesk::android_controller {
// File I/O is bounded to one chunk per pump. Feedback is delivered by MiniRTC
// callback threads; no callback waits for the RTC executor or for a remote ACK.
class FileTransferSession {
public:
using Clock = std::chrono::steady_clock;
using Send = std::function<int(const char*, const char*, size_t)>;
using Progress = std::function<void(const std::string&, double, bool,
const std::string&)>;
FileTransferSession(Send send, Progress progress)
: send_(std::move(send)), progress_(std::move(progress)) {}
~FileTransferSession() {
if (outgoing_) {
outgoing_->input.close();
std::error_code ec;
std::filesystem::remove(outgoing_->path, ec);
}
for (auto& entry : incoming_) Remove(entry.second);
}
void SetDirectory(std::filesystem::path directory) {
std::lock_guard<std::mutex> lock(mutex_);
directory_ = std::move(directory);
}
bool Start(const std::filesystem::path& path, const std::string& name,
Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
if (outgoing_ || name.empty() || name.size() > UINT16_MAX) return false;
std::error_code ec;
if (!std::filesystem::is_regular_file(path, ec)) return false;
const auto size = std::filesystem::file_size(path, ec);
if (ec) return false;
Outgoing file;
file.input.open(path, std::ios::binary);
if (!file.input) return false;
file.id = ++next_id_;
file.name = name;
file.path = path;
file.total = size;
file.updated = now;
outgoing_ = std::move(file);
progress_(name, 0, true, "");
return true;
}
void Pump(Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
for (auto it = incoming_.begin(); it != incoming_.end();) {
if (now - it->second.updated < timeout_) { ++it; continue; }
Reject(it->first, it->second.total, it->second.received);
progress_(it->second.name, -1, false, "");
Remove(it->second);
it = incoming_.erase(it);
}
if (!outgoing_) return;
auto& file = *outgoing_;
if (now - file.updated >= timeout_) { FinishSend(false); return; }
if (file.last || file.sent - file.acked >= 1024 * 1024) return;
std::array<char, kFileChunkSize> payload{};
const auto size = static_cast<uint32_t>(std::min<uint64_t>(payload.size(), file.total - file.sent));
if (size) {
file.input.read(payload.data(), size);
if (file.input.gcount() != size) { FinishSend(false); return; }
}
const bool first = file.sent == 0;
const bool last = file.sent + size == file.total;
const auto chunk = EncodeFileChunk(file.id, file.sent, file.total,
payload.data(), size, first ? &file.name : nullptr, first, last);
if (chunk.empty() || send_(kFileStream, chunk.data(), chunk.size()) != 0) {
FinishSend(false); return;
}
file.sent += size;
file.last = last;
}
void Ack(const char* data, size_t size, Clock::time_point now = Clock::now()) {
FileTransferAck ack{};
if (!DecodeFileTransferAck(data, size, &ack)) return;
std::lock_guard<std::mutex> lock(mutex_);
if (!outgoing_ || ack.file_id != outgoing_->id) return;
auto& file = *outgoing_;
if (ack.total_size != file.total || ack.acked_offset > file.sent ||
(ack.flags & ~3u)) return;
if (ack.flags & 2) { FinishSend(false); return; }
if (ack.flags & 1) {
if (file.last && ack.acked_offset == file.total) FinishSend(true);
return;
}
if (ack.acked_offset <= file.acked) return;
file.acked = ack.acked_offset;
file.updated = now;
Report(file.name, file.acked, file.total, true, file.percent);
}
void Receive(const char* data, size_t size, Clock::time_point now = Clock::now()) {
FileChunkView chunk;
if (!DecodeFileChunk(data, size, &chunk)) return;
const auto& h = chunk.header;
std::lock_guard<std::mutex> lock(mutex_);
auto it = incoming_.find(h.file_id);
const bool first = h.flags & 1, last = h.flags & 2;
const bool invalid = (h.flags & ~3u) || h.total_size > INT64_MAX ||
(first && h.offset != 0) || chunk.payload_size > kFileChunkSize ||
size != sizeof(FileChunkHeader) + h.name_len + chunk.payload_size ||
(!first && h.name_len) || (!chunk.payload_size && !last) ||
last != (h.offset + chunk.payload_size == h.total_size);
if (invalid || (it == incoming_.end() && incoming_.size() >= 4)) {
Reject(h.file_id, h.total_size, h.offset);
if (it != incoming_.end()) { progress_(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it); }
return;
}
if (it == incoming_.end()) {
Incoming file;
file.name = SafeName(chunk.file_name);
file.total = h.total_size;
std::error_code ec;
if (!directory_.empty()) std::filesystem::create_directories(directory_, ec);
// A per-transfer directory avoids overwrites, even for duplicate names.
auto folder = directory_ / (std::to_string(h.file_id) + "-" + std::to_string(++receive_id_));
if (directory_.empty() || ec || !std::filesystem::create_directory(folder, ec)) {
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); return;
}
file.path = folder / file.name;
file.partial = folder / ".partial";
file.output.open(file.partial, std::ios::binary | std::ios::trunc);
if (!file.output) {
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); Remove(file); return;
}
it = incoming_.emplace(h.file_id, std::move(file)).first;
}
auto& file = it->second;
if (h.total_size != file.total || (first && file.first) ||
!AddRange(file, h.offset, h.offset + chunk.payload_size)) {
Reject(h.file_id, h.total_size, h.offset);
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
}
if (first) {
file.first = true;
file.name = SafeName(chunk.file_name);
file.path = file.path.parent_path() / file.name;
}
file.last |= last;
// KCP removes messages in order, but delivers batches outside its lock on
// both RTP and timer threads. Track coverage instead of assuming callbacks
// arrive in order; ACK only the contiguous prefix and never expose holes.
file.received = !file.ranges.empty() && file.ranges.begin()->first == 0
? file.ranges.begin()->second : 0;
const bool complete = file.first && file.last && file.received == file.total;
file.output.seekp(static_cast<std::streamoff>(h.offset));
file.output.write(chunk.payload, chunk.payload_size);
if (complete) { file.output.flush(); file.output.close(); }
std::error_code ec;
if (complete && !file.output.fail()) std::filesystem::rename(file.partial, file.path, ec);
if (file.output.fail() || ec) {
Reject(h.file_id, h.total_size, h.offset);
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
}
file.updated = now;
const FileTransferAck ack{kFileAckMagic, h.file_id, file.received, file.total, complete ? 1u : 0u};
const auto packet = EncodeFileTransferAck(ack);
send_(kFileFeedbackStream, packet.data(), packet.size());
if (complete) {
progress_(file.name, 1, false, file.path.string());
incoming_.erase(it);
} else Report(file.name, file.received, file.total, false, file.percent);
}
bool Sending() {
std::lock_guard<std::mutex> lock(mutex_);
return outgoing_.has_value();
}
private:
struct Outgoing {
uint32_t id = 0;
std::string name;
std::filesystem::path path;
std::ifstream input;
uint64_t total = 0, sent = 0, acked = 0;
bool last = false;
int percent = -1;
Clock::time_point updated;
};
struct Incoming {
std::string name;
std::filesystem::path path, partial;
std::ofstream output;
uint64_t total = 0, received = 0;
bool first = false, last = false;
std::map<uint64_t, uint64_t> ranges;
int percent = -1;
Clock::time_point updated;
};
static bool AddRange(Incoming& file, uint64_t start, uint64_t end) {
if (start == end) return true;
auto next = file.ranges.lower_bound(start);
if (next != file.ranges.end() && next->first < end) return false;
if (next != file.ranges.begin()) {
auto previous = std::prev(next);
if (previous->second > start) return false;
if (previous->second == start) { start = previous->first; file.ranges.erase(previous); }
}
if (next != file.ranges.end() && next->first == end) { end = next->second; file.ranges.erase(next); }
file.ranges.emplace(start, end);
return file.ranges.size() <= 1024;
}
static std::string SafeName(std::string name) {
const auto slash = name.find_last_of("/\\");
if (slash != std::string::npos) name.erase(0, slash + 1);
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == ':') ch = '_';
if (name.empty() || name == "." || name == ".." || name == ".partial" || name.size() > 240) return "received_file";
return name;
}
static void Remove(Incoming& file) {
file.output.close();
std::error_code ec;
std::filesystem::remove(file.partial, ec);
std::filesystem::remove(file.path.parent_path(), ec);
}
void Reject(uint32_t id, uint64_t total, uint64_t offset) {
const auto packet = EncodeFileTransferAck({kFileAckMagic, id, offset, total, 2});
send_(kFileFeedbackStream, packet.data(), packet.size());
}
void Report(const std::string& name, uint64_t offset, uint64_t total, bool sending, int& previous) {
const double progress = total ? static_cast<double>(offset) / total : 0;
const int percent = static_cast<int>(progress * 100);
if (percent != previous) { previous = percent; progress_(name, std::min(progress, .99), sending, ""); }
}
void FinishSend(bool success) {
progress_(outgoing_->name, success ? 1 : -1, true, "");
outgoing_->input.close();
std::error_code ec;
std::filesystem::remove(outgoing_->path, ec);
outgoing_.reset();
}
const std::chrono::seconds timeout_{30};
std::mutex mutex_;
Send send_;
Progress progress_;
std::filesystem::path directory_;
std::optional<Outgoing> outgoing_;
std::map<uint32_t, Incoming> incoming_;
uint32_t next_id_ = 0;
uint64_t receive_id_ = 0;
};
} // namespace crossdesk::android_controller
#endif
+37 -2
View File
@@ -18,6 +18,7 @@
#include <stdexcept>
#include <vector>
#include "controller_protocol.h"
#include "file_transfer_session.h"
#include "android_codec_support.h"
#include "latest_frame_worker.h"
#include "video_picture.h"
@@ -76,6 +77,14 @@ struct Session {
float pointer_x = .5f, pointer_y = .5f;
android_controller::LatestFrameWorker<VideoPicture> renderer{
[this](VideoPicture picture) { RenderVideo(this, std::move(picture)); }};
android_controller::FileTransferSession files{
[this](const char* stream, const char* data, size_t size) {
return controller && !stopping
? SendReliableDataFrame(controller, data, size, stream) : -1;
},
[this](const std::string& name, double progress, bool sending, const std::string& path) {
Event(10, {{"name", name}, {"progress", progress}, {"sending", sending}, {"path", path}});
}};
void Event(int type, const char* data, size_t size) {
if (stopping || size > 1024 * 1024) return;
@@ -89,7 +98,7 @@ struct Session {
if (scope.env->ExceptionCheck()) scope.env->ExceptionClear();
}
void Event(int type, const json& value) {
const std::string data = value.dump(); Event(type, data.data(), data.size());
const std::string data = value.dump(-1, ' ', false, json::error_handler_t::replace); Event(type, data.data(), data.size());
}
void Send(const char* stream, const std::string& data) {
if (controller && !stopping && !data.empty())
@@ -180,6 +189,8 @@ void Data(const char* data, size_t size, const char*, size_t,
auto* ctx = static_cast<CallbackContext*>(user);
if (!ctx->controller || !data || !stream || size > kMaxClipboardBytes) return;
const std::string name(stream, stream_size);
if (name == kFileStream) { ctx->owner->files.Receive(data, size); return; }
if (name == kFileFeedbackStream) { ctx->owner->files.Ack(data, size); return; }
if (name == kControlStream) {
RemoteAction action{};
if (action.from_json(std::string(data, size))) {
@@ -352,7 +363,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
AddAudioStream(s->controller, kAudioStream);
AddDataStream(s->controller, kDataStream, false);
for (const char* name : {kMouseStream, kKeyboardStream, kControlStream,
kClipboardStream}) AddDataStream(s->controller, name, true);
kClipboardStream, kFileStream, kFileFeedbackStream}) AddDataStream(s->controller, name, true);
if (Init(s->controller) != 0) return false;
const auto join = s->remote + "@" + String(env, password);
return JoinConnection(s->controller, join.c_str()) == 0;
@@ -401,6 +412,30 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nVideoSettings)(
if (data.empty()) return false;
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
}
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jclass, jlong handle) {
auto* s = From(handle);
if (!s || !s->controller || s->stopping) return false;
const auto data = android_controller::SecureAttentionMessage();
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) {
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
}
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSendFile)(JNIEnv* env, jclass, jlong handle, jstring path, jbyteArray name) {
auto* s = From(handle);
if (!s || !s->controller || s->stopping || !name) return false;
const auto size = env->GetArrayLength(name);
if (!size || size > UINT16_MAX) return false;
std::string label(size, '\0');
env->GetByteArrayRegion(name, 0, size, reinterpret_cast<jbyte*>(label.data()));
return !env->ExceptionCheck() && s->files.Start(String(env, path), label);
}
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPumpFiles)(JNIEnv*, jclass, jlong handle) {
auto* s = From(handle);
if (!s || s->stopping) return false;
s->files.Pump();
return s->files.Sending();
}
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nAnnouncementRequest)(
JNIEnv* env, jclass, jlong handle, jbyteArray data) {
auto* s = From(handle);
+1 -1
View File
@@ -10,7 +10,7 @@ target("crossdesk_android")
set_kind("shared")
add_deps("minirtc")
add_packages("nlohmann_json", "libyuv", "spdlog")
add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp")
add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp", "../../../libs/wire/src/file_transfer.cpp")
add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include")
-- This source-level JNI adapter is the only application layer using MiniRTC internals.
add_includedirs("../../../deps/submodules/minirtc/src/media/mediacodec")
@@ -118,6 +118,10 @@ typedef struct {
didReceiveAudioPCM:(NSData *)pcmData;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveClipboardText:(NSString *)text;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveServiceAvailable:(BOOL)available;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didSendSecureAttention:(BOOL)success;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateFileTransfer:(NSString *)fileName
progress:(double)progress
@@ -942,68 +942,61 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
- (void)sendFileAtURL:(NSURL *)fileURL {
if (!fileURL.isFileURL) return;
NSURL *url = [fileURL copy];
NSString *fileName = url.lastPathComponent.length > 0 ? url.lastPathComponent : @"file";
const uint64_t generation = _activeControllerGeneration.load();
void (^reportProgress)(double) = ^(double progress) {
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
[delegate rtcBridge:self didUpdateFileTransfer:fileName progress:progress sending:YES];
}
});
};
// Acquire the document picker's sandbox extension before its completion
// callback returns; the actual reads happen on the serialized RTC queue.
const BOOL scoped = [url startAccessingSecurityScopedResource];
dispatch_async(_rtcQueue, ^{
if (!self->_state->controller_peer) {
FILE *input = nullptr;
const auto finish = [&](bool failed) {
if (input) std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
if (failed) reportProgress(-1);
};
if (![self isControllerGenerationActive:generation] || !self->_state->controller_peer) {
finish(false);
return;
}
const char *path_bytes = url.path.fileSystemRepresentation;
FILE *input = path_bytes ? std::fopen(path_bytes, "rb") : nullptr;
if (!input) {
if (scoped) [url stopAccessingSecurityScopedResource];
return;
}
if (fseeko(input, 0, SEEK_END) != 0) {
std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
input = path_bytes ? std::fopen(path_bytes, "rb") : nullptr;
if (!input || fseeko(input, 0, SEEK_END) != 0) {
finish(true);
return;
}
const off_t end = ftello(input);
rewind(input);
if (end < 0) {
std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
return;
}
NSString *last_component = url.lastPathComponent.length > 0
? url.lastPathComponent
: @"file";
NSData *name_data = [last_component dataUsingEncoding:NSUTF8StringEncoding];
if (name_data.length == 0 ||
NSData *name_data = [fileName dataUsingEncoding:NSUTF8StringEncoding];
if (end < 0 || name_data.length == 0 ||
name_data.length > std::numeric_limits<uint16_t>::max()) {
std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
finish(true);
return;
}
const std::string file_name(static_cast<const char *>(name_data.bytes), name_data.length);
const uint32_t file_id = g_next_file_id.fetch_add(1);
const uint64_t total_size = static_cast<uint64_t>(end);
{
std::lock_guard<std::mutex> lock(self->_fileMutex);
self->_state->outgoing_files[file_id] = {
std::string(last_component.UTF8String ?: "file"), total_size};
self->_state->outgoing_files[file_id] = {file_name, total_size};
}
DispatchMain(^{
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
[delegate rtcBridge:self
didUpdateFileTransfer:last_component
progress:0
sending:YES];
}
});
reportProgress(0);
std::vector<char> payload(kFileChunkSize);
uint64_t offset = 0;
bool first = true;
int send_result = 0;
do {
if (![self isControllerGenerationActive:generation]) { send_result = -1; break; }
const size_t to_read = static_cast<size_t>(
std::min<uint64_t>(kFileChunkSize, total_size - offset));
const size_t bytes_read = to_read > 0
@@ -1014,7 +1007,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
break;
}
const bool last = offset + bytes_read >= total_size;
const std::string file_name(last_component.UTF8String ?: "file");
const std::string* file_name_pointer = first ? &file_name : nullptr;
std::vector<char> chunk = crossdesk::EncodeFileChunk(
file_id, offset, total_size, payload.data(),
@@ -1031,32 +1023,32 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
if (send_result != 0) break;
} while (offset < total_size);
std::fclose(input);
if (scoped) [url stopAccessingSecurityScopedResource];
if (send_result != 0) {
{
std::lock_guard<std::mutex> lock(self->_fileMutex);
self->_state->outgoing_files.erase(file_id);
}
DispatchMain(^{
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
[delegate rtcBridge:self
didUpdateFileTransfer:last_component
progress:-1
sending:YES];
}
});
std::lock_guard<std::mutex> lock(self->_fileMutex);
self->_state->outgoing_files.erase(file_id);
}
finish(send_result != 0);
});
}
- (void)sendSecureAttentionSequence {
RemoteAction action{};
action.type = ControlType::service_command;
action.c.flag = ServiceCommandFlag::send_sas;
[self sendMessage:action.to_json() reliable:YES stream:kControlStream];
const uint64_t generation = _activeControllerGeneration.load();
dispatch_async(_rtcQueue, ^{
if (![self isControllerGenerationActive:generation]) return;
RemoteAction action{};
action.type = ControlType::service_command;
action.c.flag = ServiceCommandFlag::send_sas;
const auto message = action.to_json();
const BOOL success = self->_state->controller_peer &&
SendReliableDataFrame(self->_state->controller_peer, message.data(), message.size(), kControlStream) == 0;
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didSendSecureAttention:)]) {
[delegate rtcBridge:self didSendSecureAttention:success];
}
});
});
}
- (void)sendMessage:(const std::string &)message
@@ -1692,6 +1684,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
[self notifyVideoSettings:action.vs generation:generation];
return;
}
if (action.type == ControlType::service_status) {
const BOOL available = action.ss.available;
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveServiceAvailable:)]) {
[delegate rtcBridge:self didReceiveServiceAvailable:available];
}
});
return;
}
if (action.type != ControlType::host_infomation) return;
const BOOL supports_video_settings = action.i.supports_video_settings;
NSString *platform = [NSString stringWithUTF8String:
@@ -1785,6 +1788,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
encoding:NSUTF8StringEncoding];
if (!text) return;
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didReceiveClipboardText:)]) {
@@ -1804,6 +1808,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
std::lock_guard<std::mutex> lock(_fileMutex);
auto it = _state->outgoing_files.find(ack.file_id);
if (it == _state->outgoing_files.end()) return;
if (ack.total_size != it->second.total_size) return;
name = [NSString stringWithUTF8String:it->second.name.c_str()];
progress = ack.total_size == 0
? 1
@@ -1816,6 +1821,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
}
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
@@ -1929,7 +1935,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
dispatch_async(_rtcQueue, ^{
if (self->_state->controller_peer) {
if ([self isControllerGenerationActive:generation] && self->_state->controller_peer) {
const auto encoded_ack =
crossdesk::EncodeFileTransferAck(ack);
SendReliableDataFrame(self->_state->controller_peer,
@@ -1939,6 +1945,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
});
if (progress_name) {
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) {
@@ -326,6 +326,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published var selectedDisplay = 0
@Published var audioEnabled = true
@Published private(set) var clipboardStatus = ""
@Published private(set) var controlStatus = ""
@Published private(set) var transferStatus = ""
@Published private(set) var transferProgress = 0.0
@Published private(set) var receivedFileURL: URL?
@@ -354,6 +355,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private var shouldCaptureThumbnail = false
private var activeDisplayName = ""
private var remoteCursorSequence: UInt32?
private var remoteServiceAvailable = false
private var videoWasBackgrounded = false
private var videoRecoveryTask: Task<Void, Never>?
private var videoSettingsTimeoutTask: Task<Void, Never>?
@@ -654,6 +656,11 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func resetConnection() {
clipboardStatus = ""
controlStatus = ""
transferStatus = ""
transferProgress = 0
receivedFileURL = nil
resetRemoteHostInfo()
resetVideoSettings()
connectionFailureMessage = nil
@@ -692,6 +699,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func resetRemoteHostInfo() {
remoteServiceAvailable = false
remoteVersionCheckTask?.cancel()
remoteVersionCheckTask = nil
remoteVersionCheckGeneration = UUID()
@@ -1020,6 +1028,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func sendClipboard() {
guard hasNetworkConsent, isConnected else { return }
guard let text = UIPasteboard.general.string, !text.isEmpty else {
clipboardStatus = "剪贴板中没有文本"
return
@@ -1033,11 +1042,27 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func sendFile(_ url: URL) {
guard hasNetworkConsent, isConnected else { return }
transferStatus = "正在发送 \(url.lastPathComponent)"
transferProgress = 0
bridge.sendFile(at: url)
}
func sendSecureAttention() {
guard hasNetworkConsent, isConnected else { return }
guard remoteServiceAvailable else {
controlStatus = "Ctrl+Alt+Del 需要远端启用 Windows 系统服务"
return
}
bridge.sendSecureAttentionSequence()
controlStatus = "正在发送 Ctrl+Alt+Del…"
}
func fileImportFailed() {
transferStatus = "无法读取所选文件,请重试"
transferProgress = 0
}
func sendKeyStroke(_ keyCode: UInt) {
bridge.sendWindowsKeyCode(keyCode, isDown: true)
bridge.sendWindowsKeyCode(keyCode, isDown: false)
@@ -1296,10 +1321,21 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
clipboardStatus = "已接收远端剪贴板"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveServiceAvailable available: Bool) {
guard hasNetworkConsent, isConnecting || isConnected else { return }
remoteServiceAvailable = available
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didSendSecureAttention success: Bool) {
guard hasNetworkConsent, isConnected else { return }
controlStatus = success ? "已发送 Ctrl+Alt+Del" : "指令发送失败,请重试"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateFileTransfer fileName: String,
progress: Double,
sending: Bool) {
guard hasNetworkConsent, isConnected else { return }
if progress < 0 {
transferStatus = "\(fileName) 传输失败"
transferProgress = 0
@@ -1311,6 +1347,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveFileAt fileURL: URL) {
guard hasNetworkConsent, isConnected else { return }
receivedFileURL = fileURL
transferStatus = "已接收 \(fileURL.lastPathComponent)"
transferProgress = 1
@@ -184,6 +184,8 @@ struct RemoteSessionView: View {
allowsMultipleSelection: false) { result in
if case let .success(urls) = result, let url = urls.first {
session.sendFile(url)
} else if case .failure = result {
session.fileImportFailed()
}
}
}
@@ -1205,13 +1207,21 @@ private struct FloatingSessionMenu: View {
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.bridge.sendSecureAttentionSequence)
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
}
if !session.transferStatus.isEmpty || !session.clipboardStatus.isEmpty {
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.clipboardStatus.isEmpty {
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.transferStatus.isEmpty {
HStack(spacing: 8) {
Text(!session.transferStatus.isEmpty
? session.transferStatus : session.clipboardStatus)
Text(session.transferStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
+12 -2
View File
@@ -86,6 +86,12 @@ Keep signing enabled for simulator runs. Xcode uses a local ad-hoc signature
and supplies the application identity required by Keychain. Disabling signing
causes identity storage to fail with `-34018` before signaling can connect.
Simulator H.264 decoding uses VideoToolbox's available decoder, including its
software path. A simulator reporting no hardware H.264 support must still be
allowed to create a decoder; otherwise ICE can connect and receive video packets
while the app remains on the waiting-for-picture screen. Physical iOS devices
retain the hardware capability check.
The first simulator build compiles its own MiniRTC and dependencies; later builds
reuse `.xmake/packages-simulator` and `Vendor/iphonesimulator`. This runs the
native application, including version checks and signaling. Validate hardware
@@ -336,8 +342,8 @@ a data-channel problem:
16-bit PCM through `AVAudioEngine`.
3. **Clipboard:** copy a short text value on the remote desktop, then paste it
into a text field on the phone to verify reception. Text is limited to
128 KiB. The bridge supports sending local text, but the current UI has no
**Send local clipboard** action.
128 KiB. Tap **剪贴板** in the floating menu to send local clipboard text,
then paste it on the remote desktop. Empty or oversized text displays a hint.
4. **Displays:** open the display menu, switch every listed monitor, and check
that the selected monitor appears and the displayed resolution updates
after a new key frame.
@@ -346,6 +352,10 @@ a data-channel problem:
from the desktop. Progress is ACK-driven. Received files are stored in the
app's `Documents/Received` directory and can be exported with the share
button beside the transfer status or Finder's Files tab.
6. **Ctrl+Alt+Del:** the floating menu sends the existing Windows service
command when the remote advertises an available system service. Other hosts
display a service requirement hint. Clipboard, control and transfer feedback
are shown separately and cleared when the session ends.
Network status samples and caches displayed measurements once per second while
the panel is open; video-frame redraws reuse that snapshot. Traffic and connection RTT