mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-12 03:28:20 +08:00
[feat] add macOS and mobile resolution controls with disconnect restore
This commit is contained in:
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("调整失败,请重试。") : ''; }
|
||||
}
|
||||
@@ -330,6 +330,15 @@ class MobileSession {
|
||||
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
|
||||
if(MiniRtcSendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0)
|
||||
Event({{"type","settingsFailed"},{"request",v.at("request")}},true);
|
||||
} else if (op=="displayResolution") {
|
||||
const auto request = v.at("request").get<int64_t>();
|
||||
if (request <= 0 || request > UINT32_MAX) return;
|
||||
const auto bytes = mobile_controller::DisplayResolutionMessage(
|
||||
v.at("displayId").get<int>(), v.at("displayKey").get<std::string>(),
|
||||
v.at("width").get<int>(), v.at("height").get<int>(), uint32_t(request));
|
||||
if (bytes.empty() || MiniRtcSendReliableDataFrame(
|
||||
controller_, bytes.data(), bytes.size(), kControlStream) != 0)
|
||||
Event({{"type", "resolutionFailed"}, {"request", request}}, true);
|
||||
} else if (op=="privacy") {
|
||||
const auto bytes = mobile_controller::PrivacyMessage(
|
||||
v.at("flag").get<int>(), v.at("blockLocalInput").get<bool>());
|
||||
|
||||
Reference in new issue
Block a user