mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 00:09:39 +08:00
[feat] complete mobile session controls and file transfer
This commit is contained in:
@@ -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();}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: ec6e20c242...3f6f7f2725
Reference in New Issue
Block a user