[feat] add HarmonyOS desktop and mobile clients,issue #117

This commit is contained in:
kunkundi
2026-10-16 00:42:55 +08:00
parent 43a0789c1e
commit 45bf6ec4ee
149 changed files with 11587 additions and 93 deletions
@@ -0,0 +1,18 @@
cmake_minimum_required(VERSION 3.16)
project(crossdesk_harmony LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
get_filename_component(HARMONY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../.." ABSOLUTE)
set(CORE_LIBRARY "${HARMONY_ROOT}/entry/libs/${OHOS_ARCH}/libcrossdesk_harmony_core.so")
if(NOT EXISTS "${CORE_LIBRARY}")
message(FATAL_ERROR "Build MiniRTC first: python3 apps/harmony/scripts/build.py --mobile --native-only")
endif()
add_library(crossdesk_core SHARED IMPORTED)
set_target_properties(crossdesk_core PROPERTIES
IMPORTED_LOCATION "${CORE_LIBRARY}"
IMPORTED_SONAME "libcrossdesk_harmony_core.so")
add_library(crossdesk SHARED napi_init.cpp)
# HAP libraries are resolved by the app's linker namespace. The host build
# directory is not a useful runtime search path on a HarmonyOS device.
set_target_properties(crossdesk PROPERTIES SKIP_BUILD_RPATH TRUE)
target_include_directories(crossdesk PRIVATE "${HARMONY_ROOT}/native")
target_link_libraries(crossdesk PRIVATE crossdesk_core libace_napi.z.so)
@@ -0,0 +1,37 @@
#include <napi/native_api.h>
#include "mobile_session.h"
#include <string>
namespace {
napi_value Command(napi_env env, napi_callback_info info) {
size_t count = 1; napi_value argument;
if (napi_get_cb_info(env, info, &count, &argument, nullptr, nullptr) != napi_ok || count != 1) {
napi_throw_type_error(env, nullptr, "Expected a JSON command string"); return nullptr;
}
size_t size = 0;
if (napi_get_value_string_utf8(env, argument, nullptr, 0, &size) != napi_ok || size > 1024 * 1024) {
napi_throw_type_error(env, nullptr, "Invalid command"); return nullptr;
}
std::string value(size + 1, '\0');
napi_get_value_string_utf8(env, argument, value.data(), value.size(), &size); value.resize(size);
crossdesk::harmony::MobileCommand(value);
napi_value result; napi_get_undefined(env, &result); return result;
}
napi_value Poll(napi_env env, napi_callback_info) {
const std::string data = crossdesk::harmony::MobilePoll();
napi_value result;
napi_create_string_utf8(env, data.data(), data.size(), &result); return result;
}
napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor properties[] = {
{"mobileCommand", nullptr, Command, nullptr, nullptr, nullptr, napi_default, nullptr},
{"mobilePoll", nullptr, Poll, nullptr, nullptr, nullptr, napi_default, nullptr}};
napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties);
napi_add_env_cleanup_hook(env, [](void*) {
crossdesk::harmony::MobileShutdown();
}, nullptr);
return exports;
}
napi_module module = {1, 0, nullptr, Init, "crossdesk", nullptr, {0}};
}
extern "C" __attribute__((constructor)) void RegisterCrossDesk() { napi_module_register(&module); }
@@ -0,0 +1,2 @@
export const mobileCommand: (json: string) => void;
export const mobilePoll: () => string;
@@ -0,0 +1,5 @@
{
"name": "libcrossdesk.so",
"version": "0.1.0",
"types": "index.d.ts"
}
@@ -0,0 +1,147 @@
import { announcementByteLength } from './AnnouncementText';
export interface AnnouncementRevision { id: number; revision: number; }
export interface AnnouncementItem extends AnnouncementRevision { title: string; body: string; updated_at: number; read?: boolean; }
export interface AnnouncementResponse {
type: string; request_id: string; summary_only: boolean; offset: number; total: number;
catalog_revision: number; items: AnnouncementItem[]; error?: string;
}
export interface AnnouncementRequest { type: string; request_id: string; summary_only: boolean; offset: number; }
interface LocalRevision { id: number; revision: number; }
interface LocalState { scope: string; reads: LocalRevision[]; dismissed: LocalRevision[]; }
// Revisions, pagination, stale replies and local dismissal follow AnnouncementInbox.java.
export class AnnouncementInbox {
items: AnnouncementItem[] = [];
total: number = 0;
unread: number = 0;
loaded: boolean = false;
loading: boolean = false;
failed: boolean = false;
saveFailed: boolean = false;
connected: boolean = false;
changed: () => void = () => {};
private scope: string = '';
private reads: Map<number, number> = new Map();
private dismissed: Map<number, number> = new Map();
private catalog: Map<number, number> = new Map();
private pending: Map<number, number> = new Map();
private pageIds: Map<number, number[]> = new Map();
private bodies: Map<number, AnnouncementItem[]> = new Map();
private revision: number = -1;
private pendingRevision: number = -1;
private pendingTotal: number = 0;
private pageOffset: number = 0;
private limit: number = 20;
private summary: boolean = true;
private requestId: string = '';
private sequence: number = 0;
private timeout: number = -1;
private send: (request: AnnouncementRequest) => void;
private load: (scope: string) => string;
private save: (scope: string, body: string) => void;
constructor(send: (request: AnnouncementRequest) => void, load: (scope: string) => string, save: (scope: string, body: string) => void) {
this.send = send; this.load = load; this.save = save;
}
configure(scope: string): void {
if (this.scope === scope) return;
this.reset(); this.scope = scope;
try {
const local = JSON.parse(this.load(scope)) as LocalState;
if (local.scope === scope) {
local.reads.forEach((v: LocalRevision) => { if (v.id > 0 && v.revision > 0) this.reads.set(v.id, v.revision); });
local.dismissed.forEach((v: LocalRevision) => { if (v.id > 0 && v.revision > 0) this.dismissed.set(v.id, v.revision); });
}
} catch { /* First use or corrupt local metadata. Content always comes from the server. */ }
}
reset(): void {
this.cancel(); this.connected = false; this.scope = ''; this.items = []; this.catalog.clear(); this.pending.clear();
this.reads.clear(); this.dismissed.clear(); this.pageIds.clear(); this.bodies.clear(); this.revision = this.pendingRevision = -1;
this.total = this.unread = 0; this.limit = 20; this.loaded = this.failed = this.saveFailed = false; this.changed();
}
connect(value: boolean): void {
if (value === this.connected) return;
this.connected = value; if (value) this.refresh(); else this.cancel(); this.changed();
}
refresh(): void { this.cancel(); this.pending.clear(); this.pendingRevision = -1; this.pendingTotal = 0; this.summary = true; this.request(0); this.changed(); }
more(): void { if (!this.connected || this.loading || this.failed || this.limit >= this.total) return; this.limit += Math.min(20, this.total - this.limit); this.rebuild(); this.changed(); }
markRead(item: AnnouncementItem): void {
if (!this.items.some((v: AnnouncementItem) => v.id === item.id && v.revision === item.revision)) return;
const next = new Map(this.reads); next.set(item.id, item.revision);
if (this.persist(next, this.dismissed)) { this.reads = next; this.reading(); } this.changed();
}
dismiss(item: AnnouncementItem): void {
if (!this.items.some((v: AnnouncementItem) => v.id === item.id && v.revision === item.revision)) return;
const next = new Map(this.dismissed); next.set(item.id, item.revision);
if (this.persist(this.reads, next)) { this.dismissed = next; this.cancel(); this.rebuild(); } this.changed();
}
private persist(reads: Map<number, number>, dismissed: Map<number, number>): boolean {
const body: LocalState = { scope: this.scope, reads: [], dismissed: [] };
reads.forEach((revision: number, id: number) => body.reads.push({ id: id, revision: revision }));
dismissed.forEach((revision: number, id: number) => body.dismissed.push({ id: id, revision: revision }));
try { this.save(this.scope, JSON.stringify(body)); this.saveFailed = false; return true; }
catch { this.saveFailed = true; return false; }
}
fail(id: string): void { if (id && id === this.requestId) { this.cancel(); this.failed = true; this.changed(); } }
receive(message: AnnouncementResponse): void {
if (!this.connected || !this.requestId || message.request_id !== this.requestId) return;
const request = this.requestId;
try {
if (message.type !== 'announcements' || message.error || message.summary_only !== this.summary ||
!Number.isSafeInteger(message.offset) || !Number.isSafeInteger(message.total) || !Number.isSafeInteger(message.catalog_revision) ||
message.offset < 0 || message.total < message.offset || message.total > 1000000 || message.catalog_revision < 0 ||
!Array.isArray(message.items) || message.items.length !== Math.min(this.summary ? 200 : 20, message.total - message.offset)) throw new Error('Invalid announcement page');
const ids: number[] = [];
for (const item of message.items) {
if (!Number.isSafeInteger(item.id) || !Number.isSafeInteger(item.revision) || item.id <= 0 || item.revision <= 0 || ids.includes(item.id)) throw new Error('Invalid revision');
ids.push(item.id);
}
const expected = this.summary ? this.pendingRevision : this.revision;
if (expected >= 0 && expected !== message.catalog_revision) { this.refresh(); return; }
if (this.summary) {
if (message.offset !== this.pending.size || (this.pendingRevision >= 0 && this.pendingTotal !== message.total)) throw new Error('Invalid offset');
for (const item of message.items) { if (this.pending.has(item.id)) throw new Error('Duplicate item'); this.pending.set(item.id, item.revision); }
this.pendingRevision = message.catalog_revision; this.pendingTotal = message.total; this.cancel();
if (this.pending.size < this.pendingTotal) { this.request(this.pending.size); this.changed(); return; }
if (this.revision !== this.pendingRevision) { this.pageIds.clear(); this.bodies.clear(); }
this.catalog = new Map(this.pending); this.revision = this.pendingRevision;
} else {
if (message.offset !== this.pageOffset || message.total !== this.catalog.size) throw new Error('Invalid body page');
for (const item of message.items) {
if (this.catalog.get(item.id) !== item.revision || typeof item.title !== 'string' || typeof item.body !== 'string' ||
announcementByteLength(item.title) > 240 || announcementByteLength(item.body) > 8000 || !Number.isSafeInteger(item.updated_at) || item.updated_at <= 0) throw new Error('Invalid body');
this.pageIds.forEach((entries: number[], offset: number) => { if (offset !== message.offset && entries.includes(item.id)) throw new Error('Duplicate page'); });
}
this.cancel(); this.pageIds.set(message.offset, ids); this.bodies.set(message.offset, message.items);
}
this.failed = false; this.rebuild(); this.changed();
} catch { this.fail(request); }
}
private reading(): void {
this.total = this.unread = 0;
this.catalog.forEach((revision: number, id: number) => {
if (this.dismissed.get(id) === revision) return;
this.total++; if (this.reads.get(id) !== revision) this.unread++;
});
this.items.forEach((item: AnnouncementItem) => item.read = this.reads.get(item.id) === item.revision);
}
private rebuild(): void {
this.reading(); const wanted = Math.min(this.limit, this.total); const visible: AnnouncementItem[] = [];
this.summary = false; let next = -1;
for (let offset = 0; offset < this.catalog.size && visible.length < wanted; offset += 20) {
const ids = this.pageIds.get(offset); if (!ids) { next = offset; break; }
const selected = ids.filter((id: number) => this.dismissed.get(id) !== this.catalog.get(id));
if (!selected.length) { this.bodies.delete(offset); continue; }
const body = this.bodies.get(offset); if (!body) { next = offset; break; }
for (const item of body) if (selected.includes(item.id) && visible.length < wanted) visible.push(item);
}
this.items = visible; this.loaded = true; this.reading();
if (next >= 0) { this.pageOffset = next; this.request(next); }
}
private request(offset: number): void {
if (!this.connected || !this.scope) return;
this.requestId = `hm-${++this.sequence}-${Date.now()}`; this.loading = true; this.failed = false;
const id = this.requestId; this.timeout = setTimeout(() => this.fail(id), 12000);
this.send({ type: 'announcements_list', request_id: id, summary_only: this.summary, offset: offset });
}
private cancel(): void { if (this.timeout >= 0) clearTimeout(this.timeout); this.timeout = -1; this.requestId = ''; this.loading = false; }
}
@@ -0,0 +1,65 @@
export interface AnnouncementSpan { text: string; url: string; }
// Same restricted plain-text/link grammar as Android AnnouncementText.java.
export function isAnnouncementURL(address: string): boolean {
return !/[\x00-\x20\x7f\\<>\"]/.test(address) &&
/^https?:\/\/(?:[^\/?#]*@)?(?:\[[0-9a-f:.]+\]|[^\/?#:@\[\]]+)(?::[0-9]+)?(?:[\/?#]|$)/i.test(address);
}
function hasScheme(text: string, start: number): boolean {
return /^https?:\/\//i.test(text.substring(start, start + 8));
}
function urlEnd(text: string, start: number): number {
let parentheses = 0, brackets = 0, end = start;
for (; end < text.length; end++) {
const c = text.charAt(end);
if (c.charCodeAt(0) <= 0x20 || c.charCodeAt(0) === 0x7f || c === '\\' || c === '`' ||
'<>\"\'{},。;:!?、()【】《》「」『』“”‘’'.includes(c)) break;
if (c === '(') parentheses++;
if (c === ')') { if (parentheses === 0) break; parentheses--; }
if (c === '[') brackets++;
if (c === ']') { if (brackets === 0) break; brackets--; }
}
return end;
}
export function announcementSpans(body: string): AnnouncementSpan[] {
const text = body.replace(/\r\n/g, '\n'), result: AnnouncementSpan[] = [];
let plain = '';
for (let position = 0; position < text.length;) {
let label = '', address = '', next = position;
if (text.charAt(position) === '[') {
const labelEnd = text.indexOf(']', position + 1), start = labelEnd + 2;
if (labelEnd > position + 1 && start < text.length && text.charAt(labelEnd + 1) === '(' &&
!/[\[\]\r\n]/.test(text.substring(position + 1, labelEnd)) && hasScheme(text, start)) {
const end = urlEnd(text, start), url = text.substring(start, end);
if (text.charAt(end) === ')' && isAnnouncementURL(url)) {
label = text.substring(position + 1, labelEnd); address = url; next = end + 1;
}
}
}
if (!address && hasScheme(text, position)) {
let end = urlEnd(text, position);
while (end > position && '.,;:!?'.includes(text.charAt(end - 1))) end--;
const url = text.substring(position, end);
if (isAnnouncementURL(url)) { label = address = url; next = end; }
}
if (address) {
if (plain) { result.push({ text: plain, url: '' }); plain = ''; }
result.push({ text: label, url: address }); position = next;
} else plain += text.charAt(position++);
}
if (plain) result.push({ text: plain, url: '' });
return result;
}
export function announcementByteLength(text: string): number {
let bytes = 0;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (c < 0x80) bytes++;
else if (c < 0x800) bytes += 2;
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length &&
text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) { bytes += 4; i++; }
else bytes += c >= 0xd800 && c <= 0xdfff ? 1 : 3; // Java UTF-8 replaces lone surrogates with '?'.
}
return bytes;
}
@@ -0,0 +1,250 @@
// Platform-independent controller rules ported from the Android client.
// Keep gesture geometry, acknowledgement ordering and expiry behavior identical.
export function clamp(value: number, low: number, high: number): number {
return Math.max(low, Math.min(high, value));
}
export class RemoteViewport {
baseWidth: number = 1;
baseHeight: number = 1;
baseLeft: number = 0;
baseTop: number = 0;
scale: number = 1;
offsetX: number = 0;
offsetY: number = 0;
fit(width: number, height: number, videoWidth: number, videoHeight: number): void {
if (Math.min(width, height, videoWidth, videoHeight) <= 0) return;
const scale = Math.min(width / videoWidth, height / videoHeight);
this.baseWidth = Math.max(1, videoWidth * scale); this.baseHeight = Math.max(1, videoHeight * scale);
this.baseLeft = (width - this.baseWidth) / 2; this.baseTop = (height - this.baseHeight) / 2;
this.constrain();
}
width(): number { return this.baseWidth * this.scale; }
height(): number { return this.baseHeight * this.scale; }
left(): number { return this.baseLeft + this.baseWidth * (1 - this.scale) / 2 + this.offsetX; }
top(): number { return this.baseTop + this.baseHeight * (1 - this.scale) / 2 + this.offsetY; }
contains(x: number, y: number): boolean { return x >= this.left() && x < this.left() + this.width() && y >= this.top() && y < this.top() + this.height(); }
x(value: number): number { return clamp((value - this.left()) / this.width(), 0, 1); }
y(value: number): number { return clamp((value - this.top()) / this.height(), 0, 1); }
zoom(factor: number, x: number, y: number): void {
if (!Number.isFinite(factor) || factor <= 0) return;
const next = clamp(this.scale * factor, 1, 10), ratio = next / this.scale;
const fx = x - this.baseLeft - this.baseWidth / 2, fy = y - this.baseTop - this.baseHeight / 2;
this.offsetX = fx - (fx - this.offsetX) * ratio; this.offsetY = fy - (fy - this.offsetY) * ratio;
this.scale = next; this.constrain();
}
pan(x: number, y: number): void { this.offsetX += x; this.offsetY += y; this.constrain(); }
reset(): void { this.scale = 1; this.offsetX = this.offsetY = 0; }
finish(): void { if (this.scale <= 1.001) this.reset(); }
private constrain(): void {
this.offsetX = clamp(this.offsetX, -this.baseWidth * (this.scale - 1) / 2, this.baseWidth * (this.scale - 1) / 2);
this.offsetY = clamp(this.offsetY, -this.baseHeight * (this.scale - 1) / 2, this.baseHeight * (this.scale - 1) / 2);
}
}
export class MouseInput {
x: number = 0.5;
y: number = 0.5;
gesture: boolean = false;
left: boolean = false;
right: boolean = false;
revision: number = 0;
private sender: (x: number, y: number, flag: number, scroll: number) => void;
constructor(sender: (x: number, y: number, flag: number, scroll: number) => void) { this.sender = sender; }
held(): boolean { return this.left || this.right; }
move(x: number, y: number): void { this.x = clamp(x, 0, 1); this.y = clamp(y, 0, 1); this.send(0); }
drag(dx: number, dy: number, width: number, height: number): void {
if (this.held() && width > 0 && height > 0) this.move(this.x + dx / width, this.y + dy / height);
}
gestureLeft(down: boolean): void {
if ((down && this.held()) || this.gesture === down) return;
this.gesture = down; this.send(down ? 1 : 2);
}
button(left: boolean, down: boolean): void {
if ((left ? this.left : this.right) === down) return;
if (down) { this.gestureLeft(false); this.revision++; }
if (left) this.left = down; else this.right = down;
this.send(left ? (down ? 1 : 2) : (down ? 3 : 4));
}
click(right: boolean = false): void { if (!this.held()) { this.send(right ? 3 : 1); this.send(right ? 4 : 2); } }
scroll(delta: number): void { if (delta !== 0) this.sender(this.x, this.y, 7, delta > 0 ? 1 : -1); }
release(): void { this.gestureLeft(false); this.button(true, false); this.button(false, false); this.revision++; }
private send(flag: number): void { this.sender(this.x, this.y, flag, 0); }
}
export interface VideoValues { quality: number; frameRate: number; preference: number; }
export class VideoSettings {
supported: boolean = false;
pending: boolean = false;
failed: boolean = false;
selection: VideoValues;
applied: VideoValues;
requestId: number = 0;
appliedRequestId: number = 0;
constructor(preference: number = 1, longEdge: number = 1920) {
this.selection = { quality: longEdge > 0 && longEdge <= 1280 ? 0 : 1, frameRate: 30, preference: clamp(preference, 0, 2) };
this.applied = this.selection;
}
valid(v: VideoValues): boolean { return Number.isInteger(v.quality) && v.quality >= 0 && v.quality <= 2 &&
(v.frameRate === 30 || v.frameRate === 60) && Number.isInteger(v.preference) && v.preference >= 0 && v.preference <= 2; }
support(value: boolean): void { this.supported = value; if (!value) { this.selection = this.applied; this.pending = this.failed = false; } }
begin(value: VideoValues): number {
if (!this.supported || !this.valid(value)) return -1;
this.requestId = (this.requestId + 1) >>> 0; this.selection = value; this.pending = true; this.failed = false; return this.requestId;
}
receive(value: VideoValues, id: number, accepted: boolean): void {
if (!this.supported || !this.valid(value) || !Number.isInteger(id) || id < 0 || id > 0xFFFFFFFF || ((this.requestId - id) | 0) < 0) return;
if (accepted && ((id - this.appliedRequestId) | 0) >= 0) { this.applied = value; this.appliedRequestId = id; }
if (id !== this.requestId) return;
this.pending = false; this.failed = !accepted; this.selection = this.applied;
}
expire(id: number): void { if (this.pending && id === this.requestId) { this.pending = false; this.failed = true; this.selection = this.applied; } }
feedback(): string { return !this.supported ? '连接建立后可调整,需被控端支持。' : this.failed ? '调整失败,请重试。' : this.pending ? '等待电脑端确认…' : ''; }
}
export interface Traffic { inbound: number; outbound: number; loss: number; }
export interface NetworkReport { rtt: number; mode: number; srtp: boolean; video: Traffic; audio: Traffic; data: Traffic; total: Traffic; }
export interface FrameSample { time: number; count: number; latency: number; latencyCount: number; }
export class NetworkStatistics {
report?: NetworkReport;
reportTime: number = -Infinity;
rttTime: number = -Infinity;
rtt: number = -1;
width: number = 0;
height: number = 0;
lastFrame: number = 0;
frames: FrameSample[] = [];
receive(report: NetworkReport, now: number): void {
this.report = report; this.reportTime = now;
if (Number.isFinite(report.rtt) && report.rtt >= 0 && report.rtt <= 2000) {
this.rtt = this.rtt >= 0 && now - this.rttTime < 3000 ? this.rtt + 0.25 * (report.rtt - this.rtt) : report.rtt;
this.rttTime = now;
}
}
frame(id: number, latency: number, width: number, height: number, now: number, latencyCount: number = -1): void {
if (id <= this.lastFrame || width <= 0 || height <= 0) return;
const count = Math.min(240, id - this.lastFrame);
this.frames.push({ time: now, count: count, latency: latency, latencyCount: latencyCount < 0 ? count : latencyCount });
this.lastFrame = id; this.width = width; this.height = height; this.prune(now);
}
reset(): void { this.frames = []; this.width = this.height = 0; }
prune(now: number): void { this.frames = this.frames.filter((f: FrameSample) => now - f.time < 1000).slice(-240); }
fps(now: number): number { this.prune(now); return this.frames.reduce((n: number, f: FrameSample) => n + f.count, 0); }
latency(now: number): number {
this.prune(now); const valid = this.frames.filter((f: FrameSample) => Number.isFinite(f.latency) && f.latency >= 0);
const count = valid.reduce((n: number, f: FrameSample) => n + f.latencyCount, 0);
return count ? valid.reduce((n: number, f: FrameSample) => n + f.latency * f.latencyCount, 0) / count : -1;
}
current(now: number): NetworkReport | undefined { return now - this.reportTime < 3000 ? this.report : undefined; }
mode(now: number): string { const report = this.current(now); return !report ? '—' : report.mode === 0 ? 'P2P 直连' : report.mode === 1 ? 'TURN 中继' : '—'; }
static rate(n: number): string { return !Number.isFinite(n) || n < 0 ? '—' : n >= 1000000 ? `${(n / 1000000).toFixed(1)} Mbps` : n >= 1000 ? `${(n / 1000).toFixed(0)} Kbps` : `${n} bps`; }
static loss(n: number): string { return Number.isFinite(n) && n >= 0 ? `${(n * 100).toFixed(1)}%` : '—'; }
static delay(n: number): string { return !Number.isFinite(n) || n < 0 ? '—' : n < 1 ? '<1 ms' : `${n.toFixed(0)} ms`; }
}
export interface PresenceDevice { id: string; online: boolean; }
export interface PresenceMessage { type: string; devices?: PresenceDevice[]; id?: string; online?: boolean; }
export class RecentPresence {
watched: string[] = [];
connected: boolean = false;
awaiting: boolean = true;
lastRefresh: number = 0;
values: Map<string, boolean> = new Map();
times: Map<string, number> = new Map();
private sender: (ids: string[]) => void;
constructor(sender: (ids: string[]) => void) { this.sender = sender; }
watch(ids: string[], now: number): void {
if (JSON.stringify(ids) === JSON.stringify(this.watched)) return;
this.watched = ids; this.values.clear(); this.times.clear(); this.awaiting = true; this.refresh(now);
}
connect(value: boolean, now: number): void {
if (value === this.connected) return;
this.connected = value; this.values.clear(); this.times.clear(); this.awaiting = true; this.refresh(now);
}
online(id: string, now: number): boolean { return this.connected && !this.awaiting && this.values.get(id) === true && now - (this.times.get(id) ?? -Infinity) < 60000; }
receive(message: PresenceMessage, now: number): void {
if (!this.connected) return;
let devices: PresenceDevice[] = [];
if (message.type === 'presence' && Array.isArray(message.devices)) {
this.values.clear(); this.times.clear(); this.awaiting = false; devices = message.devices;
} else if (message.type === 'presence_update' && !this.awaiting && typeof message.id === 'string' && typeof message.online === 'boolean') {
devices = [{ id: message.id, online: message.online }];
}
devices.forEach((d: PresenceDevice) => {
if (this.watched.includes(d.id) && typeof d.online === 'boolean') { this.values.set(d.id, d.online); this.times.set(d.id, now); }
});
}
tick(now: number): void { if (this.connected && now - this.lastRefresh >= 30000) this.refresh(now); }
private refresh(now: number): void { if (this.connected) { this.lastRefresh = now; this.sender(this.watched.slice()); } }
}
export function asciiKey(character: string): number {
if (character.length !== 1) return 0;
const c = character.charCodeAt(0);
if (c >= 97 && c <= 122) return c - 32;
if (c >= 65 && c <= 90) return c | 0x100;
if (c >= 48 && c <= 57) return c;
if (c === 32 || c === 9 || c === 13) return c;
if (c === 10) return 13;
const plain = ';=,-./`[\\]\'', shifted = ':+<_>?~{|}"';
const codes: number[] = [186, 187, 188, 189, 190, 191, 192, 219, 220, 221, 222];
const index = plain.indexOf(character); if (index >= 0) return codes[index];
const upper = shifted.indexOf(character); if (upper >= 0) return codes[upper] | 0x100;
const digit = ')!@#$%^&*('.indexOf(character); return digit >= 0 ? (48 + digit) | 0x100 : 0;
}
export interface TouchPoint { x: number; y: number; }
export class TouchInput {
relative: boolean = true;
private active: boolean = false;
private multi: boolean = false;
private moved: boolean = false;
private long: boolean = false;
private pinching: boolean = false;
private revision: number = 0;
private startX: number = 0;
private startY: number = 0;
private lastX: number = 0;
private lastY: number = 0;
private focusX: number = 0;
private focusY: number = 0;
private span: number = 0;
private startSpan: number = 0;
private scrollY: number = 0;
readonly viewport: RemoteViewport;
readonly mouse: MouseInput;
constructor(viewport: RemoteViewport, mouse: MouseInput) { this.viewport = viewport; this.mouse = mouse; }
cancel(): void { this.active = false; this.multi = true; this.mouse.release(); this.viewport.finish(); }
hold(): void { if (this.active && !this.multi && !this.moved && this.revision === this.mouse.revision && !this.mouse.held()) { this.mouse.click(true); this.long = true; } }
touch(points: TouchPoint[], kind: string): void {
if (kind === 'cancel') { this.cancel(); return; }
if (points.length >= 2) {
const x = (points[0].x + points[1].x) / 2, y = (points[0].y + points[1].y) / 2;
const span = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y);
if (!this.multi) {
this.mouse.gestureLeft(false); this.multi = true; this.startSpan = this.span = span;
this.focusX = x; this.scrollY = this.focusY = y; this.pinching = false;
} else if (kind === 'move' && !this.mouse.held()) {
if (!this.pinching && Math.abs(span - this.startSpan) > 6 && this.viewport.contains(x, y)) this.pinching = true;
if (this.pinching && this.span > 0) this.viewport.zoom(span / this.span, x, y);
if (this.viewport.scale > 1.001 || this.pinching) this.viewport.pan(x - this.focusX, y - this.focusY);
else if (this.viewport.contains(x, y) && Math.abs(y - this.scrollY) > 16) { this.mouse.scroll(y - this.scrollY); this.scrollY = y; }
this.span = span; this.focusX = x; this.focusY = y;
}
return;
}
if (this.multi) {
if (kind === 'up' && points.length === 0) { this.active = false; this.viewport.finish(); }
return;
}
if (kind === 'down' && points.length === 1) {
const p = points[0]; this.active = true; this.moved = this.long = false; this.pinching = false;
this.revision = this.mouse.revision; this.startX = this.lastX = p.x; this.startY = this.lastY = p.y;
if (!this.relative) { this.active = this.viewport.contains(p.x, p.y); if (this.active) this.mouse.move(this.viewport.x(p.x), this.viewport.y(p.y)); }
} else if (kind === 'move' && this.active && points.length) {
const p = points[0];
if (Math.hypot(p.x - this.startX, p.y - this.startY) > 4) this.moved = true;
if (this.moved && !this.relative && !this.long && this.revision === this.mouse.revision && !this.mouse.held()) this.mouse.gestureLeft(true);
if (this.relative) this.mouse.move(this.mouse.x + (p.x - this.lastX) / this.viewport.width(), this.mouse.y + (p.y - this.lastY) / this.viewport.height());
else this.mouse.move(this.viewport.x(p.x), this.viewport.y(p.y));
this.lastX = p.x; this.lastY = p.y;
} else if (kind === 'up') {
if (this.active && !this.moved && !this.long && this.revision === this.mouse.revision) this.mouse.click();
this.mouse.gestureLeft(false); this.active = false;
}
}
begin(): void { if (!this.active) this.multi = false; }
}
@@ -0,0 +1,15 @@
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { MobileLifecycle } from './MobilePlatform';
export default class EntryAbility extends UIAbility {
onWindowStageCreate(stage: window.WindowStage): void {
stage.loadContent('pages/Mobile', (error) => {
if (error.code) hilog.error(0, 'CrossDesk', 'Unable to load the main page: %{public}d', error.code);
});
}
onBackground(): void { MobileLifecycle.background(); }
onForeground(): void { MobileLifecycle.foreground(); }
onDestroy(): void { MobileLifecycle.destroy(); }
}
@@ -0,0 +1,450 @@
import bridge from 'libcrossdesk.so';
import { common, bundleManager } from '@kit.AbilityKit';
import { display, promptAction } from '@kit.ArkUI';
import { util } from '@kit.ArkTS';
import { pasteboard, systemDateTime } from '@kit.BasicServicesKit';
import { MobileStore, RecentDevice } from './MobileStore';
import { MobilePlatform, MobileLifecycle } from './MobilePlatform';
import { AnnouncementInbox, AnnouncementItem, AnnouncementRequest, AnnouncementResponse } from './AnnouncementInbox';
import { VideoSettings, VideoValues, NetworkStatistics, NetworkReport, RecentPresence, PresenceMessage, asciiKey } from './ControllerModels';
interface Command {
op: string; session?: number; connection?: number; server?: string; port?: number; identity?: string;
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;
}
interface NativeEvent {
type: string; session: number; connection?: number; status?: number; controller?: boolean; generation?: number;
id?: string; identity?: string; message?: Object; report?: NetworkReport; width?: number; height?: number;
frame?: number; latency?: number; latencyCount?: number; text?: string; reason?: string; name?: string; progress?: number;
sending?: boolean; path?: string; ok?: boolean; request?: number | string; latest?: string;
}
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 PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
interface SignalType { type: string; }
@Observed
export class MobileState {
page: string = 'home';
consent: boolean = false;
privacyAvailable: boolean = false;
modal: string = '';
notice: string = '';
server: string = 'api.crossdesk.cn';
port: string = '9099';
remote: string = '';
password: string = '';
remember: boolean = false;
signal: number = -1;
signalText: string = '未连接';
identity: string = '';
connecting: boolean = false;
ready: boolean = false;
relative: boolean = true;
muted: boolean = false;
previews: boolean = false;
videoPreference: number = 1;
version: string = '0.1.0';
update: string = '';
updateStatus: string = '';
recent: RecentDevice[] = [];
width: number = 16;
height: number = 9;
hasFrame: boolean = false;
displays: RemoteDisplay[] = [];
selectedDisplay: number = 0;
viewportReset: number = 0;
platform: string = '';
hostName: string = '';
transfer: string = '';
receivedPath: string = '';
receivedName: string = '';
remoteClipboard: string = '';
clipboardText: string = '';
videoQuality: number = 1;
videoRate: number = 30;
videoSelectedPreference: number = 1;
videoSupported: boolean = false;
videoFeedback: string = '';
networkTime: number = 0;
unread: number = 0;
announcements: AnnouncementItem[] = [];
announcement?: AnnouncementItem;
documentTitle: string = '';
documentText: string = '';
documentBack: string = 'about';
}
export class MobileController {
state: MobileState = new MobileState();
store: MobileStore;
platform: MobilePlatform;
video: VideoSettings = new VideoSettings();
network: NetworkStatistics = new NetworkStatistics();
presence: RecentPresence;
inbox: AnnouncementInbox;
releaseInput: () => void = () => {};
private foreground: boolean = true;
private context: common.UIAbilityContext;
private timer: number = -1;
private seconds: number = 0;
private session: number = 0;
private connection: number = 0;
private started: boolean = false;
private starting: boolean = false;
private signalGeneration: number = -1;
private pendingPassword: string = '';
private connectingId: string = '';
private sendingFile: boolean = false;
private serviceAvailable: boolean = false;
private settingsTimeout: number = -1;
private versionRequest: number = 0;
private localVersionRequest: number = 0;
private remoteVersionRequest: number = 0;
private checkingLocal: boolean = false;
private checkedRemote: boolean = false;
private remoteVersion: string = '';
private receivedHostInfo: boolean = false;
private previewEligible: boolean = false;
private historySaved: boolean = false;
private previewPending: boolean = false;
private previewSaved: boolean = false;
private previewAttempts: number = 0;
private uiEpoch: number = 0;
private privacy: string = '';
constructor(context: common.UIAbilityContext) {
this.context = context; this.store = new MobileStore(context);
this.state.consent = this.store.flag('consent'); this.state.server = this.store.text('server', 'api.crossdesk.cn');
this.state.port = this.store.text('port', '9099'); this.state.remote = this.store.text('lastRemote');
this.state.relative = this.store.flag('relative', true); this.state.videoPreference = this.store.number('videoPreference', 1);
this.state.previews = this.store.flag('previews');
try { this.state.version = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT).versionName; } catch {}
try {
this.privacy = util.TextDecoder.create('utf-8').decodeWithStream(context.resourceManager.getRawFileContentSync('PRIVACY.md'));
this.state.privacyAvailable = this.privacy.trim().length > 0;
} catch { this.state.privacyAvailable = false; }
this.platform = new MobilePlatform(context, (reason: string) => this.end(reason), () => this.release(), (text: string) => this.toast(text));
this.platform.onMute = (muted: boolean) => { if (muted !== this.state.muted) this.mute(); };
this.platform.onVisibility = (visible: boolean) => { if (this.state.ready) this.send({ op: 'visible', visible: visible }); };
this.presence = new RecentPresence((ids: string[]) => {
const request: PresenceRequest = { type: 'recent_connections_presence', devices: ids, subscribe: true };
this.send({ op: 'signal', message: request });
});
this.inbox = new AnnouncementInbox((request: AnnouncementRequest) => this.send({ op: 'signal', message: request }),
(scope: string) => this.store.text(`inbox:${scope}`, '{}'), (scope: string, body: string) => this.store.put(`inbox:${scope}`, body));
this.inbox.changed = () => { this.state.announcements = this.inbox.items.slice(); this.state.unread = this.inbox.unread; };
MobileLifecycle.foreground = () => this.resume(); MobileLifecycle.background = () => this.background(); MobileLifecycle.destroy = () => this.destroy();
this.history(); this.timer = setInterval(() => this.poll(), 50);
if (!this.state.consent) this.state.modal = 'consent'; else this.resume();
}
private send(command: Command): void { bridge.mobileCommand(JSON.stringify(command)); }
scope(): string { return MobileStore.scope(this.state.server, Number(this.state.port)); }
now(): number { return systemDateTime.getUptime(systemDateTime.TimeType.STARTUP, false); }
toast(text: string): void { if (this.foreground) promptAction.showToast({ message: text, duration: 3000 }); else this.state.notice = text; }
async consent(): Promise<void> {
if (!this.state.privacyAvailable) { this.state.notice = '隐私政策无法读取,请重新安装应用'; return; }
try { this.store.put('consent', true); this.state.consent = true; this.state.modal = ''; await this.ensureSignal(); this.checkLocal(); }
catch { this.toast('无法保存联网授权,请重试'); }
}
revoke(): void {
this.end(''); this.stopSignal(); this.state.consent = false; this.state.previews = false;
this.store.put('consent', false); this.store.put('previews', false); this.store.clearPreviews(); this.history();
this.state.update = ''; this.state.updateStatus = ''; this.state.page = 'home'; this.state.modal = 'consent';
}
resume(): void {
this.foreground = true; this.platform.resume();
if (this.state.consent) { this.ensureSignal(); this.checkLocal(); this.inbox.connect(this.state.signal === 1); }
else this.state.modal = 'consent';
}
background(): void {
this.foreground = false; this.uiEpoch++; this.release(); this.platform.background();
this.localVersionRequest = this.remoteVersionRequest = 0; this.checkingLocal = false; this.inbox.connect(false);
if (!this.state.ready && !this.platform.choosing) { if (this.state.connecting) this.end(''); this.stopSignal(); }
}
destroy(): void { this.foreground = false; this.end(''); this.stopSignal(); this.platform.dispose(); if (this.timer >= 0) clearInterval(this.timer); this.timer = -1; }
private async ensureSignal(): Promise<void> {
if (!this.foreground || !this.state.consent || this.started || this.starting) return;
this.starting = true; const session = ++this.session, scope = this.scope();
try {
let identity = await this.store.secret(`identity:${scope}`);
// A public ID alone is not a login credential. Discard legacy incomplete records.
if (identity && !/^[0-9]{9}@.+$/.test(identity)) { await this.store.removeSecret(`identity:${scope}`); identity = ''; }
if (session !== this.session || !this.foreground || !this.state.consent) return;
this.state.signalText = '正在连接…'; this.state.signal = 0; this.started = true;
this.send({ op: 'start', session: session, server: this.state.server, port: Number(this.state.port), identity: identity,
logs: this.context.cacheDir, certificates: '/etc/security/certificates', directory: this.store.directory,
previewDirectory: this.store.previewDirectory, version: this.state.version });
} catch { this.state.signalText = '安全存储不可用,请解锁设备后重试'; this.state.signal = -1; }
finally { if (session === this.session) this.starting = false; }
}
private stopSignal(): void {
this.session++; this.starting = this.started = false; this.signalGeneration = -1;
this.send({ op: 'stop' }); this.state.signal = -1; this.state.identity = ''; this.state.signalText = '未连接';
this.presence.connect(false, this.now()); this.inbox.connect(false); this.platform.cancelRequests();
this.localVersionRequest = this.remoteVersionRequest = 0; this.checkingLocal = false;
}
reconnect(): void { if (!this.state.consent) { this.state.modal = 'consent'; return; } this.stopSignal(); this.ensureSignal(); }
saveSettings(server: string, port: string): void {
server = server.trim(); const number = Number(port);
if (!server || server.length > 253 || /[\s/@?#]/.test(server) || !Number.isInteger(number) || number < 1 || number > 65535) {
this.toast('请输入有效的服务器地址和端口'); return;
}
const changed = server !== this.state.server || port !== this.state.port;
this.store.put('server', server); this.store.put('port', port); this.state.server = server; this.state.port = port;
if (changed) { this.end(''); this.stopSignal(); this.inbox.reset(); this.history(); this.ensureSignal(); }
this.navigate('settings');
}
navigate(page: string): void { this.state.page = page; this.state.modal = ''; }
history(): void {
const values = this.store.recent(this.scope()), now = this.now(); this.presence?.watch(values.map((d: RecentDevice) => d.id), now);
this.state.recent = values.filter((d: RecentDevice) => this.presence?.online(d.id, now))
.concat(values.filter((d: RecentDevice) => !this.presence?.online(d.id, now)));
}
async deleteRecent(id: string): Promise<void> {
try { await this.store.delete(this.scope(), id); this.history(); } catch { this.toast('记录或密码删除失败,请重试'); }
}
async requestConnection(id: string): Promise<void> {
id = id.replace(/\s/g, ''); this.state.remote = id;
if (!/^[0-9]{9}$/.test(id)) { this.toast('请输入 9 位设备 ID'); return; }
if (!this.state.consent) { this.state.modal = 'consent'; return; }
const epoch = ++this.uiEpoch, scope = this.scope();
this.state.password = ''; this.state.notice = '';
this.state.remember = this.store.recent(scope).find((d: RecentDevice) => d.id === id)?.remember ?? false;
try {
const password = await this.store.secret(`password:${scope}:${id}`);
if (epoch !== this.uiEpoch || !this.foreground || !this.state.consent) return;
if (password) { this.state.remember = true; this.begin(password); return; }
} catch { if (epoch === this.uiEpoch) this.state.notice = '已保存密码无法读取,请重新输入'; }
if (epoch === this.uiEpoch) this.state.modal = 'password';
}
begin(password: string): void {
if (!this.state.consent || !/^[0-9]{9}$/.test(this.state.remote) || !password || password.length > 128 || password.includes('@')) { this.toast('请输入有效的访问密码'); return; }
this.joinSent = false; this.connection++; this.connectingId = this.state.remote; this.pendingPassword = password;
this.state.connecting = true; this.state.ready = false; this.state.modal = 'connecting'; this.state.notice = '';
this.state.muted = false; this.state.platform = ''; this.state.hostName = ''; this.state.receivedPath = this.state.receivedName = this.state.transfer = '';
this.state.remoteClipboard = ''; this.state.displays = []; this.state.width = 16; this.state.height = 9; this.state.hasFrame = false;
this.state.selectedDisplay = 0; this.state.viewportReset++; this.remoteVersion = ''; this.receivedHostInfo = false;
this.serviceAvailable = this.checkedRemote = this.sendingFile = false;
this.previewEligible = this.state.previews; this.historySaved = false; this.previewSaved = this.previewPending = false; this.previewAttempts = 0;
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.store.put('lastRemote', this.state.remote); this.state.password = '';
this.ensureSignal(); this.joinIfReady();
}
private joinSent: boolean = false;
private joinIfReady(): void {
if (!this.state.connecting || this.joinSent || this.state.signal !== 1 || !this.state.identity) return;
this.joinSent = true; this.send({ op: 'connect', connection: this.connection, remote: this.connectingId, password: this.pendingPassword });
}
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.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;
this.history(); if (reason) { this.state.notice = reason; this.state.modal = 'notice'; }
if (this.foreground && this.state.consent) this.checkLocal();
}
release(): void { this.releaseInput(); this.send({ op: 'release' }); }
pointer(x: number, y: number, flag: number, scroll: number): void { this.send({ op: 'pointer', x: x, y: y, flag: flag, scroll: scroll }); }
physicalKey(code: number, down: boolean): void { this.send({ op: 'key', code: code, down: down }); }
key(code: number, down: boolean): void { this.send({ op: 'virtualKey', code: code, down: down }); }
chord(code: number, modifiers: number[] = []): void {
modifiers.forEach((key: number) => this.key(key, true)); this.key(code, true); this.key(code, false);
modifiers.slice().reverse().forEach((key: number) => this.key(key, false));
}
shortcut(code: number): void { this.chord(code, [this.state.platform === 'macos' ? 91 : 17]); }
text(text: string): void {
if (text.length > 2048 || Array.from(text).some((c: string) => asciiKey(c) === 0)) { this.toast('中文等文本请通过剪贴板发送,再点击粘贴'); return; }
for (const c of text) { const code = asciiKey(c); this.chord(code & 255, code & 256 ? [16] : []); }
}
setRelative(value: boolean): void { this.release(); this.state.relative = value; this.store.put('relative', value); }
mute(): void { this.state.muted = !this.state.muted; this.platform.setMuted(this.state.muted); this.send({ op: 'mute', muted: this.state.muted }); }
selectDisplay(index: number): void {
if (index < 0 || index >= this.state.displays.length) return;
this.release(); this.state.selectedDisplay = index; this.state.viewportReset++; this.state.hasFrame = false; this.network.reset();
this.send({ op: 'display', index: index }); this.state.modal = '';
}
updateVideo(field: number, value: number): void {
const old = this.video.selection;
const next: VideoValues = { quality: field === 0 ? value : old.quality, frameRate: field === 1 ? value : old.frameRate, preference: field === 2 ? value : old.preference };
const id = this.video.begin(next); if (id < 0) return;
this.send({ op: 'videoSettings', quality: next.quality, frameRate: next.frameRate, preference: next.preference, request: id });
if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout);
this.settingsTimeout = setTimeout(() => { this.video.expire(id); this.syncVideo(); }, 5000); this.syncVideo();
}
setPreference(value: number): void { this.store.put('videoPreference', value); this.state.videoPreference = value; }
setPreviews(value: boolean): void {
this.store.put('previews', value); this.state.previews = value;
if (!value) { this.send({ op: 'cancelPreview' }); this.previewEligible = false; this.store.clearPreviews(); this.history(); }
this.state.modal = '';
}
clearPreviews(): void { this.send({ op: 'cancelPreview' }); this.previewEligible = false; this.store.clearPreviews(); this.history(); this.toast('已清除预览'); }
clipboard(): void {
const text = this.state.clipboardText;
if (new util.TextEncoder().encodeInto(text).length > 128 * 1024) { this.toast('文本超过 128 KB'); return; }
if (text) { this.send({ op: 'clipboard', text: text }); this.toast('已发送,请在远端粘贴'); }
}
async copyRemote(): Promise<void> {
if (!this.state.remoteClipboard) { this.toast('尚未收到远端文本'); return; }
try { await pasteboard.getSystemPasteboard().setData(pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.state.remoteClipboard)); this.toast('已复制远端文本'); }
catch { this.toast('复制失败,请重试'); }
}
sas(): void {
if (!this.serviceAvailable) { this.toast(this.state.platform === 'windows' ? '请在远端启用 CrossDesk 系统服务后重试' : 'Ctrl+Alt+Del 需要远端 Windows 系统服务支持'); return; }
this.send({ op: 'sas' });
}
async chooseFile(): Promise<void> {
if (this.sendingFile) { this.toast('请等待当前文件发送完成'); return; }
const connection = this.connection; const file = await this.platform.choose(this.store.directory);
if (connection !== this.connection || !this.state.ready || !file) { if (file) this.store.discardOutgoing(file.path); return; }
this.sendingFile = true; this.state.transfer = `发送 ${file.name} · 0%`;
this.send({ op: 'sendFile', path: file.path, name: file.name });
}
saveFile(): void { if (this.state.receivedPath) this.platform.save(this.state.receivedPath, this.state.receivedName); }
surface(id: string, controller?: XComponentController): void {
this.send({ op: 'surface', id: id }); if (controller) this.platform.surface(controller, this.state.width, this.state.height);
}
async checkLocal(manual: boolean = false): Promise<void> {
if (!this.state.consent || !this.foreground || this.checkingLocal) return;
this.checkingLocal = true; const request = ++this.versionRequest; this.localVersionRequest = request;
const epoch = this.uiEpoch; if (manual) this.state.updateStatus = '正在检查…';
const body = await this.platform.version();
if (request !== this.localVersionRequest || epoch !== this.uiEpoch || !this.foreground || !this.state.consent) return;
this.checkingLocal = false;
if (body) this.send({ op: 'version', body: body, local: true, version: this.state.version, request: request });
else if (manual) this.state.updateStatus = '检查失败,请稍后重试';
}
private async checkRemote(version: string): Promise<void> {
if (this.checkedRemote || !this.foreground || !this.state.consent || !this.state.ready) return;
this.checkedRemote = true;
if (!version || !/^\d+\.\d+\.\d+/.test(version)) { this.state.notice = '被控端版本过低,请升级到最新版本。'; this.state.modal = 'upgrade'; return; }
const request = ++this.versionRequest, connection = this.connection; this.remoteVersionRequest = request;
const body = await this.platform.version();
if (body && request === this.remoteVersionRequest && connection === this.connection && this.foreground && this.state.consent)
this.send({ op: 'version', body: body, version: version, request: request });
}
document(name: string, title: string, back: string = 'about'): void {
try {
this.state.documentText = name === 'PRIVACY.md' ? this.privacy : util.TextDecoder.create('utf-8').decodeWithStream(this.context.resourceManager.getRawFileContentSync(name));
this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = '';
} catch { this.toast('无法读取文档'); }
}
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;
this.state.videoFeedback = this.video.feedback();
}
private poll(): void {
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) {
this.state.networkTime = this.now(); this.presence.tick(this.state.networkTime); this.history();
}
}
private event(e: NativeEvent): void {
if (e.type !== 'version' && (e.session !== this.session || (e.connection !== undefined && e.connection !== this.connection))) {
if (e.type === 'preview' && e.path) this.store.discardRaw(e.path); return;
}
if (e.type === 'identity' && e.identity) {
this.state.identity = e.identity.split('@')[0]; const scope = this.scope();
if (e.identity.includes('@')) this.store.saveSecret(`identity:${scope}`, e.identity).catch(() => this.toast('设备身份保存失败,请检查安全存储'));
this.joinIfReady(); this.configureInbox();
} else if (e.type === 'signal' && !e.controller) {
if (e.generation !== this.signalGeneration) { this.signalGeneration = e.generation ?? -1; this.presence.connect(false, this.now()); this.inbox.connect(false); }
this.state.signal = e.status ?? -1; this.state.identity = e.id || this.state.identity;
const labels = ['正在连接…', '已连接', '连接失败', '服务器已断开', '正在重连…', '服务器已断开', '服务器证书校验失败'];
this.state.signalText = labels[this.state.signal] ?? '未连接'; this.presence.connect(this.state.signal === 1, this.now()); this.configureInbox(); this.joinIfReady();
} else if (e.type === 'signalMessage' && e.generation === this.signalGeneration && e.message) {
const type = (e.message as SignalType).type;
if (type === 'announcements_changed') this.inbox.refresh();
else if (type === 'announcements') this.inbox.receive(e.message as AnnouncementResponse);
else this.presence.receive(e.message as PresenceMessage, this.now());
} else if (e.type === 'requestFailed') this.inbox.fail(String(e.request ?? ''));
else if (e.type === 'connection') {
if (e.status === 1 && !this.state.ready) this.connected();
else if (e.status === 6) {
this.end(''); this.state.remote = this.connectingId; this.state.notice = '访问密码错误,请重新输入'; this.state.modal = 'password';
} else if ((e.status ?? 0) >= 3) {
const reasons = ['', '', '', '连接已断开', '连接失败', '远端已关闭连接', '访问密码错误', '找不到该设备 ID', '远端当前不可用'];
this.end(reasons[e.status ?? 0] ?? '连接已结束');
}
} else if (e.type === 'ended') this.end(e.reason ?? '');
else if (e.type === 'data' && e.message) this.data(e.message as ControlData);
else if (e.type === 'frame') {
this.state.hasFrame = true;
this.state.width = e.width ?? 16; this.state.height = e.height ?? 9;
this.network.frame(e.frame ?? 0, e.latency ?? -1, this.state.width, this.state.height, this.now(), e.latencyCount ?? -1);
this.platform.videoSize(this.state.width, this.state.height);
if (this.previewEligible && this.historySaved && !this.previewSaved && !this.previewPending && this.previewAttempts < 3) {
this.previewAttempts++; this.previewPending = true; this.send({ op: 'preview' });
}
} else if (e.type === 'network' && e.report) this.network.receive(e.report, this.now());
else if (e.type === 'clipboard') this.state.remoteClipboard = e.text ?? '';
else if (e.type === 'feedback') this.toast(e.text ?? '');
else if (e.type === 'file') {
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 === 'preview') {
this.previewPending = false;
if (e.ok && e.path && this.previewEligible && this.state.previews) {
this.previewSaved = true; this.store.savePreview(this.scope(), this.connectingId, e.path).then(() => this.history()).catch(() => this.previewSaved = false);
} else if (e.path) this.store.discardRaw(e.path);
} else if (e.type === 'version') {
if (e.request === this.localVersionRequest && this.foreground && this.state.consent) {
if (e.ok) { this.state.update = e.latest ?? ''; this.state.updateStatus = this.state.update ? `发现新版本 ${this.state.update}` : '已是最新版本'; }
else this.state.updateStatus = '暂无鸿蒙版本信息,请稍后重试';
} else if (e.request === this.remoteVersionRequest && e.ok && e.latest && this.foreground && this.state.ready) {
this.state.notice = '被控端版本过低,请升级到最新版本。'; this.state.modal = 'upgrade';
}
} else if (e.type === 'error') {
this.state.signalText = e.text ?? '连接失败'; if (this.state.connecting) { this.end(e.text ?? '连接失败'); this.stopSignal(); }
else if (!this.state.ready) { const text = e.text ?? '操作失败'; this.stopSignal(); this.state.signalText = text; } else this.toast(e.text ?? '操作失败');
}
}
private configureInbox(): void {
if (this.state.identity) this.inbox.configure(`${this.scope()}:${this.state.identity}`);
this.inbox.connect(this.state.signal === 1 && this.foreground && this.state.consent);
}
private connected(): void {
this.state.ready = true; this.state.connecting = false; this.state.modal = ''; this.state.page = 'session';
const device: RecentDevice = { id: this.connectingId, name: this.state.hostName || this.connectingId,
platform: this.state.platform, remember: this.state.remember, preview: '' };
const password = this.pendingPassword; this.pendingPassword = '';
const scope = this.scope(), connection = this.connection;
this.store.saveRecent(scope, device, password).then(() => {
if (connection === this.connection && scope === this.scope()) {
this.historySaved = true;
if (this.state.hostName) this.store.metadata(scope, device.id, this.state.hostName, this.state.platform);
this.history();
}
}).catch(() => this.toast('密码保存失败,本次连接仍可使用'));
this.platform.connected();
if (this.video.supported && this.video.requestId === 0) this.updateVideo(0, this.video.selection.quality);
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
}
private data(message: ControlData): void {
if (message.type === 5) { this.serviceAvailable = message.service_status?.available === true; return; }
if (message.type === 12 && message.video_settings) {
const v = message.video_settings;
this.video.receive({ quality: v.quality, frameRate: v.frame_rate, preference: v.preference }, v.request_id, v.accepted === true);
if (!this.video.pending && this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.syncVideo(); return;
}
if (message.host_info) {
const info = message.host_info; this.state.platform = info.platform; this.state.hostName = info.host_name;
this.state.displays = Array.isArray(info.displays) ? info.displays.slice(0, 8) : [];
this.store.metadata(this.scope(), this.connectingId, info.host_name, info.platform);
const supported = this.video.supported; this.video.support(info.supports_video_settings === true); this.syncVideo();
if (this.state.ready && this.video.supported && !supported) this.updateVideo(0, this.video.selection.quality);
this.receivedHostInfo = true; this.remoteVersion = info.app_version; this.checkRemote(info.app_version);
}
}
}
@@ -0,0 +1,186 @@
import { deviceInfo } from '@kit.BasicServicesKit';
import { common, wantAgent } from '@kit.AbilityKit';
import { PiPWindow, window, display } from '@kit.ArkUI';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { picker, fileIo as fs, fileUri } from '@kit.CoreFileKit';
import { http } from '@kit.NetworkKit';
import { isAnnouncementURL } from './AnnouncementText';
export class MobileLifecycle {
static foreground: () => void = () => {};
static background: () => void = () => {};
static destroy: () => void = () => {};
}
export class MobilePlatform {
private pip?: PiPWindow.PiPController;
private pipPending: number = -1;
private backgroundQueue: Promise<void> = Promise.resolve();
private muted: boolean = false;
onMute: (muted: boolean) => void = () => {};
onVisibility: (visible: boolean) => void = () => {};
private generation: number = 0;
private backgroundActive: boolean = false;
private active: boolean = false;
private foreground: boolean = true;
choosing: boolean = false;
pipActive: boolean = false;
private requests: http.HttpRequest[] = [];
readonly context: common.UIAbilityContext;
private ended: (reason: string) => void;
private release: () => void;
private feedback: (message: string) => void;
private backgroundCancelled: () => void = () => {
this.backgroundActive = false;
if (this.active && !this.foreground && !this.pipActive && !this.choosing) this.ended('系统已停止后台会话');
};
constructor(context: common.UIAbilityContext, ended: (reason: string) => void, release: () => void, feedback: (message: string) => void) {
this.context = context; this.ended = ended; this.release = release; this.feedback = feedback;
try { backgroundTaskManager.on('continuousTaskCancel', this.backgroundCancelled); } catch {}
}
async connected(): Promise<void> {
this.active = true; const generation = ++this.generation;
this.muted = false; this.orientation(true);
this.backgroundQueue = this.backgroundQueue.then(async () => {
try {
const agent = await wantAgent.getWantAgent({ wants: [{ bundleName: this.context.abilityInfo.bundleName,
abilityName: this.context.abilityInfo.name }], operationType: wantAgent.OperationType.START_ABILITY,
requestCode: 0, wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] });
if (generation !== this.generation || !this.active) return;
await backgroundTaskManager.startBackgroundRunning(this.context, backgroundTaskManager.BackgroundMode.DATA_TRANSFER, agent);
if (generation !== this.generation || !this.active) { await backgroundTaskManager.stopBackgroundRunning(this.context); return; }
this.backgroundActive = true;
} catch { this.backgroundActive = false; }
});
await this.backgroundQueue;
}
async surface(controller: XComponentController, width: number, height: number): Promise<void> {
if (!this.active || this.pip || this.pipPending === this.generation) return;
const generation = this.generation;
this.pipPending = generation;
try {
if (!PiPWindow.isPiPEnabled()) return;
const pip = await PiPWindow.create({ context: this.context, componentController: controller,
templateType: PiPWindow.PiPTemplateType.VIDEO_LIVE, contentWidth: width, contentHeight: height,
controlGroups: [PiPWindow.VideoLiveControlGroup.MUTE_SWITCH] });
if (generation !== this.generation || !this.active) return;
this.pip = pip; pip.setAutoStartEnabled(!this.choosing);
this.setMuted(this.muted);
pip.on('controlEvent', (event: PiPWindow.ControlEventParam) => {
if (generation === this.generation && event.controlType === PiPWindow.PiPControlType.MUTE_SWITCH)
this.onMute(event.status !== PiPWindow.PiPControlStatus.PLAY);
});
pip.on('stateChange', (state: PiPWindow.PiPState, reason: string) => {
if (generation !== this.generation) return;
if (state === PiPWindow.PiPState.ABOUT_TO_START) this.release();
if (state === PiPWindow.PiPState.STARTED) { this.pipActive = true; this.onVisibility(true); }
if (state === PiPWindow.PiPState.ABOUT_TO_RESTORE) { this.foreground = true; this.onVisibility(true); }
if (state === PiPWindow.PiPState.STOPPED) {
this.pipActive = false;
this.onVisibility(this.foreground);
if (!this.foreground && !this.choosing && this.active) this.ended('画中画已关闭');
}
});
} catch { /* A continuous data-transfer task remains the fallback. */ }
finally { if (this.pipPending === generation) this.pipPending = -1; }
}
setMuted(muted: boolean): void {
this.muted = muted;
try { this.pip?.updatePiPControlStatus(PiPWindow.PiPControlType.MUTE_SWITCH,
muted ? PiPWindow.PiPControlStatus.PAUSE : PiPWindow.PiPControlStatus.PLAY); } catch {}
}
videoSize(width: number, height: number): void { try { this.pip?.updateContentSize(width, height); } catch { /* Not supported on this device. */ } }
async background(): Promise<void> {
this.foreground = false; this.release(); this.cancelRequests();
this.onVisibility(this.pipActive);
if (!this.active) return;
const generation = this.generation;
if (!this.choosing && this.pip && !this.pipActive) {
try { await this.pip.startPiP(); return; } catch { /* Fall back to the registered task. */ }
}
await this.backgroundQueue;
if (generation === this.generation && !this.foreground && this.active && !this.backgroundActive && !this.pipActive && !this.choosing)
this.ended('系统未允许后台会话,连接已断开');
}
resume(): void { this.foreground = true; this.dismissPiP(); this.orientation(this.active); this.onVisibility(true); }
private async dismissPiP(): Promise<void> {
if (!this.pipActive) return;
this.pipActive = false;
try { await this.pip?.stopPiP(); } catch { /* Already restored or closed. */ }
}
dispose(): void { this.disconnect(); try { backgroundTaskManager.off('continuousTaskCancel', this.backgroundCancelled); } catch {} }
disconnect(): void {
this.active = false; this.choosing = false; this.generation++; this.cancelRequests();
const pip = this.pip; this.pip = undefined; this.pipActive = false;
if (pip) { try { pip.setAutoStartEnabled(false); pip.stopPiP().catch(() => {}); } catch { /* Already stopped. */ } }
this.backgroundQueue = this.backgroundQueue.then(async () => {
if (this.backgroundActive) { this.backgroundActive = false; try { await backgroundTaskManager.stopBackgroundRunning(this.context); } catch {} }
});
this.orientation(false);
}
private async orientation(session: boolean): Promise<void> {
const generation = this.generation;
try {
const win = await window.getLastWindow(this.context);
if (generation !== this.generation) return;
await win.setWindowKeepScreenOn(session);
if (generation !== this.generation) return;
// Tablets and resizable windows follow the user orientation in both views.
const phone = deviceInfo.deviceType === 'phone';
const narrow = win.getWindowProperties().windowRect.width / display.getDefaultDisplaySync().densityPixels < 600;
await win.setPreferredOrientation(!session && phone && narrow ? window.Orientation.PORTRAIT : window.Orientation.AUTO_ROTATION);
} catch { /* Window may already be going away. */ }
}
private setChoosing(value: boolean): void {
this.choosing = value; this.release();
try { this.pip?.setAutoStartEnabled(!value); } catch { /* No PiP controller. */ }
}
async choose(directory: string): Promise<PreparedFile | undefined> {
if (this.choosing) return undefined;
this.setChoosing(true); const generation = this.generation; let path = '';
try {
await this.dismissPiP();
if (generation !== this.generation || !this.active) return undefined;
const uris = await new picker.DocumentViewPicker(this.context).select({ maxSelectNumber: 1 });
if (!uris.length || generation !== this.generation || !this.active) return undefined;
const name = new fileUri.FileUri(uris[0]).name || 'received-file';
path = `${directory}/outgoing/${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
const input = await fs.open(uris[0], fs.OpenMode.READ_ONLY);
try { await fs.copyFile(input.fd, path); } finally { await fs.close(input); }
if (generation !== this.generation || !this.active) { await fs.unlink(path); return undefined; }
return { path: path, name: name };
} catch { if (path) try { await fs.unlink(path); } catch {} this.feedback('无法读取所选文件或存储空间不足'); return undefined; }
finally { if (generation === this.generation) this.setChoosing(false); }
}
async save(path: string, name: string): Promise<void> {
if (this.choosing || !this.active) return;
this.setChoosing(true); const generation = this.generation;
try {
await this.dismissPiP();
if (generation !== this.generation || !this.active) return;
const uris = await new picker.DocumentViewPicker(this.context).save({ newFileNames: [name] });
if (!uris.length || generation !== this.generation || !this.active) return;
const output = await fs.open(uris[0], fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
try { await fs.copyFile(path, output.fd); } finally { await fs.close(output); }
if (generation === this.generation) this.feedback('文件已保存');
} catch { if (generation === this.generation) this.feedback('文件保存失败,请重试'); }
finally { if (generation === this.generation) this.setChoosing(false); }
}
async version(): Promise<string | undefined> {
const request = http.createHttp(); this.requests.push(request);
try {
const response = await request.request('https://version.crossdesk.cn/version.json', {
method: http.RequestMethod.GET, connectTimeout: 15000, readTimeout: 15000,
expectDataType: http.HttpDataType.STRING, usingCache: false, maxLimit: 256 * 1024
});
if (response.responseCode !== 200 || typeof response.result !== 'string' || response.result.length > 256 * 1024) return undefined;
return response.result;
} catch { return undefined; }
finally { this.requests = this.requests.filter((r: http.HttpRequest) => r !== request); request.destroy(); }
}
cancelRequests(): void { for (const request of this.requests) request.destroy(); this.requests = []; }
openLink(url: string): void {
if (!isAnnouncementURL(url)) return;
this.context.startAbility({ action: 'ohos.want.action.viewData', uri: url }).catch(() => this.feedback('无法打开网页'));
}
}
export interface PreparedFile { path: string; name: string; }
@@ -0,0 +1,350 @@
import { MobileController, MobileState } from './MobileController';
import { RemoteViewport, MouseInput, TouchInput, TouchPoint, clamp, NetworkStatistics, Traffic, asciiKey } from './ControllerModels';
import { MobileTheme, MobileSegment, MobileIcon, MobileIconButton } from './MobileWidgets';
interface KeyRow { keys: string[]; }
@Component
export struct MobileRemoteView {
@ObjectLink @Watch('stateChanged') state: MobileState;
controller?: MobileController;
@State private viewWidth: number = 1;
@State private viewHeight: number = 1;
@State private videoWidth: number = 1;
@State private videoHeight: number = 1;
@State private videoLeft: number = 0;
@State private videoTop: number = 0;
@State private zoom: number = 1;
@State private cursorX: number = 0;
@State private cursorY: number = 0;
@State private orbX: number = 12;
@State private orbY: number = 100;
@State private menu: string = '';
@State private menuHeight: number = 390;
@State private keyboardHeight: number = 220;
private orbPositioned: boolean = false;
@State private keyboard: boolean = false;
@State private computerKeyboard: boolean = false;
@State private shift: boolean = false;
@State private modifiers: number[] = [];
@State private keyboardX: number = 8;
@State private keyboardY: number = 150;
@State private mouseVisible: boolean = false;
@State private leftHeld: boolean = false;
@State private rightHeld: boolean = false;
@State private wheelOffset: number = 0;
private surface: XComponentController = new XComponentController();
private viewport: RemoteViewport = new RemoteViewport();
private mouse: MouseInput = new MouseInput((x: number, y: number, flag: number, scroll: number) => {
this.controller?.pointer(x, y, flag, scroll); this.updateCursor();
});
private touches: TouchInput = new TouchInput(this.viewport, this.mouse);
private longTimer: number = -1;
private lastReset: number = -1;
private lastWidth: number = 0;
private lastHeight: number = 0;
private lastRelative: boolean = true;
private orbStartX: number = 0;
private orbStartY: number = 0;
private originX: number = 0;
private originY: number = 0;
private orbMoved: boolean = false;
private barX: number = 0;
private barY: number = 0;
private wheelY: number = 0;
private wheelLast: number = 0;
private keyboardOriginX: number = 0;
private keyboardOriginY: number = 0;
aboutToAppear(): void { if (this.controller) this.controller.releaseInput = () => this.release(); this.stateChanged(); }
aboutToDisappear(): void { this.release(); if (this.controller) this.controller.releaseInput = () => {}; this.controller?.surface('0'); }
private stateChanged(): void {
if (this.lastReset !== this.state.viewportReset || this.lastRelative !== this.state.relative) {
this.release();
if (this.lastReset !== this.state.viewportReset) { this.viewport.reset(); this.applyViewport(); }
this.lastReset = this.state.viewportReset; this.lastRelative = this.state.relative;
}
this.touches.relative = this.state.relative;
if (this.lastWidth !== this.state.width || this.lastHeight !== this.state.height) {
this.lastWidth = this.state.width; this.lastHeight = this.state.height; this.fit();
}
}
private release(): void {
this.touches.cancel(); this.leftHeld = this.rightHeld = false; this.modifiers = []; this.wheelOffset = 0;
if (this.longTimer >= 0) clearTimeout(this.longTimer); this.longTimer = -1;
}
private fit(): void { this.viewport.fit(this.viewWidth, this.viewHeight, this.state.width, this.state.height); this.applyViewport(); }
private applyViewport(): void {
this.videoWidth = this.viewport.baseWidth; this.videoHeight = this.viewport.baseHeight; this.zoom = this.viewport.scale;
this.videoLeft = this.viewport.baseLeft + this.viewport.offsetX; this.videoTop = this.viewport.baseTop + this.viewport.offsetY; this.updateCursor();
}
private updateCursor(): void {
this.cursorX = this.viewport.left() + this.mouse.x * this.viewport.width(); this.cursorY = this.viewport.top() + this.mouse.y * this.viewport.height();
}
private touch(event: TouchEvent): void {
if (event.source === SourceType.Mouse) return;
const kind = event.type === TouchType.Down ? 'down' : event.type === TouchType.Move ? 'move' : event.type === TouchType.Up ? 'up' : 'cancel';
if (kind === 'down') {
this.touches.begin(); if (this.longTimer >= 0) clearTimeout(this.longTimer);
if (event.touches.length === 1) this.longTimer = setTimeout(() => this.touches.hold(), 500);
}
if (kind === 'up' || kind === 'cancel' || event.touches.length > 1) { if (this.longTimer >= 0) clearTimeout(this.longTimer); this.longTimer = -1; }
this.touches.touch(event.touches.map((t: TouchObject): TouchPoint => ({ x: t.x, y: t.y })), kind); this.applyViewport();
}
private physicalMouse(event: MouseEvent): void {
if (!this.viewport.contains(event.x, event.y) && event.action !== MouseAction.Release) return;
this.mouse.move(this.viewport.x(event.x), this.viewport.y(event.y));
const button = event.button === MouseButton.Left ? 0 : event.button === MouseButton.Right ? 1 : event.button === MouseButton.Middle ? 2 : -1;
if (button >= 0 && (event.action === MouseAction.Press || event.action === MouseAction.Release))
this.controller?.pointer(this.mouse.x, this.mouse.y, (event.action === MouseAction.Press ? 1 : 2) + button * 2, 0);
}
private orbTouch(event: TouchEvent): void {
const p = event.changedTouches[0]; if (!p) return;
if (event.type === TouchType.Down) { this.release(); this.orbStartX = p.screenX; this.orbStartY = p.screenY; this.originX = this.orbX; this.originY = this.orbY; this.orbMoved = false; }
if (event.type === TouchType.Move) {
const dx = p.screenX - this.orbStartX, dy = p.screenY - this.orbStartY;
if (Math.hypot(dx, dy) > 4) { this.orbMoved = true; this.menu = ''; }
if (this.orbMoved) { this.orbX = clamp(this.originX + dx, 8, this.viewWidth - 62); this.orbY = clamp(this.originY + dy, 8, this.viewHeight - 62); }
}
if (event.type === TouchType.Up && !this.orbMoved) this.menu = this.menu ? '' : 'controls';
}
private barTouch(event: TouchEvent, button: number): void {
const p = event.changedTouches[0]; if (!p) return;
if (event.type === TouchType.Down) {
this.barX = p.screenX; this.barY = this.wheelY = this.wheelLast = p.screenY;
if (button !== 1) this.mouse.button(button === 0, true);
} else if (event.type === TouchType.Move) {
if (button === 1) {
const delta = p.screenY - this.wheelY; this.wheelOffset = ((p.screenY - this.wheelLast) % 8 + 8) % 8;
if (Math.abs(delta) > 8) { this.mouse.scroll(delta); this.wheelY = p.screenY; }
} else this.mouse.drag(p.screenX - this.barX, p.screenY - this.barY, this.viewport.width(), this.viewport.height());
this.barX = p.screenX; this.barY = p.screenY;
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
if (button !== 1) this.mouse.button(button === 0, false); this.wheelOffset = 0;
}
this.leftHeld = this.mouse.left; this.rightHeld = this.mouse.right;
}
private action(index: number): void {
if (index === 0) { this.keyboard = !this.keyboard; this.menu = ''; this.modifiers = []; if (this.keyboard) { this.keyboardX = (this.viewWidth - this.keyboardWidth()) / 2; this.keyboardY = Math.max(8, this.viewHeight - this.keyboardHeight - 20); } }
if (index === 1) this.controller?.setRelative(!this.state.relative);
if (index === 2) { this.release(); this.mouseVisible = !this.mouseVisible; this.menu = ''; }
if (index === 3) { this.state.modal = 'displays'; this.menu = ''; }
if (index === 4) this.menu = 'video';
if (index === 5) this.controller?.mute();
if (index === 6) this.controller?.chooseFile();
if (index === 7) this.menu = 'network';
if (index === 8) this.controller?.sas();
if (index === 9) { this.state.modal = 'clipboard'; this.menu = ''; }
}
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 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); }
private mousePosition(): Position {
let x = this.orbX >= 154 ? this.orbX - 146 : this.orbX + 60, y = this.orbY + 3;
if (x + 140 > this.viewWidth - 8) { x = this.orbX; y = this.orbY + 60; if (y + 48 > this.viewHeight - 8) y = this.orbY - 54; }
if (this.keyboard && x < this.keyboardX + this.keyboardWidth() && x + 140 > this.keyboardX && y < this.keyboardY + this.keyboardHeight && y + 48 > this.keyboardY) {
y = this.keyboardY - 54; if (y < 8) y = this.keyboardY + this.keyboardHeight + 6;
}
return { x: clamp(x, 8, this.viewWidth - 148), y: clamp(y, 8, this.viewHeight - 56) };
}
private keyboardWidth(): number { return Math.max(1, Math.min(this.computerKeyboard ? 520 : 440, this.viewWidth - 16)); }
private keyboardRows(): KeyRow[] {
return this.computerKeyboard ? [
{ keys: ['Esc', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6'] }, { keys: ['Tab', 'Ctrl', 'Alt', 'Win', '↑', '⌫'] },
{ keys: ['复制', '粘贴', '全选', '←', '↓', '→'] }, { keys: ['ABC', '空格', 'Enter'] }
] : [{ keys: ['1','2','3','4','5','6','7','8','9','0'] }, { keys: ['q','w','e','r','t','y','u','i','o','p'] },
{ keys: ['a','s','d','f','g','h','j','k','l'] }, { keys: ['⇧','z','x','c','v','b','n','m','⌫'] },
{ keys: ['电脑','剪贴板','空格','.','Enter'] }];
}
private keyboardKey(value: string): void {
if (value === '⇧') { this.shift = !this.shift; return; }
if (value === '电脑' || value === 'ABC') { this.computerKeyboard = value === '电脑'; return; }
if (value === '剪贴板') { this.state.modal = 'clipboard'; return; }
const modifier = value === 'Ctrl' ? 17 : value === 'Alt' ? 18 : value === 'Win' ? 91 : 0;
if (modifier) { this.modifiers = this.modifiers.includes(modifier) ? this.modifiers.filter((m: number) => m !== modifier) : [...this.modifiers, modifier]; return; }
if (value === '复制' || value === '粘贴' || value === '全选') { this.controller?.shortcut(value === '复制' ? 67 : value === '粘贴' ? 86 : 65); return; }
const names = ['空格','⌫','Enter','Esc','Tab','↑','↓','←','→']; const codes = [32,8,13,27,9,38,40,37,39];
let code = names.includes(value) ? codes[names.indexOf(value)] : /^F[1-6]$/.test(value) ? 111 + Number(value.substring(1)) : 0;
if (!code && this.modifiers.length) { const key = asciiKey(this.shift ? value.toUpperCase() : value); code = key & 255; if (key & 256) this.modifiers = [...this.modifiers, 16]; }
if (code) { this.controller?.chord(code, this.modifiers); this.modifiers = []; }
else this.controller?.text(this.shift ? value.toUpperCase() : value);
}
private keyboardDrag(event: GestureEvent): void {
this.keyboardX = clamp(this.keyboardOriginX + event.offsetX, 8, this.viewWidth - this.keyboardWidth() - 8);
this.keyboardY = clamp(this.keyboardOriginY + event.offsetY, 8, Math.max(8, this.viewHeight - this.keyboardHeight - 8));
}
@Builder
NetworkRow(title: string, traffic: Traffic | undefined) {
Row({ space: 4 }) {
Text(title).fontSize(11).fontWeight(title === '合计' ? 600 : 400).fontColor(MobileTheme.ink).width(28)
Text(NetworkStatistics.rate(traffic?.inbound ?? -1)).fontSize(11).minFontSize(8).maxLines(1).layoutWeight(1).textAlign(TextAlign.End)
Text(NetworkStatistics.rate(traffic?.outbound ?? -1)).fontSize(11).minFontSize(8).maxLines(1).layoutWeight(1).textAlign(TextAlign.End)
Text(NetworkStatistics.loss(traffic?.loss ?? -1)).fontSize(11).width(46).textAlign(TextAlign.End)
}.padding({ left: 4, right: 4 }).height(26).width('100%').borderRadius(5).backgroundColor(title === '合计' ? '#122E7ADB' : Color.Transparent)
}
@Builder
NetworkDetail(label: string, value: string, encrypted: boolean = false) {
Row({ space: 4 }) {
Text(label).fontSize(12).fontColor(MobileTheme.secondary).layoutWeight(1)
if (encrypted) { MobileIcon({ name: 'lock', iconExtent: 14, color: MobileTheme.green }) }
Text(value).fontSize(12).fontColor(encrypted ? MobileTheme.green : MobileTheme.ink).textAlign(TextAlign.End)
}.width('100%').constraintSize({ minHeight: 21 })
}
@Builder
Menu() {
Column({ space: 10 }) {
Row({ space: 6 }) {
if (this.menu !== 'controls') { MobileIconButton({ icon: 'back', label: '返回控制栏', iconExtent: 30, iconSize: 19, action: () => this.menu = 'controls' }) }
Column({ space: 3 }) {
Row({ space: 6 }) {
if (this.menu === 'controls') { Row() {}.width(6).height(6).borderRadius(3).backgroundColor(MobileTheme.green) }
Text(this.menu === 'controls' ? '已连接' : this.menu === 'video' ? '画面设置' : '网络状态').fontSize(14).fontWeight(600).fontColor(MobileTheme.ink)
}.width('100%')
if (this.menu === 'controls') {
Text(`${this.controller?.network.mode(this.state.networkTime) ?? '—'} · ${this.state.width}×${this.state.height}`).fontSize(10).fontColor(MobileTheme.secondary).width('100%')
}
}.layoutWeight(1)
MobileIconButton({ icon: 'close', label: '关闭控制栏', iconExtent: 30, iconSize: 20, action: () => this.menu = '' })
}.width('100%')
Scroll() {
Column({ space: 8 }) {
if (this.menu === 'controls') {
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))
}
}, (label: string) => label)
}
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') {
Text(this.state.videoFeedback).fontSize(12).fontColor(MobileTheme.secondary).width('100%')
Text('画面质量').fontSize(12).width('100%')
MobileSegment({ labels: ['低', '中', '高'], selection: this.state.videoQuality,
available: this.state.videoSupported, changed: (i: number) => this.controller?.updateVideo(0, i) })
Text('画面采集帧率').fontSize(12).width('100%')
MobileSegment({ labels: ['30 fps', '60 fps'], selection: this.state.videoRate === 60 ? 1 : 0,
available: this.state.videoSupported, changed: (i: number) => this.controller?.updateVideo(1, i ? 60 : 30) })
Text('画面偏好').fontSize(12).width('100%')
MobileSegment({ labels: ['帧率优先', '画质优先', '平衡'], selection: this.state.videoSelectedPreference,
available: this.state.videoSupported, changed: (i: number) => this.controller?.updateVideo(2, i) })
} else {
Row({ space: 4 }) {
Text('').width(28)
Text('接收').fontSize(11).fontColor(MobileTheme.secondary).layoutWeight(1).textAlign(TextAlign.End)
Text('发送').fontSize(11).fontColor(MobileTheme.secondary).layoutWeight(1).textAlign(TextAlign.End)
Text('丢包率').fontSize(11).fontColor(MobileTheme.secondary).width(46).textAlign(TextAlign.End)
}.padding({ left: 4, right: 4 }).height(24).width('100%')
Column() {
this.NetworkRow('视频', this.controller?.network.current(this.state.networkTime)?.video)
this.NetworkRow('音频', this.controller?.network.current(this.state.networkTime)?.audio)
this.NetworkRow('数据', this.controller?.network.current(this.state.networkTime)?.data)
this.NetworkRow('合计', this.controller?.network.current(this.state.networkTime)?.total)
}.width('100%')
Divider().strokeWidth(0.5).color(MobileTheme.line).margin({ top: 2, bottom: 2 })
this.NetworkDetail('帧率', `${this.controller?.network.fps(this.state.networkTime) ?? '—'} FPS`)
this.NetworkDetail('分辨率', this.state.hasFrame ? `${this.state.width} × ${this.state.height}` : '—')
this.NetworkDetail('画面延时', NetworkStatistics.delay(this.controller?.network.latency(this.state.networkTime) ?? -1))
this.NetworkDetail('连接延时(RTT)', NetworkStatistics.delay(this.state.networkTime - (this.controller?.network.rttTime ?? 0) < 3000 ? this.controller?.network.rtt ?? -1 : -1))
this.NetworkDetail('连接方式', this.controller?.network.mode(this.state.networkTime) ?? '—')
this.NetworkDetail('媒体加密', this.controller?.network.current(this.state.networkTime) === undefined ? '—' : this.controller?.network.report?.srtp ? 'SRTP 已启用' : '未启用', this.controller?.network.current(this.state.networkTime)?.srtp === true)
}
}.width('100%')
}.constraintSize({ maxHeight: Math.max(48, this.viewHeight - 132) }).width('100%').scrollBar(BarState.Off)
Button('断开连接', { type: ButtonType.Normal }).fontSize(14).fontColor(Color.White).width('100%').height(34).borderRadius(8).backgroundColor(MobileTheme.red)
.onClick(() => { this.release(); this.state.modal = 'disconnect'; this.menu = ''; this.keyboard = false; })
}.padding(12).width(this.menuWidth()).backgroundColor(Color.White).borderRadius(18)
.border({ width: 0.5, color: MobileTheme.line }).shadow({ radius: 24, color: '#33000000', offsetY: 6 })
.onAreaChange((_old: Area, next: Area) => this.menuHeight = Number(next.height))
.position({ x: this.menuX(), y: clamp(this.orbY, 8, Math.max(8, this.viewHeight - this.menuHeight - 8)) })
}
@Builder
Keyboard() {
Column({ space: 3 }) {
Row() {
Stack() { Row() {}.width(32).height(4).backgroundColor(MobileTheme.secondary).borderRadius(2) }.layoutWeight(1).height(28).accessibilityText('拖动键盘')
.gesture(PanGesture().onActionStart(() => { this.keyboardOriginX = this.keyboardX; this.keyboardOriginY = this.keyboardY; })
.onActionUpdate((e: GestureEvent) => this.keyboardDrag(e)))
MobileIconButton({ icon: 'close', label: '收起键盘', iconExtent: 28, iconSize: 18, action: () => { this.keyboard = false; this.modifiers = []; } })
}.width('100%')
Scroll() {
Column({ space: 3 }) {
ForEach(this.keyboardRows(), (row: KeyRow) => {
Row({ space: 3 }) {
ForEach(row.keys, (key: string) => {
Text(this.shift && key.length === 1 ? key.toUpperCase() : key).fontSize(key.length > 2 ? 11 : 14)
.height(31).layoutWeight(key === '空格' ? 3 : 1).textAlign(TextAlign.Center).borderRadius(5).fontColor(MobileTheme.ink)
.shadow({ radius: 0.5, color: '#33000000', offsetY: 1 })
.backgroundColor((key === '⇧' && this.shift) || this.modifiers.includes(key === 'Ctrl' ? 17 : key === 'Alt' ? 18 : key === 'Win' ? 91 : -1) ? '#9BC7F7' : key.length > 1 || key === '⇧' ? '#AFB8C5' : '#FFFFFF')
.accessibilityText(key).accessibilityLevel('yes').onClick(() => this.keyboardKey(key))
}, (key: string) => key)
}.width('100%')
})
}.width('100%')
}.width('100%').constraintSize({ maxHeight: Math.max(31, this.viewHeight - 60) }).scrollBar(BarState.Off)
}.padding(5).width(this.keyboardWidth()).backgroundColor('#D1D6DE').borderRadius(12)
.shadow({ radius: 20, color: '#33000000', offsetY: 5 }).onAreaChange((_old: Area, next: Area) => this.keyboardHeight = Number(next.height))
.position({ x: clamp(this.keyboardX, 8, this.viewWidth - this.keyboardWidth() - 8), y: clamp(this.keyboardY, 8, Math.max(8, this.viewHeight - this.keyboardHeight - 8)) })
}
@Builder
MouseBar() {
Row({ space: 2 }) {
ForEach([0, 1, 2], (button: number) => {
Stack() {
if (button === 1) {
Stack() {
Column({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6], (line: number) => { Row() {}.height(3).width(14).backgroundColor('#737378') })
}.translate({ y: this.wheelOffset - 8 })
}.width(18).height(30).clip(true)
} else { MobileIcon({ name: button === 0 ? 'mouse_left' : 'mouse_right', iconExtent: 22 }) }
}.width(44).height(44).borderRadius(7).clip(true)
.backgroundColor(button === 0 && this.leftHeld || button === 2 && this.rightHeld ? '#9BC7F7' : '#E9E9EE')
.accessibilityText(button === 0 ? '鼠标左键,按住拖动' : button === 1 ? '滚轮,上下滑动' : '鼠标右键,按住拖动')
.onTouch((event: TouchEvent) => this.barTouch(event, button))
})
}.padding(2).width(140).height(48).backgroundColor('#FFFFFF').borderRadius(10)
.border({ width: 0.5, color: MobileTheme.line }).shadow({ radius: 12, color: '#33000000', offsetY: 4 }).position(this.mousePosition())
}
build() {
Stack({ alignContent: Alignment.TopStart }) {
XComponent({ id: 'mobileVideo', type: XComponentType.SURFACE, controller: this.surface })
.width(this.videoWidth).height(this.videoHeight).position({ x: this.videoLeft, y: this.videoTop })
.scale({ x: this.zoom, y: this.zoom }).onLoad(() => this.controller?.surface(this.surface.getXComponentSurfaceId(), this.surface))
.onDestroy(() => this.controller?.surface('0'))
if (!this.state.hasFrame) {
Column({ space: 12 }) {
LoadingProgress().width(32).height(32).color(Color.White)
Text('等待远端画面…').fontSize(14).fontColor(Color.White)
}.padding(20).constraintSize({ minWidth: 200 }).backgroundColor('#B0202024').borderRadius(14)
.position({ x: (this.viewWidth - 200) / 2, y: (this.viewHeight - 104) / 2 }).hitTestBehavior(HitTestMode.None)
}
Column() {}.width('100%').height('100%').focusable(true).defaultFocus(true)
.onTouch((e: TouchEvent) => this.touch(e)).onMouse((e: MouseEvent) => this.physicalMouse(e))
.onAxisEvent((event: AxisEvent) => { const delta = event.getVerticalAxisValue(); if (delta) this.mouse.scroll(-delta); })
.onKeyEvent((e: KeyEvent) => { if (e.type === KeyType.Down || e.type === KeyType.Up) { this.controller?.physicalKey(e.keyCode, e.type === KeyType.Down); return true; } return false; })
.onBlur(() => this.controller?.release())
.accessibilityText('远程桌面:轻触点击、长按右键、双指捏合缩放、放大后双指拖动画面,原始比例下双指滚动')
MobileIcon({ name: 'cursor', iconExtent: 24, color: '#000000' })
.position({ x: this.cursorX - 1, y: this.cursorY - 1 }).hitTestBehavior(HitTestMode.None)
if (this.menu) { Column() {}.width('100%').height('100%').onClick(() => this.menu = ''); this.Menu() }
if (this.keyboard) { this.Keyboard() }
if (this.mouseVisible && !this.menu && this.state.hasFrame) { this.MouseBar() }
Stack() {
Image($r('app.media.mobile_brand')).width(46).height(46).objectFit(ImageFit.Cover).borderRadius(23).draggable(false).hitTestBehavior(HitTestMode.None)
}.width(54).height(54).backgroundColor('#F5F5F7').borderRadius(27).shadow({ radius: 14, color: '#33000000', offsetY: 4 })
.position({ x: this.orbX, y: this.orbY }).accessibilityText('远程控制菜单,拖动可移动').accessibilityLevel('yes')
.onTouch((e: TouchEvent) => this.orbTouch(e))
}.width('100%').height('100%').backgroundColor(Color.Black).clip(true)
.onAreaChange((_old: Area, next: Area) => {
this.release(); this.viewWidth = Number(next.width); this.viewHeight = Number(next.height); this.fit();
if (!this.orbPositioned && this.viewWidth > 100) { this.orbX = this.viewWidth - 66; this.orbY = 17; this.orbPositioned = true; }
this.orbX = clamp(this.orbX, 8, this.viewWidth - 62); this.orbY = clamp(this.orbY, 8, this.viewHeight - 62);
this.keyboardX = clamp(this.keyboardX, 8, this.viewWidth - this.keyboardWidth() - 8);
})
}
}
@@ -0,0 +1,138 @@
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { asset } from '@kit.AssetStoreKit';
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { util } from '@kit.ArkTS';
import { fileIo as fs } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
export interface RecentDevice { id: string; name: string; platform: string; remember: boolean; preview: string; }
export class MobileStore {
private data: preferences.Preferences;
readonly directory: string;
readonly previewDirectory: string;
private previewEpoch: number = 0;
constructor(context: common.UIAbilityContext) {
this.data = preferences.getPreferencesSync(context, { name: 'crossdesk_mobile' });
this.directory = `${context.filesDir}/mobile`;
this.previewDirectory = `${context.cacheDir}/mobile-previews`;
try { fs.mkdirSync(this.previewDirectory, true); } catch {}
for (const name of ['', '/outgoing', '/received']) {
try { fs.mkdirSync(this.directory + name, true); } catch { /* Existing directories are reusable. */ }
}
}
text(key: string, fallback: string = ''): string { const value = this.data.getSync(key, fallback); return typeof value === 'string' ? value : fallback; }
flag(key: string, fallback: boolean = false): boolean { const value = this.data.getSync(key, fallback); return typeof value === 'boolean' ? value : fallback; }
number(key: string, fallback: number): number { const value = this.data.getSync(key, fallback); return typeof value === 'number' ? value : fallback; }
put(key: string, value: string | number | boolean): void { this.data.putSync(key, value); this.data.flushSync(); }
static scope(server: string, port: number): string { return `${server.trim().toLowerCase().replace(/\.$/, '')}:${port}`; }
static async hash(value: string): Promise<string> {
const md = cryptoFramework.createMd('SHA256'); await md.update({ data: new util.TextEncoder().encodeInto(value) });
const digest = await md.digest(); return Array.from(digest.data).map((b: number) => b.toString(16).padStart(2, '0')).join('');
}
private async query(key: string): Promise<asset.AssetMap> {
const query: asset.AssetMap = new Map();
query.set(asset.Tag.ALIAS, new util.TextEncoder().encodeInto(await MobileStore.hash(`crossdesk-mobile:${key}`)));
return query;
}
async secret(key: string): Promise<string> {
const query = await this.query(key); query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ALL);
try {
const found = await asset.query(query); const value = found.length ? found[0].get(asset.Tag.SECRET) : undefined;
return value instanceof Uint8Array ? util.TextDecoder.create('utf-8').decodeWithStream(value) : '';
} catch (error) {
if ((error as ErrorWithCode).code === asset.ErrorCode.NOT_FOUND) return '';
throw new Error('安全存储不可用,请解锁设备后重试');
}
}
async saveSecret(key: string, value: string): Promise<void> {
if (!value) { await this.removeSecret(key); return; }
const attributes = await this.query(key);
attributes.set(asset.Tag.SECRET, new util.TextEncoder().encodeInto(value));
attributes.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_FIRST_UNLOCKED);
attributes.set(asset.Tag.SYNC_TYPE, asset.SyncType.NEVER);
attributes.set(asset.Tag.CONFLICT_RESOLUTION, asset.ConflictResolution.OVERWRITE);
await asset.add(attributes);
}
async removeSecret(key: string): Promise<void> {
try { await asset.remove(await this.query(key)); }
catch (error) { if ((error as ErrorWithCode).code !== asset.ErrorCode.NOT_FOUND) throw new Error('无法删除已保存的凭据'); }
}
recent(scope: string): RecentDevice[] {
try {
const entries = JSON.parse(this.text(`recent:${scope}`, '[]')) as RecentDevice[];
return entries.filter((d: RecentDevice) => typeof d.id === 'string' && /^[0-9]{9}$/.test(d.id) && typeof d.name === 'string').slice(0, 20)
.map((d: RecentDevice) => {
try { if (typeof d.preview !== 'string' || !d.preview.startsWith(`${this.previewDirectory}/`) || !fs.accessSync(d.preview)) d.preview = ''; }
catch { d.preview = ''; }
return d;
});
} catch { return []; }
}
async saveRecent(scope: string, device: RecentDevice, password: string): Promise<void> {
if (device.remember) await this.saveSecret(`password:${scope}:${device.id}`, password);
else await this.removeSecret(`password:${scope}:${device.id}`);
const old = this.recent(scope), previous = old.find((d: RecentDevice) => d.id === device.id);
device.preview = previous?.preview ?? '';
const next = [device, ...old.filter((d: RecentDevice) => d.id !== device.id)].slice(0, 20);
this.put(`recent:${scope}`, JSON.stringify(next));
for (const dropped of old.filter((d: RecentDevice) => !next.some((n: RecentDevice) => n.id === d.id))) {
await this.removeSecret(`password:${scope}:${dropped.id}`); this.removePreview(dropped.preview);
}
}
metadata(scope: string, id: string, name: string, platform: string): void {
const entries = this.recent(scope);
entries.forEach((d: RecentDevice) => { if (d.id === id) { d.name = name || id; d.platform = platform; } });
this.put(`recent:${scope}`, JSON.stringify(entries));
}
async delete(scope: string, id: string): Promise<void> {
await this.removeSecret(`password:${scope}:${id}`);
const old = this.recent(scope); this.put(`recent:${scope}`, JSON.stringify(old.filter((d: RecentDevice) => d.id !== id)));
this.removePreview(old.find((d: RecentDevice) => d.id === id)?.preview ?? '');
}
private removePreview(path: string): void {
if (!path.startsWith(`${this.previewDirectory}/`)) return;
try { fs.unlinkSync(path); } catch { /* Already removed. */ }
}
clearPreviews(): void {
this.previewEpoch++;
let names: string[] = [];
try { names = fs.listFileSync(this.previewDirectory); } catch { /* The system may already have cleared the cache. */ }
for (const name of names) this.removePreview(`${this.previewDirectory}/${name}`);
}
discardRaw(path: string): void {
if (!path.startsWith(`${this.previewDirectory}/preview-`) || !/^preview-[0-9]+-[0-9]+\.rgba$/.test(path.substring(this.previewDirectory.length + 1))) return;
try { fs.unlinkSync(path); } catch { /* Already removed. */ }
}
discardOutgoing(path: string): void {
if (!path.startsWith(`${this.directory}/outgoing/`) || path.substring(this.directory.length + 10).includes('/')) return;
try { fs.unlinkSync(path); } catch { /* Already removed. */ }
}
async savePreview(scope: string, id: string, raw: string): Promise<void> {
if (!raw.startsWith(`${this.previewDirectory}/preview-`) || !/^preview-[0-9]+-[0-9]+\.rgba$/.test(raw.substring(this.previewDirectory.length + 1))) return;
const epoch = this.previewEpoch;
let pixel: image.PixelMap | undefined;
const packer = image.createImagePacker();
let output: fs.File | undefined;
let temporary = '';
try {
if (!this.flag('previews') || !this.flag('consent') || !this.recent(scope).some((d: RecentDevice) => d.id === id)) return;
const file = fs.openSync(raw, fs.OpenMode.READ_ONLY); const bytes = new ArrayBuffer(640 * 360 * 4);
try { if (fs.readSync(file.fd, bytes) !== bytes.byteLength) return; } finally { fs.closeSync(file); }
pixel = await image.createPixelMap(bytes, { size: { width: 640, height: 360 }, pixelFormat: image.PixelMapFormat.RGBA_8888 });
const destination = `${this.previewDirectory}/${await MobileStore.hash(`${scope}:${id}`)}.jpg`;
temporary = `${destination}.tmp`;
output = fs.openSync(temporary, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
await packer.packToFile(pixel, output.fd, { format: 'image/jpeg', quality: 82 }); fs.closeSync(output); output = undefined;
if (epoch !== this.previewEpoch || !this.flag('previews') || !this.flag('consent')) return;
const entries = this.recent(scope), target = entries.find((d: RecentDevice) => d.id === id); if (!target) return;
fs.renameSync(temporary, destination); temporary = ''; target.preview = destination;
this.put(`recent:${scope}`, JSON.stringify(entries));
} finally {
if (output) fs.closeSync(output); if (pixel) pixel.release(); packer.release();
try { fs.unlinkSync(raw); } catch { /* Revocation may already have removed it. */ }
if (temporary) try { fs.unlinkSync(temporary); } catch { /* Best effort cleanup. */ }
}
}
}
interface ErrorWithCode { code: number; }
@@ -0,0 +1,130 @@
import { LengthMetricsUnit } from '@kit.ArkUI';
export class MobileTheme {
static readonly background: string = '#F2F2F7';
static readonly ink: string = '#1C1C1E';
static readonly secondary: string = '#737378';
static readonly line: string = '#E2E2E7';
static readonly blue: string = '#2E7ADB';
static readonly red: string = '#FF3B30';
static readonly green: string = '#248A3D';
}
// Vector geometry follows Android MobileUi.Icon; no font-dependent emoji glyphs.
@Component
export struct MobileIcon {
@Prop @Watch('draw') name: string = 'info';
@Prop @Watch('draw') color: string = MobileTheme.ink;
iconExtent: number = 22;
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true), LengthMetricsUnit.PX);
private paths(): string[] {
switch (this.name) {
case 'cursor': return ['M1 1L1 13L5 10L9 17L12 15L8 9L15 9Z'];
case 'folder': return ["M2 19V5h7l3 3h10v11z"];
case 'keyboard': return ["M5.0 5.0H19.0Q22.0 5.0 22.0 8.0V16.0Q22.0 19.0 19.0 19.0H5.0Q2.0 19.0 2.0 16.0V8.0Q2.0 5.0 5.0 5.0Z", "M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 16h10"];
case 'envelope': return ["M4.0 5.0H20.0Q22.0 5.0 22.0 7.0V17.0Q22.0 19.0 20.0 19.0H4.0Q2.0 19.0 2.0 17.0V7.0Q2.0 5.0 4.0 5.0Z", "M3 6l9 7 9-7"];
case 'settings': return ["M9.807,4.828L10.264,2.152L13.736,2.152L14.193,4.828L15.521,5.378L17.736,3.808L20.192,6.264L18.622,8.479L19.172,9.807L21.848,10.264L21.848,13.736L19.172,14.193L18.622,15.521L20.192,17.736L17.736,20.192L15.521,18.622L14.193,19.172L13.736,21.848L10.264,21.848L9.807,19.172L8.479,18.622L6.264,20.192L3.808,17.736L5.378,15.521L4.828,14.193L2.152,13.736L2.152,10.264L4.828,9.807L5.378,8.479L3.808,6.264L6.264,3.808L8.479,5.378Z", "M8.8 12.0a3.2 3.2 0 1 0 6.4 0a3.2 3.2 0 1 0 -6.4 0"];
case 'refresh': return ["M19.5 8A8 8 0 1 0 20 15M20 3v6h-6"];
case 'chart': return ["M3 3v18h19M7 17v-4M12 17V8M18 17V4"];
case 'arrow': return ["M4 12h16M14 6l6 6-6 6"];
case 'muted': return ["M3 9h4l5-5v16l-5-5H3zM16 9l6 6m0-6-6 6"];
case 'history': return ["M5.5 5.5a8.5 8.5 0 1 1-1 12M3 4v6h6M12 7v6l4 2"];
case 'unlock': return ["M7.0 10.0H17.0Q19.0 10.0 19.0 12.0V20.0Q19.0 22.0 17.0 22.0H7.0Q5.0 22.0 5.0 20.0V12.0Q5.0 10.0 7.0 10.0Z", "M8 10V6a4 4 0 0 1 7.5-2M12 15v2"];
case 'check': return ["M5 12l4 4L19 6"];
case 'info': return ["M3.0 12.0a9.0 9.0 0 1 0 18.0 0a9.0 9.0 0 1 0 -18.0 0", "M12 10v7M12 6h.01"];
case 'display': return ["M4.0 3.0H20.0Q22.0 3.0 22.0 5.0V15.0Q22.0 17.0 20.0 17.0H4.0Q2.0 17.0 2.0 15.0V5.0Q2.0 3.0 4.0 3.0Z", "M12 17v4M7 21h10"];
case 'lock': return ["M7.0 10.0H17.0Q19.0 10.0 19.0 12.0V20.0Q19.0 22.0 17.0 22.0H7.0Q5.0 22.0 5.0 20.0V12.0Q5.0 10.0 7.0 10.0Z", "M8 10V6a4 4 0 0 1 8 0v4M12 15v2"];
case 'eye': return ["M2 12c5-9 15-9 20 0-5 9-15 9-20 0z", "M9.0 12.0a3.0 3.0 0 1 0 6.0 0a3.0 3.0 0 1 0 -6.0 0"];
case 'mouse_left': return ["M5 12v-3a7 7 0 0 1 7-7v10z", "M12.0 2.0H12.0Q19.0 2.0 19.0 9.0V15.0Q19.0 22.0 12.0 22.0H12.0Q5.0 22.0 5.0 15.0V9.0Q5.0 2.0 12.0 2.0Z", "M12 2v10M5 12h14"];
case 'sliders': return ["M3 6h3m4 0h11M3 12h10m4 0h4M3 18h3m4 0h11", "M6.0 6.0a2.0 2.0 0 1 0 4.0 0a2.0 2.0 0 1 0 -4.0 0", "M13.0 12.0a2.0 2.0 0 1 0 4.0 0a2.0 2.0 0 1 0 -4.0 0", "M6.0 18.0a2.0 2.0 0 1 0 4.0 0a2.0 2.0 0 1 0 -4.0 0"];
case 'close': return ["M6 6l12 12M18 6L6 18"];
case 'eye_off': return ["M3 3l18 18M10 5c5-1 9 3 12 7a21 21 0 0 1-4 5M6 6a22 22 0 0 0-4 6c5 8 11 9 16 5M10 10a3 3 0 0 0 4 4"];
case 'chevron': return ["M9 5l7 7-7 7"];
case 'back': return ["M15 4l-8 8 8 8"];
case 'mouse': return ["M12.0 2.0H12.0Q19.0 2.0 19.0 9.0V15.0Q19.0 22.0 12.0 22.0H12.0Q5.0 22.0 5.0 15.0V9.0Q5.0 2.0 12.0 2.0Z", "M12 3v7"];
case 'clipboard': return ["M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-2", "M10.0 2.0H14.0Q16.0 2.0 16.0 4.0V5.0Q16.0 7.0 14.0 7.0H10.0Q8.0 7.0 8.0 5.0V4.0Q8.0 2.0 10.0 2.0Z"];
case 'audio': return ["M3 9h4l5-5v16l-5-5H3zM17 6a8 8 0 0 1 0 12M15 9a4 4 0 0 1 0 6"];
case 'mouse_right': return ["M12 2a7 7 0 0 1 7 7v3h-7z", "M12.0 2.0H12.0Q19.0 2.0 19.0 9.0V15.0Q19.0 22.0 12.0 22.0H12.0Q5.0 22.0 5.0 15.0V9.0Q5.0 2.0 12.0 2.0Z", "M12 2v10M5 12h14"];
case 'key': return ["M2.0 8.0a5.0 5.0 0 1 0 10.0 0a5.0 5.0 0 1 0 -10.0 0", "M10.5 11.5L19 20m-4-4 3-3m0 6 3-3M6 7h.01"];
default: return ['M12 10v7M12 6h.01'];
}
}
private draw(): void {
const ctx = this.context;
// SVG path coordinates are pixels. Scale both geometry and stroke from the
// 24-unit viewport into physical pixels, including the display density.
const extent = this.getUIContext().vp2px(this.iconExtent);
ctx.clearRect(0, 0, extent, extent); ctx.save(); ctx.scale(extent / 24, extent / 24);
ctx.strokeStyle = this.color; ctx.fillStyle = this.color; ctx.lineWidth = 1.7; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
this.paths().forEach((data: string, index: number) => {
const path = new Path2D(data, LengthMetricsUnit.PX);
if (this.name === 'cursor') { ctx.fillStyle = '#FFFFFF'; ctx.lineWidth = 1.5; ctx.fill(path); }
if (index === 0 && (this.name === 'mouse_left' || this.name === 'mouse_right')) ctx.fill(path);
ctx.stroke(path);
});
ctx.restore();
}
build() {
Canvas(this.context).width(this.iconExtent).height(this.iconExtent).onReady(() => this.draw())
.hitTestBehavior(HitTestMode.None).accessibilityLevel('no')
}
}
@Component
export struct MobileIconButton {
@Prop icon: string = 'close';
@Prop label: string = '';
iconExtent: number = 40;
iconSize: number = 22;
color: string = MobileTheme.ink;
buttonFill: string = '#FFFFFF';
action: () => void = () => {};
build() {
Button({ type: ButtonType.Normal, stateEffect: true }) {
MobileIcon({ name: this.icon, iconExtent: this.iconSize, color: this.color })
}.width(this.iconExtent).height(this.iconExtent).padding(0).borderRadius(10).backgroundColor(this.buttonFill)
.accessibilityText(this.label).onClick(this.action)
}
}
@Component
export struct MobilePrimaryButton {
label: string = '连接';
@Prop available: boolean = true;
arrow: boolean = false;
buttonHeight: number = 48;
action: () => void = () => {};
build() {
Button({ type: ButtonType.Normal, stateEffect: true }) {
Row({ space: 8 }) {
Text(this.label).fontSize(16).fontWeight(600).fontColor(Color.White)
if (this.arrow) { MobileIcon({ name: 'arrow', iconExtent: 18, color: '#FFFFFF' }) }
}.justifyContent(FlexAlign.Center).width('100%')
}.height(this.buttonHeight).width('100%').padding({ left: 12, right: 12 }).borderRadius(12)
.linearGradient({ angle: 135, colors: [[MobileTheme.blue, 0], ['#1F61C7', 1]] })
.enabled(this.available).opacity(this.available ? 1 : 0.45).accessibilityText(this.label).onClick(this.action)
}
}
@Component
export struct MobileSegment {
labels: string[] = [];
@Prop @Watch('selectionChanged') selection: number = 0;
@Prop available: boolean = true;
changed: (index: number) => void = () => {};
@State private selected: number = 0;
@State private viewWidth: number = 1;
aboutToAppear(): void { this.selected = this.selection; }
private selectionChanged(): void { this.getUIContext().animateTo({ duration: 200, curve: Curve.EaseInOut }, () => this.selected = this.selection); }
build() {
Stack({ alignContent: Alignment.Start }) {
Row() {}.width(Math.max(0, this.viewWidth - 4) / Math.max(1, this.labels.length)).height(28)
.backgroundColor(Color.White).borderRadius(7).shadow({ radius: 2, color: '#18000000', offsetY: 1 }).translate({ x: 2 + this.selected * Math.max(0, this.viewWidth - 4) / Math.max(1, this.labels.length) })
Row() {
ForEach(this.labels, (label: string, index: number) => {
Text(label).fontSize(12).fontWeight(this.selection === index ? 600 : 400).fontColor(MobileTheme.ink)
.textAlign(TextAlign.Center).height(32).layoutWeight(1).enabled(this.available)
.accessibilityLevel('yes').accessibilityText(label).accessibilitySelected(this.selection === index)
.onClick(() => this.changed(index))
}, (label: string) => label)
}.padding({ left: 2, right: 2 }).width('100%')
}.height(32).width('100%').backgroundColor('#E9E9EE').borderRadius(9).opacity(this.available ? 1 : 0.5)
.onAreaChange((_old: Area, next: Area) => this.viewWidth = Number(next.width))
}
}
@@ -0,0 +1,637 @@
import { common } from '@kit.AbilityKit';
import { MobileController, MobileState, RemoteDisplay } from '../mobile/MobileController';
import { MobileRemoteView } from '../mobile/MobileRemoteView';
import { MobileTheme, MobileSegment, MobileIcon, MobileIconButton, MobilePrimaryButton } from '../mobile/MobileWidgets';
import { RecentDevice } from '../mobile/MobileStore';
import { AnnouncementItem } from '../mobile/AnnouncementInbox';
import { AnnouncementSpan, announcementSpans } from '../mobile/AnnouncementText';
import { util } from '@kit.ArkTS';
import { KeyboardAvoidMode } from '@kit.ArkUI';
interface LicenseDocument { title: string; asset: string; sha256?: string; }
interface LicenseComponent { id: string; name: string; license: string; version: string; sourceURL: string; documents: LicenseDocument[]; }
interface LicenseCatalog { components: LicenseComponent[]; }
interface SourceMetadata { revision: string; miniRTCRevision: string; isModified: boolean; sourceURL: string; buildDocuments: LicenseDocument[]; }
@Entry
@Component
struct Mobile {
@State private state: MobileState = new MobileState();
@State private viewWidth: number = 400;
@State private viewHeight: number = 720;
@State private idFocused: boolean = false;
@State private serverExpanded: boolean = false;
@State private customServer: boolean = false;
@State private previewToggle: boolean = false;
@State private privacyText: string = '';
@State private editingServer: string = '';
@State private editingPort: string = '';
@State private showPassword: boolean = false;
@State private accepted: boolean = false;
@State private deleteId: string = '';
@State private refreshing: boolean = false;
@State private licenses: LicenseComponent[] = [];
@State private component: LicenseComponent | undefined = undefined;
@State private source: SourceMetadata | undefined = undefined;
private controller?: MobileController;
aboutToAppear(): void {
// Keep the entire password/clipboard sheet above the software keyboard.
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.controller = new MobileController(context); this.state = this.controller.state;
try {
const decoder = util.TextDecoder.create('utf-8');
this.privacyText = decoder.decodeWithStream(context.resourceManager.getRawFileContentSync('PRIVACY.md'));
this.licenses = (JSON.parse(decoder.decodeWithStream(context.resourceManager.getRawFileContentSync('ThirdPartyLicenses.json'))) as LicenseCatalog).components;
this.source = JSON.parse(decoder.decodeWithStream(context.resourceManager.getRawFileContentSync('SourceMetadata.json'))) as SourceMetadata;
} catch { /* The package verification rejects missing legal resources. */ }
}
onBackPress(): boolean {
if (this.state.modal === 'connecting') { this.controller?.end(''); return true; }
if (this.state.modal) { if (this.state.modal !== 'consent') this.dismissModal(); return true; }
if (this.state.ready) { this.state.modal = 'disconnect'; return true; }
if (this.state.page === 'home') return false;
this.back(); return true;
}
private navigate(page: string): void {
if (page === 'settings') {
this.editingServer = this.state.server; this.editingPort = this.state.port;
this.customServer = this.state.server !== 'api.crossdesk.cn' || this.state.port !== '9099';
this.previewToggle = this.state.previews;
}
this.getUIContext().animateTo({ duration: 200, curve: Curve.EaseInOut }, () => this.controller?.navigate(page));
}
private back(): void {
const page = this.state.page;
if (page === 'settings' && this.customServer && this.serverExpanded && !this.saveServer()) return;
this.navigate(page === 'document' ? this.state.documentBack : page === 'announcement' ? 'announcements' :
page === 'component' ? 'licenses' : page === 'licenses' || page === 'source' ? 'about' : page === 'privacy' || page === 'about' ? 'settings' : 'home');
if (this.state.page === 'home' && !this.state.consent) this.state.modal = 'consent';
}
private title(): string {
switch (this.state.page) {
case 'settings': return '设置'; case 'privacy': return '隐私与授权';
case 'about': return '关于 CrossDesk'; case 'licenses': return '开源组件'; case 'component': return this.component?.name ?? '';
case 'source': return '源码与构建说明'; case 'announcements': return '通知公告'; case 'announcement': return '公告详情';
case 'document': return this.state.documentTitle; default: return '';
}
}
private deviceId(value: string): string { return value.replace(/[^0-9]/g, '').substring(0, 9); }
private formatId(value: string): string { return this.deviceId(value).replace(/(.{3})(?=.)/g, '$1 '); }
private policyParagraphs(body: string): string[] {
const text = body.replace(/\r\n/g, '\n');
const start = text.indexOf('## 中文\n'), end = start < 0 ? -1 : text.indexOf('## English', start);
const localized = start >= 0 && end > start ? text.substring(start + '## 中文\n'.length, end) : text;
return localized.split(/\n\s*\n/).map((paragraph: string) => paragraph.trim()).filter((paragraph: string) => !!paragraph && paragraph !== '---');
}
private openAnnouncement(item: AnnouncementItem): void { this.controller?.inbox.markRead(item); this.state.announcement = item; this.navigate('announcement'); }
private saveServer(): boolean {
this.controller?.saveSettings(this.editingServer, this.editingPort);
const saved = this.state.server === this.editingServer.trim() && this.state.port === this.editingPort;
if (saved) this.serverExpanded = false;
return saved;
}
private dismissModal(): void {
if (this.state.modal === 'password') { this.state.password = ''; this.showPassword = false; }
this.previewToggle = this.state.previews; this.state.modal = '';
}
private confirmModal(): void {
const modal = this.state.modal; this.state.modal = '';
if (modal === 'disconnect') this.controller?.end('');
if (modal === 'revoke') this.controller?.revoke();
if (modal === 'previews') { this.controller?.setPreviews(true); this.previewToggle = true; }
if (modal === 'clearPreviews') this.controller?.clearPreviews();
if (modal === 'delete') this.controller?.deleteRecent(this.deleteId);
if (modal === 'deleteAnnouncement' && this.state.announcement) { this.controller?.inbox.dismiss(this.state.announcement); this.navigate('announcements'); }
}
@Builder
SectionTitle(label: string) {
Text(label).fontSize(13).fontColor(MobileTheme.secondary).width('100%').padding({ left: 16, top: 12, bottom: 8 })
}
@Builder
Footer(label: string) {
Text(label).fontSize(12).lineHeight(18).fontColor(MobileTheme.secondary).width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
@Builder
Rule() { Divider().strokeWidth(0.5).color(MobileTheme.line) }
@Builder
ValueRow(label: string, value: string) {
Row({ space: 16 }) {
Text(label).fontSize(16).fontColor(MobileTheme.ink)
Text(value).fontSize(15).fontColor(MobileTheme.secondary).layoutWeight(1).textAlign(TextAlign.End).copyOption(CopyOptions.LocalDevice)
}.constraintSize({ minHeight: 52 }).width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
}
@Builder
Action(label: string, action: () => void, danger: boolean = false) {
Button(label, { type: ButtonType.Normal, stateEffect: true }).fontSize(16).fontWeight(500)
.fontColor(danger ? MobileTheme.red : MobileTheme.blue).backgroundColor(Color.White)
.height(48).width('100%').borderRadius(12).onClick(action)
}
@Builder
SettingRow(label: string, detail: string, action: () => void) {
Button({ type: ButtonType.Normal, stateEffect: true }) {
Row({ space: 8 }) {
Text(label).fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1).textAlign(TextAlign.Start)
if (detail) { Text(detail).fontSize(15).fontColor(MobileTheme.secondary).maxLines(1).constraintSize({ maxWidth: '46%' }).textOverflow({ overflow: TextOverflow.Ellipsis }) }
MobileIcon({ name: 'chevron', iconExtent: 16, color: '#B7B7BD' })
}.width('100%').constraintSize({ minHeight: 52 }).padding({ left: 16, right: 12, top: 12, bottom: 12 })
}.width('100%').padding(0).backgroundColor(Color.White).borderRadius(0).onClick(action).accessibilityText(`${label} ${detail}`)
}
@Builder
ConnectionCard() {
Column({ space: 16 }) {
Text('远程桌面').fontSize(20).fontWeight(600).fontColor(MobileTheme.ink).width('100%')
Row({ space: 10 }) {
TextInput({ text: this.formatId(this.state.remote), placeholder: '对端 ID' }).type(InputType.Number).maxLength(11)
.fontSize(20).fontWeight(500).fontColor(MobileTheme.ink).placeholderColor(MobileTheme.secondary)
.height(54).layoutWeight(1).backgroundColor(MobileTheme.background).borderRadius(12)
.border({ width: this.idFocused ? 2 : 1, color: this.idFocused ? MobileTheme.blue : MobileTheme.line })
.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,
action: () => this.controller?.requestConnection(this.state.remote) })
}.width(96)
}.width('100%')
}.padding(18).width('100%').backgroundColor(Color.White).borderRadius(20)
.border({ width: 0.5, color: MobileTheme.line }).shadow({ radius: 14, color: '#0E000000', offsetY: 5 })
}
@Builder
RecentCard(device: RecentDevice) {
Column() {
Stack() {
Text(device.platform === 'windows' ? 'Windows' : device.platform === 'macos' ? 'macOS' : device.platform === 'linux' ? 'Linux' : '远程电脑')
.fontSize(19).fontWeight(600).fontColor(Color.White).width('100%').height('100%').textAlign(TextAlign.Center)
if (this.state.previews && device.preview) { Image(`file://${device.preview}`).objectFit(ImageFit.Cover).width('100%').height('100%') }
}.aspectRatio(16 / 9).width('100%').backgroundColor(device.platform === 'windows' ? '#316AB7' : device.platform === 'macos' ? '#8057B9' : device.platform === 'linux' ? '#AE642B' : '#677587')
Column({ space: 4 }) {
Text(device.name || device.id).fontSize(12).fontWeight(600).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
Row({ space: 4 }) {
Text(`ID ${device.id}`).fontSize(10).fontColor(MobileTheme.secondary).layoutWeight(1)
if (device.remember) { MobileIcon({ name: 'key', iconExtent: 11, color: MobileTheme.secondary }).accessibilityText('已保存密码') }
Text(this.controller?.presence.online(device.id, this.controller?.now() ?? 0) ? '在线' : '离线').fontSize(10)
.fontColor(this.controller?.presence.online(device.id, this.controller?.now() ?? 0) ? MobileTheme.green : MobileTheme.secondary)
}.width('100%')
}.padding(9).width('100%')
}.width('100%').backgroundColor(Color.White).border({ width: 0.5, color: MobileTheme.line }).borderRadius(14).clip(true)
.onClick(() => this.controller?.requestConnection(device.id))
.gesture(LongPressGesture().onAction(() => { this.deleteId = device.id; this.state.modal = 'recentActions'; }))
}
@Builder
Recent() {
Column({ space: 14 }) {
Row() {
Text('最近连接').fontSize(20).fontWeight(600).fontColor(MobileTheme.ink).layoutWeight(1)
if (this.state.recent.length) { Text(`${this.state.recent.length} 台设备`).fontSize(12).fontColor(MobileTheme.secondary) }
}.width('100%')
if (this.state.recent.length === 0) {
Column() {
MobileIcon({ name: 'history', iconExtent: 34, color: '#B6B6BB' })
Text('还没有连接记录').fontSize(17).fontWeight(600).fontColor(MobileTheme.ink).margin({ top: 12, bottom: 8 })
Text('成功连接后,这里会显示最近的远程设备。').fontSize(13).lineHeight(19).fontColor(MobileTheme.secondary).textAlign(TextAlign.Center)
}.layoutWeight(1).width('100%').justifyContent(FlexAlign.Center).padding({ left: 6, right: 6 })
} else {
Scroll() {
GridRow({ columns: { xs: 1, sm: 2, md: 3, lg: 4 }, breakpoints: { value: ['300vp', '564vp', '756vp'], reference: BreakpointsReference.ComponentSize }, gutter: 12 }) {
ForEach(this.state.recent, (device: RecentDevice) => { GridCol() { this.RecentCard(device) } }, (device: RecentDevice) => device.id)
}.width('100%')
}.layoutWeight(1).width('100%').align(Alignment.Top).scrollBar(BarState.Off)
}
}.padding(16).width('100%').height('100%').backgroundColor(Color.White).borderRadius(18).border({ width: 0.5, color: MobileTheme.line })
}
@Builder
Home() {
Column() {
Row({ space: 12 }) {
Button({ type: ButtonType.Normal }) {
Row({ space: 6 }) {
Row() {}.width(6).height(6).borderRadius(3).backgroundColor(this.state.signal === 1 ? MobileTheme.green : MobileTheme.secondary)
Text(this.state.signalText).fontSize(12).fontWeight(600).fontColor(this.state.signal === 1 ? MobileTheme.green : MobileTheme.secondary)
}
}.padding({ left: 11, right: 11 }).height(34).backgroundColor(Color.White).borderRadius(17).onClick(() => this.controller?.reconnect())
Blank()
Stack({ alignContent: Alignment.TopEnd }) {
MobileIconButton({ icon: 'envelope', label: '通知公告', action: () => this.navigate('announcements') })
if (this.state.unread > 0) {
Text(this.state.unread > 99 ? '99+' : `${this.state.unread}`).fontSize(10).fontWeight(600).fontColor(Color.White)
.textAlign(TextAlign.Center).padding({ left: 4, right: 4 }).constraintSize({ minWidth: 16 }).height(16)
.backgroundColor(MobileTheme.red).borderRadius(8).translate({ x: 5, y: -3 }).hitTestBehavior(HitTestMode.None)
}
}
Stack({ alignContent: Alignment.TopEnd }) {
MobileIconButton({ icon: 'settings', label: '设置', action: () => this.navigate('settings') })
if (this.state.update) {
Text('!').fontSize(10).fontWeight(600).fontColor(Color.White).textAlign(TextAlign.Center).width(16).height(16)
.backgroundColor(MobileTheme.red).borderRadius(8).translate({ x: 5, y: -3 }).hitTestBehavior(HitTestMode.None)
}
}
}.padding({ left: 20, right: 20 }).height(56).width('100%').constraintSize({ maxWidth: 1200 })
Scroll() {
if (this.viewWidth >= 900) {
Row({ space: 24 }) {
Column() { this.ConnectionCard() }.width(360)
Column() { this.Recent() }.layoutWeight(1).height('100%')
}.alignItems(VerticalAlign.Top).width('100%').constraintSize({ maxWidth: 1200 })
.height(Math.max(360, this.viewHeight - 56)).padding({ left: 20, right: 20, top: 24, bottom: 12 })
} else {
Column({ space: 20 }) {
this.ConnectionCard()
Column() { this.Recent() }.layoutWeight(1).width('100%')
}.width('100%').height(Math.max(440, this.viewHeight - 56)).padding({ left: 20, right: 20, top: 24, bottom: 12 })
}
}.layoutWeight(1).width('100%').align(Alignment.Top).scrollBar(BarState.Off)
}.width('100%').height('100%')
}
@Builder
PreviewRow() {
Row({ space: 12 }) {
Text('保存远程画面预览').fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.previewToggle }).selectedColor(MobileTheme.green)
.onChange((value: boolean) => {
this.previewToggle = value;
if (value && !this.state.previews) this.state.modal = 'previews';
else if (!value) this.controller?.setPreviews(false);
}).accessibilityText('保存远程画面预览')
}.constraintSize({ minHeight: 52 }).padding({ left: 16, right: 16, top: 8, bottom: 8 }).width('100%')
}
@Builder
Settings() {
Column() {
this.SectionTitle('鼠标控制')
Column() {
MobileSegment({ labels: ['相对位置', '绝对位置'], selection: this.state.relative ? 0 : 1,
changed: (i: number) => this.controller?.setRelative(i === 0) })
}.padding(12).width('100%').backgroundColor(Color.White).borderRadius(26).border({ width: 0.5, color: MobileTheme.line })
this.SectionTitle('画面偏好')
Column() {
MobileSegment({ labels: ['帧率优先', '画质优先', '平衡'], selection: this.state.videoPreference,
changed: (i: number) => this.controller?.setPreference(i) })
}.padding(12).width('100%').backgroundColor(Color.White).borderRadius(26).border({ width: 0.5, color: MobileTheme.line })
Column() {
this.SettingRow('服务器', this.customServer ? '自定义' : '默认', () => this.serverExpanded = !this.serverExpanded)
if (this.serverExpanded) {
this.Rule()
Row() {
Text('使用自定义服务器').fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.customServer }).selectedColor(MobileTheme.green).onChange((value: boolean) => {
this.customServer = value;
if (!value) { this.editingServer = 'api.crossdesk.cn'; this.editingPort = '9099'; this.controller?.saveSettings(this.editingServer, this.editingPort); }
}).accessibilityText('使用自定义服务器')
}.padding({ left: 16, right: 16, top: 8, bottom: 8 }).width('100%')
if (this.customServer) {
Column({ space: 8 }) {
TextInput({ text: this.editingServer, placeholder: '服务器地址' }).fontSize(17).height(48).borderRadius(12)
.backgroundColor(MobileTheme.background).onChange((v: string) => this.editingServer = v)
TextInput({ text: this.editingPort, placeholder: '服务器端口' }).fontSize(17).height(48).borderRadius(12)
.backgroundColor(MobileTheme.background).type(InputType.Number).onChange((v: string) => this.editingPort = v)
this.Action('完成', () => { this.saveServer(); })
}.padding({ left: 12, right: 12, bottom: 12 }).width('100%')
}
}
}.width('100%').backgroundColor(Color.White).borderRadius(26).clip(true).border({ width: 0.5, color: MobileTheme.line }).margin({ top: 28 })
Text(this.state.identity ? `本机 ID ${this.formatId(this.state.identity)}` : this.state.signalText).fontSize(12).fontColor(MobileTheme.secondary)
.textAlign(TextAlign.Center).padding({ left: 16, right: 16, top: 12, bottom: 16 }).width('100%').copyOption(CopyOptions.LocalDevice)
this.SectionTitle('隐私')
Column() {
this.SettingRow('隐私与授权', this.state.consent ? '已授权' : '未授权', () => this.navigate('privacy'))
this.Rule()
this.PreviewRow()
this.Rule()
Button({ type: ButtonType.Normal }) { Text('清除预览图').fontSize(16).fontColor(MobileTheme.red).width('100%').textAlign(TextAlign.Start) }
.height(48).width('100%').padding({ left: 16 }).backgroundColor(Color.White)
.onClick(() => this.state.modal = 'clearPreviews')
}.width('100%').backgroundColor(Color.White).borderRadius(26).clip(true).border({ width: 0.5, color: MobileTheme.line })
Column() { this.SettingRow('关于', this.state.update ? '新版本 ●' : '', () => this.navigate('about')) }
.width('100%').backgroundColor(Color.White).borderRadius(26).clip(true).border({ width: 0.5, color: MobileTheme.line }).margin({ top: 28 })
}.width('100%')
}
@Builder
Privacy() {
Column() {
Column() {
this.ValueRow('授权状态', this.state.consent ? '已授权' : '未授权')
this.Rule()
this.SettingRow('隐私政策', '', () => this.controller?.document('PRIVACY.md', '隐私政策', 'privacy'))
}.width('100%').backgroundColor(Color.White).borderRadius(12).clip(true).border({ width: 0.5, color: MobileTheme.line })
this.Footer('设备身份及选择保存的访问密码加密保存在本机。画面预览默认关闭,开启后从下一次连接起保存。剪贴板内容在结束会话时清除。离开应用后通过画中画或后台任务保持会话,可随时返回应用断开。')
if (this.state.consent) { this.Action('撤回联网授权', () => this.state.modal = 'revoke', true) }
else { MobilePrimaryButton({ label: '阅读并授权', action: () => this.state.modal = 'consent' }) }
}.width('100%')
}
@Builder
About() {
Column() {
Column() {
this.ValueRow('版本', this.state.version)
if (this.state.update) { this.Rule(); this.SettingRow('新版本可用', `v${this.state.update}`, () => this.controller?.platform.openLink('https://crossdesk.cn')) }
if (this.state.updateStatus) { this.Footer(this.state.updateStatus) }
this.Rule()
this.Action('检查更新', () => this.controller?.checkLocal(true))
}.width('100%').backgroundColor(Color.White).borderRadius(12).clip(true).border({ width: 0.5, color: MobileTheme.line })
Column() {
this.SettingRow('软件许可', 'GPL-3.0-only', () => this.controller?.document('LICENSE', '软件许可'))
this.Rule()
this.SettingRow('开源软件权利说明', '', () => this.controller?.document('OPEN_SOURCE_NOTICE.txt', '开源软件权利说明'))
this.Rule()
this.SettingRow('开源组件', '', () => this.navigate('licenses'))
this.Rule()
this.SettingRow('源码与构建说明', '', () => this.navigate('source'))
}.backgroundColor(Color.White).borderRadius(12).clip(true).width('100%').border({ width: 0.5, color: MobileTheme.line }).margin({ top: 28 })
this.Footer('许可与版权声明可离线查阅。')
}.width('100%')
}
@Builder
Announcements() {
Column({ space: 12 }) {
if (!this.state.consent) { Text('同意联网后可查看公告').fontSize(14); this.Action('查看联网说明', () => this.state.modal = 'consent') }
if (this.controller?.inbox.failed) { this.Action('加载失败,点击重试', () => this.controller?.inbox.refresh()) }
if (this.controller?.inbox.loading) { LoadingProgress().width(28).height(28) }
if (this.controller?.inbox.saveFailed) { Text('已读或删除状态保存失败,请重试').fontSize(12).fontColor(MobileTheme.red) }
if (!this.state.announcements.length && this.controller?.inbox.loaded && !this.controller?.inbox.loading) { Text('暂无公告').fontSize(16).fontColor(MobileTheme.secondary).padding(40) }
ForEach(this.state.announcements, (item: AnnouncementItem) => {
Column({ space: 8 }) {
Row({ space: 8 }) { if (!item.read) { Row() {}.width(6).height(6).borderRadius(3).backgroundColor(MobileTheme.blue) } Text(item.title).fontSize(17).fontWeight(600).layoutWeight(1) }.width('100%')
Text(new Date(item.updated_at * 1000).toLocaleDateString('zh-CN')).fontSize(12).fontColor(MobileTheme.secondary).width('100%')
Text(item.body).fontSize(13).fontColor(MobileTheme.secondary).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
}.padding(18).width('100%').backgroundColor(Color.White).borderRadius(18).border({ width: 0.5, color: MobileTheme.line })
.onClick(() => this.openAnnouncement(item)).gesture(LongPressGesture().onAction(() => { this.state.announcement = item; this.state.modal = 'deleteAnnouncement'; }))
}, (item: AnnouncementItem) => `${item.id}:${item.revision}:${item.read}`)
if ((this.controller?.inbox.total ?? 0) > this.state.announcements.length) { this.Action('加载更多', () => this.controller?.inbox.more()) }
}.width('100%')
}
@Builder
Content() {
if (this.state.page === 'settings') { this.Settings() }
else if (this.state.page === 'privacy') { this.Privacy() }
else if (this.state.page === 'about') { this.About() }
else if (this.state.page === 'announcements') { this.Announcements() }
else if (this.state.page === 'announcement') {
Column({ space: 16 }) {
Text(this.state.announcement?.title ?? '').fontSize(24).fontWeight(600).width('100%').copyOption(CopyOptions.LocalDevice)
Text() {
ForEach(announcementSpans(this.state.announcement?.body ?? ''), (part: AnnouncementSpan) => {
if (part.url) {
Span(part.text).fontColor(MobileTheme.blue).onClick(() => this.controller?.platform.openLink(part.url))
} else { Span(part.text) }
})
}.fontSize(16).lineHeight(25).width('100%').copyOption(CopyOptions.LocalDevice)
this.Action('删除公告', () => this.state.modal = 'deleteAnnouncement', true)
}.width('100%')
} else if (this.state.page === 'document') {
if (this.state.documentTitle === '隐私政策') { this.PolicyText(this.state.documentText) }
else {
Column({ space: 12 }) {
ForEach(this.state.documentText.match(/[\s\S]{1,4000}/g) ?? [], (paragraph: string) => {
Text(paragraph).fontSize(15).lineHeight(24).width('100%').copyOption(CopyOptions.LocalDevice)
})
}.width('100%')
}
} else if (this.state.page === 'licenses') {
Column() {
this.SectionTitle('OpenFEC 告知')
Column() { this.Footer('OpenFEC 提供前向纠错能力,遵循 CeCILL-C 1.0 许可。其许可规定了有限保证及责任限制,请阅读组件详情。') }
.width('100%').backgroundColor(Color.White).borderRadius(12).border({ width: 0.5, color: MobileTheme.line })
Column() {
ForEach(this.licenses, (component: LicenseComponent, index: number) => {
if (index > 0) { this.Rule() }
Button({ type: ButtonType.Normal }) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(component.name).fontSize(16).fontColor(MobileTheme.ink).width('100%')
Text(component.license).fontSize(12).fontColor(MobileTheme.secondary).width('100%')
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
MobileIcon({ name: 'chevron', iconExtent: 16, color: '#B7B7BD' })
}.width('100%').padding({ left: 16, right: 12, top: 12, bottom: 12 })
}.width('100%').padding(0).backgroundColor(Color.White).onClick(() => { this.component = component; this.navigate('component'); })
}, (component: LicenseComponent) => component.id)
}.width('100%').backgroundColor(Color.White).borderRadius(12).clip(true).border({ width: 0.5, color: MobileTheme.line }).margin({ top: 28 })
this.Footer('各组件遵循所列开源许可证。许可与版权声明可离线查阅,查看源码需要网络连接。')
}.width('100%')
} else if (this.state.page === 'component') {
Column() {
Column() {
this.ValueRow('版本', this.component?.version ?? '')
this.Rule()
this.ValueRow('许可', this.component?.license ?? '')
this.Rule()
this.SettingRow('查看源码', '', () => this.controller?.platform.openLink(this.component?.sourceURL ?? ''))
}.width('100%').backgroundColor(Color.White).borderRadius(12).clip(true).border({ width: 0.5, color: MobileTheme.line })
this.SectionTitle('许可与版权声明')
Column() {
ForEach(this.component?.documents ?? [], (document: LicenseDocument, index: number) => {
if (index > 0) { this.Rule() }
this.SettingRow(document.title, '', () => this.controller?.document(document.asset, document.title, 'component'))
})
}.width('100%').backgroundColor(Color.White).borderRadius(12).clip(true).border({ width: 0.5, color: MobileTheme.line })
}.width('100%')
} else if (this.state.page === 'source') {
Column() {
this.SectionTitle('本版本源码与构建说明')
Column() {
this.ValueRow('源码版本', (this.source?.revision ?? '').substring(0, 12))
this.Rule()
this.ValueRow('MiniRTC', (this.source?.miniRTCRevision ?? '').substring(0, 12))
if (this.source?.isModified) { this.Rule(); this.Footer('开发版本包含尚未发布的修改,以下链接为基础版本源码。') }
this.Rule()
this.SettingRow('查看源码', '', () => this.controller?.platform.openLink(this.source?.sourceURL ?? ''))
}.width('100%').backgroundColor(Color.White).borderRadius(12).clip(true).border({ width: 0.5, color: MobileTheme.line })
this.SectionTitle('构建文档')
Column() {
ForEach(this.source?.buildDocuments ?? [], (document: LicenseDocument, index: number) => {
if (index > 0) { this.Rule() }
this.SettingRow(document.title, '', () => this.controller?.document(document.asset, document.title, 'source'))
})
}.width('100%').backgroundColor(Color.White).borderRadius(12).clip(true).border({ width: 0.5, color: MobileTheme.line })
}.width('100%')
}
}
@Builder
SheetHeader(title: string, subtitle: string = '') {
Row({ space: 12 }) {
Column({ space: 5 }) {
Text(title).fontSize(22).fontWeight(600).fontColor(MobileTheme.ink).width('100%')
if (subtitle) { Text(subtitle).fontSize(15).fontColor(MobileTheme.secondary).width('100%') }
}.layoutWeight(1)
MobileIconButton({ icon: 'close', label: '关闭', iconExtent: 36, iconSize: 20, action: () => this.dismissModal() })
}.width('100%')
}
@Builder
PasswordSheet() {
Scroll() {
Column() {
Row() {}.width(36).height(5).backgroundColor('#C6C6CB').borderRadius(3).margin({ bottom: 20 })
this.SheetHeader('连接远程桌面', `对端 ID ${this.formatId(this.state.remote)}`)
Row() {
TextInput({ text: this.state.password, placeholder: '访问密码' }).type(this.showPassword ? InputType.Normal : InputType.Password)
.showPasswordIcon(false).maxLength(128).fontSize(17).height(50).layoutWeight(1).backgroundColor(Color.Transparent)
.padding({ left: 14, right: 4 }).onChange((value: string) => this.state.password = value).onSubmit(() => this.controller?.begin(this.state.password))
MobileIconButton({ icon: this.showPassword ? 'eye_off' : 'eye', label: this.showPassword ? '隐藏密码' : '显示密码', iconExtent: 44,
action: () => this.showPassword = !this.showPassword })
}.width('100%').backgroundColor(Color.White).borderRadius(10).border({ width: 0.5, color: MobileTheme.line }).margin({ top: 18 })
if (this.state.notice) { Text(this.state.notice).fontSize(13).fontColor(MobileTheme.red).width('100%').margin({ top: 8 }) }
Row({ space: 12 }) {
Column({ space: 3 }) {
Text('保存密码').fontSize(15).fontWeight(600).fontColor(MobileTheme.ink).width('100%')
Text('密码将加密保存在此设备上').fontSize(11).fontColor(MobileTheme.secondary).width('100%')
}.layoutWeight(1)
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) })
}.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)
.shadow({ radius: 28, color: '#18000000', offsetY: 8 })
}
@Builder
PolicyText(body: string) {
Column({ space: 14 }) {
ForEach(this.policyParagraphs(body), (paragraph: string) => {
Text(paragraph.replace(/^#+\s*/, '').replace(/\*\*/g, '')).fontSize(paragraph.startsWith('更新日期:') ? 12 : 16)
.fontWeight(paragraph.startsWith('#') ? 600 : 400).lineHeight(24)
.fontColor(paragraph.startsWith('更新日期:') ? MobileTheme.secondary : MobileTheme.ink).width('100%').copyOption(CopyOptions.LocalDevice)
})
}.width('100%')
}
@Builder
ConsentSheet() {
Column() {
Text('隐私政策').fontSize(17).fontWeight(600).fontColor(MobileTheme.ink).height(56).width('100%').textAlign(TextAlign.Center)
this.Rule()
Scroll() {
Column({ space: 12 }) {
if (!this.state.privacyAvailable) { Text('隐私政策无法读取,请重新安装应用').fontSize(14).fontColor(MobileTheme.red) }
this.PolicyText(this.privacyText)
}.padding(20).width('100%')
}.layoutWeight(1).width('100%').align(Alignment.Top).scrollBar(BarState.Off)
this.Rule()
Column({ space: 12 }) {
Row({ space: 8 }) {
Checkbox({ name: 'consent' }).width(20).height(20).margin(0).select(this.accepted).selectedColor(MobileTheme.blue)
.onChange((value: boolean) => this.accepted = value).accessibilityText('我已阅读并同意隐私政策')
Text('我已阅读并同意《隐私政策》').fontSize(14).fontColor(MobileTheme.ink).layoutWeight(1).onClick(() => this.accepted = !this.accepted)
}.width('100%').constraintSize({ minHeight: 44 })
MobilePrimaryButton({ label: '同意并继续', available: this.accepted && this.state.privacyAvailable, action: () => this.controller?.consent() })
Button('暂不同意', { type: ButtonType.Normal }).fontSize(16).fontColor(MobileTheme.secondary).height(44).width('100%')
.backgroundColor(Color.Transparent).onClick(() => this.dismissModal())
}.padding({ left: 20, right: 20, top: 12, bottom: 12 }).width('100%')
}.width(this.viewWidth < 600 ? '100%' : Math.min(640, this.viewWidth - 48)).height(Math.min(760, this.viewHeight * 0.9))
.backgroundColor(Color.White).borderRadius(20).clip(true)
}
@Builder
DialogBody() {
if (this.state.modal === 'connecting') {
Column({ space: 14 }) {
LoadingProgress().width(34).height(34).color(MobileTheme.blue)
Text('正在连接').fontSize(20).fontWeight(600).fontColor(MobileTheme.ink)
Text(this.state.signal === 1 ? '正在建立安全连接…' : this.state.signalText).fontSize(14).fontColor(MobileTheme.secondary).textAlign(TextAlign.Center).constraintSize({ minHeight: 36 })
}.padding({ left: 26, right: 26, top: 24, bottom: 20 }).width('100%')
this.Rule()
this.Action('取消连接', () => this.controller?.end(''), true)
} else if (this.state.modal === 'clipboard') {
Column({ space: 16 }) {
this.SheetHeader('剪贴板')
Text('发送文本后,点击“粘贴到远端”使用。').fontSize(13).fontColor(MobileTheme.secondary).width('100%')
TextArea({ text: this.state.clipboardText, placeholder: '要发送到远端的文本' }).fontSize(15).height(120).borderRadius(12).barState(BarState.Off)
.backgroundColor(MobileTheme.background).onChange((value: string) => this.state.clipboardText = value)
MobilePrimaryButton({ label: '发送文本', action: () => this.controller?.clipboard() })
Row({ space: 8 }) {
Column() { this.Action('粘贴到远端', () => this.controller?.shortcut(86)) }.layoutWeight(1)
Column() { this.Action('复制远端文本', () => this.controller?.copyRemote()) }.layoutWeight(1)
}.width('100%')
}.padding(20).width('100%')
} else if (this.state.modal === 'displays') {
Column({ space: 12 }) {
this.SheetHeader('显示器')
if (!this.state.displays.length) { this.Footer('等待远端屏幕信息') }
ForEach(this.state.displays, (screen: RemoteDisplay, index: number) => {
Button({ type: ButtonType.Normal }) {
Row({ space: 12 }) {
MobileIcon({ name: 'display', iconExtent: 22, color: index === this.state.selectedDisplay ? MobileTheme.blue : MobileTheme.secondary })
Text(`${index + 1} · ${screen.name || '显示器'}`).fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1).textAlign(TextAlign.Start)
if (index === this.state.selectedDisplay) { MobileIcon({ name: 'check', iconExtent: 20, color: MobileTheme.blue }) }
}.padding(14).width('100%')
}.width('100%').padding(0).backgroundColor(index === this.state.selectedDisplay ? '#EDF4FC' : MobileTheme.background).borderRadius(12)
.onClick(() => this.controller?.selectDisplay(index))
})
}.padding(20).width('100%')
} else if (this.state.modal === 'recentActions') {
Text(this.state.recent.find((d: RecentDevice) => d.id === this.deleteId)?.name || this.formatId(this.deleteId))
.fontSize(17).fontWeight(600).fontColor(MobileTheme.ink).padding(20).width('100%').textAlign(TextAlign.Center)
this.Rule()
this.Action('连接', () => { this.dismissModal(); this.controller?.requestConnection(this.deleteId); })
this.Rule()
this.Action('删除记录', () => this.state.modal = 'delete', true)
this.Rule()
this.Action('取消', () => this.dismissModal())
} else {
Column({ space: 12 }) {
Text(this.state.modal === 'notice' ? '连接提示' : this.state.modal === 'upgrade' ? '升级提示' : this.state.modal === 'disconnect' ? '断开远程连接?' :
this.state.modal === 'revoke' ? '撤回联网授权?' : this.state.modal === 'previews' ? '保存远程画面预览?' : this.state.modal === 'clearPreviews' ? '清除预览图?' : this.state.modal === 'deleteAnnouncement' ? '删除公告?' : '删除记录?')
.fontSize(20).fontWeight(600).fontColor(MobileTheme.ink).textAlign(TextAlign.Center).width('100%')
Text(this.state.modal === 'notice' || this.state.modal === 'upgrade' ? this.state.notice : this.state.modal === 'disconnect' ? '断开后将返回首页。' :
this.state.modal === 'previews' ? '预览可能包含远程设备上的敏感内容。仅保存在本机,从下一次连接起生效,可随时关闭并清除。' :
this.state.modal === 'clearPreviews' ? '将清除此设备保存的所有远程画面预览,保留连接记录和访问密码。' :
this.state.modal === 'revoke' ? '将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。' :
this.state.modal === 'delete' ? '同时删除该设备保存的密码和预览。' : '此公告将从列表中移除。')
.fontSize(14).lineHeight(21).fontColor(MobileTheme.secondary).textAlign(TextAlign.Center).width('100%')
}.padding(24).width('100%')
this.Rule()
if (this.state.modal === 'notice' || this.state.modal === 'upgrade') { this.Action('知道了', () => this.dismissModal()) }
else {
Row() {
Button('取消', { type: ButtonType.Normal }).fontSize(16).fontColor(MobileTheme.blue).height(50).layoutWeight(1)
.backgroundColor(Color.White).onClick(() => this.dismissModal())
Divider().vertical(true).height(50).strokeWidth(0.5).color(MobileTheme.line)
Button(this.state.modal === 'previews' ? '开启' : this.state.modal === 'disconnect' ? '断开连接' : this.state.modal === 'revoke' ? '撤回授权' : this.state.modal === 'clearPreviews' ? '清除' : '删除', { type: ButtonType.Normal })
.fontSize(16).fontWeight(600).fontColor(this.state.modal === 'previews' ? MobileTheme.blue : MobileTheme.red).height(50).layoutWeight(1)
.backgroundColor(Color.White).onClick(() => this.confirmModal())
}.width('100%')
}
}
}
@Builder
Modal() {
Stack({ alignContent: this.viewWidth < 600 && (this.state.modal === 'password' || this.state.modal === 'consent') ? Alignment.Bottom : Alignment.Center }) {
Column() {}.width('100%').height('100%').backgroundColor(this.state.modal === 'connecting' ? '#14000000' : '#38000000')
.onClick(() => { if (this.state.modal !== 'consent' && this.state.modal !== 'connecting') this.dismissModal(); })
if (this.state.modal === 'password') { this.PasswordSheet() }
else if (this.state.modal === 'consent') { this.ConsentSheet() }
else {
Scroll() { Column() { this.DialogBody() }.width('100%') }
.width(Math.min(this.state.modal === 'connecting' ? 292 : 420, this.viewWidth - 40)).constraintSize({ maxHeight: this.viewHeight * 0.9 })
.backgroundColor(Color.White).borderRadius(24).clip(true).scrollBar(BarState.Off).shadow({ radius: 28, color: '#18000000', offsetY: 8 })
}
}.width('100%').height('100%').transition(TransitionEffect.OPACITY.animation({ duration: 160 }))
}
build() {
Stack({ alignContent: Alignment.Center }) {
if (this.state.page === 'session' && this.state.ready) {
MobileRemoteView({ state: this.state, controller: this.controller }).transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
} else if (this.state.page === 'home') { this.Home() }
else {
Column() {
Row({ space: 0 }) {
MobileIconButton({ icon: 'back', label: '返回', iconExtent: 44, iconSize: 22, action: () => this.back() })
Text(this.title()).fontSize(17).fontWeight(600).fontColor(MobileTheme.ink).layoutWeight(1).textAlign(TextAlign.Center)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.state.page === 'announcements') {
MobileIconButton({ icon: 'refresh', label: '刷新公告', iconExtent: 44, iconSize: 22, action: () => this.controller?.inbox.refresh() })
} else { Blank().width(44) }
}.width('100%').constraintSize({ maxWidth: 720 }).height(56).padding({ left: 12, right: 12 })
if (this.state.page === 'announcements') {
Refresh({ refreshing: this.refreshing }) {
Scroll() { Column() { this.Content() }.width('100%').constraintSize({ maxWidth: 720 }).padding({ left: 20, right: 20, top: 16, bottom: 28 }) }
.width('100%').height('100%').align(Alignment.Top).scrollBar(BarState.Off)
}.layoutWeight(1).width('100%').onRefreshing(() => { this.controller?.inbox.refresh(); this.refreshing = false; })
} else {
Scroll() { Column() { this.Content() }.width('100%').constraintSize({ maxWidth: 720 }).padding({ left: 20, right: 20, top: 16, bottom: 28 }) }
.layoutWeight(1).width('100%').align(Alignment.Top).scrollBar(BarState.Off)
}
}.width('100%').height('100%').transition(TransitionEffect.asymmetric(TransitionEffect.move(TransitionEdge.END), TransitionEffect.move(TransitionEdge.END)).animation({ duration: 200 }))
}
if (this.state.modal) { this.Modal() }
}.width('100%').height('100%').backgroundColor(MobileTheme.background)
.onAreaChange((_old: Area, next: Area) => { this.viewWidth = Number(next.width); this.viewHeight = Number(next.height); })
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:app_name",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone",
"tablet"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/mobile/EntryAbility.ets",
"description": "$string:app_name",
"icon": "$media:app_icon",
"label": "$string:app_name",
"startWindowIcon": "$media:app_icon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"ohos.want.action.home"
]
}
],
"backgroundModes": [
"dataTransfer"
]
}
],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
}
]
}
}
@@ -0,0 +1,8 @@
{
"color": [
{
"name": "start_window_background",
"value": "#F3F5F9"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

@@ -0,0 +1 @@
{"src": ["pages/Mobile"]}