mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-28 00:33:06 +08:00
[feat] add mobile privacy controls and Windows secure text input
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user