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

This commit is contained in:
dijunkun
2026-10-27 02:37:23 +08:00
parent 1e25ec15e1
commit b15925f857
37 changed files with 795 additions and 80 deletions
+1
View File
@@ -26,6 +26,7 @@ apps/harmony/.native/mobile-project/entry/build/default/outputs/default/entry-de
| Android 功能 | 鸿蒙实现 |
| --- | --- |
| 前台自动登录、空闲后台停止、服务器隔离 | 独立 identity peer;只有主动远控才创建 controller peer;拒绝任何入站被控连接 |
| 电脑隐私屏 | 悬浮菜单直接开关,无详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠;5 秒状态失效、15 秒指令超时与跨会话隔离 |
| 首次隐私确认、撤回与离线阅读 | 同意前不发起身份、公告、在线状态或版本网络请求;撤回结束会话并清除预览 |
| 加密身份、可选记住访问密码 | 系统 Asset Store,设备首次解锁后可读,禁止跨设备同步;普通 Preferences 不存密码 |
| 最近连接、删除、在线状态 | 最近 20 台、在线优先、真实信令订阅、30 秒刷新和 60 秒过期;删除同步清理密码与预览 |
@@ -1,3 +1,4 @@
import { RemotePrivacyScreen, PrivacyStatus } from './RemotePrivacyScreen';
import bridge from 'libcrossdesk.so';
import { common, bundleManager } from '@kit.AbilityKit';
import { display, promptAction } from '@kit.ArkUI';
@@ -13,7 +14,7 @@ interface Command {
logs?: string; certificates?: string; directory?: string; previewDirectory?: string; remote?: string; password?: string; version?: string;
id?: string; x?: number; y?: number; flag?: number; scroll?: number; code?: number; down?: boolean;
index?: number; muted?: boolean; visible?: boolean; text?: string; message?: Object; quality?: number; frameRate?: number;
preference?: number; request?: number; path?: string; name?: string; body?: string; local?: boolean;
preference?: number; blockLocalInput?: boolean; request?: number; path?: string; name?: string; body?: string; local?: boolean;
}
interface NativeEvent {
type: string; session: number; connection?: number; status?: number; controller?: boolean; generation?: number;
@@ -25,7 +26,7 @@ export interface RemoteDisplay { name: string; }
interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; }
interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; }
interface ServiceStatus { available: boolean; }
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; }
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; }
interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
interface SignalType { type: string; }
@@ -73,6 +74,12 @@ export class MobileState {
videoSelectedPreference: number = 1;
videoSupported: boolean = false;
videoFeedback: string = '';
privacyStatus: string = '';
privacyButtonTitle: string = '隐私屏';
privacyFeedback: string = '';
privacyEnabled: boolean = false;
privacyActive: boolean = false;
privacyCanToggle: boolean = false;
networkTime: number = 0;
unread: number = 0;
announcements: AnnouncementItem[] = [];
@@ -86,6 +93,7 @@ export class MobileController {
store: MobileStore;
platform: MobilePlatform;
video: VideoSettings = new VideoSettings();
private privacyScreen: RemotePrivacyScreen = new RemotePrivacyScreen();
network: NetworkStatistics = new NetworkStatistics();
presence: RecentPresence;
inbox: AnnouncementInbox;
@@ -159,7 +167,7 @@ export class MobileController {
this.state.update = ''; this.state.updateStatus = ''; this.state.page = 'home'; this.state.modal = 'consent';
}
resume(): void {
this.foreground = true; this.platform.resume();
this.foreground = true; this.platform.resume(); this.syncPrivacy();
if (this.state.consent) { this.ensureSignal(); this.checkLocal(); this.inbox.connect(this.state.signal === 1); }
else this.state.modal = 'consent';
}
@@ -236,6 +244,7 @@ export class MobileController {
this.network = new NetworkStatistics(); let edge = 1920;
try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {}
this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo();
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
this.store.put('lastRemote', this.state.remote); this.state.password = '';
this.ensureSignal(); this.joinIfReady();
}
@@ -247,6 +256,7 @@ export class MobileController {
end(reason: string): void {
this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect();
this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false;
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = '';
this.state.receivedPath = this.state.receivedName = this.state.remoteClipboard = this.state.clipboardText = ''; this.remoteVersionRequest = 0;
if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.settingsTimeout = -1;
@@ -339,6 +349,21 @@ export class MobileController {
this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = '';
} catch { this.toast('无法读取文档'); }
}
togglePrivacy(): void {
if (!this.state.ready || !this.state.consent) return;
const enable = !this.privacyScreen.active(), request = this.privacyScreen.begin(this.now());
if (request < 0) return;
this.send({ op: 'privacy', flag: enable ? 1 : 2, request: request,
blockLocalInput: true });
this.syncPrivacy();
}
private syncPrivacy(): void {
const now = this.now(); this.privacyScreen.tick(now);
this.state.privacyStatus = this.privacyScreen.status(now); this.state.privacyFeedback = this.privacyScreen.feedback(now);
this.state.privacyButtonTitle = this.privacyScreen.buttonTitle(now);
this.state.privacyEnabled = this.privacyScreen.enabled(now);
this.state.privacyActive = this.privacyScreen.active(); this.state.privacyCanToggle = this.privacyScreen.canToggle(now);
}
private syncVideo(): void {
this.state.videoQuality = this.video.selection.quality; this.state.videoRate = this.video.selection.frameRate;
this.state.videoSelectedPreference = this.video.selection.preference; this.state.videoSupported = this.video.supported;
@@ -348,6 +373,7 @@ export class MobileController {
try { const events = JSON.parse(bridge.mobilePoll()) as NativeEvent[]; events.forEach((event: NativeEvent) => this.event(event)); }
catch { this.state.signalText = '原生通信库不可用,请重新构建并安装'; }
if (++this.seconds % 20 === 0) {
if (this.state.ready) this.syncPrivacy();
this.state.networkTime = this.now(); this.presence.tick(this.state.networkTime); this.history();
}
}
@@ -395,7 +421,8 @@ export class MobileController {
if (e.sending && ((e.progress ?? 0) < 0 || (e.progress ?? 0) >= 1)) this.sendingFile = false;
this.state.transfer = (e.progress ?? 0) < 0 ? `${e.name} 传输失败` : `${e.sending ? '发送' : '接收'} ${e.name} · ${(e.progress ?? 0) >= 1 ? '已完成' : Math.floor((e.progress ?? 0) * 100) + '%'}`;
if (e.path) { this.state.receivedPath = e.path; this.state.receivedName = e.name ?? 'received-file'; }
} else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
} else if (e.type === 'privacyFailed') { this.privacyScreen.failed(Number(e.request)); this.syncPrivacy(); }
else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
else if (e.type === 'preview') {
this.previewPending = false;
if (e.ok && e.path && this.previewEligible && this.state.previews) {
@@ -435,6 +462,7 @@ export class MobileController {
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
}
private data(message: ControlData): void {
if (message.type === 10 && message.privacy_status) { this.privacyScreen.receive(message.privacy_status, this.now()); this.syncPrivacy(); return; }
if (message.type === 5) { this.serviceAvailable = message.service_status?.available === true; return; }
if (message.type === 12 && message.video_settings) {
const v = message.video_settings;
@@ -133,9 +133,10 @@ export struct MobileRemoteView {
if (index === 7) this.menu = 'network';
if (index === 8) this.controller?.sas();
if (index === 9) { this.state.modal = 'clipboard'; this.menu = ''; }
if (index === 10) this.controller?.togglePrivacy();
}
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板']; }
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard']; }
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板', this.state.privacyButtonTitle]; }
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard', 'lock']; }
private columns(): number { return this.viewHeight < 400 && this.viewWidth >= 400 ? 4 : 3; }
private menuWidth(): number { return Math.max(1, Math.min(this.viewWidth - 16, this.columns() === 4 ? 380 : Math.max(280, Math.min(340, this.viewWidth - 160)))); }
private menuX(): number { return clamp(this.orbX > this.viewWidth / 2 ? this.orbX - this.menuWidth() - 12 : this.orbX + 66, 8, this.viewWidth - this.menuWidth() - 8); }
@@ -212,12 +213,20 @@ export struct MobileRemoteView {
GridRow({ columns: this.columns(), gutter: 7 }) {
ForEach(this.labels(), (label: string, index: number) => {
GridCol() {
Column({ space: 4 }) { MobileIcon({ name: this.icons()[index], iconExtent: 19 }); Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1) }
.height(52).width('100%').justifyContent(FlexAlign.Center).backgroundColor('#F0F0F2').borderRadius(10)
.accessibilityText(label).accessibilityLevel('yes').onClick(() => this.action(index))
Column({ space: 4 }) {
MobileIcon({ name: this.icons()[index], iconExtent: 19 })
Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1)
}
.height(52).width('100%').justifyContent(FlexAlign.Center)
.backgroundColor('#F0F0F2').borderRadius(10)
.enabled(index !== 10 || this.state.ready && this.state.privacyCanToggle)
.opacity(index === 10 && (!this.state.ready || !this.state.privacyCanToggle) ? 0.45 : 1)
.accessibilitySelected(index === 10 && this.state.privacyEnabled)
.accessibilityText(index === 10 ? `${label},${this.state.privacyStatus}` : label).accessibilityLevel('yes').onClick(() => this.action(index))
}
}, (label: string) => label)
}
if (this.state.privacyFeedback) { Text(this.state.privacyFeedback).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
if (this.state.receivedPath) { Button('保存收到的文件', { type: ButtonType.Normal }).fontSize(14).fontColor(MobileTheme.blue).backgroundColor(Color.White).height(42).width('100%').onClick(() => this.controller?.saveFile()) }
} else if (this.menu === 'video') {
@@ -0,0 +1,87 @@
export interface PrivacyStatus {
state: number; supported: boolean; input_block_supported: boolean;
overlay_active: boolean; input_blocked: boolean; remote_paused: boolean;
revision: number; reason: string;
}
export class RemotePrivacyScreen {
snapshot?: PrivacyStatus;
pending: boolean = false;
requestId: number = 0;
private requestRevision: number = 0;
private receivedAt: number = 0;
private requestedAt: number = 0;
private failure: string = '';
receive(s: PrivacyStatus, now: number): void {
if (!Number.isInteger(s.state) || s.state < 0 || s.state > 5 ||
!Number.isInteger(s.revision) || s.revision < 0 || s.revision > 0xffffffff ||
typeof s.supported !== 'boolean' || typeof s.input_block_supported !== 'boolean' ||
typeof s.overlay_active !== 'boolean' || typeof s.input_blocked !== 'boolean' ||
typeof s.remote_paused !== 'boolean' || typeof s.reason !== 'string' || s.reason.includes('\0')) return;
if (s.state === 3 && (!s.supported || !s.overlay_active || s.remote_paused)) return;
if (this.snapshot && ((s.revision - this.snapshot.revision) | 0) < 0) return;
this.snapshot = s; this.receivedAt = now; this.failure = '';
if (this.pending && ((s.revision - this.requestRevision) | 0) > 0 && s.state !== 2 && s.state !== 4) this.pending = false;
}
active(): boolean {
const s = this.snapshot;
return s !== undefined && (s.overlay_active || s.remote_paused || s.state === 2 || s.state === 3 || s.state === 4);
}
fresh(now: number): boolean { return this.snapshot !== undefined && now - this.receivedAt <= 5000; }
enabled(now: number): boolean {
const s = this.snapshot;
return s !== undefined && this.fresh(now) && s.state === 3 && s.overlay_active && !s.remote_paused;
}
buttonTitle(now: number): string {
if (this.enabled(now)) return '隐私屏已开启';
const s = this.snapshot;
if (this.fresh(now) && s?.supported && s.state === 1 && !this.active()) return '隐私屏已关闭';
return '隐私屏';
}
feedback(now: number): string {
const s = this.snapshot;
if (!this.failure && (this.pending || this.fresh(now) && (s?.state === 2 || s?.state === 4))) return '';
if (!this.failure && !this.pending && this.fresh(now) && s?.supported &&
(this.enabled(now) || s.state === 1 && !this.active())) return '';
const detail = this.detail(now);
return this.status(now) + (detail ? ':' + detail : '');
}
canToggle(now: number): boolean {
const s = this.snapshot;
return s !== undefined && !this.pending && this.fresh(now) && s.state !== 4 && (this.active() || s.supported && s.state === 1);
}
begin(now: number): number {
this.tick(now);
if (!this.canToggle(now) || !this.snapshot) return -1;
this.pending = true; this.requestedAt = now; this.requestRevision = this.snapshot.revision; this.failure = '';
this.requestId = (this.requestId + 1) >>> 0;
return this.requestId;
}
failed(id: number): void {
if (this.pending && this.requestId === id) { this.pending = false; this.failure = '指令发送失败,请重试'; }
}
tick(now: number): void {
if (this.pending && now - this.requestedAt >= 15000) {
this.pending = false; this.failure = '请求超时,尚未确认电脑的隐私屏状态';
}
}
status(now: number): string {
if (this.failure) return this.failure;
const s = this.snapshot;
if (!s) return '等待电脑返回隐私屏状态;旧版电脑端可能不支持';
if (!this.fresh(now)) return '隐私屏状态已过期,等待电脑确认';
if (s.state === 2) return '正在开启隐私屏…';
if (s.state === 4) return '正在关闭隐私屏…';
if (this.pending) return '指令已发送,等待电脑确认…';
if (s.state === 5) return '隐私屏异常';
if (!s.supported || s.state === 0) return '隐私屏暂不可用';
if (s.state === 3 && s.overlay_active && !s.remote_paused)
return '隐私屏已开启';
return s.state === 1 && !this.active() ? '隐私屏已关闭' : '隐私屏状态待确认';
}
detail(now: number): string {
const s = this.snapshot;
return s && this.fresh(now) && (s.state === 0 || s.state === 5 || !s.supported) ? s.reason : '';
}
}
@@ -149,7 +149,7 @@ struct Mobile {
.padding({ left: 14, right: 14 }).onFocus(() => this.idFocused = true).onBlur(() => this.idFocused = false)
.onChange((value: string) => this.state.remote = this.deviceId(value)).onSubmit(() => this.controller?.requestConnection(this.state.remote))
Column() {
MobilePrimaryButton({ label: '连接', arrow: true, buttonHeight: 54, available: this.state.remote.length > 0,
MobilePrimaryButton({ label: '连接', buttonHeight: 54, available: this.state.remote.length > 0,
action: () => this.controller?.requestConnection(this.state.remote) })
}.width(96)
}.width('100%')
@@ -486,7 +486,7 @@ struct Mobile {
Toggle({ type: ToggleType.Switch, isOn: this.state.remember }).selectedColor(MobileTheme.green)
.onChange((value: boolean) => this.state.remember = value).accessibilityText('保存密码')
}.width('100%').margin({ top: 18, bottom: 18 })
MobilePrimaryButton({ label: '连接', arrow: true, available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
MobilePrimaryButton({ label: '连接', available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
}.padding({ left: 22, right: 22, top: 10, bottom: 22 }).width('100%')
}.width(this.viewWidth < 600 ? '100%' : Math.min(520, this.viewWidth - 48)).constraintSize({ maxHeight: this.viewHeight * 0.92 })
.backgroundColor(MobileTheme.background).borderRadius(24).scrollBar(BarState.Off)
+7 -1
View File
@@ -143,7 +143,7 @@ class MobileSession {
s->Event({{"type", "connection"}, {"status", status}}, true);
if (status == Connected && !s->connected_) {
s->connecting_ = false; s->connected_ = true;
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, false, s->version_, HostPlatform::HarmonyOS);
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, true, s->version_, HostPlatform::HarmonyOS);
s->Send(info); FreeRemoteAction(info);
RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio);
s->audio_.Start();
@@ -328,6 +328,12 @@ class MobileSession {
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
if(SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0)
Event({{"type","settingsFailed"},{"request",v.at("request")}},true);
} else if (op=="privacy") {
const auto bytes = mobile_controller::PrivacyMessage(
v.at("flag").get<int>(), v.at("blockLocalInput").get<bool>());
if (bytes.empty() ||
SendReliableDataFrame(controller_, bytes.data(), bytes.size(), kControlStream) != 0)
Event({{"type", "privacyFailed"}, {"request", v.at("request")}}, true);
} else if (op=="sas") {
const auto bytes=mobile_controller::SecureAttentionMessage();
const bool ok=SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)==0;
+4 -1
View File
@@ -61,11 +61,14 @@ def bootstrap(env):
seed = Path.home() / '.xmake/repositories/xmake-repo'
source = seed if (seed / '.git').exists() else 'https://github.com/xmake-io/xmake-repo.git'
run(['git', 'clone', '--no-hardlinks', source, repository], env=env)
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
if revision != PACKAGE_REVISION:
run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, env=env)
run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, env=env)
# Mark this pinned checkout as ready so Xmake's first-run repository update
# cannot replace it with a mirror or advance it beyond PACKAGE_REVISION.
(repository / 'updated').touch()
def mobile_project():