[feat] add mobile privacy controls and Windows secure text input

This commit is contained in:
dijunkun
2026-10-27 02:37:23 +08:00
parent 1e25ec15e1
commit b15925f857
37 changed files with 795 additions and 80 deletions
+5 -2
View File
@@ -14,6 +14,7 @@
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
- 悬浮菜单提供“隐私屏”直接开关,无需进入详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示。开启时与桌面端一致屏蔽电脑本地键鼠;声明控制能力后可使用电脑的连接时自动开启设置。状态超过 5 秒未更新时停止显示成功,指令 15 秒未确认时提示超时;后台保活与画中画恢复沿用同一会话状态。
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
@@ -25,7 +26,7 @@
- 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。
- “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。
当前不包含安卓被控端、隐私屏或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
当前不包含安卓被控端或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
## 本机更新提示
@@ -152,7 +153,9 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。Windows 通过当前已解锁用户桌面的 Unicode 键盘事件输入文本,无需切换电脑输入法或覆盖剪贴板;锁屏和 UAC 安全桌面不接受此文本通道。Windows 被控端也必须更新到包含文本输入支持的版本,仅更新 Android 不会启用该能力。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
Windows 文本输入使用 Unicode 键盘事件,无需切换电脑输入法或覆盖剪贴板。本次开发版将完整 UTF-8 文本通过 Windows 服务转发到锁屏、登录与 UAC 安全桌面,保留数字、大小写字母、符号、空格及中文等字符,不将密码转换成数字或 ASCII 按键。该通道需要同时更新 Windows 客户端、CrossDesk Service 和会话辅助程序;仅更新 Android,或仅确认旧服务正在运行,均不会启用修复。旧版可能已声明 `supports_text_input`,但只支持普通桌面文本输入,因此“电脑键盘可输入、搜狗等系统输入法不可输入”仍可能出现。锁屏实际输入效果需在更新后的 Windows 环境验证。
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -80,6 +80,7 @@ final class BackgroundSession implements NativeSession.Listener {
if (view != null) view.fileTransfer(name, progress, sending, received);
}
@Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); }
@Override public void privacyChanged() { if (view != null) view.privacyChanged(); }
@Override public void presence(JSONObject message) { if (view != null) view.presence(message); }
@Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); }
@Override public void announcementFailed(String id) { if (view != null) view.announcementFailed(id); }
@@ -218,7 +218,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);});
remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote","")));
entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1));
TextView connect=ui.primary("连接 →",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
TextView connect=ui.primary("连接",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run();
remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}});
LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p);
@@ -295,7 +295,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save);
remember.setChecked(rememberDefault);
ui.gap(sheet,18);TextView connect=ui.primary("连接 →",()->{
ui.gap(sheet,18);TextView connect=ui.primary("连接",()->{
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
@@ -331,6 +331,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
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 togglePrivacy(){if(ready&&session!=null)session.togglePrivacy();}
public void key(int code){tapKey(code);}
public boolean textEdit(int count,String value){return sendTextEdit(count,value);}
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);}
@@ -339,6 +340,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput));
privacyChanged();
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
controls.requestApplyInsets();
video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
@@ -655,6 +657,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
}
}
@Override public void privacyChanged(){if(controls!=null&&session!=null)controls.privacyScreen(session.privacyScreen);}
@Override public void videoSize(int width,int height){videoWidth=width;videoHeight=height;if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);pictureInPicture.videoSize(width,height);updatePictureInPicture();}
@Override public void clipboard(String value){remoteClipboard=value;}
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
@@ -782,7 +785,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
}
@Override protected void onResume(){super.onResume();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
@Override protected void onResume(){super.onResume();privacyChanged();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
@@ -30,6 +30,7 @@ final class NativeSession implements AutoCloseable {
void statistics(RemoteNetworkStatistics.Snapshot value);
default void fileTransfer(String name,double progress,boolean sending,File received) { }
default void controlFeedback(String message) { }
default void privacyChanged() { }
default void signaling(int state, String deviceId) { }
default void presence(JSONObject message) { }
default void announcement(JSONObject message) { }
@@ -49,6 +50,7 @@ final class NativeSession implements AutoCloseable {
private long handle;
private boolean signalReady, joining, started;
private volatile boolean connected, supportsTextInput;
final RemotePrivacyScreen privacyScreen = new RemotePrivacyScreen();
private int signalState;
private volatile long signalGeneration;
private boolean presenceRequested;
@@ -63,6 +65,8 @@ final class NativeSession implements AutoCloseable {
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
private final Runnable statisticsTick=new Runnable(){public void run(){
if(closed)return;
privacyScreen.tick(android.os.SystemClock.elapsedRealtime());
listener.privacyChanged();
listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now()));
if(!closed)main.postDelayed(this,1000);
}};
@@ -193,7 +197,13 @@ final class NativeSession implements AutoCloseable {
} else if (type == 4) {
JSONObject info=event.optJSONObject("host_info");
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
ui(() -> listener.data(event));
ui(() -> {
if(event.optInt("type",-1)==10){
privacyScreen.receive(event,android.os.SystemClock.elapsedRealtime());
listener.privacyChanged();
}
listener.data(event);
});
}
else if (type == 5) {
int width = event.getInt("width"), height = event.getInt("height");
@@ -266,6 +276,16 @@ final class NativeSession implements AutoCloseable {
if (type == 2 && player != null) player.setEnabled(value != 0);
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
}
void togglePrivacy() {
if(closed||!connected)return;
boolean enable=!privacyScreen.active();
long request=privacyScreen.begin(android.os.SystemClock.elapsedRealtime());
if(request<0)return;
listener.privacyChanged();
execute(()->{if(connected&&!nPrivacy(handle,enable?1:2,true))ui(()->{
privacyScreen.failed(request);listener.privacyChanged();
});});
}
void clipboard(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
@@ -319,6 +339,7 @@ final class NativeSession implements AutoCloseable {
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 boolean nPrivacy(long handle,int flag,boolean blockLocalInput);
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);
@@ -0,0 +1,79 @@
package cn.crossdesk.mobile;
import org.json.JSONObject;
/** Host-confirmed privacy state; all access belongs to the main thread. */
final class RemotePrivacyScreen {
boolean received, supported, inputBlockSupported, overlay, inputBlocked, remotePaused, pending;
int state;
long revision, requestRevision, requestId, receivedAt, requestedAt;
String reason = "", failure = "";
void receive(JSONObject message, long now) {
try {
if (message.getInt("type") != 10) return;
JSONObject value = message.getJSONObject("privacy_status");
int next = value.getInt("state");
long rev = value.getLong("revision");
if (next < 0 || next > 5 || rev < 0 || rev > 0xffffffffL ||
received && (int)(rev - revision) < 0) return;
boolean support = value.getBoolean("supported"), cover = value.getBoolean("overlay_active");
boolean paused = value.getBoolean("remote_paused");
if (next == 3 && (!support || !cover || paused)) return;
boolean blockSupport = value.getBoolean("input_block_supported"), blocked = value.getBoolean("input_blocked");
String detail = value.getString("reason");
state = next; revision = rev; supported = support; overlay = cover; remotePaused = paused;
inputBlockSupported = blockSupport; inputBlocked = blocked; reason = detail;
received = true; receivedAt = now; failure = "";
if (pending && (int)(revision - requestRevision) > 0 && state != 2 && state != 4) pending = false;
} catch (org.json.JSONException ignored) { }
}
boolean active() { return overlay || remotePaused || state == 2 || state == 3 || state == 4; }
boolean fresh(long now) { return received && now - receivedAt <= 5000; }
boolean enabled(long now) { return fresh(now) && state == 3 && overlay && !remotePaused; }
String buttonTitle(long now) {
if (enabled(now)) return "隐私屏已开启";
if (fresh(now) && supported && state == 1 && !active()) return "隐私屏已关闭";
return "隐私屏";
}
String feedback(long now) {
if (failure.isEmpty() && (pending || fresh(now) && (state == 2 || state == 4))) return "";
if (failure.isEmpty() && !pending && fresh(now) && supported &&
(enabled(now) || state == 1 && !active())) return "";
String detail = detail(now);
return status(now) + (detail.isEmpty() ? "" : ":" + detail);
}
boolean canToggle(long now) {
return !pending && fresh(now) && state != 4 && (active() || supported && state == 1);
}
long begin(long now) {
tick(now);
if (!canToggle(now)) return -1;
pending = true; requestedAt = now; requestRevision = revision; failure = "";
return ++requestId;
}
void failed(long id) {
if (pending && requestId == id) { pending = false; failure = "指令发送失败,请重试"; }
}
void tick(long now) {
if (pending && now - requestedAt >= 15000) {
pending = false; failure = "请求超时,尚未确认电脑的隐私屏状态";
}
}
String status(long now) {
if (!failure.isEmpty()) return failure;
if (!received) return "等待电脑返回隐私屏状态;旧版电脑端可能不支持";
if (!fresh(now)) return "隐私屏状态已过期,等待电脑确认";
if (state == 2) return "正在开启隐私屏…";
if (state == 4) return "正在关闭隐私屏…";
if (pending) return "指令已发送,等待电脑确认…";
if (state == 5) return "隐私屏异常";
if (!supported || state == 0) return "隐私屏暂不可用";
if (state == 3 && overlay && !remotePaused)
return "隐私屏已开启";
return state == 1 && !active() ? "隐私屏已关闭" : "隐私屏状态待确认";
}
String detail(long now) {
return fresh(now) && (state == 0 || state == 5 || !supported) ? reason : "";
}
}
@@ -15,6 +15,7 @@ final class SessionControls extends FrameLayout {
default boolean textEdit(int deletions,String value){return false;}
void videoSettings(int field,int value);
void sendFile(); void saveFile(); void secureAttention();
default void togglePrivacy() { }
}
private final MobileUi ui;
private final Actions actions;
@@ -36,6 +37,9 @@ final class SessionControls extends FrameLayout {
keyboardInsets(height,height>ui.dp(80),0);
}
private TextView networkLabel,videoFeedback;
private RemotePrivacyScreen privacy=new RemotePrivacyScreen();
private View privacyButton;
private TextView privacyFeedback,privacyCaption;
private TextView transferLabel,saveFileButton;
private String transferStatus="";
private boolean hasReceivedFile;
@@ -100,12 +104,13 @@ 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();}
void privacyScreen(RemotePrivacyScreen value){privacy=value;renderPrivacy();}
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;menuScroll=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("展开远程控制菜单");positionMouse();}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
private void openMenu(String page){
cancelMouseInput();
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
@@ -121,13 +126,15 @@ final class SessionControls extends FrameLayout {
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
}
if(page.equals("controls")){
String[] labels={"键盘",mouseVisible?"收起鼠标":"虚拟鼠标",relative?"相对鼠标":"绝对鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
String[] labels={"键盘",mouseVisible?"收起鼠标":"虚拟鼠标",relative?"相对鼠标":"绝对鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板","隐私屏"};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard","lock"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard,actions::togglePrivacy};
menuColumns=controlColumns();
for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}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==10){privacyButton=control;privacyCaption=caption;}
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+1)*menuColumns<labels.length)ui.gap(body,7);}
privacyFeedback=ui.text("",11,false);privacyFeedback.setTextColor(SECONDARY);privacyFeedback.setPadding(0,ui.dp(7),0,0);privacyFeedback.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(privacyFeedback);renderPrivacy();
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")){
@@ -141,6 +148,16 @@ final class SessionControls extends FrameLayout {
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
positionMouse();
}
private void renderPrivacy(){
if(privacyButton==null)return;
long now=android.os.SystemClock.elapsedRealtime();privacy.tick(now);
boolean enabled=privacy.enabled(now);String action=privacy.active()?"关闭隐私屏":"开启隐私屏";
privacyCaption.setText(privacy.buttonTitle(now));
privacyButton.setSelected(enabled);
privacyButton.setContentDescription(action+","+privacy.status(now));
privacyButton.setEnabled(privacy.canToggle(now));privacyButton.setAlpha(privacyButton.isEnabled()?1f:.45f);
String feedback=privacy.feedback(now);privacyFeedback.setText(feedback);privacyFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);
}
private final class SettingRow {
final int field;final int[] values;final SegmentedControl group;
SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){
@@ -10,6 +10,7 @@
#include "../../../libs/controller/include/mobile_controller_protocol.h"
namespace crossdesk::android_controller {
using mobile_controller::SecureAttentionMessage;
using mobile_controller::PrivacyMessage;
using mobile_controller::VideoSettingsMessage;
using mobile_controller::NetworkReport;
}
+9 -1
View File
@@ -370,7 +370,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
auto* s = From(handle); if (!s || !s->controller) return;
auto action = MakeHostInformation("CrossDesk Android", {}, false, "1.6.0", HostPlatform::Android);
auto action = MakeHostInformation("CrossDesk Android", {}, true, "1.6.0", HostPlatform::Android);
s->Send(kControlStream, action.to_json()); FreeRemoteAction(action);
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)(
@@ -429,6 +429,14 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jcla
const auto data = android_controller::SecureAttentionMessage();
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
}
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) {
auto* s = From(handle);
if (!s || !s->controller || s->stopping) return false;
const auto data = android_controller::PrivacyMessage(flag, block_input);
return !data.empty() &&
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));
}
@@ -21,6 +21,8 @@
#include "interactive_desktop.h"
#include "service_host.h"
#include "unattended_config.h"
#include "platform/windows/input/windows_input_injector.h"
#include "platform/windows/input/windows_text_input.h"
#endif
namespace crossdesk {
@@ -275,11 +277,51 @@ InputOutcome KeyboardController::ApplyRemoteText(const std::string& remote_id,
std::lock_guard input_lock(remote_input_mutex_);
if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
#if _WIN32
std::vector<INPUT> inputs;
if (!BuildWindowsTextInputs(text, inputs)) return InputOutcome::failed;
if (owner_.privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable())
owner_.privacy_.SuspendForDesktop();
static const bool process_elevated = IsAdministratorProcess();
const bool injected = DispatchDesktopInput(
[&] {
return PreferUserDesktopInput(IsCurrentSessionUserDesktopActive(),
owner_.windows_consent_ui_.load(),
owner_.local_service_available_.load(),
process_elevated);
},
[&] {
const UINT sent = SendWindowsTextInputs(
inputs, [](INPUT* batch, UINT count) {
return SendInputOnUserDesktop(batch, count);
});
const DWORD error = GetLastError();
const bool blocked =
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
// A text commit is a batch: only zero insertion permits a retry.
return DesktopInputResult{
sent == inputs.size(),
sent == 0 && (IsDesktopTransitionInputError(error) || blocked),
sent == 0 && blocked};
},
[&]() -> DesktopInputResult {
const auto response = SendCrossDeskSecureDesktopTextInput(text, 1000);
const auto json = nlohmann::json::parse(response, nullptr, false);
if (json.is_object() && json.value("ok", false)) return {true, false};
const std::string error =
json.is_object() ? json.value("error", "") : "invalid_response";
// No text or raw reply in diagnostics; commits may be passwords.
LOG_WARN("Secure desktop text injection failed: error={}", error);
return {false, IsDesktopInputSetupPending(error)};
});
return injected ? InputOutcome::applied : InputOutcome::failed;
#else
if (!owner_.devices_.SendTextCommand(text)) {
LOG_WARN("Failed to inject committed remote text");
return InputOutcome::failed;
}
return InputOutcome::applied;
#endif
}
InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id,
@@ -7,6 +7,7 @@
#include "windows_input_injector.h"
#include "windows_input_marker.h"
#include "windows_key_metadata.h"
#include "windows_text_input.h"
namespace crossdesk {
namespace {
@@ -393,52 +394,17 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
}
int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) {
if (text.empty() || text.size() > kMaxTextInputBytes ||
text.find('\0') != std::string::npos) {
return -1;
}
// Validate the entire UTF-8 commit before injecting any characters.
const int byte_count = static_cast<int>(text.size());
const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
text.data(), byte_count, nullptr, 0);
if (length == 0) return -1;
std::wstring characters(length, L'\0');
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(),
byte_count, characters.data(), length) != length) {
return -1;
}
std::vector<INPUT> inputs;
inputs.reserve(characters.size() * 2);
for (size_t index = 0; index < characters.size(); ++index) {
const wchar_t character = characters[index];
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.dwExtraInfo = static_cast<ULONG_PTR>(kInjectedKeyboardInputMarker);
if (character == L'\r' || character == L'\n' || character == L'\t') {
input.ki.wVk = character == L'\t' ? VK_TAB : VK_RETURN;
if (character == L'\r' && index + 1 < characters.size() &&
characters[index + 1] == L'\n') {
++index;
}
} else {
// VK_PACKET carries UTF-16 units, including both halves of emoji.
input.ki.wScan = static_cast<WORD>(character);
input.ki.dwFlags = KEYEVENTF_UNICODE;
}
inputs.push_back(input);
input.ki.dwFlags |= KEYEVENTF_KEYUP;
inputs.push_back(input);
}
if (!BuildWindowsTextInputs(text, inputs)) return -1;
// One batch keeps surrogate pairs and key releases together with the text.
const UINT count = static_cast<UINT>(inputs.size());
const UINT sent = SendInputOnUserDesktop(inputs.data(), count);
const UINT sent = SendWindowsTextInputs(
inputs, [](INPUT* batch, UINT size) {
return SendInputOnUserDesktop(batch, size);
});
if (sent != count) {
const DWORD error = GetLastError();
if (sent < count && sent % 2 != 0) {
SendInputOnUserDesktop(inputs[sent]); // Best-effort release.
}
LOG_WARN(
"SendInput failed for committed text, sent={}, expected={}, err={}",
sent, count, error);
@@ -0,0 +1,71 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-26
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOWS_TEXT_INPUT_H_
#define _WINDOWS_TEXT_INPUT_H_
#include <Windows.h>
#include <string>
#include <vector>
#include "remote_action.h"
#include "windows_input_marker.h"
namespace crossdesk {
inline bool BuildWindowsTextInputs(const std::string& text,
std::vector<INPUT>& inputs) {
inputs.clear();
if (text.empty() || text.size() > kMaxTextInputBytes ||
text.find('\0') != std::string::npos) return false;
// Validate the entire commit before injecting any characters.
const int bytes = static_cast<int>(text.size());
const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
text.data(), bytes, nullptr, 0);
if (length == 0) return false;
std::wstring characters(length, L'\0');
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), bytes,
characters.data(), length) != length) return false;
inputs.reserve(characters.size() * 2);
for (size_t index = 0; index < characters.size(); ++index) {
const wchar_t character = characters[index];
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.dwExtraInfo = static_cast<ULONG_PTR>(kInjectedKeyboardInputMarker);
if (character == L'\r' || character == L'\n' || character == L'\t') {
input.ki.wVk = character == L'\t' ? VK_TAB : VK_RETURN;
if (character == L'\r' && index + 1 < characters.size() &&
characters[index + 1] == L'\n') ++index;
} else {
input.ki.wScan = static_cast<WORD>(character);
input.ki.dwFlags = KEYEVENTF_UNICODE;
}
inputs.push_back(input);
input.ki.dwFlags |= KEYEVENTF_KEYUP;
inputs.push_back(input);
}
return true;
}
// A partial batch must never be replayed. Release its final key if necessary,
// retaining the original count and error for the desktop-routing decision.
template <typename Sender>
UINT SendWindowsTextInputs(std::vector<INPUT>& inputs, Sender send) {
const UINT count = static_cast<UINT>(inputs.size());
SetLastError(ERROR_SUCCESS);
const UINT sent = send(inputs.data(), count);
if (sent == count) return sent;
const DWORD error = GetLastError();
if (sent < count && sent % 2 != 0) send(&inputs[sent], 1);
SetLastError(error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE);
return sent;
}
} // namespace crossdesk
#endif
@@ -0,0 +1,60 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-26
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SECURE_TEXT_INPUT_H_
#define _SECURE_TEXT_INPUT_H_
#include <nlohmann/json.hpp>
#include <string>
#include "remote_action.h"
namespace crossdesk {
inline constexpr char kSecureTextInputCommandPrefix[] = "text:";
// JSON can escape each text byte as six ASCII bytes. Leave room for metadata.
inline constexpr size_t kSecureInputCommandBufferBytes =
6 * kMaxTextInputBytes + 1024;
struct SecureTextInputRequest {
std::string text;
std::string stage;
std::string desktop;
};
inline std::string BuildSecureTextInputCommand(
const std::string& text, const std::string& stage = {},
const std::string& desktop = {}) {
return kSecureTextInputCommandPrefix +
nlohmann::json{{"text", text}, {"stage", stage},
{"desktop", desktop}}.dump();
}
inline bool ParseSecureTextInputCommand(const std::string& command,
SecureTextInputRequest& request) {
request = {};
if (command.size() > kSecureInputCommandBufferBytes ||
command.rfind(kSecureTextInputCommandPrefix, 0) != 0) return false;
const auto json = nlohmann::json::parse(
command.substr(sizeof(kSecureTextInputCommandPrefix) - 1), nullptr, false);
if (!json.is_object() || !json.contains("text") ||
!json["text"].is_string() || !json.contains("stage") ||
!json["stage"].is_string() || !json.contains("desktop") ||
!json["desktop"].is_string()) return false;
const auto& text = json["text"].get_ref<const std::string&>();
const auto& stage = json["stage"].get_ref<const std::string&>();
const auto& desktop = json["desktop"].get_ref<const std::string&>();
if (text.empty() || text.size() > kMaxTextInputBytes ||
text.find('\0') != std::string::npos || stage.size() > 64 ||
desktop.size() > 256 || stage.find('\0') != std::string::npos ||
desktop.find('\0') != std::string::npos) return false;
request = {text, stage, desktop};
return true;
}
} // namespace crossdesk
#endif
@@ -23,6 +23,7 @@
#include "path_manager.h"
#include "rd_log.h"
#include "session_helper_shared.h"
#include "secure_text_input.h"
#include "unattended_config.h"
#include "unattended_policy.h"
#include "rdp_session_handoff.h"
@@ -1421,7 +1422,7 @@ void CrossDeskServiceHost::IpcServerLoop() {
}
}
char buffer[1024] = {0};
char buffer[kSecureInputCommandBufferBytes] = {0};
DWORD bytes_read = 0;
DWORD transfer_error = ERROR_SUCCESS;
if (pipe_deadline_detail::Transfer(
@@ -2235,6 +2236,14 @@ void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type,
std::string CrossDeskServiceHost::HandleIpcCommand(const std::string& command,
DWORD client_session_id,
HANDLE client_pipe) {
// Password text is case/whitespace sensitive. Parse before normalizing the
// legacy command verbs, and never accept the caller's desktop selection.
if (command.rfind(kSecureTextInputCommandPrefix, 0) == 0) {
SecureTextInputRequest request;
if (!ParseSecureTextInputCommand(command, request))
return BuildErrorJson("invalid_text_input");
return SendSecureDesktopTextInput(client_session_id, request.text);
}
std::string normalized = ToLower(Trim(command));
if (normalized == kRdpHandoffCommand) {
bool observed_rdp = false;
@@ -2618,6 +2627,16 @@ std::string CrossDeskServiceHost::SendSecureDesktopMouseInput(
1000);
}
std::string CrossDeskServiceHost::SendSecureDesktopTextInput(
DWORD client_session_id, const std::string& text) {
SecureInputTarget target;
const auto error = ResolveSecureInputTarget(client_session_id, target);
if (!error.empty()) return error;
return QueryNamedPipeMessage(
GetCrossDeskSecureInputHelperPipeName(target.session_id),
BuildSecureTextInputCommand(text, target.stage, target.desktop), 1000);
}
bool InstallCrossDeskService(const std::wstring& binary_path) {
SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS);
if (manager == nullptr) {
@@ -2892,4 +2911,9 @@ std::string SendCrossDeskSecureDesktopMouseInput(int x, int y, int wheel,
BuildSecureDesktopMouseIpcCommand(x, y, wheel, flag), timeout_ms);
}
std::string SendCrossDeskSecureDesktopTextInput(const std::string& text,
DWORD timeout_ms) {
return QueryDesktopInput(BuildSecureTextInputCommand(text), timeout_ms);
}
} // namespace crossdesk
@@ -90,6 +90,8 @@ class CrossDeskServiceHost {
bool extended = false);
std::string SendSecureDesktopMouseInput(DWORD client_session_id,
int x, int y, int wheel, int flag);
std::string SendSecureDesktopTextInput(DWORD client_session_id,
const std::string& text);
static void WINAPI ServiceMain(DWORD argc, LPWSTR* argv);
static BOOL WINAPI ConsoleControlHandler(DWORD control_type);
@@ -197,6 +199,8 @@ std::string SendCrossDeskSecureDesktopKeyInput(int key_code, bool is_down,
std::string SendCrossDeskSecureDesktopMouseInput(int x, int y, int wheel,
int flag,
DWORD timeout_ms = 1000);
std::string SendCrossDeskSecureDesktopTextInput(const std::string& text,
DWORD timeout_ms = 1000);
} // namespace crossdesk
@@ -28,6 +28,7 @@
#include "platform/path_backend.h"
#include "rd_log.h"
#include "session_helper_shared.h"
#include "secure_text_input.h"
#include "secure_desktop_dxgi_capture.h"
#include "secure_desktop_frame_pacer.h"
#include "secure_desktop_nv12_cache.h"
@@ -40,6 +41,7 @@
#include "windows_cursor_state.h"
#include "cursor_draw.h"
#include "windows_input_marker.h"
#include "windows_text_input.h"
#include "windows_key_metadata.h"
#include "../windows_thread_dpi.h"
@@ -948,6 +950,31 @@ InputInjectionResult InjectKeyboardInput(
return BuildInputSuccess(std::move(desktop_switch));
}
InputInjectionResult InjectTextInput(
const crossdesk::SecureTextInputRequest& request) {
std::vector<INPUT> inputs;
if (!crossdesk::BuildWindowsTextInputs(request.text, inputs)) {
return BuildInputFailure("invalid_text_input", ERROR_INVALID_PARAMETER,
BuildDesktopSwitchDetails(request.stage,
request.desktop));
}
ScopedInteractiveDesktop desktop;
DesktopSwitchDetails desktop_switch;
if (!EnsureThreadInteractiveDesktopForStage(request.stage, request.desktop,
&desktop, &desktop_switch)) {
return BuildInputFailure("switch_interactive_desktop_failed", GetLastError(),
desktop_switch);
}
const UINT sent = crossdesk::SendWindowsTextInputs(
inputs, [](INPUT* batch, UINT count) {
return SendInput(count, batch, sizeof(INPUT));
});
if (sent != inputs.size()) {
return BuildInputFailure("send_input_failed", GetLastError(), desktop_switch);
}
return BuildInputSuccess(std::move(desktop_switch));
}
void ParseInteractionTail(const std::string& tail,
std::string* interactive_stage_out,
std::string* interactive_desktop_out) {
@@ -1833,6 +1860,21 @@ std::vector<uint8_t> StartSecureDesktopSharedCapture(
std::vector<uint8_t> HandleSecureInputHelperCommand(
const std::string& command,
SecureSharedCaptureState* capture_state) {
if (command.rfind(crossdesk::kSecureTextInputCommandPrefix, 0) == 0) {
crossdesk::SecureTextInputRequest request;
if (!crossdesk::ParseSecureTextInputCommand(command, request))
return BuildTextResponseBytes(BuildErrorJson("invalid_text_input"));
const auto result = InjectTextInput(request);
if (!result.ok) {
// Text may contain a login password. Never echo it in logs or replies.
LOG_WARN("Secure input helper text injection failed: error={}, code={}, "
"stage={}, target={}, current={}",
result.error, result.error_code, result.desktop.stage,
result.desktop.target_desktop, result.desktop.current_desktop);
return BuildTextResponseBytes(BuildInputFailureJson(result).dump());
}
return BuildTextResponseBytes("{\"ok\":true,\"injected\":\"text\"}");
}
if (command == "ping") {
return BuildTextResponseBytes("{\"ok\":true,\"reply\":\"pong\"}");
}
@@ -1922,7 +1964,7 @@ std::vector<uint8_t> HandleSecureInputHelperCommand(
void HandleSecureInputHelperPipeClient(
HANDLE pipe, HANDLE event_handle,
std::shared_ptr<SecureSharedCaptureState> capture_state) {
char buffer[1024] = {0};
char buffer[crossdesk::kSecureInputCommandBufferBytes] = {0};
DWORD bytes_read = 0;
DWORD error = ERROR_SUCCESS;
if (crossdesk::pipe_deadline_detail::Transfer(
+1
View File
@@ -664,6 +664,7 @@ function setup_targets()
target("crossdesk_service")
set_kind("binary")
add_packages("nlohmann_json")
add_includedirs("libs/wire/include")
add_deps("rd_log", "path_manager", "windows_unattended_config")
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
add_files("apps/desktop/src/platform/windows/service/main.cpp",
+1
View File
@@ -26,6 +26,7 @@ apps/harmony/.native/mobile-project/entry/build/default/outputs/default/entry-de
| Android 功能 | 鸿蒙实现 |
| --- | --- |
| 前台自动登录、空闲后台停止、服务器隔离 | 独立 identity peer;只有主动远控才创建 controller peer;拒绝任何入站被控连接 |
| 电脑隐私屏 | 悬浮菜单直接开关,无详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠;5 秒状态失效、15 秒指令超时与跨会话隔离 |
| 首次隐私确认、撤回与离线阅读 | 同意前不发起身份、公告、在线状态或版本网络请求;撤回结束会话并清除预览 |
| 加密身份、可选记住访问密码 | 系统 Asset Store,设备首次解锁后可读,禁止跨设备同步;普通 Preferences 不存密码 |
| 最近连接、删除、在线状态 | 最近 20 台、在线优先、真实信令订阅、30 秒刷新和 60 秒过期;删除同步清理密码与预览 |
@@ -1,3 +1,4 @@
import { RemotePrivacyScreen, PrivacyStatus } from './RemotePrivacyScreen';
import bridge from 'libcrossdesk.so';
import { common, bundleManager } from '@kit.AbilityKit';
import { display, promptAction } from '@kit.ArkUI';
@@ -13,7 +14,7 @@ interface Command {
logs?: string; certificates?: string; directory?: string; previewDirectory?: string; remote?: string; password?: string; version?: string;
id?: string; x?: number; y?: number; flag?: number; scroll?: number; code?: number; down?: boolean;
index?: number; muted?: boolean; visible?: boolean; text?: string; message?: Object; quality?: number; frameRate?: number;
preference?: number; request?: number; path?: string; name?: string; body?: string; local?: boolean;
preference?: number; blockLocalInput?: boolean; request?: number; path?: string; name?: string; body?: string; local?: boolean;
}
interface NativeEvent {
type: string; session: number; connection?: number; status?: number; controller?: boolean; generation?: number;
@@ -25,7 +26,7 @@ export interface RemoteDisplay { name: string; }
interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; }
interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; }
interface ServiceStatus { available: boolean; }
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; }
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; }
interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
interface SignalType { type: string; }
@@ -73,6 +74,12 @@ export class MobileState {
videoSelectedPreference: number = 1;
videoSupported: boolean = false;
videoFeedback: string = '';
privacyStatus: string = '';
privacyButtonTitle: string = '隐私屏';
privacyFeedback: string = '';
privacyEnabled: boolean = false;
privacyActive: boolean = false;
privacyCanToggle: boolean = false;
networkTime: number = 0;
unread: number = 0;
announcements: AnnouncementItem[] = [];
@@ -86,6 +93,7 @@ export class MobileController {
store: MobileStore;
platform: MobilePlatform;
video: VideoSettings = new VideoSettings();
private privacyScreen: RemotePrivacyScreen = new RemotePrivacyScreen();
network: NetworkStatistics = new NetworkStatistics();
presence: RecentPresence;
inbox: AnnouncementInbox;
@@ -159,7 +167,7 @@ export class MobileController {
this.state.update = ''; this.state.updateStatus = ''; this.state.page = 'home'; this.state.modal = 'consent';
}
resume(): void {
this.foreground = true; this.platform.resume();
this.foreground = true; this.platform.resume(); this.syncPrivacy();
if (this.state.consent) { this.ensureSignal(); this.checkLocal(); this.inbox.connect(this.state.signal === 1); }
else this.state.modal = 'consent';
}
@@ -236,6 +244,7 @@ export class MobileController {
this.network = new NetworkStatistics(); let edge = 1920;
try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {}
this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo();
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
this.store.put('lastRemote', this.state.remote); this.state.password = '';
this.ensureSignal(); this.joinIfReady();
}
@@ -247,6 +256,7 @@ export class MobileController {
end(reason: string): void {
this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect();
this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false;
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = '';
this.state.receivedPath = this.state.receivedName = this.state.remoteClipboard = this.state.clipboardText = ''; this.remoteVersionRequest = 0;
if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.settingsTimeout = -1;
@@ -339,6 +349,21 @@ export class MobileController {
this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = '';
} catch { this.toast('无法读取文档'); }
}
togglePrivacy(): void {
if (!this.state.ready || !this.state.consent) return;
const enable = !this.privacyScreen.active(), request = this.privacyScreen.begin(this.now());
if (request < 0) return;
this.send({ op: 'privacy', flag: enable ? 1 : 2, request: request,
blockLocalInput: true });
this.syncPrivacy();
}
private syncPrivacy(): void {
const now = this.now(); this.privacyScreen.tick(now);
this.state.privacyStatus = this.privacyScreen.status(now); this.state.privacyFeedback = this.privacyScreen.feedback(now);
this.state.privacyButtonTitle = this.privacyScreen.buttonTitle(now);
this.state.privacyEnabled = this.privacyScreen.enabled(now);
this.state.privacyActive = this.privacyScreen.active(); this.state.privacyCanToggle = this.privacyScreen.canToggle(now);
}
private syncVideo(): void {
this.state.videoQuality = this.video.selection.quality; this.state.videoRate = this.video.selection.frameRate;
this.state.videoSelectedPreference = this.video.selection.preference; this.state.videoSupported = this.video.supported;
@@ -348,6 +373,7 @@ export class MobileController {
try { const events = JSON.parse(bridge.mobilePoll()) as NativeEvent[]; events.forEach((event: NativeEvent) => this.event(event)); }
catch { this.state.signalText = '原生通信库不可用,请重新构建并安装'; }
if (++this.seconds % 20 === 0) {
if (this.state.ready) this.syncPrivacy();
this.state.networkTime = this.now(); this.presence.tick(this.state.networkTime); this.history();
}
}
@@ -395,7 +421,8 @@ export class MobileController {
if (e.sending && ((e.progress ?? 0) < 0 || (e.progress ?? 0) >= 1)) this.sendingFile = false;
this.state.transfer = (e.progress ?? 0) < 0 ? `${e.name} 传输失败` : `${e.sending ? '发送' : '接收'} ${e.name} · ${(e.progress ?? 0) >= 1 ? '已完成' : Math.floor((e.progress ?? 0) * 100) + '%'}`;
if (e.path) { this.state.receivedPath = e.path; this.state.receivedName = e.name ?? 'received-file'; }
} else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
} else if (e.type === 'privacyFailed') { this.privacyScreen.failed(Number(e.request)); this.syncPrivacy(); }
else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
else if (e.type === 'preview') {
this.previewPending = false;
if (e.ok && e.path && this.previewEligible && this.state.previews) {
@@ -435,6 +462,7 @@ export class MobileController {
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
}
private data(message: ControlData): void {
if (message.type === 10 && message.privacy_status) { this.privacyScreen.receive(message.privacy_status, this.now()); this.syncPrivacy(); return; }
if (message.type === 5) { this.serviceAvailable = message.service_status?.available === true; return; }
if (message.type === 12 && message.video_settings) {
const v = message.video_settings;
@@ -133,9 +133,10 @@ export struct MobileRemoteView {
if (index === 7) this.menu = 'network';
if (index === 8) this.controller?.sas();
if (index === 9) { this.state.modal = 'clipboard'; this.menu = ''; }
if (index === 10) this.controller?.togglePrivacy();
}
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板']; }
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard']; }
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板', this.state.privacyButtonTitle]; }
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard', 'lock']; }
private columns(): number { return this.viewHeight < 400 && this.viewWidth >= 400 ? 4 : 3; }
private menuWidth(): number { return Math.max(1, Math.min(this.viewWidth - 16, this.columns() === 4 ? 380 : Math.max(280, Math.min(340, this.viewWidth - 160)))); }
private menuX(): number { return clamp(this.orbX > this.viewWidth / 2 ? this.orbX - this.menuWidth() - 12 : this.orbX + 66, 8, this.viewWidth - this.menuWidth() - 8); }
@@ -212,12 +213,20 @@ export struct MobileRemoteView {
GridRow({ columns: this.columns(), gutter: 7 }) {
ForEach(this.labels(), (label: string, index: number) => {
GridCol() {
Column({ space: 4 }) { MobileIcon({ name: this.icons()[index], iconExtent: 19 }); Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1) }
.height(52).width('100%').justifyContent(FlexAlign.Center).backgroundColor('#F0F0F2').borderRadius(10)
.accessibilityText(label).accessibilityLevel('yes').onClick(() => this.action(index))
Column({ space: 4 }) {
MobileIcon({ name: this.icons()[index], iconExtent: 19 })
Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1)
}
.height(52).width('100%').justifyContent(FlexAlign.Center)
.backgroundColor('#F0F0F2').borderRadius(10)
.enabled(index !== 10 || this.state.ready && this.state.privacyCanToggle)
.opacity(index === 10 && (!this.state.ready || !this.state.privacyCanToggle) ? 0.45 : 1)
.accessibilitySelected(index === 10 && this.state.privacyEnabled)
.accessibilityText(index === 10 ? `${label},${this.state.privacyStatus}` : label).accessibilityLevel('yes').onClick(() => this.action(index))
}
}, (label: string) => label)
}
if (this.state.privacyFeedback) { Text(this.state.privacyFeedback).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
if (this.state.receivedPath) { Button('保存收到的文件', { type: ButtonType.Normal }).fontSize(14).fontColor(MobileTheme.blue).backgroundColor(Color.White).height(42).width('100%').onClick(() => this.controller?.saveFile()) }
} else if (this.menu === 'video') {
@@ -0,0 +1,87 @@
export interface PrivacyStatus {
state: number; supported: boolean; input_block_supported: boolean;
overlay_active: boolean; input_blocked: boolean; remote_paused: boolean;
revision: number; reason: string;
}
export class RemotePrivacyScreen {
snapshot?: PrivacyStatus;
pending: boolean = false;
requestId: number = 0;
private requestRevision: number = 0;
private receivedAt: number = 0;
private requestedAt: number = 0;
private failure: string = '';
receive(s: PrivacyStatus, now: number): void {
if (!Number.isInteger(s.state) || s.state < 0 || s.state > 5 ||
!Number.isInteger(s.revision) || s.revision < 0 || s.revision > 0xffffffff ||
typeof s.supported !== 'boolean' || typeof s.input_block_supported !== 'boolean' ||
typeof s.overlay_active !== 'boolean' || typeof s.input_blocked !== 'boolean' ||
typeof s.remote_paused !== 'boolean' || typeof s.reason !== 'string' || s.reason.includes('\0')) return;
if (s.state === 3 && (!s.supported || !s.overlay_active || s.remote_paused)) return;
if (this.snapshot && ((s.revision - this.snapshot.revision) | 0) < 0) return;
this.snapshot = s; this.receivedAt = now; this.failure = '';
if (this.pending && ((s.revision - this.requestRevision) | 0) > 0 && s.state !== 2 && s.state !== 4) this.pending = false;
}
active(): boolean {
const s = this.snapshot;
return s !== undefined && (s.overlay_active || s.remote_paused || s.state === 2 || s.state === 3 || s.state === 4);
}
fresh(now: number): boolean { return this.snapshot !== undefined && now - this.receivedAt <= 5000; }
enabled(now: number): boolean {
const s = this.snapshot;
return s !== undefined && this.fresh(now) && s.state === 3 && s.overlay_active && !s.remote_paused;
}
buttonTitle(now: number): string {
if (this.enabled(now)) return '隐私屏已开启';
const s = this.snapshot;
if (this.fresh(now) && s?.supported && s.state === 1 && !this.active()) return '隐私屏已关闭';
return '隐私屏';
}
feedback(now: number): string {
const s = this.snapshot;
if (!this.failure && (this.pending || this.fresh(now) && (s?.state === 2 || s?.state === 4))) return '';
if (!this.failure && !this.pending && this.fresh(now) && s?.supported &&
(this.enabled(now) || s.state === 1 && !this.active())) return '';
const detail = this.detail(now);
return this.status(now) + (detail ? ':' + detail : '');
}
canToggle(now: number): boolean {
const s = this.snapshot;
return s !== undefined && !this.pending && this.fresh(now) && s.state !== 4 && (this.active() || s.supported && s.state === 1);
}
begin(now: number): number {
this.tick(now);
if (!this.canToggle(now) || !this.snapshot) return -1;
this.pending = true; this.requestedAt = now; this.requestRevision = this.snapshot.revision; this.failure = '';
this.requestId = (this.requestId + 1) >>> 0;
return this.requestId;
}
failed(id: number): void {
if (this.pending && this.requestId === id) { this.pending = false; this.failure = '指令发送失败,请重试'; }
}
tick(now: number): void {
if (this.pending && now - this.requestedAt >= 15000) {
this.pending = false; this.failure = '请求超时,尚未确认电脑的隐私屏状态';
}
}
status(now: number): string {
if (this.failure) return this.failure;
const s = this.snapshot;
if (!s) return '等待电脑返回隐私屏状态;旧版电脑端可能不支持';
if (!this.fresh(now)) return '隐私屏状态已过期,等待电脑确认';
if (s.state === 2) return '正在开启隐私屏…';
if (s.state === 4) return '正在关闭隐私屏…';
if (this.pending) return '指令已发送,等待电脑确认…';
if (s.state === 5) return '隐私屏异常';
if (!s.supported || s.state === 0) return '隐私屏暂不可用';
if (s.state === 3 && s.overlay_active && !s.remote_paused)
return '隐私屏已开启';
return s.state === 1 && !this.active() ? '隐私屏已关闭' : '隐私屏状态待确认';
}
detail(now: number): string {
const s = this.snapshot;
return s && this.fresh(now) && (s.state === 0 || s.state === 5 || !s.supported) ? s.reason : '';
}
}
@@ -149,7 +149,7 @@ struct Mobile {
.padding({ left: 14, right: 14 }).onFocus(() => this.idFocused = true).onBlur(() => this.idFocused = false)
.onChange((value: string) => this.state.remote = this.deviceId(value)).onSubmit(() => this.controller?.requestConnection(this.state.remote))
Column() {
MobilePrimaryButton({ label: '连接', arrow: true, buttonHeight: 54, available: this.state.remote.length > 0,
MobilePrimaryButton({ label: '连接', buttonHeight: 54, available: this.state.remote.length > 0,
action: () => this.controller?.requestConnection(this.state.remote) })
}.width(96)
}.width('100%')
@@ -486,7 +486,7 @@ struct Mobile {
Toggle({ type: ToggleType.Switch, isOn: this.state.remember }).selectedColor(MobileTheme.green)
.onChange((value: boolean) => this.state.remember = value).accessibilityText('保存密码')
}.width('100%').margin({ top: 18, bottom: 18 })
MobilePrimaryButton({ label: '连接', arrow: true, available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
MobilePrimaryButton({ label: '连接', available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
}.padding({ left: 22, right: 22, top: 10, bottom: 22 }).width('100%')
}.width(this.viewWidth < 600 ? '100%' : Math.min(520, this.viewWidth - 48)).constraintSize({ maxHeight: this.viewHeight * 0.92 })
.backgroundColor(MobileTheme.background).borderRadius(24).scrollBar(BarState.Off)
+7 -1
View File
@@ -143,7 +143,7 @@ class MobileSession {
s->Event({{"type", "connection"}, {"status", status}}, true);
if (status == Connected && !s->connected_) {
s->connecting_ = false; s->connected_ = true;
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, false, s->version_, HostPlatform::HarmonyOS);
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, true, s->version_, HostPlatform::HarmonyOS);
s->Send(info); FreeRemoteAction(info);
RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio);
s->audio_.Start();
@@ -328,6 +328,12 @@ class MobileSession {
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
if(SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0)
Event({{"type","settingsFailed"},{"request",v.at("request")}},true);
} else if (op=="privacy") {
const auto bytes = mobile_controller::PrivacyMessage(
v.at("flag").get<int>(), v.at("blockLocalInput").get<bool>());
if (bytes.empty() ||
SendReliableDataFrame(controller_, bytes.data(), bytes.size(), kControlStream) != 0)
Event({{"type", "privacyFailed"}, {"request", v.at("request")}}, true);
} else if (op=="sas") {
const auto bytes=mobile_controller::SecureAttentionMessage();
const bool ok=SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)==0;
+4 -1
View File
@@ -61,11 +61,14 @@ def bootstrap(env):
seed = Path.home() / '.xmake/repositories/xmake-repo'
source = seed if (seed / '.git').exists() else 'https://github.com/xmake-io/xmake-repo.git'
run(['git', 'clone', '--no-hardlinks', source, repository], env=env)
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
if revision != PACKAGE_REVISION:
run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, env=env)
run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, env=env)
# Mark this pinned checkout as ready so Xmake's first-run repository update
# cannot replace it with a mirror or advance it beyond PACKAGE_REVISION.
(repository / 'updated').touch()
def mobile_project():
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F20000000000000000001 /* RemotePrivacyScreen.swift */; };
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
@@ -37,6 +38,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
C10F20000000000000000001 /* RemotePrivacyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePrivacyScreen.swift; sourceTree = "<group>"; };
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
@@ -118,6 +120,7 @@
20000000000000000000001C /* AppUpdateChecker.swift */,
200000000000000000000018 /* RecentConnection.swift */,
200000000000000000000016 /* RemoteVideoSettings.swift */,
C10F20000000000000000001 /* RemotePrivacyScreen.swift */,
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
200000000000000000000003 /* ContentView.swift */,
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
@@ -291,6 +294,7 @@
10000000000000000000001C /* AppUpdateChecker.swift in Sources */,
100000000000000000000018 /* RecentConnection.swift in Sources */,
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */,
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
100000000000000000000003 /* ContentView.swift in Sources */,
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */,
@@ -123,6 +123,10 @@ typedef struct {
didReceiveServiceAvailable:(BOOL)available;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didSendSecureAttention:(BOOL)success;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePrivacyStatus:(NSData *)data;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didSendPrivacyRequest:(uint32_t)requestID success:(BOOL)success;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateFileTransfer:(NSString *)fileName
progress:(double)progress
@@ -186,6 +190,9 @@ typedef struct {
- (void)sendClipboardText:(NSString *)text;
- (void)sendFileAtURL:(NSURL *)fileURL;
- (void)sendSecureAttentionSequence;
- (void)sendPrivacyCommand:(BOOL)enable
blockLocalInput:(BOOL)blockLocalInput
requestID:(uint32_t)requestID;
@end
@@ -1043,6 +1043,30 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
});
}
- (void)sendPrivacyCommand:(BOOL)enable
blockLocalInput:(BOOL)blockLocalInput
requestID:(uint32_t)requestID {
const uint64_t generation = _activeControllerGeneration.load();
dispatch_async(_rtcQueue, ^{
if (![self isControllerGenerationActive:generation]) return;
RemoteAction action{};
action.type = ControlType::privacy_command;
action.pc = {enable ? crossdesk::PrivacyCommandFlag::enable
: crossdesk::PrivacyCommandFlag::disable,
static_cast<bool>(blockLocalInput)};
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:didSendPrivacyRequest:success:)])
[delegate rtcBridge:self didSendPrivacyRequest:requestID success:success];
});
});
}
- (void)sendSecureAttentionSequence {
const uint64_t generation = _activeControllerGeneration.load();
dispatch_async(_rtcQueue, ^{
@@ -1409,6 +1433,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
RemoteAction action{};
action.type = ControlType::host_infomation;
action.i.platform = crossdesk::HostPlatform::IOS;
action.i.supports_privacy_screen = true;
CopyCString(action.i.host_name, sizeof(action.i.host_name),
controller_name);
action.i.host_name_size = std::strlen(action.i.host_name);
@@ -1694,6 +1719,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
if (source == kControlStream) {
RemoteAction action{};
if (!action.from_json(std::string(data, size))) return;
if (action.type == ControlType::privacy_status) {
const auto message = action.to_json();
NSData *status = [NSData dataWithBytes:message.data() length:message.size()];
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didReceivePrivacyStatus:)])
[delegate rtcBridge:self didReceivePrivacyStatus:status];
});
return;
}
if (action.type == ControlType::video_settings_status) {
[self notifyVideoSettings:action.vs generation:generation];
return;
+3 -6
View File
@@ -431,10 +431,7 @@ private struct ConnectionHomeView: View {
Button {
presentPasswordPrompt(for: trimmedRemoteID)
} label: {
HStack(spacing: 6) {
Text("连接")
Image(systemName: "arrow.right")
}
Text("连接")
.font(.system(size: 16, weight: .bold))
.frame(width: 96, height: 54)
.foregroundStyle(.white)
@@ -701,7 +698,7 @@ private struct RecentConnectionCard: View {
.buttonStyle(.plain)
.contextMenu {
Button(action: connect) {
Label("连接", systemImage: "arrow.right.circle")
Text("连接")
}
Button(role: .destructive, action: delete) {
Label("删除记录", systemImage: "trash")
@@ -785,7 +782,7 @@ private struct PasswordPromptView: View {
}
Button(action: connect) {
Label("连接", systemImage: "arrow.right")
Text("连接")
.font(.headline)
.frame(maxWidth: .infinity)
.frame(height: 48)
@@ -0,0 +1,99 @@
import Foundation
struct RemotePrivacyStatus: Decodable {
let state: Int
let supported: Bool
let inputBlockSupported: Bool
let overlayActive: Bool
let inputBlocked: Bool
let remotePaused: Bool
let revision: UInt32
let reason: String
}
struct RemotePrivacyScreen {
private(set) var snapshot: RemotePrivacyStatus?
private(set) var pending = false
private(set) var requestID: UInt32 = 0
private var requestRevision: UInt32 = 0
private var receivedAt: TimeInterval = 0
private var requestedAt: TimeInterval = 0
private var failure = ""
var active: Bool {
guard let s = snapshot else { return false }
return s.overlayActive || s.remotePaused || [2, 3, 4].contains(s.state)
}
func fresh(at now: TimeInterval) -> Bool { snapshot != nil && now - receivedAt <= 5 }
func enabled(at now: TimeInterval) -> Bool {
guard let s = snapshot else { return false }
return fresh(at: now) && s.state == 3 && s.overlayActive && !s.remotePaused
}
func buttonTitle(at now: TimeInterval) -> String {
if enabled(at: now) { return "隐私屏已开启" }
if fresh(at: now), let s = snapshot, s.supported && s.state == 1 && !active {
return "隐私屏已关闭"
}
return "隐私屏"
}
func feedback(at now: TimeInterval) -> String {
if failure.isEmpty && (pending || fresh(at: now) && [2, 4].contains(snapshot?.state ?? -1)) {
return ""
}
if failure.isEmpty && !pending && fresh(at: now), let s = snapshot, s.supported,
enabled(at: now) || (s.state == 1 && !active) { return "" }
let reason = detail(at: now)
return status(at: now) + (reason.isEmpty ? "" : ":" + reason)
}
func canToggle(at now: TimeInterval) -> Bool {
guard let s = snapshot else { return false }
return !pending && fresh(at: now) && s.state != 4 && (active || s.supported && s.state == 1)
}
mutating func receive(_ data: Data, at now: TimeInterval) {
struct Envelope: Decodable { let type: Int; let privacyStatus: RemotePrivacyStatus }
let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let message = try? decoder.decode(Envelope.self, from: data), message.type == 10 else { return }
let s = message.privacyStatus
guard (0...5).contains(s.state), s.reason.utf8.count < 256,
!s.reason.contains("\0"),
s.state != 3 || (s.supported && s.overlayActive && !s.remotePaused) else { return }
if let previous = snapshot, Int32(bitPattern: s.revision &- previous.revision) < 0 { return }
snapshot = s; receivedAt = now; failure = ""
if pending && Int32(bitPattern: s.revision &- requestRevision) > 0 && ![2, 4].contains(s.state) {
pending = false
}
}
mutating func begin(at now: TimeInterval) -> UInt32? {
tick(at: now)
guard canToggle(at: now), let s = snapshot else { return nil }
pending = true; requestedAt = now; requestRevision = s.revision; failure = ""
requestID &+= 1
return requestID
}
mutating func failed(request: UInt32) {
if pending && requestID == request { pending = false; failure = "指令发送失败,请重试" }
}
mutating func tick(at now: TimeInterval) {
if pending && now - requestedAt >= 15 {
pending = false; failure = "请求超时,尚未确认电脑的隐私屏状态"
}
}
func status(at now: TimeInterval) -> String {
if !failure.isEmpty { return failure }
guard let s = snapshot else { return "等待电脑返回隐私屏状态;旧版电脑端可能不支持" }
if !fresh(at: now) { return "隐私屏状态已过期,等待电脑确认" }
if s.state == 2 { return "正在开启隐私屏…" }
if s.state == 4 { return "正在关闭隐私屏…" }
if pending { return "指令已发送,等待电脑确认…" }
if s.state == 5 { return "隐私屏异常" }
if !s.supported || s.state == 0 { return "隐私屏暂不可用" }
if s.state == 3 && s.overlayActive && !s.remotePaused {
return "隐私屏已开启"
}
return s.state == 1 && !active ? "隐私屏已关闭" : "隐私屏状态待确认"
}
func detail(at now: TimeInterval) -> String {
guard let s = snapshot, fresh(at: now), s.state == 0 || s.state == 5 || !s.supported else { return "" }
return s.reason
}
}
@@ -316,6 +316,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
didSet { UserDefaults.standard.set(autoLandscape, forKey: AppOrientation.autoLandscapeKey) }
}
@Published private(set) var videoSettings = RemoteVideoSettingsState()
@Published var privacyScreen = RemotePrivacyScreen()
@Published private(set) var privacyClock = ProcessInfo.processInfo.systemUptime
private var privacyTimer: Timer?
@Published var remoteID = ""
@Published var password = ""
@Published private(set) var signalStatus = "等待隐私授权"
@@ -432,6 +435,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
deinit {
privacyTimer?.invalidate()
presenceTimer?.invalidate()
remoteVersionCheckTask?.cancel()
}
@@ -439,6 +443,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
var privacyNoticeVisible: Bool { privacyNotice.isVisible }
func applicationDidBecomeActive() {
privacyClock = ProcessInfo.processInfo.systemUptime
privacyScreen.tick(at: privacyClock)
appIsActive = true
AppOrientation.resume()
pictureInPicture.didBecomeActive()
@@ -1029,6 +1035,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func resetVideoSettings() {
privacyTimer?.invalidate(); privacyTimer = nil
privacyScreen = RemotePrivacyScreen()
privacyClock = ProcessInfo.processInfo.systemUptime
videoSettingsTimeoutTask?.cancel()
videoSettingsTimeoutTask = nil
videoSettings = RemoteVideoSettingsState(
@@ -1087,6 +1096,35 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
bridge.sendFile(at: url)
}
private func startPrivacyMonitoring() {
guard privacyTimer == nil else { return }
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.privacyClock = ProcessInfo.processInfo.systemUptime
self.privacyScreen.tick(at: self.privacyClock)
}
RunLoop.main.add(timer, forMode: .common)
privacyTimer = timer
}
func togglePrivacyScreen() {
guard hasNetworkConsent, isConnected else { return }
privacyClock = ProcessInfo.processInfo.systemUptime
let enable = !privacyScreen.active
guard let request = privacyScreen.begin(at: privacyClock) else { return }
bridge.sendPrivacyCommand(enable, blockLocalInput: true, requestID: request)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceivePrivacyStatus data: Data) {
guard isConnected || isConnecting else { return }
privacyClock = ProcessInfo.processInfo.systemUptime
privacyScreen.receive(data, at: privacyClock)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didSendPrivacyRequest requestID: UInt32, success: Bool) {
if !success { privacyScreen.failed(request: requestID) }
}
func sendSecureAttention() {
guard hasNetworkConsent, isConnected else { return }
guard remoteServiceAvailable else {
@@ -1190,6 +1228,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
case 0: connectionStatus = "正在建立远程连接"
case 1:
connectionStatus = "已连接"
startPrivacyMonitoring()
sessionVisible = true
pictureInPicture.beginSession()
if videoSettings.requestID == 0 { updateVideoSettings() }
@@ -1250,8 +1250,24 @@ private struct FloatingSessionMenu: View {
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
FloatingControlButton(
title: session.privacyScreen.buttonTitle(at: session.privacyClock),
symbol: "eye.slash",
selected: session.privacyScreen.enabled(at: session.privacyClock),
action: session.togglePrivacyScreen
)
.disabled(!session.isConnected || !session.privacyScreen.canToggle(at: session.privacyClock))
.opacity(session.isConnected && session.privacyScreen.canToggle(at: session.privacyClock) ? 1 : 0.45)
.accessibilityLabel("隐私屏")
.accessibilityValue(session.privacyScreen.status(at: session.privacyClock))
.accessibilityHint(session.privacyScreen.active ? "关闭隐私屏" : "开启隐私屏")
}
let privacyFeedback = session.privacyScreen.feedback(at: session.privacyClock)
if !privacyFeedback.isEmpty {
Text(privacyFeedback).font(.caption2).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
@@ -1435,6 +1451,7 @@ private struct FloatingVideoSettings: View {
private struct FloatingControlButton: View {
let title: String
let symbol: String
var selected = false
let action: () -> Void
var body: some View {
@@ -1442,6 +1459,7 @@ private struct FloatingControlButton: View {
FloatingControlLabel(title: title, symbol: symbol)
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
+23 -4
View File
@@ -8,6 +8,17 @@ UPnP discovery and router port mapping are disabled in iOS builds, matching
Android. Local connections, ICE/STUN hole punching, and TURN UDP/TCP relay
remain available. The native archive excludes GUPnP and its HTTP/XML dependencies.
## Remote privacy screen
The floating menu includes a **Privacy screen** button that toggles the host's
local cover directly, without opening a detail page. Its label reports the confirmed
on/off state without changing colors; normal toggling shows no extra text; errors and unavailable status still appear inline. It supports enabling/disabling
the local cover, blocks local keyboard/mouse input when enabled like the desktop
controller, and advertises support
for the host’s automatic privacy-on-connect setting. Status older than five
seconds is shown as stale; commands require confirmation within fifteen seconds.
PiP/background transitions preserve the session state; disconnecting clears it.
## Background connection and Picture in Picture
An established remote desktop session supports native Picture in Picture (PiP).
@@ -270,16 +281,24 @@ Closing the keyboard, switching keyboards, disconnecting or entering the
background discards unfinished composition and pending callbacks. Already sent
text remains on the remote computer.
Direct UTF-8 text requires a host advertising `supports_text_input`. The macOS
host implements this with native Unicode keyboard events and does not modify
Direct UTF-8 text requires a host advertising `supports_text_input`. Windows and
macOS hosts implement this with native Unicode keyboard events and do not modify
the clipboard. Each replacement is validated before remote Backspace events are
sent; text is limited to 16 KiB and deletions to 16,384 characters per update.
Hosts without this capability retain ASCII key input. Unsupported input is
rejected with an explanation and is not queued for later replay. For Windows,
Linux or older macOS hosts, copy text from another app and send it with
rejected with an explanation and is not queued for later replay. For hosts
without text input support on an ordinary desktop, send text with
**剪贴板** in the remote session menu, then use the computer keyboard's paste
shortcut.
This development build forwards complete UTF-8 commits through the Windows
service on lock screens, sign-in screens and secure desktops. Digits, mixed-case
letters, punctuation, spaces and non-ASCII characters stay on the text path.
The Windows client, service and session helper must all be updated; updating
iOS alone cannot enable this fix. Older Windows hosts may advertise
`supports_text_input` for ordinary desktops while lacking secure desktop text
forwarding. Actual password entry still requires validation on updated Windows.
## App updates
After network consent, the app checks the public version manifest once on each