mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-19 10:23:10 +08:00
[feat] add HarmonyOS desktop and mobile clients,issue #117
This commit is contained in:
@@ -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); })
|
||||
}
|
||||
}
|
||||
@@ -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"]}
|
||||
Reference in New Issue
Block a user