mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-27 08:12:18 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b15925f857 | ||
|
|
1e25ec15e1 | ||
|
|
9f7411e5c0 | ||
|
|
8fd7ca1e8d |
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Android artifact version, for example v1.6.0-20261002."
|
||||
description: "Android artifact version, for example v1.7.3-20261026 or v1.7.3-1-20261026."
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_tag:
|
||||
description: "Existing Android release to sign without rebuilding, for example android-v1.6.0-20261002."
|
||||
description: "Existing Android release to sign without rebuilding, for example android-v1.7.3-1-20261026."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -61,11 +61,11 @@ jobs:
|
||||
if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" and not version:
|
||||
tag = os.environ["SOURCE_TAG"]
|
||||
if not tag.startswith("android-v"):
|
||||
raise ValueError("Use an existing android-v<app-version>-YYYYMMDD release")
|
||||
raise ValueError("Use an existing android-v<app-version>[-<patch>]-YYYYMMDD release")
|
||||
version = tag.removeprefix("android-")
|
||||
match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+-([0-9]{8})", version)
|
||||
match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9]+)?-([0-9]{8})", version)
|
||||
if not match:
|
||||
raise ValueError("Invalid Android artifact version")
|
||||
raise ValueError("Invalid Android artifact version; expected v<app-version>[-<patch>]-YYYYMMDD")
|
||||
datetime.datetime.strptime(match[1], "%Y%m%d")
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as stream:
|
||||
stream.write(f"version={version}\n")
|
||||
|
||||
@@ -138,7 +138,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
|
||||
|
||||
### Android
|
||||
|
||||
原生控制端支持触控与外接键鼠、多屏切换、文件收发、文本剪贴板和会话画面设置。H.264 / AV1 优先使用硬件解码,不支持或运行失败时回退到软件解码;切换应用时优先通过画中画保持会话,画中画不可用时尝试前台服务保活。当前不提供手机被控或隐私屏功能。
|
||||
原生控制端支持触控与外接键鼠、多屏切换、文件收发、文本剪贴板和会话画面设置。H.264 / AV1 优先使用硬件解码,不支持或运行失败时回退到软件解码;切换应用时优先通过画中画保持会话,画中画不可用时尝试前台服务保活。当前不提供手机被控功能。悬浮菜单中的“隐私屏”按钮可直接开启或关闭电脑本地遮罩,按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠。
|
||||
|
||||
[使用说明](docs/USER_GUIDE.md#android) · [源码构建](apps/android/README.md)
|
||||
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ The native controller supports touch and a virtual mouse, multiple monitors, rem
|
||||
|
||||
### Android
|
||||
|
||||
The native controller supports touch and external keyboards/mice, monitor switching, file transfers, text clipboard, and video settings. H.264 / AV1 use hardware decoding when available, with software fallback when unsupported or when decoding fails. Active sessions use Picture in Picture when switching apps, with a foreground service as a fallback when Picture in Picture is unavailable. Phone-as-host and privacy screens are not currently supported.
|
||||
The native controller supports touch and external keyboards/mice, monitor switching, file transfers, text clipboard, and video settings. H.264 / AV1 use hardware decoding when available, with software fallback when unsupported or when decoding fails. Active sessions use Picture in Picture when switching apps, with a foreground service as a fallback when Picture in Picture is unavailable. Phone-as-host is not currently supported. The floating menu’s **Privacy screen** button toggles the computer’s local cover directly, labels the host-confirmed on/off state without changing colors, and shows inline feedback only for errors or unavailable status. Enabling privacy also blocks local keyboard/mouse input, matching the desktop controller.
|
||||
|
||||
[User guide](docs/USER_GUIDE_EN.md#android) · [Build from source](apps/android/README.md) (Chinese)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,9 +58,8 @@ bool IsHex(const std::string& text, size_t size) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||
});
|
||||
}
|
||||
void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
|
||||
const auto temporary = path.parent_path() / (".write-" + RandomHex(16));
|
||||
#ifdef _WIN32
|
||||
std::vector<unsigned char> CurrentUser() {
|
||||
HANDLE token = nullptr;
|
||||
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
|
||||
"Cannot open user token");
|
||||
@@ -70,25 +69,39 @@ void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
|
||||
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
|
||||
CloseHandle(token);
|
||||
Require(ok, "Cannot read user token");
|
||||
return data;
|
||||
}
|
||||
PSECURITY_DESCRIPTOR PrivateSecurityDescriptor(bool directory) {
|
||||
auto user = CurrentUser();
|
||||
LPWSTR sid = nullptr;
|
||||
Require(ConvertSidToStringSidW(
|
||||
reinterpret_cast<TOKEN_USER*>(data.data())->User.Sid, &sid),
|
||||
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
|
||||
"Cannot read user SID");
|
||||
std::wstring acl = L"D:P(A;;FA;;;" + std::wstring(sid) + L")";
|
||||
// An elevated token can default to Administrators as the owner even when
|
||||
// its DACL grants access only to TokenUser. Always set both explicitly.
|
||||
const auto acl = L"O:" + std::wstring(sid) + L"D:P(A;" +
|
||||
(directory ? L"OICI" : L"") + L";FA;;;" + sid + L")";
|
||||
LocalFree(sid);
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
|
||||
"Cannot protect LAN file");
|
||||
return descriptor;
|
||||
}
|
||||
#endif
|
||||
void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
|
||||
const auto temporary = path.parent_path() / (".write-" + RandomHex(16));
|
||||
#ifdef _WIN32
|
||||
auto descriptor = PrivateSecurityDescriptor(false);
|
||||
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
|
||||
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security,
|
||||
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
LocalFree(descriptor);
|
||||
Require(file != INVALID_HANDLE_VALUE, "Cannot create LAN file");
|
||||
DWORD written = 0;
|
||||
ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()), &written,
|
||||
nullptr) &&
|
||||
written == text.size() && FlushFileBuffers(file);
|
||||
bool ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()),
|
||||
&written, nullptr) &&
|
||||
written == text.size() && FlushFileBuffers(file);
|
||||
CloseHandle(file);
|
||||
if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
|
||||
@@ -114,34 +127,42 @@ void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
|
||||
#endif
|
||||
}
|
||||
#ifdef _WIN32
|
||||
std::vector<unsigned char> CurrentUser() {
|
||||
HANDLE token = nullptr;
|
||||
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
|
||||
"Cannot open user token");
|
||||
DWORD size = 0;
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
|
||||
std::vector<unsigned char> data(size);
|
||||
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
|
||||
CloseHandle(token);
|
||||
Require(ok, "Cannot read user token");
|
||||
return data;
|
||||
using FileHandle = std::unique_ptr<void, decltype(&CloseHandle)>;
|
||||
FileHandle OpenLanPath(const std::filesystem::path& path, DWORD access) {
|
||||
// Keep the object open without FILE_SHARE_DELETE so validation and legacy
|
||||
// owner repair cannot operate on a replacement at the same path.
|
||||
HANDLE file = CreateFileW(
|
||||
path.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
|
||||
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
|
||||
nullptr);
|
||||
Require(file != INVALID_HANDLE_VALUE, "Cannot open LAN path");
|
||||
return {file, CloseHandle};
|
||||
}
|
||||
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
|
||||
const auto attributes = GetFileAttributesW(path.c_str());
|
||||
Require(attributes != INVALID_FILE_ATTRIBUTES &&
|
||||
!(attributes & FILE_ATTRIBUTE_REPARSE_POINT),
|
||||
auto file = OpenLanPath(path, READ_CONTROL);
|
||||
BY_HANDLE_FILE_INFORMATION info{};
|
||||
Require(GetFileInformationByHandle(file.get(), &info) &&
|
||||
!(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT),
|
||||
"LAN path cannot be a reparse point");
|
||||
auto user = CurrentUser();
|
||||
auto sid = reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid;
|
||||
PSID owner = nullptr;
|
||||
PACL acl = nullptr;
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
Require(GetNamedSecurityInfoW(
|
||||
const_cast<wchar_t*>(path.c_str()), SE_FILE_OBJECT,
|
||||
Require(GetSecurityInfo(
|
||||
file.get(), SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
|
||||
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
|
||||
"Cannot read LAN file permissions");
|
||||
bool ok = owner && EqualSid(owner, sid) && acl && acl->AceCount > 0;
|
||||
std::unique_ptr<void, decltype(&LocalFree)> security(descriptor, LocalFree);
|
||||
bool ok = acl && acl->AceCount > 0;
|
||||
// Only the exact protected, full-access DACL written by older versions is
|
||||
// eligible for owner repair. Never adopt a shared or foreign-user profile.
|
||||
SECURITY_DESCRIPTOR_CONTROL control{};
|
||||
DWORD revision = 0;
|
||||
bool legacy = owner && IsWellKnownSid(owner, WinBuiltinAdministratorsSid) &&
|
||||
GetSecurityDescriptorControl(descriptor, &control, &revision) &&
|
||||
(control & SE_DACL_PROTECTED) && acl && acl->AceCount == 1;
|
||||
for (DWORD i = 0; ok && i < acl->AceCount; ++i) {
|
||||
void* raw = nullptr;
|
||||
if (!GetAce(acl, i, &raw)) {
|
||||
@@ -151,23 +172,30 @@ void CheckPrivateWindowsPath(const std::filesystem::path& path) {
|
||||
const auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
|
||||
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
|
||||
EqualSid(const_cast<DWORD*>(&ace->SidStart), sid);
|
||||
const BYTE flags = (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
? OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
|
||||
: 0;
|
||||
legacy =
|
||||
legacy && ace->Mask == FILE_ALL_ACCESS && ace->Header.AceFlags == flags;
|
||||
}
|
||||
Require(ok && owner && (EqualSid(owner, sid) || legacy),
|
||||
"LAN files must be accessible only by the current user");
|
||||
if (legacy) {
|
||||
auto writable = OpenLanPath(path, WRITE_OWNER);
|
||||
BY_HANDLE_FILE_INFORMATION current{};
|
||||
Require(GetFileInformationByHandle(writable.get(), ¤t) &&
|
||||
current.dwVolumeSerialNumber == info.dwVolumeSerialNumber &&
|
||||
current.nFileIndexHigh == info.nFileIndexHigh &&
|
||||
current.nFileIndexLow == info.nFileIndexLow &&
|
||||
SetSecurityInfo(writable.get(), SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION, sid, nullptr,
|
||||
nullptr, nullptr) == ERROR_SUCCESS,
|
||||
"Cannot repair LAN file owner");
|
||||
}
|
||||
LocalFree(descriptor);
|
||||
Require(ok, "LAN files must be accessible only by the current user");
|
||||
}
|
||||
void CreatePrivateDirectory(const std::filesystem::path& path) {
|
||||
if (!std::filesystem::exists(path)) {
|
||||
auto user = CurrentUser();
|
||||
LPWSTR sid = nullptr;
|
||||
Require(ConvertSidToStringSidW(
|
||||
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
|
||||
"Cannot read user SID");
|
||||
const auto acl = L"D:P(A;OICI;FA;;;" + std::wstring(sid) + L")";
|
||||
LocalFree(sid);
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
|
||||
"Cannot protect LAN directory");
|
||||
auto descriptor = PrivateSecurityDescriptor(true);
|
||||
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
|
||||
bool ok = CreateDirectoryW(path.c_str(), &security);
|
||||
LocalFree(descriptor);
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: 576f3d3e61...bd90bc7c8c
+12
@@ -32,6 +32,16 @@ Wayland 画面捕获依赖启用了 Wayland 支持的构建、宿主系统 PipeW
|
||||
|
||||
在被控电脑检查 CrossDesk Service 是否安装、是否运行,并保持 CrossDesk 客户端运行。便携版可在“设置 → 锁屏控制服务”安装。服务状态检查命令见 [Windows 服务说明](../README.md#windows-service)。
|
||||
|
||||
“断开连接后自动锁屏”可用不代表锁屏控制服务可用:锁屏由客户端调用系统功能,输入登录密码则需要服务。若 Android 手机能看到登录界面但输入法无法输入,可在远程键盘中切换到 **电脑键盘**,对比数字、字母与回车是否有效;反馈时注明两端版本、安装版或便携版,以及两种键盘的表现。
|
||||
|
||||
若电脑键盘可用,而搜狗等系统输入法不可用,请确认被控端已安装包含文本转发修复的新 Windows 构建,并一并更新客户端、服务与会话辅助程序。旧版的按键转发与输入法文本转发是不同通道,仅更新手机端不会修复旧 Windows 的文本通道。本次修复转发完整文本,保留大小写、符号、空格及中文等字符,不限于数字密码;仍需在更新后的 Windows 锁屏、登录界面验证。
|
||||
|
||||
## 移动端连接后没有进入隐私屏
|
||||
|
||||
Android、iOS 和鸿蒙原生移动端可直接点击悬浮菜单的隐私屏按钮切换,按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,无需进入详情页;开启时与桌面端一致屏蔽电脑本地键鼠,不提供独立屏蔽选项。自动开启还需要电脑勾选“被连接时开启隐私屏”,且电脑当前桌面支持遮罩;锁屏、登录界面或无人值守被控端不提供隐私遮罩。
|
||||
|
||||
v1.7.3-1 的 Android 端尚未实现上述控制能力,需要使用包含该功能的新构建。若一直等待状态,请检查电脑端版本;若显示不可用或失败,请查看控制栏内的电脑返回原因。当前 PC 策略要求所有已连接控制端均支持隐私屏,旧版或 Web 控制端同时在线时仍会阻止开启。“断开连接后自动锁屏”是独立功能。Windows 被控端仍可通过 Ctrl+Alt+Shift+F12 应急恢复本地显示与输入。
|
||||
|
||||
## 被控 Windows 电脑没有接显示器
|
||||
|
||||
没有显示器时,Windows 会把桌面放在一个分辨率固定的虚拟目标上,GPU 采集路径不可用。CrossDesk 会先尝试插入内置的 usbmmidd 虚拟显示器(见下一节);无法插入时自动进入**无屏兼容模式**:用 GDI 采集,分辨率由系统决定(常见为 1024×768),帧率和画质低于正常模式,被控端日志会记录进入兼容模式的原因。键鼠控制、隐私屏、文件传输不受影响。
|
||||
@@ -90,6 +100,8 @@ Windows 安装包和便携包内置了 Amyuni 的 `usbmmidd_v2` 虚拟显示器
|
||||
| No capture on Linux Wayland | Check the Wayland build option, host PipeWire 0.3, and desktop portal permission. Compare with an X11 session. See [Linux build instructions](BUILD_EN.md#linux). |
|
||||
| Linux host has no monitor or graphical session | Start CrossDesk normally to automatically discover and select your existing desktop, preserving its Dock and apps; no display number is needed. A bare Xvfb shows only CrossDesk on a black background; use an existing session or install Xfce and specify `--headless-session startxfce4`. See [Linux headless operation](HEADLESS_EN.md). |
|
||||
| Cannot control a Windows lock screen | Install/start CrossDesk Service and keep the host client running. See [service commands](../README_EN.md#windows-service). |
|
||||
| Android can connect but cannot type a Windows login password | Locking on disconnect does not verify service availability. Check CrossDesk Service, then compare the phone IME with **Computer keyboard** in the remote keyboard panel. Include both client versions, installer/portable edition, and both keyboard results in the report. |
|
||||
| Privacy screen does not activate for a mobile connection | Toggle **Privacy screen** directly in the native Android, iOS or HarmonyOS floating menu. The button labels the host-confirmed on/off state without changing colors; normal toggling shows no extra text; errors and unavailable status still appear inline. Android v1.7.3-1 lacks this feature; use a build containing it. Automatic activation also needs the host setting and a supported unlocked desktop. The current host policy requires every connected controller to support privacy; older or Web controllers can prevent activation. Locking on disconnect is independent. |
|
||||
| Windows host has no monitor attached | The Windows installer and portable archive bundle Amyuni's `usbmmidd_v2` virtual display driver (in the `usbmmidd_v2` folder with its License.txt). On each connection to a host without a monitor (capture method Auto), CrossDesk starts streaming in compatibility mode and, in the background, asks the CrossDesk Service session helper to install the Microsoft-signed driver (first time only) and plug one virtual monitor at 1920×1080; capture then switches to DXGI/WGC on that monitor and it is unplugged when the session ends. Without the service only an elevated CrossDesk can plug it itself; otherwise the host stays in **headless compatibility mode**: GDI capture at an OS-fixed resolution (often 1024×768) with reduced frame rate; the host log records why. Input, privacy screen and file transfer still work. A real monitor or an HDMI/DP dummy plug also restores GPU capture. |
|
||||
| Removing the bundled virtual display driver | The uninstaller removes the driver when CrossDesk installed it and leaves a driver you installed yourself. For the portable build run `deviceinstaller64 enableidd 0`, `deviceinstaller64 stop usbmmidd` and `deviceinstaller64 remove usbmmidd` as administrator from the `usbmmidd_v2` folder, or uninstall "USB Mobile Monitor Virtual Display" in Device Manager. |
|
||||
| Saved password no longer works | Use 6 ASCII letters or digits when changing the password, with the host connected to the server. Wait for the change and reconnection to complete, then copy the current password and enter it on the controller. |
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ Android 客户端支持 Android 8.0+ 的 arm64 手机和平板,可从 [官网]
|
||||
3. 展开悬浮菜单,可切换显示器、调节画质与帧率、使用虚拟鼠标、收发文件、查看网络状态,以及发送剪贴板文本。接收的文件可通过保存入口导出。
|
||||
4. 切换应用时,已有会话优先通过系统画中画保持;画中画不可用时尝试使用带通知的前台服务。关闭画中画窗口或后台保活未能启动会结束会话。
|
||||
|
||||
H.264 / AV1 优先使用 MediaCodec 硬件解码;设备不支持或运行失败时回退到对应软件解码器。实际效果取决于设备能力。Android 当前不提供手机被控或隐私屏功能。
|
||||
H.264 / AV1 优先使用 MediaCodec 硬件解码;设备不支持或运行失败时回退到对应软件解码器。实际效果取决于设备能力。Android 当前不提供手机被控功能。悬浮菜单中的“隐私屏”按钮可直接开启或关闭电脑本地遮罩,按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠。
|
||||
|
||||
<a id="harmonyos"></a>
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ The Android client supports Android 8.0+ arm64 phones and tablets. Install the s
|
||||
3. Open the floating menu to switch displays, adjust video quality and frame rate, use the virtual mouse, send and receive files, inspect network statistics, and send clipboard text. Use the save action to export received files.
|
||||
4. When switching apps, an active session uses system Picture in Picture when available, with a foreground service and notification as a fallback. Closing the Picture in Picture window or failing to start background session support ends the session.
|
||||
|
||||
H.264 / AV1 use MediaCodec hardware decoding when available, with software fallback when unsupported or when decoding fails. Results depend on the device. Android does not currently support phone-as-host or privacy screens.
|
||||
H.264 / AV1 use MediaCodec hardware decoding when available, with software fallback when unsupported or when decoding fails. Results depend on the device. Android does not currently support phone-as-host. The floating menu’s **Privacy screen** button toggles the computer’s local cover directly, labels the host-confirmed on/off state without changing colors, and shows inline feedback only for errors or unavailable status. Enabling privacy also blocks local keyboard/mouse input, matching the desktop controller.
|
||||
|
||||
<a id="harmonyos"></a>
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
#include <remote_action.h>
|
||||
|
||||
namespace crossdesk::mobile_controller {
|
||||
inline std::string PrivacyMessage(int flag, bool block_local_input) {
|
||||
if (flag < 0 || flag > 2) return {};
|
||||
RemoteAction action{};
|
||||
action.type = privacy_command;
|
||||
action.pc = {static_cast<PrivacyCommandFlag>(flag), block_local_input};
|
||||
return action.to_json();
|
||||
}
|
||||
inline std::string SecureAttentionMessage() {
|
||||
RemoteAction action{};
|
||||
action.type = service_command;
|
||||
|
||||
Reference in New Issue
Block a user