[feat] add macOS and mobile resolution controls with disconnect restore

This commit is contained in:
dijunkun committed 2026-11-11 11:02:41 +08:00
1 parent a3159b198e
commit 34b59de15d
50 files changed
+1086 -36

No files matched your search

@@ -240,4 +240,6 @@ export const translations: Record<string, string[]> = {
"鼠标右键,按住拖动": ["鼠标右键,按住拖动", "Right mouse button, hold down and drag", "Правая кнопка мыши, удерживайте ее и перетащите", "滑鼠右鍵,按住拖動", "マウスの右ボタンを押したままドラッグします", "마우스 오른쪽 버튼을 누르고 드래그하세요.", "Rechte Maustaste, gedrückt halten und ziehen", "Bouton droit de la souris, maintenez enfoncé et faites glisser", "Botón derecho del ratón, mantén pulsado y arrastra", "Botão direito do mouse, mantenha pressionado e arraste"],
"鼠标左键,按住拖动": ["鼠标左键,按住拖动", "Left mouse button, hold down and drag", "Левая кнопка мыши, удерживая ее, перетащите", "滑鼠左鍵,按住拖動", "マウスの左ボタンを押したままドラッグします", "마우스 왼쪽 버튼을 누른 채 드래그", "Linke Maustaste, gedrückt halten und ziehen", "Bouton gauche de la souris, maintenez enfoncé et faites glisser", "Botón izquierdo del ratón, mantén pulsado y arrastra", "Botão esquerdo do mouse, mantenha pressionado e arraste"],
"鼠标控制": ["鼠标控制", "Mouse control", "Управление мышью", "滑鼠控制", "マウスコントロール", "마우스 제어", "Maussteuerung", "Contrôle de la souris", "Control del ratón", "Controle do mouse"],
"被控端分辨率": ["被控端分辨率", "Remote display resolution", "Разрешение удалённого экрана", "被控端解析度", "接続先の画面解像度", "원격 화면 해상도", "Auflösung des entfernten Bildschirms", "Résolution de l’écran distant", "Resolución de la pantalla remota", "Resolução da tela remota"],
"设备不支持": ["设备不支持", "Unsupported", "Не поддерживается", "裝置不支援", "サポートされていません", "지원되지 않음", "Nicht unterstützt", "Non pris en charge", "No compatible", "Não suportado"],
};
@@ -1,4 +1,5 @@
import { tr } from './Localization';
import { RemoteDisplayResolutions, ResolutionDisplay, ResolutionMode, ResolutionRequest, resolutionLabel } from './RemoteDisplayResolutions';
import { RemotePrivacyScreen, PrivacyStatus } from './RemotePrivacyScreen';
import bridge from 'libcrossdesk.so';
import { common, bundleManager } from '@kit.AbilityKit';
@@ -11,6 +12,7 @@ import { AnnouncementInbox, AnnouncementItem, AnnouncementRequest, AnnouncementR
import { VideoSettings, VideoValues, NetworkStatistics, NetworkReport, RecentPresence, PresenceMessage, asciiKey } from './ControllerModels';
interface Command {
displayId?: number; displayKey?: string; width?: number; height?: number;
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;
@@ -24,11 +26,11 @@ interface NativeEvent {
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 HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; display_resolutions?: ResolutionDisplay[]; }
interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; }
interface ServiceStatus { available: boolean; interactive_stage?: string; }
interface VideoCodecStatus { encoder: string; }
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; video_codec?: VideoCodecStatus; }
interface ControlData { display_resolution?: ResolutionRequest; display_resolutions?: ResolutionDisplay[]; type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; video_codec?: VideoCodecStatus; }
interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
interface SignalType { type: string; }
@@ -76,6 +78,11 @@ export class MobileState {
videoSelectedPreference: number = 1;
videoSupported: boolean = false;
videoFeedback: string = '';
resolutionModes: ResolutionMode[] = [];
resolutionKey: string = '';
resolutionCurrent: string = tr("设备不支持");
resolutionEnabled: boolean = false;
resolutionFeedback: string = '';
privacyStatus: string = '';
privacyButtonTitle: string = tr("隐私屏");
privacyFeedback: string = '';
@@ -97,6 +104,8 @@ export class MobileController {
store: MobileStore;
platform: MobilePlatform;
video: VideoSettings = new VideoSettings();
private resolutions: RemoteDisplayResolutions = new RemoteDisplayResolutions();
private resolutionTimeout: number = -1;
private privacyScreen: RemotePrivacyScreen = new RemotePrivacyScreen();
network: NetworkStatistics = new NetworkStatistics();
presence: RecentPresence;
@@ -248,6 +257,7 @@ export class MobileController {
this.network = new NetworkStatistics(); let edge = 1920;
try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {}
this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo();
this.resetResolutions();
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
this.store.put('lastRemote', this.state.remote); this.state.password = '';
this.ensureSignal(); this.joinIfReady();
@@ -259,6 +269,7 @@ export class MobileController {
}
end(reason: string): void {
this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect();
this.resetResolutions();
this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false;
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = '';
@@ -285,7 +296,7 @@ export class MobileController {
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 = '';
this.send({ op: 'display', index: index }); this.state.modal = ''; this.syncResolutions();
}
updateVideo(field: number, value: number): void {
const old = this.video.selection;
@@ -295,6 +306,31 @@ export class MobileController {
if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout);
this.settingsTimeout = setTimeout(() => { this.video.expire(id); this.syncVideo(); }, 5000); this.syncVideo();
}
updateResolution(mode: ResolutionMode, displayId: number, displayKey: string): void {
if (!this.state.ready || !this.state.consent) return;
const display = this.resolutions.display(this.state.selectedDisplay);
if (!display || display.display_id !== displayId || display.display_key !== displayKey) return;
const request = this.resolutions.begin(displayId, mode);
if (!request) return;
this.send({ op: 'displayResolution', displayId: request.display_id, displayKey: request.display_key,
width: request.width, height: request.height, request: request.request_id });
if (this.resolutionTimeout >= 0) clearTimeout(this.resolutionTimeout);
this.resolutionTimeout = setTimeout(() => { this.resolutions.expire(request.request_id); this.syncResolutions(); }, 10000);
this.syncResolutions();
}
private resetResolutions(): void {
if (this.resolutionTimeout >= 0) clearTimeout(this.resolutionTimeout);
this.resolutionTimeout = -1; this.resolutions = new RemoteDisplayResolutions(); this.syncResolutions();
}
private syncResolutions(): void {
const display = this.resolutions.display(this.state.selectedDisplay);
this.state.resolutionModes = display?.modes ?? [];
this.state.resolutionKey = display?.display_key ?? '';
this.state.resolutionCurrent = display ? resolutionLabel(display) : tr("设备不支持");
this.state.resolutionEnabled = this.state.ready && !this.resolutions.pending && this.state.resolutionModes.length > 0;
this.state.resolutionFeedback = this.resolutions.feedback();
if (!this.resolutions.pending && this.resolutionTimeout >= 0) { clearTimeout(this.resolutionTimeout); this.resolutionTimeout = -1; }
}
setPreference(value: number): void { this.store.put('videoPreference', value); this.state.videoPreference = value; }
setAutoLandscape(value: boolean): void { this.store.put('autoLandscape', value); this.state.autoLandscape = value; }
setPreviews(value: boolean): void {
@@ -441,6 +477,7 @@ export class MobileController {
this.state.transfer = (e.progress ?? 0) < 0 ? tr("{0} 传输失败", [String(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 === 'privacyFailed') { this.privacyScreen.failed(Number(e.request)); this.syncPrivacy(); }
else if (e.type === 'resolutionFailed') { this.resolutions.expire(Number(e.request)); this.syncResolutions(); }
else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
else if (e.type === 'preview') {
this.previewPending = false;
@@ -477,6 +514,7 @@ export class MobileController {
}
}).catch(() => this.toast(tr("密码保存失败,本次连接仍可使用")));
this.platform.connected(this.state.autoLandscape);
this.syncResolutions();
if (this.video.supported && this.video.requestId === 0) this.updateVideo(0, this.video.selection.quality);
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
}
@@ -493,9 +531,13 @@ export class MobileController {
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.type === 16 && message.display_resolution && message.display_resolutions) {
this.resolutions.receive(message.display_resolution, message.display_resolutions); this.syncResolutions(); 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.resolutions.host(info.display_resolutions === undefined ? [] : info.display_resolutions); this.syncResolutions();
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);
@@ -1,4 +1,5 @@
import { tr } from './Localization';
import { ResolutionMode, resolutionLabel } from './RemoteDisplayResolutions';
import { MobileController, MobileState } from './MobileController';
import { RemoteViewport, MouseInput, TouchInput, TouchPoint, clamp, NetworkStatistics, Traffic, asciiKey } from './ControllerModels';
import { MobileTheme, MobileSegment, MobileIcon, MobileIconButton } from './MobileWidgets';
@@ -192,6 +193,16 @@ export struct MobileRemoteView {
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
ResolutionMenu(modes: ResolutionMode[], displayId: number, displayKey: string) {
Menu() {
ForEach(modes, (mode: ResolutionMode) => {
MenuItem({ content: resolutionLabel(mode) })
.onClick(() => this.controller?.updateResolution(mode, displayId, displayKey))
}, (mode: ResolutionMode) => resolutionLabel(mode))
}
}
@Builder
NetworkRow(title: string, traffic: Traffic | undefined) {
Row({ space: 4 }) {
@@ -252,6 +263,12 @@ export struct MobileRemoteView {
if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
if (this.state.receivedPath) { Button(tr("保存收到的文件"), { 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(tr("被控端分辨率")).fontSize(12).width('100%')
Button(`${this.state.resolutionCurrent} ▾`, { type: ButtonType.Normal })
.fontSize(13).fontColor(MobileTheme.ink).backgroundColor('#F0F0F2').borderRadius(7)
.width('100%').height(34).enabled(this.state.resolutionEnabled)
.bindMenu(() => { this.ResolutionMenu(this.state.resolutionModes, this.state.selectedDisplay, this.state.resolutionKey) })
if (this.state.resolutionFeedback) { Text(this.state.resolutionFeedback).fontSize(12).fontColor(MobileTheme.secondary).width('100%') }
Text(this.state.videoFeedback).fontSize(12).fontColor(MobileTheme.secondary).width('100%')
Text(tr("画面质量")).fontSize(12).width('100%')
MobileSegment({ labels: [tr("低"), tr("中"), tr("高")], selection: this.state.videoQuality,
@@ -0,0 +1,66 @@
import { tr } from './Localization';
export interface ResolutionMode { width: number; height: number; }
export interface ResolutionDisplay extends ResolutionMode {
display_id: number; display_key: string; width: number; height: number; modes: ResolutionMode[];
}
export interface ResolutionRequest extends ResolutionMode {
display_id: number; display_key: string; width: number; height: number; request_id: number; accepted: boolean;
}
export function resolutionLabel(mode: ResolutionMode): string { return `${mode.width} × ${mode.height}`; }
function sameMode(a: ResolutionMode, b: ResolutionMode): boolean { return a.width === b.width && a.height === b.height; }
function validMode(mode: ResolutionMode): boolean {
return !!mode && Number.isInteger(mode.width) && Number.isInteger(mode.height) &&
mode.width > 0 && mode.width <= 32768 && mode.height > 0 && mode.height <= 32768;
}
export class RemoteDisplayResolutions {
displays: ResolutionDisplay[] = [];
requested?: ResolutionRequest;
pending: boolean = false;
failed: boolean = false;
display(id: number): ResolutionDisplay | undefined { return this.displays.find((d: ResolutionDisplay) => d.display_id === id); }
begin(id: number, mode: ResolutionMode): ResolutionRequest | undefined {
const display = this.display(id);
if (this.pending || !display || !validMode(mode) || sameMode(display, mode) ||
!display.modes.some((m: ResolutionMode) => sameMode(m, mode))) return undefined;
const next = ((this.requested?.request_id ?? 0) + 1) >>> 0;
this.requested = { display_id: id, display_key: display.display_key, width: mode.width,
height: mode.height, request_id: next || 1, accepted: false };
this.pending = true; this.failed = false;
return this.requested;
}
expire(id: number): void { if (this.pending && this.requested?.request_id === id) { this.pending = false; this.failed = true; } }
private valid(displays: ResolutionDisplay[]): boolean {
if (!Array.isArray(displays) || displays.length > 32) return false;
const ids: number[] = [];
for (const d of displays) {
if (!d || !Number.isInteger(d.display_id) || d.display_id < 0 || d.display_id >= 32 || ids.includes(d.display_id) ||
typeof d.display_key !== 'string' || !d.display_key.length || d.display_key.length > 1024 || d.display_key.includes('\0') ||
!validMode(d) || !Array.isArray(d.modes) || d.modes.length > 256 || !d.modes.every((m: ResolutionMode) => validMode(m))) return false;
const bytes = Array.from(d.display_key).reduce((n: number, c: string) => {
const point = c.codePointAt(0) ?? 0;
return n + (point < 128 ? 1 : point < 2048 ? 2 : point < 65536 ? 3 : 4);
}, 0);
if (bytes > 1024) return false;
ids.push(d.display_id);
}
return true;
}
host(displays: ResolutionDisplay[]): void {
if (!this.valid(displays)) return;
this.displays = displays;
const request = this.requested;
if (this.pending && request && !displays.some((d: ResolutionDisplay) =>
d.display_id === request.display_id && d.display_key === request.display_key &&
d.modes.some((m: ResolutionMode) => sameMode(m, request)))) this.expire(request.request_id);
}
receive(response: ResolutionRequest, displays: ResolutionDisplay[]): void {
const request = this.requested;
if (!request || !response || response.request_id !== request.request_id || response.display_id !== request.display_id ||
response.display_key !== request.display_key || !sameMode(response, request) || typeof response.accepted !== 'boolean' || !this.valid(displays)) return;
this.displays = displays; this.pending = false;
this.failed = !response.accepted || !displays.some((d: ResolutionDisplay) =>
d.display_id === response.display_id && d.display_key === response.display_key && sameMode(d, response));
}
feedback(): string { return this.pending ? tr("等待电脑端确认…") : this.failed ? tr("调整失败,请重试。") : ''; }
}