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

This commit is contained in:
kunkundi
2026-10-16 00:42:55 +08:00
parent 43a0789c1e
commit 45bf6ec4ee
149 changed files with 11587 additions and 93 deletions
@@ -0,0 +1,66 @@
import { autoStartupManager } from '@kit.AbilityKit';
export interface AutostartEvent {
op: string;
pending?: boolean;
ok?: boolean;
supported?: boolean;
active?: boolean;
}
// Public APIs can only query the user's system setting. Never derive this
// state from an application preference, or treat a failed query as disabled.
export class AutostartHost {
private static running: boolean = false;
private static sequence: number = 0;
private static timer: number = -1;
private static notify: (event: AutostartEvent) => void = () => {};
static start(notify: (event: AutostartEvent) => void): void {
AutostartHost.stop();
AutostartHost.notify = notify;
AutostartHost.running = true;
AutostartHost.refresh();
}
static stop(): void {
AutostartHost.running = false;
++AutostartHost.sequence;
clearTimeout(AutostartHost.timer);
AutostartHost.timer = -1;
}
static refresh(): void {
if (!AutostartHost.running) return;
const sequence = ++AutostartHost.sequence;
clearTimeout(AutostartHost.timer);
AutostartHost.notify({ op: 'autostartState', pending: true });
// A stalled system service must not leave the settings page loading forever.
AutostartHost.timer = setTimeout(() => {
AutostartHost.finish(sequence, { op: 'autostartState', ok: false });
}, 5000);
try {
if (!autoStartupManager.isAutoStartupSupported()) {
AutostartHost.finish(sequence, { op: 'autostartState', ok: true, supported: false });
return;
}
autoStartupManager.getAutoStartupStatusForSelf().then((active: boolean) => {
AutostartHost.finish(sequence, { op: 'autostartState', ok: true, supported: true, active });
}).catch((error: Error) => {
console.warn(`Slint autostart query: ${error.message}`);
AutostartHost.finish(sequence, { op: 'autostartState', ok: false });
});
} catch (error) {
console.warn(`Slint autostart capability: ${error.message}`);
AutostartHost.finish(sequence, { op: 'autostartState', ok: false });
}
}
private static finish(sequence: number, event: AutostartEvent): void {
if (!AutostartHost.running || sequence !== AutostartHost.sequence) return;
++AutostartHost.sequence; // Also reject a result arriving after its timeout.
clearTimeout(AutostartHost.timer);
AutostartHost.timer = -1;
AutostartHost.notify(event);
}
}
+50
View File
@@ -0,0 +1,50 @@
# Compile the desktop application itself. Harmony owns only platform services;
# callbacks, settings, history, DeskHub and session state stay in apps/desktop.
set(DESKTOP "${CROSSDESK_ROOT}/apps/desktop/src")
set(HARMONY "${CROSSDESK_ROOT}/apps/harmony")
file(GLOB RUNTIME CONFIGURE_DEPENDS "${DESKTOP}/gui/runtime/*.cpp")
file(GLOB_RECURSE FEATURES CONFIGURE_DEPENDS "${DESKTOP}/gui/features/*.cpp")
file(GLOB COMMON CONFIGURE_DEPENDS "${DESKTOP}/common/*.cpp" "${DESKTOP}/thumbnail/*.cpp"
"${DESKTOP}/version_checker/*.cpp" "${CROSSDESK_ROOT}/libs/wire/src/*.cpp")
target_sources(slint_desktop PRIVATE ${RUNTIME} ${FEATURES} ${COMMON}
"${DESKTOP}/gui/application/gui_application.cpp"
"${DESKTOP}/gui/rendering/slint_video_presenter.cpp"
"${DESKTOP}/platform/common/gui/service_status_runtime.cpp"
"${DESKTOP}/privacy/privacy_controller.cpp"
"${DESKTOP}/speaker_capturer/speaker_capture_controller.cpp"
"${DESKTOP}/config_center/config_center.cpp" "${DESKTOP}/autostart/autostart.cpp"
"${DESKTOP}/path_manager/path_manager.cpp" "${DESKTOP}/log/rd_log.cpp"
"${DESKTOP}/tools/file_transfer.cpp"
"${CMAKE_CURRENT_LIST_DIR}/tray.cpp" "${CMAKE_CURRENT_LIST_DIR}/services.cpp" "${CMAKE_CURRENT_LIST_DIR}/devices.cpp"
"${CMAKE_CURRENT_LIST_DIR}/keyboard_hook.cpp"
"${HARMONY}/native/input.cpp" "${HARMONY}/native/audio.cpp" "${HARMONY}/native/capture.cpp"
"${HARMONY}/native/capture_surface.cpp")
target_include_directories(slint_desktop PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_LIST_DIR}" "${HARMONY}/native" "${DESKTOP}" "${DESKTOP}/gui"
"${DESKTOP}/common" "${DESKTOP}/config_center" "${DESKTOP}/autostart"
"${DESKTOP}/path_manager" "${DESKTOP}/log" "${DESKTOP}/tools"
"${DESKTOP}/thumbnail" "${DESKTOP}/version_checker" "${DESKTOP}/privacy"
"${DESKTOP}/device_controller" "${DESKTOP}/speaker_capturer" "${DESKTOP}/screen_capturer"
"${DESKTOP}/platform/common/input" "${CROSSDESK_ROOT}/libs/wire/include"
"${CROSSDESK_ROOT}/libs/controller/include"
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/api"
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/common"
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/ws"
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/pc"
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/media/resolution_adapter")
file(GLOB PACKAGE_INCLUDES "${HARMONY}/.native/packages/*/*/*/*/include")
target_include_directories(slint_desktop SYSTEM PRIVATE ${PACKAGE_INCLUDES})
file(GLOB QUEUE_INCLUDE "${HARMONY}/.native/packages/c/concurrentqueue/*/*/include/concurrentqueue/moodycamel")
target_include_directories(slint_desktop SYSTEM PRIVATE ${QUEUE_INCLUDE} "${SLINT_DESKTOP_DEPS}")
target_compile_definitions(slint_desktop PRIVATE FMT_CONSTEVAL= CPPHTTPLIB_OPENSSL_SUPPORT
CROSSDESK_VERSION="${CROSSDESK_VERSION}" CROSSDESK_HARMONY_DESKTOP=1)
# Use exactly the native dependency versions built by scripts/build.py.
set(NATIVE_LIB "${HARMONY}/.native/build/harmony/arm64-v8a/release/libminirtc.a")
if(NOT EXISTS "${NATIVE_LIB}")
message(FATAL_ERROR "Build apps/harmony/scripts/build.py --native-only first")
endif()
file(GLOB PACKAGE_LIBS "${HARMONY}/.native/packages/*/*/*/*/lib/*.a")
target_link_libraries(slint_desktop PRIVATE "-Wl,--start-group" "${NATIVE_LIB}"
${PACKAGE_LIBS} "-Wl,--end-group" native_buffer native_image ohaudio native_avscreen_capture
native_media_core native_media_codecbase native_media_vdec ohinput pasteboard udmf)
target_link_options(slint_desktop PRIVATE -Wl,--no-undefined)
+133
View File
@@ -0,0 +1,133 @@
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { UIContext } from '@kit.ArkUI';
import { DesktopEvent } from './DesktopHost';
// OS authorization only; session policy stays in the shared desktop runtime.
export class DesktopAccess {
private static context: common.UIAbilityContext;
private static enabled: boolean = false;
private static hostEnabled: boolean = false;
private static hostRequested: boolean = false;
private static running: boolean = false;
private static generation: number = 0;
private static requested: Set<Permissions> = new Set();
private static requests: Promise<void> = Promise.resolve();
private static notify: (event: DesktopEvent) => void = () => {};
private static manager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
private static permissions: Permissions[] = [];
private static changed: (info: abilityAccessCtrl.PermissionStateChangeInfo) => void = () => DesktopAccess.refresh();
static start(context: common.UIAbilityContext, enabled: boolean, notify: (event: DesktopEvent) => void,
hostEnabled: boolean = false): void {
if (DesktopAccess.running) DesktopAccess.stop();
DesktopAccess.context = context; DesktopAccess.enabled = enabled;
DesktopAccess.hostEnabled = hostEnabled;
DesktopAccess.hostRequested = false;
DesktopAccess.running = true; DesktopAccess.notify = notify;
++DesktopAccess.generation; DesktopAccess.requested.clear();
DesktopAccess.permissions = [];
if (enabled) DesktopAccess.permissions.push('ohos.permission.HOOK_KEY_EVENT', 'ohos.permission.READ_PASTEBOARD');
if (hostEnabled) DesktopAccess.permissions.push('ohos.permission.CUSTOM_SCREEN_RECORDING', 'ohos.permission.CONTROL_DEVICE');
if (DesktopAccess.permissions.length > 0) {
try { DesktopAccess.manager.on('selfPermissionStateChange', DesktopAccess.permissions, DesktopAccess.changed); }
catch (error) { console.warn(`Slint permission observation: ${error.message}`); }
}
DesktopAccess.refresh();
}
static stop(): void {
if (!DesktopAccess.running) return;
DesktopAccess.notify({ op: 'hostPermissions', controlDevice: false, screenRecording: false });
DesktopAccess.running = false;
++DesktopAccess.generation;
if (DesktopAccess.permissions.length > 0) {
try { DesktopAccess.manager.off('selfPermissionStateChange', DesktopAccess.permissions, DesktopAccess.changed); }
catch (error) { console.warn(`Slint permission cleanup: ${error.message}`); }
}
}
static granted(permission: Permissions): boolean {
try {
return DesktopAccess.manager.checkAccessTokenSync(DesktopAccess.context.applicationInfo.accessTokenId, permission) ===
abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
} catch { return false; }
}
static refresh(): void {
if (!DesktopAccess.running) return;
DesktopAccess.notify({ op: 'keyboardPermission', ok: DesktopAccess.granted('ohos.permission.HOOK_KEY_EVENT') });
DesktopAccess.notify({ op: 'clipboardChanged' });
DesktopAccess.notify({ op: 'hostPermissions',
controlDevice: DesktopAccess.hostEnabled && DesktopAccess.granted('ohos.permission.CONTROL_DEVICE'),
screenRecording: DesktopAccess.hostEnabled && DesktopAccess.granted('ohos.permission.CUSTOM_SCREEN_RECORDING') });
}
static requestKeyboard(): void {
if (DesktopAccess.enabled) DesktopAccess.request('ohos.permission.HOOK_KEY_EVENT', true);
}
static requestClipboard(): void {
if (DesktopAccess.enabled) DesktopAccess.request('ohos.permission.READ_PASTEBOARD', false);
}
static requestHost(ui: UIContext): void {
if (!DesktopAccess.running || !DesktopAccess.hostEnabled || DesktopAccess.hostRequested) return;
DesktopAccess.hostRequested = true;
const generation = DesktopAccess.generation;
DesktopAccess.requests = DesktopAccess.requests.then(async () => {
if (!DesktopAccess.running || generation !== DesktopAccess.generation) return;
const switches = DesktopAccess.missingHostSwitches();
if (switches.length === 0) return;
const prompt = ui.getPromptAction();
const result = await prompt.showDialog({
title: '设置远程访问权限',
message: '请在接下来打开的应用设置页开启:\n\n' + switches.join('\n') +
'\n\n仅用于已通过连接密码验证的远程会话。你可以随时在该页面关闭权限。',
buttons: [{ text: '暂不开启', color: '#666666' }, { text: '前往设置', color: '#007DFF' }]
});
if (result.index !== 1 || !DesktopAccess.running || generation !== DesktopAccess.generation ||
DesktopAccess.missingHostSwitches().length === 0) return;
try {
// The system manual-permission dialog describes a different settings
// route on PC. Open this app's actual switches together instead.
await DesktopAccess.context.startAbility({
bundleName: 'com.huawei.hmos.settings',
abilityName: 'com.huawei.hmos.settings.MainAbility',
uri: 'application_info_entry',
parameters: { pushParams: DesktopAccess.context.applicationInfo.name }
});
} catch (error) {
if (!DesktopAccess.running || generation !== DesktopAccess.generation) return;
console.warn(`Slint host permission settings: ${error.message}`);
await prompt.showDialog({
title: '未能打开应用设置',
message: '请在系统设置的应用列表中找到本应用,再开启:\n\n' +
DesktopAccess.missingHostSwitches().join('\n'),
buttons: [{ text: '知道了', color: '#007DFF' }]
});
}
}).catch((error: Error) => console.warn(`Slint host permission guide: ${error.message}`))
.then(() => { if (generation === DesktopAccess.generation) DesktopAccess.refresh(); });
}
private static missingHostSwitches(): string[] {
const switches: string[] = [];
if (!DesktopAccess.granted('ohos.permission.CUSTOM_SCREEN_RECORDING'))
switches.push('“屏幕共享”:允许远程设备查看本机屏幕。');
if (!DesktopAccess.granted('ohos.permission.CONTROL_DEVICE'))
switches.push('“辅助操作”:允许远程设备使用本机键盘和鼠标。');
return switches;
}
private static request(permission: Permissions, manual: boolean): void {
if (!DesktopAccess.running || DesktopAccess.requested.has(permission) || DesktopAccess.granted(permission)) return;
DesktopAccess.requested.add(permission);
const generation = DesktopAccess.generation;
DesktopAccess.requests = DesktopAccess.requests.then(async () => {
if (!DesktopAccess.running || generation !== DesktopAccess.generation || DesktopAccess.granted(permission)) return;
if (manual) await DesktopAccess.manager.openPermissionOnSetting(DesktopAccess.context, permission);
else await DesktopAccess.manager.requestPermissionsFromUser(DesktopAccess.context, [permission]);
}).catch((error: Error) => console.warn(`Slint permission ${permission}: ${error.message}`))
.then(() => { if (generation === DesktopAccess.generation) DesktopAccess.refresh(); });
}
}
+448
View File
@@ -0,0 +1,448 @@
import native from 'libslint_desktop.so';
import { TextInputHost } from './TextInputHost';
import { TrayHost } from './TrayHost';
import { FileHost } from './FileHost';
import { DisplayHost, DesktopDisplay } from './DisplayHost';
import { DesktopAccess } from './DesktopAccess';
import { AutostartHost, AutostartEvent } from './AutostartHost';
import { desktopPermissions, hostPermissions } from './DesktopCapabilities';
import { pointer } from '@kit.InputKit';
import { common, abilityAccessCtrl } from '@kit.AbilityKit';
import { display, window } from '@kit.ArkUI';
import { pasteboard } from '@kit.BasicServicesKit';
import { certificateManager } from '@kit.DeviceCertificateKit';
export interface DesktopEvent {
op: string;
displays?: DesktopDisplay[];
decor?: number;
noFrame?: boolean;
topmost?: boolean;
cursor?: number;
anchor?: number;
inputType?: number;
commit?: boolean;
id?: number;
request?: number;
ok?: boolean;
controlDevice?: boolean;
screenRecording?: boolean;
text?: string;
message?: string;
url?: string;
path?: string;
title?: string;
show?: string;
settings?: string;
exit?: string;
visible?: boolean;
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
x?: number;
y?: number;
dx?: number;
dy?: number;
fullscreen?: boolean;
maximized?: boolean;
minimized?: boolean;
surface?: string;
density?: number;
active?: boolean;
kind?: number;
button?: number;
code?: number;
down?: boolean;
control?: boolean;
shift?: boolean;
unicode?: number;
capsLock?: boolean;
numLock?: boolean;
imeSession?: number;
stateRevision?: number;
}
// Window/OS service host only. The desktop C++ application owns every product
// action and keeps running when another window gains focus or is minimized.
export class DesktopHost {
private static stage: window.WindowStage;
private static context: common.UIAbilityContext;
private static windows: Map<number, window.Window> = new Map();
private static queues: Map<number, Promise<void>> = new Map();
private static clipboardQueue: Promise<void> = Promise.resolve();
private static clipboardRevision: number = 0;
private static clipboardCacheRevision: number = -1;
private static clipboardCache: string = '';
private static clipboardObserved: boolean = false;
private static clipboardChanged: () => void = () => {
if (!DesktopHost.started || DesktopHost.terminating) return;
++DesktopHost.clipboardRevision;
DesktopHost.clipboardCache = '';
DesktopHost.send({ op: 'clipboardChanged' });
};
private static properties: Map<number, DesktopEvent> = new Map();
private static timer: number = -1;
private static cursorSupported: boolean = true;
private static cursorHidden: boolean = false;
private static trayRemoval: Promise<void> = Promise.resolve();
private static started: boolean = false;
private static terminating: boolean = false;
private static activeWindow: number = 1;
private static mainShown: boolean = false;
private static stateRevisions: Map<number, number> = new Map();
static send(event: DesktopEvent): void { native.send(JSON.stringify(event)); }
static requestExit(): boolean {
if (!DesktopHost.started || DesktopHost.terminating) return false;
DesktopHost.send({ op: 'tray', text: 'exit' });
return true;
}
static restore(): void {
const target = DesktopHost.windows.get(1);
if (target === undefined) return;
DesktopHost.send({ op: 'restore', id: 1 });
}
static foreground(): void {
if (!DesktopHost.started || DesktopHost.terminating) return;
AutostartHost.refresh();
}
static start(stage: window.WindowStage, context: common.UIAbilityContext): void {
DesktopHost.stage = stage; DesktopHost.context = context;
const screen = display.getDefaultDisplaySync();
const storage = new LocalStorage();
storage.setOrCreate('slintWindowId', 1);
DesktopHost.windows.set(1, stage.getMainWindowSync());
stage.loadContent('pages/Index', storage).then(async () => {
await FileHost.prepare(context);
const screens = await DisplayHost.snapshot();
DesktopHost.observe(1, stage.getMainWindowSync());
// Publish actual grants before native auto-connect can start capture.
DesktopAccess.start(context, desktopPermissions, (event: DesktopEvent) => {
if (event.op === 'clipboardChanged') DesktopHost.clipboardChanged();
else DesktopHost.send(event);
}, hostPermissions);
native.start(JSON.stringify({ filesDir: context.filesDir, cacheDir: context.cacheDir,
width: screen.width, height: screen.height, density: screen.densityPixels,
displayId: screen.id, displays: screens, name: 'Harmony PC',
certificates: certificateManager.getCertificateStorePath({ certType: certificateManager.CertType.CA_CERT_SYSTEM }) }));
DesktopHost.started = true;
AutostartHost.start((event: AutostartEvent) => native.send(JSON.stringify(event)));
try {
pasteboard.getSystemPasteboard().on('update', DesktopHost.clipboardChanged);
DesktopHost.clipboardObserved = true;
} catch (error) { console.warn(`Slint clipboard observation: ${error.message}`); }
DisplayHost.observe((screens: DesktopDisplay[]) => {
DesktopHost.send({ op: 'displays', displays: screens });
DesktopHost.windows.forEach((target: window.Window, id: number) => DesktopHost.metrics(id, target));
});
DesktopHost.timer = setInterval(() => DesktopHost.poll(), 8);
}).catch((error: Error) => console.error(`Slint startup: ${error.message}`));
}
private static observe(id: number, target: window.Window): void {
const current = (): boolean => DesktopHost.windows.get(id) === target && !DesktopHost.terminating;
const watch = (subscribe: () => void): void => {
try { subscribe(); }
catch (error) { console.warn(`Slint window observation: ${error.message}`); }
};
watch(() => target.on('windowEvent', (event: window.WindowEventType) => {
if (!current()) return;
if (event === window.WindowEventType.WINDOW_ACTIVE) {
DesktopHost.activeWindow = id;
DesktopAccess.refresh();
DesktopHost.foreground();
}
if (event === window.WindowEventType.WINDOW_ACTIVE || event === window.WindowEventType.WINDOW_INACTIVE)
DesktopHost.send({ op: 'active', id, active: event === window.WindowEventType.WINDOW_ACTIVE });
}));
watch(() => target.on('windowRectChange', () => { if (current()) DesktopHost.metrics(id, target); }));
watch(() => target.on('displayIdChange', () => { if (current()) DesktopHost.metrics(id, target); }));
DesktopHost.metrics(id, target);
watch(() => target.on('windowStatusDidChange', (status: window.WindowStatusType) => {
if (!current()) return;
const revision = (DesktopHost.stateRevisions.get(id) ?? 0) + 1;
DesktopHost.stateRevisions.set(id, revision);
// Minimization does not change the underlying fullscreen/maximized mode.
const state: DesktopEvent = status === window.WindowStatusType.MINIMIZE ?
{ op: 'state', id, stateRevision: revision, minimized: true } :
{ op: 'state', id, stateRevision: revision, fullscreen: status === window.WindowStatusType.FULL_SCREEN,
maximized: status === window.WindowStatusType.MAXIMIZE, minimized: false };
const saved = DesktopHost.properties.get(id) ?? { op: 'properties', id };
saved.fullscreen = state.fullscreen ?? saved.fullscreen;
saved.maximized = state.maximized ?? saved.maximized;
saved.minimized = state.minimized;
saved.stateRevision = revision;
DesktopHost.properties.set(id, saved);
DesktopHost.send(state);
}));
watch(() => target.on('windowWillClose', async () => {
if (!current()) return false;
DesktopHost.send({ op: 'close', id });
return true; // The shared desktop callback decides hide/disconnect/exit.
}));
}
private static forget(id: number, target: window.Window): void {
DesktopHost.windows.delete(id);
TextInputHost.release(id);
// Some SDKs do not expose all listeners. Cleanup must still reach destroy.
try { target.off('windowEvent'); } catch (_) {}
try { target.off('windowRectChange'); } catch (_) {}
try { target.off('displayIdChange'); } catch (_) {}
try { target.off('windowStatusDidChange'); } catch (_) {}
try { target.off('windowWillClose'); } catch (_) {}
}
private static metrics(id: number, target: window.Window): void {
try {
const point = DisplayHost.global(target);
DesktopHost.send({ op: 'metrics', id, x: point.x, y: point.y,
density: DisplayHost.screen(target).densityPixels, decor: DesktopHost.decorHeight(target) });
} catch (error) { console.warn(`Slint window metrics: ${error.message}`); }
}
private static poll(): void {
const events: DesktopEvent[] = JSON.parse(native.poll()) as DesktopEvent[];
events.forEach((event: DesktopEvent) => {
if (event.request !== undefined) {
const service = (): Promise<void> => DesktopHost.service(event).catch((error: Error) =>
DesktopHost.send({ op: 'reply', request: event.request, ok: false, message: error.message }));
// Slint edits and the shared clipboard monitor run on different native
// threads. The OS permits only one copy/paste operation at a time.
if (event.op.startsWith('clipboard')) DesktopHost.clipboardQueue = DesktopHost.clipboardQueue.then(service);
else service();
} else if (event.id !== undefined) {
const id = event.id;
const prior = DesktopHost.queues.get(id) ?? Promise.resolve();
const next = prior.then(() => DesktopHost.windowCommand(event)).catch((error: Error) => console.error(`Slint window ${id}: ${error.message}`));
DesktopHost.queues.set(id, next);
next.then(() => { if (DesktopHost.queues.get(id) === next) DesktopHost.queues.delete(id); });
} else if (event.op === 'exit') {
DesktopHost.terminating = true;
DesktopAccess.stop();
AutostartHost.stop();
if (DesktopHost.clipboardObserved) {
try { pasteboard.getSystemPasteboard().off('update', DesktopHost.clipboardChanged); }
catch (error) { console.warn(`Slint clipboard cleanup: ${error.message}`); }
DesktopHost.clipboardObserved = false;
}
clearInterval(DesktopHost.timer);
DesktopHost.trayRemoval.finally(() => DesktopHost.context.terminateSelf());
} else if (event.op === 'keyboardPermission') {
DesktopAccess.requestKeyboard();
} else if (event.op === 'autostartRefresh') {
AutostartHost.refresh();
} else if (event.op === 'trayRemove') {
DesktopHost.trayRemoval = TrayHost.remove(DesktopHost.context)
.catch((error: Error) => console.warn(`Slint tray removal: ${error.message}`));
} else if (event.op === 'error') {
console.error(`Slint desktop: ${event.message}`);
}
});
}
private static async windowCommand(event: DesktopEvent): Promise<void> {
const id = event.id ?? 0;
if (event.op === 'properties') {
// Ignore mode commands based on a state older than the latest native
// transition. Keep independent title/size/stacking updates applicable.
if ((event.stateRevision ?? 0) < (DesktopHost.stateRevisions.get(id) ?? 0)) {
event.fullscreen = undefined; event.maximized = undefined; event.minimized = undefined;
}
const prior = DesktopHost.properties.get(id);
DesktopHost.properties.set(id, { op: 'properties', id, title: event.title, noFrame: event.noFrame, topmost: event.topmost,
minWidth: event.minWidth, minHeight: event.minHeight, maxWidth: event.maxWidth, maxHeight: event.maxHeight,
fullscreen: event.fullscreen ?? prior?.fullscreen,
maximized: event.maximized ?? prior?.maximized, minimized: event.minimized ?? prior?.minimized,
stateRevision: DesktopHost.stateRevisions.get(id) ?? 0 });
}
if (event.op === 'destroy') {
DesktopHost.properties.delete(id);
DesktopHost.stateRevisions.delete(id);
}
let target = DesktopHost.windows.get(id);
if (event.op === 'visible' && event.visible && target === undefined) {
target = await DesktopHost.stage.getMainWindowSync().createSubWindowWithOptions(`crossdesk-slint-${id}`,
{ title: DesktopHost.properties.get(id)?.title ?? 'CrossDesk', decorEnabled: !DesktopHost.properties.get(id)?.noFrame, isModal: false,
zLevelAboveParentLoosened: !DesktopHost.properties.get(id)?.topmost, maximizeSupported: true });
DesktopHost.windows.set(id, target);
try {
const storage = new LocalStorage();
storage.setOrCreate('slintWindowId', id);
await target.loadContent('pages/Index', storage);
await DesktopHost.resizeContent(target, event.width ?? 1216, event.height ?? 903);
if (event.x !== undefined && event.y !== undefined) await DisplayHost.move(target, event.x, event.y);
const saved = DesktopHost.properties.get(id);
if (saved !== undefined) await DesktopHost.applyProperties(target, saved);
// Publish geometry only after restoring it; the default placement must
// not overwrite the shared desktop's saved position during recreation.
DesktopHost.observe(id, target);
} catch (error) {
DesktopHost.forget(id, target);
try { await target.destroyWindow(); } catch (_) {}
throw new Error(error.message);
}
}
if (target === undefined) return;
if (event.op === 'ime') { TextInputHost.request(event, target, (reply: DesktopEvent) => DesktopHost.send(reply)); return; }
if (event.op === 'cursor') {
if (!DesktopHost.cursorSupported) return;
// Slint 1.17.1 MouseCursor order is pinned by os-services.patch.
const styles: pointer.PointerStyle[] = [pointer.PointerStyle.DEFAULT, pointer.PointerStyle.DEFAULT,
pointer.PointerStyle.HELP, pointer.PointerStyle.HAND_POINTING, pointer.PointerStyle.RUNNING,
pointer.PointerStyle.LOADING, pointer.PointerStyle.CROSS, pointer.PointerStyle.TEXT_CURSOR,
pointer.PointerStyle.CURSOR_COPY, pointer.PointerStyle.CURSOR_COPY, pointer.PointerStyle.MOVE,
pointer.PointerStyle.CURSOR_FORBID, pointer.PointerStyle.CURSOR_FORBID, pointer.PointerStyle.HAND_OPEN,
pointer.PointerStyle.HAND_GRABBING, pointer.PointerStyle.WEST_EAST, pointer.PointerStyle.NORTH_SOUTH,
pointer.PointerStyle.NORTH, pointer.PointerStyle.EAST, pointer.PointerStyle.SOUTH, pointer.PointerStyle.WEST,
pointer.PointerStyle.NORTH_EAST, pointer.PointerStyle.NORTH_WEST, pointer.PointerStyle.SOUTH_EAST,
pointer.PointerStyle.SOUTH_WEST, pointer.PointerStyle.WEST_EAST, pointer.PointerStyle.NORTH_SOUTH,
pointer.PointerStyle.NORTH_EAST_SOUTH_WEST, pointer.PointerStyle.NORTH_WEST_SOUTH_EAST];
try {
await pointer.setPointerStyle(target.getWindowProperties().id, styles[event.kind ?? 0] ?? pointer.PointerStyle.DEFAULT);
if (DesktopHost.cursorHidden !== (event.kind === 1)) {
await pointer.setPointerVisible(event.kind !== 1);
DesktopHost.cursorHidden = event.kind === 1;
}
} catch (error) {
DesktopHost.cursorSupported = false;
console.warn(`Slint cursor service unavailable: ${error.message}`);
}
return;
}
if (event.op === 'visible') {
if (event.visible) {
if (id === 1 && !DesktopHost.mainShown) {
const status = target.getWindowStatus();
if (status !== window.WindowStatusType.MAXIMIZE && status !== window.WindowStatusType.FULL_SCREEN)
await DesktopHost.resizeContent(target, event.width ?? 1216, event.height ?? 903);
DesktopHost.mainShown = true;
}
await DesktopHost.show(id, target);
if (id === 1) DesktopAccess.requestHost(target.getUIContext());
} else if (id === 1) await target.minimize();
else { DesktopHost.forget(id, target); await target.destroyWindow(); }
} else if (event.op === 'resize') {
await DesktopHost.resizeContent(target, event.width ?? 1216, event.height ?? 903);
} else if (event.op === 'move') {
await DisplayHost.move(target, event.x ?? 0, event.y ?? 0);
} else if (event.op === 'properties') await DesktopHost.applyProperties(target, event);
else if (event.op === 'destroy' && id !== 1) {
DesktopHost.forget(id, target); DesktopHost.properties.delete(id);
await target.destroyWindow();
}
}
private static decorHeight(target: window.Window): number {
try { return target.getWindowDecorVisible() ? Math.round(target.getWindowDecorHeight() * DisplayHost.screen(target).densityPixels) : 0; }
catch { return 0; }
}
private static async resizeContent(target: window.Window, width: number, height: number): Promise<void> {
await target.resize(Math.max(1, Math.round(width)), Math.max(1, Math.round(height) + DesktopHost.decorHeight(target)));
}
private static async show(id: number, target: window.Window): Promise<void> {
// restore() restores a minimized main window on 2in1; showWindow() only
// restores child windows. Avoid resizing an already-visible/maximized one.
if (id === 1 && target.getWindowStatus() === window.WindowStatusType.MINIMIZE) await target.restore();
else await target.showWindow();
}
private static async applyProperties(target: window.Window, event: DesktopEvent): Promise<void> {
if (event.noFrame !== undefined && target.getWindowDecorVisible() === event.noFrame)
target.setWindowDecorVisible(!event.noFrame);
// A decor-free subwindow has no native title component. Harmony rejects
// setWindowTitle for it, which must not prevent the Slint status bar from
// receiving its size/stacking properties or becoming visible.
if (event.title !== undefined && target.getWindowDecorVisible()) {
try { await target.setWindowTitle(event.title); }
catch (error) { console.warn(`Slint window title: ${error.message}`); }
}
if (event.id !== 1 && event.topmost !== undefined) {
try { await target.setSubWindowZLevel(event.topmost ? 10000 : 0); }
catch (error) { console.warn(`Slint window stacking: ${error.message}`); }
}
const decor = DesktopHost.decorHeight(target);
const limits: window.WindowLimits = { minWidth: Math.max(1, Math.round(event.minWidth ?? 1)),
minHeight: Math.max(1, Math.round(event.minHeight ?? 1) + decor) };
if (event.maxWidth !== undefined) limits.maxWidth = Math.max(limits.minWidth ?? 1, Math.round(event.maxWidth));
if (event.maxHeight !== undefined) limits.maxHeight = Math.max(limits.minHeight ?? 1, Math.round(event.maxHeight) + decor);
await target.setWindowLimits(limits);
if (event.fullscreen === undefined && event.maximized === undefined && event.minimized === undefined) return;
if ((event.stateRevision ?? 0) < (DesktopHost.stateRevisions.get(event.id ?? 0) ?? 0)) return;
const saved = DesktopHost.properties.get(event.id ?? 0);
const minimized = event.minimized ?? saved?.minimized ?? false;
const fullscreen = event.fullscreen ?? saved?.fullscreen ?? false;
const maximized = event.maximized ?? saved?.maximized ?? false;
let status = target.getWindowStatus();
if (minimized) {
if (status !== window.WindowStatusType.MINIMIZE) await target.minimize();
return;
}
if (status === window.WindowStatusType.MINIMIZE) {
await DesktopHost.show(event.id ?? 0, target);
status = target.getWindowStatus();
}
if (fullscreen) {
if (status !== window.WindowStatusType.FULL_SCREEN) await target.maximize(window.MaximizePresentation.ENTER_IMMERSIVE);
} else if (maximized) {
if (status !== window.WindowStatusType.MAXIMIZE) await target.maximize(window.MaximizePresentation.EXIT_IMMERSIVE);
} else if (status === window.WindowStatusType.MAXIMIZE || status === window.WindowStatusType.FULL_SCREEN) await target.recover();
}
private static async service(event: DesktopEvent): Promise<void> {
const response: DesktopEvent = { op: 'reply', request: event.request, ok: true };
const board = pasteboard.getSystemPasteboard();
// A Ctrl+V gesture grants one read. Background synchronization must never
// consume that grant before the editor does. It requires READ_PASTEBOARD;
// the explicit paste uses the system's normal gesture authorization.
if ((event.op === 'clipboardRead' || event.op === 'clipboardHas') &&
abilityAccessCtrl.createAtManager().checkAccessTokenSync(DesktopHost.context.applicationInfo.accessTokenId,
'ohos.permission.READ_PASTEBOARD') !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
response.ok = false;
DesktopHost.clipboardCache = '';
DesktopHost.clipboardCacheRevision = -1;
DesktopAccess.requestClipboard();
response.message = 'Clipboard synchronization requires READ_PASTEBOARD permission';
} else if (event.op === 'clipboardRead' || event.op === 'clipboardPaste') {
const revision = DesktopHost.clipboardRevision;
const explicitPaste = event.op === 'clipboardPaste';
if (explicitPaste || !DesktopHost.clipboardObserved || DesktopHost.clipboardCacheRevision !== revision) {
const text = board.hasDataType(pasteboard.MIMETYPE_TEXT_PLAIN) ? (await board.getData()).getPrimaryText() : '';
if (!explicitPaste && abilityAccessCtrl.createAtManager().checkAccessTokenSync(
DesktopHost.context.applicationInfo.accessTokenId, 'ohos.permission.READ_PASTEBOARD') !==
abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
DesktopHost.clipboardCache = ''; DesktopHost.clipboardCacheRevision = -1;
DesktopHost.send({ op: 'reply', request: event.request, ok: false, message: 'Clipboard permission revoked' });
return;
}
response.text = text;
// A change during getData must be read again. Explicit paste never
// populates the background cache from a one-use gesture grant.
if (!explicitPaste && revision === DesktopHost.clipboardRevision) {
DesktopHost.clipboardCache = text; DesktopHost.clipboardCacheRevision = revision;
}
} else response.text = DesktopHost.clipboardCache;
} else if (event.op === 'clipboardHas') response.ok = board.hasDataType(pasteboard.MIMETYPE_TEXT_PLAIN);
else if (event.op === 'clipboardWrite') {
await board.setData(pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, event.text ?? ''));
DesktopHost.clipboardChanged();
}
else if (event.op === 'openUrl') await DesktopHost.context.openLink(event.url ?? '');
else if (event.op === 'trayCreate') response.ok = await TrayHost.create(DesktopHost.context, event,
(reply: DesktopEvent) => DesktopHost.send(reply));
else if (event.op === 'selectFile' || event.op === 'selectFolder') {
const owner = DesktopHost.windows.get(DesktopHost.activeWindow) ?? DesktopHost.stage.getMainWindowSync();
response.path = await FileHost.select(DesktopHost.context, event.op === 'selectFolder', owner);
} else response.ok = false;
DesktopHost.send(response);
}
}
+83
View File
@@ -0,0 +1,83 @@
import { display, window } from '@kit.ArkUI';
export interface DesktopDisplay {
id: number;
name: string;
primary: boolean;
x: number;
y: number;
width: number;
height: number;
density: number;
usableX: number;
usableY: number;
usableWidth: number;
usableHeight: number;
}
export class DisplayHost {
private static revision: number = 0;
private static observed: Map<number, display.Display> = new Map();
static screen(target: window.Window): display.Display {
return display.getDisplayByIdSync(target.getWindowProperties().displayId ?? display.getDefaultDisplaySync().id);
}
static global(target: window.Window): display.Position {
const properties = target.getWindowProperties();
return display.convertRelativeToGlobalCoordinate({
displayId: properties.displayId ?? display.getDefaultDisplaySync().id,
position: { x: properties.windowRect.left, y: properties.windowRect.top }
});
}
static async move(target: window.Window, x: number, y: number): Promise<void> {
const point = display.convertGlobalToRelativeCoordinate({ x: Math.round(x), y: Math.round(y) });
await target.moveWindowToAsync(point.position.x, point.position.y, { displayId: point.displayId });
}
static async snapshot(): Promise<DesktopDisplay[]> {
const primary = display.getDefaultDisplaySync().id;
const screens = await display.getAllDisplays();
const result: DesktopDisplay[] = [];
for (const screen of screens) {
const origin = display.convertRelativeToGlobalCoordinate({ displayId: screen.id, position: { x: 0, y: 0 } });
let area: display.Rect = { left: 0, top: 0, width: screen.width, height: screen.height };
try { area = await screen.getAvailableArea(); }
catch (error) { console.warn(`Slint available display area: ${error.message}`); }
result.push({ id: screen.id, name: screen.name, primary: screen.id === primary,
x: origin.x, y: origin.y, width: screen.width, height: screen.height, density: screen.densityPixels,
usableX: origin.x + area.left, usableY: origin.y + area.top,
usableWidth: area.width, usableHeight: area.height });
}
return result;
}
static observe(changed: (screens: DesktopDisplay[]) => void): void {
const refresh = async (): Promise<void> => {
const revision = ++DisplayHost.revision;
try {
const screens = await DisplayHost.snapshot();
if (revision !== DisplayHost.revision) return;
DisplayHost.observed.forEach((screen: display.Display, id: number) => {
if (!screens.some((item: DesktopDisplay) => item.id === id)) {
try { screen.off('availableAreaChange'); } catch (_) {}
DisplayHost.observed.delete(id);
}
});
screens.forEach((item: DesktopDisplay) => {
if (DisplayHost.observed.has(item.id)) return;
const screen = display.getDisplayByIdSync(item.id);
try { screen.on('availableAreaChange', () => { refresh(); }); }
catch (error) { console.warn(`Slint display area events: ${error.message}`); }
DisplayHost.observed.set(item.id, screen);
});
changed(screens);
} catch (error) { console.warn(`Slint display refresh: ${error.message}`); }
};
display.on('add', () => { refresh(); });
display.on('remove', () => { refresh(); });
display.on('change', () => { refresh(); });
refresh();
}
}
@@ -0,0 +1,12 @@
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { DesktopHost } from '../pages/DesktopHost';
export default class EntryAbility extends UIAbility {
onPrepareToTerminate(): boolean { return DesktopHost.requestExit(); }
onNewWant(): void { DesktopHost.restore(); }
onForeground(): void { DesktopHost.foreground(); }
onWindowStageCreate(stage: window.WindowStage): void {
DesktopHost.start(stage, this.context);
}
}
+51
View File
@@ -0,0 +1,51 @@
import { common } from '@kit.AbilityKit';
import { picker, fileIo, fileUri, fileShare } from '@kit.CoreFileKit';
import { preferences } from '@kit.ArkData';
import { window } from '@kit.ArkUI';
// Only URI grants and native file access live here. Transfer/progress/cancel
// remain in the shared desktop FileTransferController and FileReceiver.
export class FileHost {
private static grants: preferences.Preferences;
private static sequence: number = 0;
static async prepare(context: common.UIAbilityContext): Promise<void> {
FileHost.grants = await preferences.getPreferences(context, { name: 'slint-file-grants' });
const folders = await FileHost.grants.get('folders', []) as string[];
for (const uri of folders) {
try { await fileShare.activatePermission([FileHost.policy(uri)]); }
catch (error) { console.warn(`Slint folder permission is no longer available: ${error.code}`); }
}
// These are snapshots from previous processes, never live transfers.
const staging = `${context.cacheDir}/slint-outgoing`;
if (fileIo.accessSync(staging)) await fileIo.rmdir(staging);
await fileIo.mkdir(staging);
}
private static policy(uri: string): fileShare.PolicyInfo {
return { uri, operationMode: fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE };
}
static async select(context: common.UIAbilityContext, folder: boolean, owner: window.Window): Promise<string> {
const selected = await new picker.DocumentViewPicker(context, owner).select({ maxSelectNumber: 1,
selectMode: folder ? picker.DocumentSelectMode.FOLDER : picker.DocumentSelectMode.FILE });
if (selected.length === 0) return '';
const uri = new fileUri.FileUri(selected[0]);
if (folder) {
const policy = FileHost.policy(selected[0]);
await fileShare.persistPermission([policy]);
await fileShare.activatePermission([policy]);
const folders = await FileHost.grants.get('folders', []) as string[];
if (!folders.includes(selected[0])) folders.push(selected[0]);
await FileHost.grants.put('folders', folders);
await FileHost.grants.flush();
return uri.path;
}
if (!uri.name || uri.name === '.' || uri.name === '..' || uri.name.includes('/'))
throw new Error('Invalid selected filename');
const directory = `${context.cacheDir}/slint-outgoing/${Date.now()}-${++FileHost.sequence}`;
await fileIo.mkdir(directory);
const path = `${directory}/${uri.name}`;
const source = await fileIo.open(selected[0], fileIo.OpenMode.READ_ONLY);
try { await fileIo.copyFile(source.fd, path); }
finally { await fileIo.close(source); }
return path;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { DesktopEvent, DesktopHost } from './DesktopHost';
import { KeyboardHost } from './KeyboardHost';
// This host owns only the native surface and event delivery. All visible
// application controls are compiled from apps/desktop's existing Slint files.
@Entry({ useSharedStorage: true })
@Component
struct Index {
private surface: XComponentController = new XComponentController();
private keyboard: KeyboardHost = new KeyboardHost();
private surfaceId: string = '';
private send(event: DesktopEvent): void {
event.surface = this.surfaceId;
DesktopHost.send(event);
}
@LocalStorageProp('slintWindowId') private windowId: number = 1;
aboutToDisappear(): void {
this.keyboard.reset();
if (this.surfaceId.length === 0) return;
this.send({ op: 'detach', id: this.windowId });
this.surfaceId = '';
}
build() {
Stack() {
XComponent({ id: 'slint-desktop', type: XComponentType.SURFACE, controller: this.surface })
.width('100%').height('100%')
.onSizeChange((_old: SizeOptions, size: SizeOptions) => {
this.surface.setXComponentSurfaceSize({ surfaceWidth: Math.round(vp2px(Number(size.width))),
surfaceHeight: Math.round(vp2px(Number(size.height))) });
this.send({ op: 'redraw', id: this.windowId });
})
.onLoad(() => {
this.surfaceId = this.surface.getXComponentSurfaceId();
this.send({ op: 'attach', id: this.windowId, surface: this.surface.getXComponentSurfaceId(),
density: this.getUIContext().vp2px(1) });
})
.onDestroy(() => this.aboutToDisappear())
// A focusable leaf is required: an empty Column is a focus scope and
// receives touch events but cannot retain hardware keyboard focus.
Text('').id('slintInput').width('100%').height('100%').focusable(true).defaultFocus(true).focusOnTouch(true)
.onBlur(() => this.keyboard.reset())
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) this.getUIContext().getFocusController().requestFocus('slintInput');
if (event.source === SourceType.Mouse) return;
if (event.changedTouches.length === 0) return;
const p = event.changedTouches[0];
const kind = event.type === TouchType.Down ? 0 : event.type === TouchType.Up ? 1 :
event.type === TouchType.Cancel ? 3 : 2;
this.send({ op: 'pointer', id: this.windowId, kind, x: p.x, y: p.y, button: 0 });
})
.onMouse((event: MouseEvent) => {
if (event.action === MouseAction.Press) this.getUIContext().getFocusController().requestFocus('slintInput');
const kind = event.action === MouseAction.Press ? 0 : event.action === MouseAction.Release ? 1 : 2;
this.send({ op: 'pointer', id: this.windowId, kind, x: event.x, y: event.y,
button: event.button === MouseButton.Right ? 1 : event.button === MouseButton.Middle ? 2 : 0 });
event.stopPropagation();
})
.onHover((hovered: boolean) => {
if (!hovered) this.send({ op: 'pointerExit', id: this.windowId });
})
.onKeyPreIme((event: KeyEvent) => this.keyboard.preIme(event, this.windowId, (reply: DesktopEvent) => this.send(reply)))
.onKeyEvent((event: KeyEvent) => {
this.send(KeyboardHost.event(event, this.windowId));
event.stopPropagation();
})
.onAxisEvent((event: AxisEvent) => {
this.send({ op: 'scroll', id: this.windowId, x: event.x, y: event.y,
dx: -event.getHorizontalAxisValue(), dy: -event.getVerticalAxisValue() });
})
}.width('100%').height('100%').backgroundColor(Color.White)
}
}
@@ -0,0 +1,40 @@
import type { DesktopEvent } from './DesktopHost';
export class KeyboardHost {
private captured: Set<number> = new Set();
private modifiers: Set<number> = new Set();
reset(): void { this.captured.clear(); this.modifiers.clear(); }
static event(event: KeyEvent, id: number): DesktopEvent {
return { op: 'key', id, code: event.keyCode, down: event.type === KeyType.Down,
unicode: event.unicode, capsLock: event.isCapsLockOn, numLock: event.isNumLockOn };
}
preIme(event: KeyEvent, id: number, send: (event: DesktopEvent) => void): boolean {
const code = event.keyCode;
const modifier = code === 2072 || code === 2073 || code === 2045 || code === 2046 ||
code === 2047 || code === 2048 || code === 2076 || code === 2077;
if (modifier) {
if (event.type === KeyType.Down) this.modifiers.add(code);
else this.modifiers.delete(code);
}
// ArkUI's query accepts only Ctrl/Alt/Shift, not Meta. Track Meta through
// its actual key transitions instead of issuing an invalid SDK query.
const control = (event.getModifierKeyState?.(['Ctrl']) ?? false) ||
this.modifiers.has(2072) || this.modifiers.has(2073);
const shortcutModifier = code === 2072 || code === 2073 || code === 2076 || code === 2077;
const shortcut = shortcutModifier || (!modifier && code !== 2050 &&
(control || event.metaKey === 1 || this.modifiers.has(2076) || this.modifiers.has(2077)));
// IMEs may turn Ctrl+A into insertText("a") instead of an edit command.
// Slint must receive shortcuts as keys before the IME consumes them. Keep
// the matching release even when the modifier is released first. Space
// remains available for the input method's own toggle. Its native event
// still carries the OS modifier state. Forward Ctrl/Meta transitions too:
// some IMEs otherwise consume the down event but pass the key-up along.
if (!shortcut && !this.captured.has(code)) return false;
if (event.type === KeyType.Down) this.captured.add(code);
else this.captured.delete(code);
send(KeyboardHost.event(event, id));
return true;
}
}
+251
View File
@@ -0,0 +1,251 @@
# HarmonyOS Slint 桌面移植
目标是与 Windows、macOS、Linux 使用同一套界面和产品行为。此目录直接编译 `apps/desktop/src/gui/application/gui_application.cpp`、`gui/runtime`、`gui/features` 和原始 `crossdesk_ui.slint`。鸿蒙 PC 只维护这一套入口,旧 ArkUI PC 会话和 Slint 验证程序已移除。
**当前是可运行的移植开发版,尚未达到全部 PC 行为一致的验收标准。** PC 与移动端共用应用信息,包名统一为 `com.chunkdash.crossdesk`,桌面名称统一为 **CrossDesk**。移动端使用独立 ArkUI 工程;旧 PC 验证包 `cn.crossdesk.slintdesktop` 的数据不会自动迁移到统一包名的新应用。
## 一致性约束
- 界面、布局、菜单、密码确认、设置保存/取消、历史记录、分组、桌面墙、会话标签、文件传输状态机、剪贴板同步和网络连接全部来自桌面公共代码。业务改动应在公共代码修复,不能在 ArkTS 再写一套行为。
- ArkTS 只负责系统窗口、XComponent、键鼠事件、输入法、剪贴板、文件选择器、目录授权、系统托盘和打开链接。
- C++ 平台层实现窗口/渲染、音频、屏幕采集及输入注入。私有 `include/SDL3` 只适配公共代码实际使用的 SDL 服务,不是完整的 SDL 移植,不能用于其他目标。
- 屏幕、窗口和输入法坐标使用系统的实际显示器、工作区和各窗口缩放比例。Slint 的无边框属性直接映射到原生窗口,置顶属性目前只保证应用内部子窗口层级。
- 所有 Slint 对象、事件、定时器和渲染在同一 GUI 线程运行。系统服务通过 ArkTS 线程处理;退出会取消等待,切换窗口保持已有会话。
- Harmony 也定义 `__linux__`。共享代码仅在使用 X11/Wayland、Linux 原生窗口等位置排除 `__OHOS__`;POSIX 和 TLS 代码继续复用。
## 本机构建和运行
当前桌面适配使用 API 26 的[独立子窗口](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/subwindow-guide)行为,要求 HarmonyOS API 26;移动端仍兼容 API 20。当前产物为 arm64,已在本机 2in1 模拟器验证。
```bash
# 构建 MiniRTC、Slint runtime、公共桌面程序和 HAP,安装到正在运行的模拟器
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --install
# 后续增量构建;runtime 补丁变更仍会自动重建
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --reuse-runtime --install
# 原生采集、输入 / wire 协议 / Slint 平台适配回归
python3 apps/harmony/tests/run.py --desktop
```
`--device` 可指定 HDC 设备。`--version` 设置与其他桌面构建相同的版本号,默认 `0.0.0`,因此开发包会出现现有的新版提示。未提供鸿蒙发布文件时,更新入口打开官网,不能下载 Linux 安装包。
需要本机 Slint compiler 1.17.1、Rust OHOS 标准库和 DevEco SDK。工具链定位及首次下载要求见 [鸿蒙构建说明](../README.md),签名选项见下文。Slint 和 httplib 源码均固定版本及 SHA-256。生成的工程位于 `.native/slint-poc/desktop-project`,可用 DevEco Studio 打开。HAP 位于其 `entry/build/default/outputs/default/entry-default-unsigned.hap`;真机安装仍需自己的调试签名。
## 本次验证(2026-10-15)
工程精简后重新通过 `tests/run.py --desktop --mobile`、默认 PC HAP、全部可选权限声明的 PC HAP 以及移动端完整构建。包检查确认 PC 仅支持 `2in1`、移动端仅支持 `phone/tablet`,两端均只包含各自所需的三个原生库;PC 不含旧验证库或移动视频渲染器。以下设备交互记录来自此前验证,本次精简未重复真机或模拟器端到端会话。
| 范围 | 证据 / 状态 |
| --- | --- |
| 共享桌面程序 | 完整 C++ 源码交叉编译、无未解析符号;HAP 的 arm64 ELF、SONAME 和动态依赖检查通过 |
| 启动和登录 | 本机 2in1 模拟器启动;系统 CA 校验下连接信令服务器;重启恢复设备身份和近期连接 |
| 真正远程会话 | 与测试 MiniRTC 被控端建立连接;共享密码对话框、会话窗口、工具栏和动态 NV12 测试画面正常;远端实际收到 control / keyboard / mouse 数据流,关闭会话后收到断开事件 |
| 中文输入 | 连接别名文本框全选替换;鸿蒙系统拼音候选 `nihao` 选择“你好”并提交到原 Slint 文本框 |
| 键盘 | 补齐扫描码、扩展位、左右修饰键及 F13–F24;复用公共按键状态对账,按下时先修饰键、释放时先普通键。左右 Ctrl、双 Shift、乱序快照、重复按键、释放失败重试和失焦清理的主机测试通过 |
| 被控输入实测 | Mac MiniRTC 测试主控经网络发送真实输入:拖动鸿蒙窗口、Ctrl+A 替换文本、右 Shift+方向键选择后删除、右 Ctrl、双 Ctrl 先放开左侧仍保持组合键均通过;按住右 Ctrl 时断开,之后本地输入正常,无修饰键滞留 |
| 剪贴板快捷键 | 最终 HAP 中经远程 Ctrl+A/C、End、Ctrl+V、Ctrl+A/X、Ctrl+V,`987` 完整复制为 `987 987`,剪切后再次粘贴恢复;补充后台同步不能抢占用户粘贴授权的回归测试 |
| 窗口 | 桌面墙独立窗口、全屏进入/退出恢复;主窗口关闭后从应用入口重新打开,保持原进程与登录状态 |
| 窗口重建 | 模拟器桌面墙移动后关闭、重开保留位置和尺寸;修复重建 XComponent 后因局部重绘缓冲被清空而出现黑块的问题 |
| 显示器适配 | 接入显示器增删、分辨率及工作区变化;负坐标、非连续屏幕 ID、主屏 ID 为 0、可用工作区和最近屏幕选择的主机测试通过。外接多屏与混合缩放仍需真机验收 |
| 被控端适配测试 | 直接编译生产 `devices.cpp`,以模拟系统接口验证 Display1/Display2 切换、失败回退、拔屏后编号稳定及恢复、采集帧率、私密场景暂停和授权失效时拒绝输入 |
| 原生采集回归 | 直接编译生产 `capture.cpp`,模拟异步授权;验证启动前不发送数据、GPU 路径优先及失败回退、CPU Surface 合并通知后排空队列、同步 fence 等待/超时/释放、私密场景和暂停/恢复、音频范围检查、重复停止及初始化失败清理;麦克风静音不影响视频和输入 |
| 模拟器被控实测 | 改用系统 Surface 输出及 GLES 纹理读取后,Mac 测试主控实际解码出 3120×2080 桌面;检查了壁纸、Slint 窗口、文字、颜色和方向。移动窗口时画面持续更新。同一鸿蒙进程断开、重新授权、重连再次收到正常画面;断开后被控栏消失,桌面墙仍能创建和绘制;授权弹窗选择“返回”收到取消状态且解码帧数为 0,授权等待中断开也能正常清理 |
| 鼠标状态 | 多键同时按下、拖出边界、取消和重复失焦的释放测试通过;切屏释放旧屏按键/鼠标,之后映射到目标屏像素范围 |
| 桌面墙 | 共享空状态、从近期连接导入设备、密码确认、单设备连接和动态视频预览通过;批量多设备会话尚未完整验收 |
| 系统托盘 | 接入 PC 原生托盘;关闭主窗口后保留原进程,左键恢复、菜单打开共享设置及退出通过;退出执行公共连接清理并移除图标 |
| 音频 | 合成 48 kHz 单声道测试音经 MiniRTC 解码后进入 OH_AudioRenderer;连接后自动播放、静音停止、取消静音恢复;尚未验收真机扬声器听感 |
| 文件 | 系统选择器发送 1 MiB 二进制文件,远端逐字节校验一致;接收 UTF-8 文件到默认应用目录和用户选择的公共目录;进程重启后无需重新选择目录即可接收 |
| 对话框及焦点 | 修复空初始目录导致的闪退;文件选择器绑定发起操作的窗口,取消后返回原会话;选择器尚未关闭时从托盘退出,也能取消等待并完成会话清理 |
| macOS 回归 | 公共 Slint UI 冒烟程序完整编译、链接和运行通过;新增间隔输入后 Ctrl+A 替换的回归,修复前失败、修复后通过。未完成整个 macOS 客户端链接回归 |
密码框沿用共享桌面的明文文本控件。输入英文密码时,请切换系统输入法到英文或先提交组合文本;自动化回归也必须控制输入法状态,不能把尚未提交的拼音候选当成已输入的密码。
文件测试使用独立的合成 MiniRTC 测试端及测试文件,不采集本机桌面。默认目录接收和公共目录接收均检查了 UTF-8 内容,发送测试文件大小为 1,048,576 字节,SHA-256 为 `fbbab289f7f94b25736c58be46a994c441fd02552cc6022352e3d86d2fab7c83`。测试记录和临时文件已清理,原有连接记录保留。
被控端联调仅共享鸿蒙模拟器桌面,临时保存主控解码帧以检查实际像素,测试结束清理;没有采集工作站桌面。原始数据回调在本机模拟器只产生音频,CPU 映射的 Surface 视频缓冲也几乎全黑;`StartScreenCaptureWithSurface` 配合 NativeImage 外部纹理、V2 变换矩阵、GLES 读取和 NV12 转换后,主控可显示实际桌面。保留 CPU Surface 和原始回调作为初始化失败时的后备路径。采集帧率设置遵循 [AVScreenCapture 接口约束](https://github.com/openharmony/docs/blob/master/en/application-dev/reference/apis-media-kit/capi-native-avscreen-capture-h.md),在异步启动成功通知之后执行。
采集纹理及 EGL context 由专用线程创建、使用和释放,生产者窗口由 NativeImage 持有,避免重复释放。停止采集只销毁自身资源,不终止与 Slint/ArkUI 共用的 EGL display。界面、会话、编码发送及连接清理继续使用公共桌面逻辑。远程拖动原先因 MOVE 事件携带 `BUTTON_NONE` 被窗口管理器拒绝,改为携带实际按下的按钮后,模拟器窗口实际移动并恢复原位。系统全局指针位置查询仍返回能力错误,与已验证的输入注入分别处理。
鸿蒙输入法可能消费硬件 Ctrl 组合键。`KeyboardHost` 在 `onKeyPreIme` 中把 Ctrl/Meta 及组合键交给原 Slint 按键分发;普通文本与 Ctrl+Space 保留系统输入法路径。输入法自身的全选、剪切、复制、粘贴、移动和选区回调也交给 Slint TextInput。公共 ID 格式化定时器改为通过 `running` 属性启停,避免原生重复定时器持续清除选区,修复同时适用于其他桌面平台。
系统 Ctrl+V 提供的剪贴板读取授权只能使用一次。显式粘贴使用独立的 `clipboardPaste` 请求,后台同步先检查 `READ_PASTEBOARD` 权限,避免公共剪贴板轮询抢先消费粘贴授权。剪贴板服务按顺序执行,读取失败不会伪装成成功的空字符串。已接入 `SystemPasteboard.update` 变更通知,隐藏/失焦窗口也向公共桌面投递 `SDL_EVENT_CLIPBOARD_UPDATE`,同步去重、大小限制及会话过滤继续复用 `ClipboardController`。后台读取使用按变更版本失效的缓存,读取期间再次变更会重读,撤权会清空缓存并拒绝结果。显式粘贴不进入后台缓存。当前普通调试包没有 `READ_PASTEBOARD` 的签名 ACL,因此跨应用后台同步的授权实机验收尚未完成。权限限制参见[系统剪贴板说明](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/use_pasteboard_to_copy_and_paste-V5)。
## 全局输入和后台剪贴板权限
`KeyboardHook` 使用 API 21 的 `OH_Input_AddKeyEventHook`,而不是局部窗口监听或热键订阅。共享 `SessionDeviceManager` 决定捕获生命周期,原生回调仅进入公共按键队列;窗口失焦、撤权和取消时释放捕获状态,未知按键/未捕获的释放事件交回系统。卸载失败后回调只放行,不再访问已销毁的会话。未授权时继续使用原 Slint 窗口事件;收到授权变化后自动重试,不因周期性失败重试打断用户正按住的回退组合键。
本机 API 26 SDK 的 `PermissionDefinitions.json` 将 `HOOK_KEY_EVENT` 定义为 `system_basic / manual_settings`(tablet、2in1),将 `READ_PASTEBOARD` 定义为 `system_basic / user_grant`,二者均需签名 profile 中获批的 ACL。代码声明不能替代签名授权。`DesktopAccess` 串行处理系统授权界面,每次启动只引导一次;用户取消后不循环弹窗,并监听自身权限变化。键盘使用 `openPermissionOnSetting`,剪贴板使用 `requestPermissionsFromUser`。普通开发包不声明这两项权限,也不打开无法使用的授权页。
获得获批 profile 后,导出一项 DevEco `signingConfigs` 配置到仓库外的私有 JSON 文件,`material.certpath`、`material.storeFile`、`material.profile` 使用绝对路径:
```sh
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --reuse-runtime \
--desktop-permissions --signing-config /absolute/private/signing.json --install
```
`--desktop-permissions` 生成权限声明、用途说明和授权流程开关;`--signing-config` 生成签名配置并选择 `entry-default-signed.hap` 安装。生成的配置仅位于忽略的 `.native` 缓存,文件权限为 `0600`。无签名参数时仍输出普通 `entry-default-unsigned.hap`。可单独用 `--desktop-permissions` 做带权限声明的编译检查,不能据此认为已获得权限或可安装到真机。
远程 `lock_workstation` 命令已接到经过采集/输入授权检查的 Meta+L 路径,先释放残留键鼠,发送后配对释放,失败保留待清理状态。Windows `send_sas` 没有等价的鸿蒙公开接口,明确返回不支持;所有 PC 客户端共用的 Slint 快捷键菜单在目标为鸿蒙时不显示 Ctrl+Alt+Del,仍保留 Win+L。普通 Ctrl/Alt/Meta 组合键由原生钩子按原顺序转发,系统最终允许拦截哪些保留键仍需授权后的真机验证。
本轮验证:普通 HAP 与带权限声明的 HAP 构建通过;普通 HAP 安装本机 2in1 模拟器后,全局钩子实际返回 `201`,无授权弹窗循环,仍收到共享窗口回退发送的 `keyboard` 数据流。真实 Ctrl+A/C、End、Ctrl+V 把 `246` 复制为 `246 246`。生产 C++/ArkTS 的模拟 SDK 回归覆盖 Alt+Tab、Meta+L、重复键、小键盘 Enter、取消、失焦、撤权、重复注册、卸载失败、后台通知、缓存失效、读取中变更/撤权,以及权限拒绝后不重复引导。macOS 公共 Slint 冒烟测试新增快捷键菜单的真实点击回归,验证移除 SAS 后首项为锁屏、弹层命中范围随菜单缩小、切回 Windows 菜单仍正常。成功授权的钩子和后台跨应用读取没有在无 ACL 包中冒充验证通过。
## 被控端持久授权(API 26)
已接入一次系统设置授权、后续连接复用权限的路径。它解决的是应用运行中重复连接的录屏和键鼠确认;重启后自启动、系统登录前控制、锁屏及休眠恢复仍需分别验收。已使用包含这两项 ACL 的调试 Profile 完成签名和模拟器安装,尚未完成本机首次设置授权及真机免确认重连验证。
| 权限 | 用途 | SDK 要求 |
| --- | --- | --- |
| `ohos.permission.CUSTOM_SCREEN_RECORDING` | 录屏持久授权,指定显示器采集时无需重复确认 | API 22 起,`system_basic / manual_settings` |
| `ohos.permission.CONTROL_DEVICE` | 直接注入键鼠,无需每次请求临时输入授权 | API 26 起,`system_basic / manual_settings`,PC/2in1 |
两项权限都需要签名 Profile 的 ACL 和用户在系统设置中的授权。`CUSTOM_SCREEN_CAPTURE` 是截图权限,不能替代上述录屏权限。`HOOK_KEY_EVENT` 是控制端捕获快捷键的权限,也不能替代被控端的 `CONTROL_DEVICE`。
使用独立的 `--host-permissions` 选项生成声明、用途说明及设置引导,不要求同时申请全局键盘钩子或剪贴板权限:
```sh
# 生成 DevEco 工程并编译未签名包,供申请权限和检查构建使用。
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --reuse-runtime --host-permissions
```
### 申请与签名
1. 在 DevEco Studio 打开 `apps/harmony/.native/slint-poc/desktop-project`。PC 与移动端的 bundle name 均为 `com.chunkdash.crossdesk`;AGC 应用、签名 Profile 和包名必须一致。构建从共享的 `AppScope/app.json5` 读取包名,不支持单独为 PC 覆盖包名。
2. 在 **File > Project Structure > Project > Signing Configs** 登录自己的华为开发者账号,关联 AGC 注册应用。在支持该流程的 DevEco Studio 中,通过 **Enable ACL Permissions** 为 `entry / EntryAbility` 申请上述两项权限。调用时机选择 `always`,因为最小化到后台后仍需接受经过认证的连接。若权限列表中没有 `CONTROL_DEVICE`,核对 IDE/SDK 的 API 26 支持,并通过 AGC 项目的“ACL权限”页签确认该权限的开放范围;网页端需先取得 ACL,再将其加入新生成的 Profile。
3. 分别填写用途和申请理由,并提交实际功能演示材料。可使用以下说明作为草稿,按真实发布场景修改:
- `CUSTOM_SCREEN_RECORDING`:CrossDesk 为鸿蒙 PC 提供远程桌面被控功能。设备所有者在本机设置中主动授权后,应用仅在远程连接通过密码验证且会话有效时采集指定显示器并传输画面,断开后停止采集。申请持久权限以支持所有者离开设备后的远程访问,避免每次连接都要求现场确认。
- `CONTROL_DEVICE`:在已认证且屏幕采集有效的远程会话中,将远端操作转换为本机键鼠输入。应用停止共享、系统进入私密场景或授权失效时不接受远程输入;用户可在系统设置中撤销授权。
4. 检查生成 Profile 的 ACL 确实包含两项权限。调试阶段官方自动签名流程可能提供临时 Profile,其有效期和权限以实际签发结果为准;发布阶段须完成正式权限及发布 Profile 申请。登录、提交申请及用户设置授权需要由账号/设备所有者完成。
5. 将工程 `build-profile.json5` 的一项 `app.signingConfigs` 对象保存为仓库外的私有 JSON 文件(不要包含外层数组)。保持原有签名字段,`material.certpath`、`material.storeFile`、`material.profile` 均使用绝对路径。构建脚本会重新生成工程配置,因此在再次运行脚本前先导出该对象。
```sh
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --reuse-runtime \
--host-permissions --signing-config /absolute/private/signing.json \
--device YOUR_DEVICE_ID --install
```
Profile 必须对应 `com.chunkdash.crossdesk`,无需额外传入 `--bundle-name`;该选项仅为兼容已有命令保留,且只接受共享包名。目标设备已有同包名应用时将尝试更新,需满足签名和版本要求。脚本只使用显式传入的签名配置,不会自动继承源工程的签名;DevEco 写入源工程的 ACL 声明也按本次构建开关重新生成,避免重复声明或普通构建意外启用权限。
如果也需要控制端快捷键和后台剪贴板同步,可同时加 `--desktop-permissions`,但 Profile 还需包含对应的两项 ACL。未签名包的构建成功不代表有权限或能安装到真机。
申请入口和流程依据:[华为受限权限申请](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/declare-permissions-in-acl)、[DevEco 自动签名及 ACL 配置](https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-signing-auto)。
### 运行行为与验收
首次显示主窗口时,启用 `--host-permissions` 的构建合并提示缺少的“屏幕共享”和“辅助操作”开关;点击“前往设置”后打开本应用的系统设置详情页。两项均已授予时不弹窗,只缺一项时只解释该项。“暂不开启”或关闭引导后本次运行不重复打开,可自行进入系统设置开启或重启应用再次引导。普通构建不声明这两项权限、不打开对应设置。
PC 系统 `openPermissionOnSetting` 弹窗中的“隐私和安全 > 辅助操作”路径与实际应用设置页不一致,且该系统文案没有应用侧修改接口。因此被控权限改用窗口所属 `UIContext` 的一次引导,再按[华为官方应用设置跳转示例](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-access-control-16)打开 `application_info_entry`,`pushParams` 取运行中应用的实际包名,兼容默认验证包和已签名包。跳转失败时显示手动查找本应用及开关的说明,不退回错误路径的系统提示。仅打开设置不视为获得权限,仍读取系统授权状态。
启动前和设置变化后,ArkTS 都读取系统实际权限并同步给 C++。输入允许条件为“采集有效,且持有 `CONTROL_DEVICE` 或已获得临时输入授权”。不能仅查询 `OH_Input_QueryAuthorizedStatus`,因为 API 26 明确说明该接口只反映弹窗授权,不反映 `CONTROL_DEVICE`。参见 [Input Kit 接口说明](https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-input-kit/capi-oh-input-manager-h.md#oh_input_requestinjection)。
持有 `CUSTOM_SCREEN_RECORDING` 时,指定屏幕采集设置不弹出内容选择器,并由系统检查权限是否允许免确认启动;未持有权限时保留原有授权流程。此行为依据本机 SDK 和 [OpenHarmony 录屏服务的 `IsSkipPrivacyWindow`](https://github.com/openharmony/multimedia_player_framework/blob/master/services/services/screen_capture/server/screen_capture_server.cpp),需要目标 HarmonyOS 固件验证。无论哪条路径,都等待 `STARTED` 后才发送画面。断开仍停止录屏、释放键鼠和撤销临时输入授权,不通过持续录屏维持权限。
主机回归覆盖持久权限下临时授权状态仍为 `UNAUTHORIZED` 的输入、断开重连、权限撤销、私密场景、设置串行化、取消后不重复引导、退出后不再打开待处理设置,以及录屏策略失败回退。真机验收需使用带相应 ACL 的签名包,依次验证首次授权、两次重连、退出重开、分别撤销两项权限及恢复授权;锁屏/唤醒和重启后场景单独记录,不能用模拟 SDK 测试代替。
2026-10-15 验证:`python3 apps/harmony/tests/run.py --desktop` 通过;普通构建与 `--host-permissions` 构建均通过 ArkTS、原生链接和 HAP 检查。构建脚本检查打包后的权限声明与所选开关一致。
2026-10-15 签名验证:使用本机 DevEco 生成的 `com.chunkdash.crossdesk` 调试 Profile,确认其 ACL 包含上述两项权限、已登记当前模拟器。签名 HAP 通过官方 `hap-sign-tool verify-app`(含代码和权限签名校验),在 API 26 的 2in1 模拟器安装并启动成功。系统读取到两项权限,但首次设置授权尚未开启,未据此宣称免确认重连已通过。交付包保存在 `.native/artifacts/acl-host/crossdesk-acl-signed.hap`。同时验证普通构建不会继承源工程签名或可选 ACL 声明。
2026-10-15 引导修正:回归覆盖两个开关合并、仅缺一项、已授权免提示、取消不跳转、不重复引导、实际包名路由、退出后的过期回调、跳转失败及设置打开不等于授权。完整 `--desktop` 主机回归和带 ACL 的签名构建通过;实际开关授权和远程重连仍需设备端验收。
## 无 ACL 的窗口与输入行为(2026-10-15)
继续使用同一套公共 Slint 界面、桌面墙、会话及编辑逻辑;本轮修复集中在鸿蒙窗口和输入事件适配层,不增加平台专用的产品交互。
- 原生标题栏的最大化/还原状态作为一次完整确认传回 Slint。Slint 的状态设置器会同步回调平台;`WindowMode` 阻止确认过程中产生的中间状态再次控制系统窗口,修复点击还原后立即重新最大化的循环。属性事件只携带实际变化的模式,状态版本过滤延迟命令,包括等待标题/尺寸服务期间发生的原生状态变化。
- 最小化保留原有全屏/最大化模式。主窗口恢复使用 `restore()`,子窗口使用 `showWindow()`;重复显示不重设主窗口的还原尺寸。由全屏返回普通最大化明确使用 `EXIT_IMMERSIVE`。窗口 API 行为参见[官方窗口参考](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-window)。
- 子窗口隐藏/重建时注销监听,按原生窗口实例过滤旧的关闭、失焦、尺寸和状态回调。XComponent 事件携带表面标识,旧表面的延迟销毁和输入不能作用于新表面。创建失败清理半成品,窗口命令队列完成后释放;标题/层级服务失败不再阻断尺寸和模式处理。
- 输入法增加独立会话编号。相同窗口内切换输入框时,旧回调、旧更新和正在等待的 attach 不能写入新编辑器;原生分发再次检查会话编号。窗口销毁会立即使输入法回调失效,异步完成后释放连接。
- 硬件事件传递系统 CapsLock、NumLock 快照和 SDK 提供的可打印 ASCII Unicode 值;小键盘在 NumLock 关闭时映射导航键,右 Alt 单独映射为 Slint AltGr。重复键和释放继续使用首次按下的符号。此项没有声称覆盖 SDK 未提供的非 ASCII 布局或全部输入法行为。
验证:新增生产 ArkTS 窗口适配回归,覆盖恢复/全屏、过期命令、重建、晚到回调、可选服务失败和创建失败清理;C++ 回归覆盖 Slint 同步状态回写,输入法回归覆盖同窗口换字段和 attach 期间销毁;扩展锁键、布局符号、右 Alt 和小键盘测试。`python3 apps/harmony/tests/run.py --desktop` 及普通无 ACL HAP 构建通过。2in1 模拟器实测主窗口最大化后还原到原尺寸、最小化后重新激活、桌面墙进入/退出全屏及关闭后重建;重建后的表面继续绘制,没有再出现状态往返。最终安装包验证普通输入及 Ctrl+A/C、End、Ctrl+V,将 `246` 复制为 `246 246`,随后清空测试输入并恢复原窗口位置。
## 网络状态面板展开回归(2026-10-15)
已复现点击网络状态图标后,鼠标留在按钮上时面板不展开、移开后才部分展开的问题。公共 `GuiApplication` 每 16 ms 对统计模型调用 `set_vector`,即使行数不变也会重置行数依赖,反复重启控制栏的高度动画。改为只在行数变化时重置模型,其余时间仅更新变化的数据行,保留各 PC 共用的 Slint 布局和 60 ms 动画。
新增 `slint_stream_render_test` 使用真实 Slint 定时器与保留缓冲区的软件渲染,持续刷新统计值,在左右停靠布局下连续点击展开/收起且不移动鼠标,同时检查完整面板像素和输入范围。修复前可复现失败,修复后与 `slint_ui_smoke_test` 均通过。普通无 ACL HAP 已构建、安装,在本机 2in1 模拟器连接合成视频源后确认点击展开和收起均不再等待鼠标移开。
```bash
xmake build slint_stream_render_test
xmake run slint_stream_render_test
```
## 其他按钮与弹窗回归(2026-10-15)
继续排查公共桌面的模型更新和弹窗输入路径,修复以下问题,所有 PC 仍复用同一套 Slint 组件和回调:
- 显示器和文件传输模型也改为行数变化时才整体重置,其余时间只更新变化的行,避免每帧刷新重建菜单及列表。
- 文件传输浮层的标题、边缘和空白区域原先会把点击、滚轮传到远端。浮层现在完整占用本地输入区域,拖出边界以及关闭瞬间都不会向远端漏发鼠标释放事件。
- 编码下拉框打开后,连接状态切换为会话中会禁用设置,但原菜单仍可修改编码。公共 `CompactComboBox` 现在在禁用时关闭菜单、取消展开定时器,选项回调也检查当前有效性。
新增 `slint_popup_render_test`,直接运行共享 Slint 窗口,使用保留像素缓冲区、真实鼠标/键盘事件和 16 ms 模型更新定时器。覆盖显示器、快捷键、视频设置、文件传输、分组筛选、设置下拉框和标签、消息、关于、桌面墙网络状态及添加弹窗、被控端控制者选择及断开确认。检查鼠标留在触发位置时的绘制、按住期间的数据刷新、Esc/取消关闭,以及本地输入不泄漏到远端。文件浮层与禁用下拉框的用例均在修复前失败、修复后通过。
验证:`slint_popup_render_test`、`slint_stream_render_test`、`slint_ui_smoke_test` 在本机 macOS 通过;普通无 ACL HAP 构建、安装成功。2in1 模拟器实测主菜单、设置下拉框、设置标签、桌面墙网络状态、添加菜单和添加弹窗,点击后鼠标保持原位也可立即显示,取消/关闭后正常恢复。模拟器本轮未连接真实设备;会话内持续刷新及被控面板状态转换由共享 Slint 回归覆盖,不代表所有平台的全部端到端验收已完成。
```bash
xmake build slint_popup_render_test
xmake run slint_popup_render_test
```
## 被控端鼠标同步(2026-10-15)
鸿蒙被控端原先忽略公共 `Start(show_cursor)` 参数,始终请求将鼠标录入视频,并仅发送 `embedded` 状态。现在原生 PC 会话关闭录屏中的鼠标,通过 `OH_Input_GetCursorInfo` 同步箭头、文本、链接、拖拽、忙碌和窗口缩放等语义形状及可见状态,复用 macOS/Windows 控制端已有的 `cursor_state` 协议和显示逻辑。需要视频内鼠标的会话继续使用 `show_cursor=true`。录屏只有确认移除鼠标后才报告独立绘制;移除失败会尝试视频内鼠标,均失败则报告采样不可用。切屏、暂停恢复及私密场景恢复保留该策略。
位置查询优先使用 `OH_Input_GetPointerLocation`;后台查询不可用时,使用录屏期间 `OH_Input_AddMouseEventMonitor` 收到的真实鼠标事件。两种来源均将屏内像素坐标和物理显示器 ID 转换为已协商的显示器编号和归一化位置,兼容负坐标排列及重叠屏幕。监听仅在有效的独立鼠标录屏会话期间注册,暂停、私密场景、切屏和停止时清空位置并移除监听;移除失败的回调保持停用。没有有效查询或监听事件时,不伪造位置,仍保留已获取的形状/可见状态。
后台位置同步使用 **`ohos.permission.INPUT_MONITORING`**,其授权方式是 `system_basic / system_grant`,需要签名 Profile 中对应 ACL。该权限出现在[华为自动签名支持的 ACL 清单](https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-signing-auto)中;仍需满足应用场景及审批要求。获得该 ACL 后,构建时增加 `--cursor-position-permission` 生成并校验声明。它独立于录屏和输入注入授权,普通构建及已有两项被控权限的构建不会自动加入该 ACL。接口依据:[OpenHarmony Input Kit 的鼠标监听接口](https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-input-kit/capi-oh-input-manager-h.md#oh_input_addmouseeventmonitor)。
更正:本机 API 26 SDK 确实定义 `INPUT_DEVICE_CONFIGURATOR`,并注明它可允许后台直接查询位置,但 SDK 定义和证书授权标记不能证明该权限已在 HarmonyOS 开发者 ACL 申请页面开放。此前据此建议直接申请不充分;当前构建不再声明或依赖它。`INPUT_MONITORING` 用于事件监听,不能给 `OH_Input_GetPointerLocation` 直接查询授权。未获得监听权限时,后台位置仍不能保证反馈;鼠标形状和可见状态同步不依赖该权限。
回归测试调用生产采集适配和鼠标采样代码,覆盖形状映射、隐藏/无设备、后台查询拒绝后的监听位置、监听权限拒绝、暂停/切屏清理、迟到回调、移除失败与重试、系统已移除监听、采样失败、屏幕 ID 映射、无效坐标、协议往返、公共显示策略及录屏模式回退。`python3 apps/harmony/tests/run.py --desktop` 通过,带监听权限声明的 API 26 AArch64 桌面 HAP 构建通过,并检查打包后只声明 `INPUT_MONITORING`、未声明 `INPUT_DEVICE_CONFIGURATOR`。尚需获批签名权限的鸿蒙 PC 真机与 macOS/Windows 控制端进行端到端验收,模拟 SDK 测试不替代实际鼠标服务和签名权限验证。
## 开机自启动设置(2026-10-15)
共享设置页的「启动与后台」保留原有自启动开关样式。鸿蒙上开关按系统查询结果显示,点击开关弹出设置引导,不直接翻转状态;引导中显示详细状态,并提供「刷新状态」。打开设置、应用回到前台或原生窗口重新激活时会重新查询;查询失败、设备不支持和未开启在引导中分别显示。查询超过 5 秒可重试,旧查询、超时后的迟到结果及退出后的回调不会覆盖新状态。其他 PC 平台继续使用原有自启动开关行为。
鸿蒙 PC 用户需要在 **系统设置 → 应用和元服务 → 应用启动管理 → 添加应用** 中选择 **CrossDesk**,点击「完成」;关闭时在该列表移除。应用内提供完整操作说明,不使用未经验证的设置深链接,也不会把保存本地配置当作系统已经开启。步骤依据[华为电脑自启动说明](https://consumer.huawei.com/cn/support/content/zh-cn16048401/);本机 API 26 SDK 的公开接口仅提供 `isAutoStartupSupported()` 和 `getAutoStartupStatusForSelf()`,不需要增加自启动 ACL。系统接口 `setApplicationAutoStartup()` 不适用于普通应用。
程序启动后继续由公共 `GuiApplication::InitializeModules()` 和 `GuiRuntime::CreateConnectionPeer()` 恢复设备身份并连接服务器,托盘也复用原有启动流程。自启动设置只决定系统是否启动应用,不等于获得录屏、输入注入权限或登录前控制能力。
验证:`python3 apps/harmony/tests/run.py --desktop` 通过,新增生产 ArkTS 回归覆盖状态变化、查询失败、超时、过期结果、进程退出,以及启动/前台/窗口激活和原生刷新命令。`slint_popup_render_test` 和 `slint_ui_smoke_test` 在 macOS 编译运行通过,覆盖开关开启/关闭时点击弹出引导且不改变状态、刷新按钮、异步状态变化、最小窗口布局、Esc/确认关闭及其他平台开关仍可直接切换。普通无 ACL HAP 构建及包检查通过,已安装到 API 26 的 2in1 模拟器,启动后自动连接服务器,自启动开关显示关闭,点击后弹出引导并显示「未开启」,手动刷新及从系统设置返回均正常。未修改模拟器的系统自启动名单;开启/关闭的状态转换由模拟 SDK 回归覆盖。真机系统自启动、重启/切换用户后自动上线仍需设备验收。
## 未完成的验收项
以下项目不能因为公共逻辑已编译就标记为支持完整:
- 真机被控端:录屏和输入注入的授权、拒绝、撤销、私密场景恢复;系统声音采集;已实现启动时连接的多显示器枚举、切换和输入映射,但模拟器只有一个屏幕。输入注入仅在采集有效且系统授权时执行。
- 真机主控音频听感、长时间连接、多会话及桌面墙批量操作;目前远程画面通过合成测试源验证。
- 文件传输中途取消、大文件与磁盘满、用户撤销目录授权、后台多任务传输;基础发送、接收及重启授权已经通过。
- 系统自启动后的真机重启/登录验收、系统服务和隐私遮罩;自启动状态、设置引导及返回刷新已接入,开启/关闭由用户在系统设置管理。无人值守的持久录屏/输入授权路径仍需带 ACL 的真机验收,详见上文。原生托盘已接入,仅在系统报告支持时启用。
- 多屏被控:MiniRTC 在初始化时协商 DisplayN;已协商屏幕拔出后保留编号、恢复到仍可用的屏幕,重新插入可恢复。运行中新增屏幕需要重新初始化连接端(当前重启应用),不能直接向已有会话发送未协商的流。切屏通过重新启动系统采集实现,系统可能再次要求授权,尚不能视为与其他 PC 无感切换完全一致。
- 全局键盘钩子的授权实机验收、系统保留键在不同 PC 固件上的路由、Meta+L 实际锁屏效果及跨应用后台剪贴板同步。无 ACL 开发包只能验证拒绝/回退路径;模拟 SDK 的成功测试不能替代系统授权后的端到端验收。
- 窗口和输入:外接多屏、混合缩放、窗口位置/尺寸的长周期恢复、所有输入法组合范围、非 US 键盘布局、系统保留快捷键及跨应用置顶浮层。模拟器不具备的鼠标服务会报告能力不足。
- GPU 视频直接导入、性能与耗电。当前视频走公共 Slint CPU 图像后备路径,再用 GLES3 呈现;手机和平板使用独立移动端。
## 代码定位
- `CMakeLists.txt`:公共桌面源码和原生依赖集合,只构建 `slint_desktop` 桥接。
- `font-discovery.patch`:关闭 OHOS 运行库不适用的 Fontconfig 自动发现;字体仍由后端显式加载。
- `../native/audio.*`:PC 与移动端共享的 OHAudio 播放;PC 不编译移动视频渲染器。
- `DisplayHost.ets` / `displays.h`:系统显示器与工作区、全局/屏内坐标转换、窗口所属屏幕及热插拔事件。
- `window-properties.patch`:把原 Slint 窗口的无边框和置顶属性交给鸿蒙窗口层;补丁只影响 OHOS。
- `pointer.h`:逐窗口跟踪鼠标按下状态,失焦/取消时完整释放。
- `desktop.cpp` / `surface.inc`:Slint GUI 线程、窗口适配、EGL/GLES3 呈现。
- `DesktopHost.ets` / `Index.ets`:鸿蒙原生窗口、表面及事件分发。
- `TextInputHost.ets` / `os-services.patch`:Slint 1.17.1 C++ 后端缺少的输入法/鼠标形状桥接,补丁只改变 OHOS 目标。
- `keyboard_hook.cpp` / `DesktopAccess.ets`:系统键盘钩子、权限变化及公共捕获回退。
- `KeyboardHost.ets`:输入法之前的硬件快捷键路由及配对释放,调用原 Slint 按键处理。
- `services.cpp` / `devices.cpp`:文件、剪贴板、音频、采集和输入等平台服务。
- `../native/capture.cpp`:原生采集生命周期、异步授权状态、音视频缓冲处理。
- `../native/capture_surface.cpp`:系统录屏 Surface 的 NativeImage/GLES 读取、方向变换及线程资源管理。
- `../native/input.cpp` / `key_mapping.h`:被控键鼠注入、扩展扫描码映射、按键快照对账及授权/断开后的释放。
- `FileHost.ets`:系统文件选择器、URI 读取快照、目录持久授权及进程启动时激活。未选公共目录时接收文件保存在应用 `files/desktop/Received Files`;可在共享设置中选择公开目录。
- `TrayHost.ets` / `tray.cpp`:PC 系统托盘事件接回公共桌面的显示、设置和退出回调。
- `AutostartHost.ets` / `platform/harmony/harmony_autostart.h`(桌面源码目录):异步查询系统自启动状态,提供共享设置页的只读状态及刷新桥接。
- `platform/harmony/gui/window_state.h`(桌面源码目录):原生窗口激活状态用于公共音频、剪贴板和输入释放逻辑。
- `keyboard.h`:鸿蒙按键到 Slint 按键的状态化映射。
日志:`hdc shell hilog -T CrossDeskDesktop`(共享桌面应用)和 `hdc shell hilog -T SlintDesktop`(表面/采集)。登录凭据不应复制到问题报告;公共桌面版本的本机密码默认可见,录屏/截图前请隐藏。
+129
View File
@@ -0,0 +1,129 @@
import { inputMethod } from '@kit.IMEKit';
import { window } from '@kit.ArkUI';
import { DisplayHost } from './DisplayHost';
import type { DesktopEvent } from './DesktopHost';
// The OS owns composition/candidates; Slint owns the text, selection and edits.
export class TextInputHost {
private static editor: inputMethod.InputMethodController = inputMethod.getController();
private static id: number = 0;
private static preview: string = '';
private static queue: Promise<void> = Promise.resolve();
private static revision: number = 0;
private static requestedId: number = 0;
private static requestedSession: number = 0;
static request(event: DesktopEvent, target: window.Window, send: (event: DesktopEvent) => void): void {
const id = event.id ?? 0;
if (event.kind === 1) {
TextInputHost.requestedId = id;
TextInputHost.requestedSession = event.imeSession ?? 0;
++TextInputHost.revision;
} else if (event.kind === 0) {
if (TextInputHost.requestedId !== id) return;
TextInputHost.requestedId = 0;
++TextInputHost.revision;
} else if (TextInputHost.requestedId !== id || TextInputHost.requestedSession !== (event.imeSession ?? 0)) return;
const revision = TextInputHost.revision;
TextInputHost.queue = TextInputHost.queue.then(() => TextInputHost.apply(event, target, send, revision))
.catch((error: Error) => console.error(`Slint input method: ${error.message}`));
}
static release(id: number): void {
if (TextInputHost.requestedId === id) {
TextInputHost.requestedId = 0;
++TextInputHost.revision;
}
TextInputHost.queue = TextInputHost.queue.then(async () => {
if (TextInputHost.id !== id || TextInputHost.requestedId === id) return;
TextInputHost.id = 0; TextInputHost.preview = '';
await TextInputHost.editor.detach();
}).catch((error: Error) => console.warn(`Slint input cleanup: ${error.message}`));
}
private static async apply(event: DesktopEvent, target: window.Window, send: (event: DesktopEvent) => void,
revision: number): Promise<void> {
if (revision !== TextInputHost.revision) return;
const id = event.id ?? 0;
const editor = TextInputHost.editor;
if (event.kind === 0) {
if (TextInputHost.id === id) { TextInputHost.id = 0; TextInputHost.preview = ''; await editor.detach(); }
return;
}
const rect = target.getWindowProperties().windowRect;
const scale = DisplayHost.screen(target).densityPixels;
const cursor: inputMethod.CursorInfo = { left: rect.left + (event.x ?? 0) * scale,
top: rect.top + ((event.y ?? 0) + (target.getWindowDecorVisible() ? target.getWindowDecorHeight() : 0)) * scale,
width: Math.max(1, (event.width ?? 1) * scale), height: Math.max(1, (event.height ?? 1) * scale) };
const selection: inputMethod.Range = { start: event.anchor ?? 0, end: event.cursor ?? 0 };
const inputType = event.inputType === 1 ? inputMethod.TextInputType.VISIBLE_PASSWORD :
event.inputType === 2 ? inputMethod.TextInputType.NUMBER :
event.inputType === 3 ? inputMethod.TextInputType.NUMBER_DECIMAL : inputMethod.TextInputType.TEXT;
if (TextInputHost.id !== id || event.kind === 1) {
if (TextInputHost.id !== 0) { TextInputHost.id = 0; await editor.detach(); }
if (revision !== TextInputHost.revision) return;
// Bind the input session to the UI context of its owning window.
await editor.attachWithUIContext(target.getUIContext(), { inputAttribute: { textInputType: inputType,
enterKeyType: inputMethod.EnterKeyType.DONE, consumeKeyEvents: true }, cursorInfo: cursor, selection,
windowId: target.getWindowProperties().id }, { showKeyboard: false });
if (revision !== TextInputHost.revision) { await editor.detach(); return; }
TextInputHost.id = id; TextInputHost.preview = '';
const current = (): boolean => TextInputHost.id === id && revision === TextInputHost.revision;
const deliver = (command: DesktopEvent): void => {
if (!current()) return;
command.imeSession = event.imeSession ?? 0;
send(command);
};
const key = (code: number): void => {
deliver({ op: 'key', id, code, down: true }); deliver({ op: 'key', id, code, down: false });
};
// off() is required when the same process attaches a new text field.
editor.off('insertText'); editor.off('deleteLeft'); editor.off('deleteRight');
editor.off('sendFunctionKey'); editor.off('setPreviewText'); editor.off('finishTextPreview');
editor.off('handleExtendAction'); editor.off('moveCursor');
editor.off('selectByRange'); editor.off('selectByMovement');
editor.on('insertText', (text: string) => {
if (!current()) return;
TextInputHost.preview = ''; deliver({ op: 'composition', id, text, commit: true });
});
editor.on('setPreviewText', (text: string, _range: inputMethod.Range) => {
if (!current()) return;
TextInputHost.preview = text; deliver({ op: 'composition', id, text, commit: false });
});
editor.on('finishTextPreview', () => {
if (!current()) return;
if (TextInputHost.preview.length > 0) deliver({ op: 'composition', id, text: TextInputHost.preview, commit: true });
TextInputHost.preview = '';
});
editor.on('deleteLeft', (length: number) => { for (let i = 0; i < Math.min(length, 65536); ++i) key(2055); });
editor.on('deleteRight', (length: number) => { for (let i = 0; i < Math.min(length, 65536); ++i) key(2071); });
editor.on('sendFunctionKey', () => key(2054));
// The input method consumes these hardware shortcuts while attached.
// Deliver editing commands to the focused Slint TextInput, without
// synthesizing modifier presses that could disturb held physical keys.
editor.on('handleExtendAction', (action: inputMethod.ExtendAction) => {
let code = 0;
if (action === inputMethod.ExtendAction.SELECT_ALL) code = 2017;
else if (action === inputMethod.ExtendAction.CUT) code = 2040;
else if (action === inputMethod.ExtendAction.COPY) code = 2019;
else if (action === inputMethod.ExtendAction.PASTE) code = 2038;
if (code !== 0) deliver({ op: 'imeKey', id, code, control: true });
});
const move = (direction: inputMethod.Direction, shift: boolean): void => {
const code = direction === inputMethod.Direction.CURSOR_UP ? 2012 :
direction === inputMethod.Direction.CURSOR_DOWN ? 2013 :
direction === inputMethod.Direction.CURSOR_LEFT ? 2014 :
direction === inputMethod.Direction.CURSOR_RIGHT ? 2015 : 0;
if (code !== 0) deliver({ op: 'imeKey', id, code, shift });
};
editor.on('moveCursor', (direction: inputMethod.Direction) => move(direction, false));
editor.on('selectByMovement', (movement: inputMethod.Movement) => move(movement.direction, true));
editor.on('selectByRange', (range: inputMethod.Range) => {
deliver({ op: 'imeSelection', id, anchor: range.start, cursor: range.end });
});
}
await editor.changeSelection(event.text ?? '', selection.start, selection.end);
if (revision !== TextInputHost.revision) return;
await editor.updateCursor(cursor);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { statusBarManager } from '@kit.DeskTopExtensionKit';
import { common } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { emitter } from '@kit.BasicServicesKit';
import type { DesktopEvent } from './DesktopHost';
export class TrayHost {
private static active: boolean = false;
private static icon: image.PixelMap | undefined;
static async create(context: common.UIAbilityContext, event: DesktopEvent,
send: (event: DesktopEvent) => void): Promise<boolean> {
if (TrayHost.active) return true;
if (!await statusBarManager.isStatusBarCapabilitySupported(context)) return false;
const data = await context.resourceManager.getMediaContent($r('app.media.app_icon').id);
const source = image.createImageSource(data.buffer as ArrayBuffer);
try { TrayHost.icon = await source.createPixelMap({ desiredSize: { width: 48, height: 48 } }); }
finally { source.release(); }
const action = (code: string): void => send({ op: 'tray', text: code });
statusBarManager.on('statusBarIconClick', () => {
action('show');
// A minimized main window cannot foreground its UIAbility with
// showWindow alone. This is an explicit click on our system tray icon.
context.startAbility({ bundleName: context.abilityInfo.bundleName,
abilityName: context.abilityInfo.name, moduleName: context.abilityInfo.moduleName })
.catch((error: Error) => console.error(`Slint tray restore: ${error.message}`));
});
statusBarManager.on('rightMenuClick', (event: emitter.EventData) => {
const code: string | undefined = (event.data as Record<string, string> | undefined)?.['menuCode'];
if (typeof code === 'string') action(code);
});
const item: statusBarManager.StatusBarItem = {
icons: { white: TrayHost.icon, black: TrayHost.icon },
hoverTips: event.title ?? 'CrossDesk',
quickOperation: { abilityName: '', moduleName: 'entry', title: 'CrossDesk', height: 30 },
statusBarGroupMenu: [[
{ title: event.show ?? '', menuCode: 'show', menuAction: { abilityName: 'EntryAbility', moduleName: 'entry', notifyOnly: true } },
{ title: event.settings ?? '', menuCode: 'settings', menuAction: { abilityName: 'EntryAbility', moduleName: 'entry', notifyOnly: true } }
]]
};
try {
await new Promise<void>((resolve, reject) => statusBarManager.addToStatusBar(context, item,
(error) => { if (error) reject(error); else resolve(); }));
TrayHost.active = true;
return true;
} catch (error) { TrayHost.release(); throw new Error(error.message); }
}
static async remove(context: common.UIAbilityContext): Promise<void> {
if (!TrayHost.active) return;
try {
await new Promise<void>((resolve, reject) => statusBarManager.removeFromStatusBar(context,
(error) => { if (error) reject(error); else resolve(); }));
} finally { TrayHost.active = false; TrayHost.release(); }
}
private static release(): void {
statusBarManager.off('statusBarIconClick'); statusBarManager.off('rightMenuClick');
TrayHost.icon?.release(); TrayHost.icon = undefined;
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _BRIDGE_H_
#define _BRIDGE_H_
#include <nlohmann/json.hpp>
#include <string>
namespace crossdesk::harmony::desktop {
using Json = nlohmann::json;
void Emit(Json command);
// Calls a platform service on the ArkTS thread. The Slint thread can wait while
// ArkUI keeps pumping window and picker events; shutdown cancels pending calls.
Json Request(Json command);
Json Environment();
}
#endif
+49
View File
@@ -0,0 +1,49 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CURSOR_SHAPE_H_
#define _CURSOR_SHAPE_H_
#include <multimodalinput/oh_pointer_style.h>
#include <remote_cursor_shape.h>
namespace crossdesk::harmony::desktop {
inline RemoteCursorShape ShapeFromPointerStyle(Input_PointerStyle style) {
using Shape = RemoteCursorShape;
switch (style) {
case EAST: case MIDDLE_BTN_EAST: return Shape::e_resize;
case WEST: case MIDDLE_BTN_WEST: return Shape::w_resize;
case SOUTH: case MIDDLE_BTN_SOUTH: return Shape::s_resize;
case NORTH: case MIDDLE_BTN_NORTH: return Shape::n_resize;
case WEST_EAST: case MIDDLE_BTN_EAST_WEST: return Shape::ew_resize;
case NORTH_SOUTH: case MIDDLE_BTN_NORTH_SOUTH: return Shape::ns_resize;
case NORTH_EAST: case MIDDLE_BTN_NORTH_EAST: return Shape::ne_resize;
case NORTH_WEST: case MIDDLE_BTN_NORTH_WEST: return Shape::nw_resize;
case SOUTH_EAST: case MIDDLE_BTN_SOUTH_EAST: return Shape::se_resize;
case SOUTH_WEST: case MIDDLE_BTN_SOUTH_WEST: return Shape::sw_resize;
case NORTH_EAST_SOUTH_WEST: return Shape::nesw_resize;
case NORTH_WEST_SOUTH_EAST: return Shape::nwse_resize;
case CROSS: case CURSOR_CROSS: case COLOR_SUCKER:
case SCREENSHOT_CHOOSE: case SCREENSHOT_CURSOR: return Shape::crosshair;
case CURSOR_COPY: return Shape::copy;
case CURSOR_FORBID: return Shape::not_allowed;
case HAND_GRABBING: return Shape::grabbing;
case HAND_OPEN: return Shape::grab;
case HAND_POINTING: return Shape::pointer;
case HELP: return Shape::help;
case MOVE: case MIDDLE_BTN_NORTH_SOUTH_WEST_EAST: return Shape::move;
case RESIZE_LEFT_RIGHT: return Shape::col_resize;
case RESIZE_UP_DOWN: return Shape::row_resize;
case TEXT_CURSOR: case HORIZONTAL_TEXT_CURSOR: return Shape::text;
case LOADING: return Shape::wait;
case RUNNING: case RUNNING_LEFT: case RUNNING_RIGHT: return Shape::progress;
// As on the other PC platforms, unsupported/custom artwork uses an arrow.
default: return Shape::default_cursor;
}
}
}
#endif
+396
View File
@@ -0,0 +1,396 @@
#include "bridge.h"
#include "keyboard.h"
#include "keyboard_hook.h"
#include "host_access.h"
#include "pointer.h"
#include "window_mode.h"
#include "platform/harmony/gui/tray/harmony_tray.h"
#include "platform/harmony/gui/window_state.h"
#include "platform/harmony/harmony_autostart.h"
#include "application/gui_application.h"
#include "SDL3/SDL.h"
#include "rd_log.h"
#include "platform/path_backend.h"
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <hilog/log.h>
#include <napi/native_api.h>
#include <native_window/external_window.h>
#include <slint-platform.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <deque>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <thread>
namespace crossdesk::harmony::desktop {
namespace {
std::mutex bridge_mutex;
std::condition_variable wake;
Json environment;
std::deque<Json> incoming, outgoing;
std::map<uint64_t, Json> replies;
std::deque<slint::platform::Platform::Task> tasks;
std::atomic<bool> stopping{false};
std::thread gui_thread;
uint64_t request_id = 0;
bool services_cancelled = false;
}
Json Environment() { std::lock_guard lock(bridge_mutex); return environment; }
void Emit(Json command) { std::lock_guard lock(bridge_mutex); outgoing.push_back(std::move(command)); }
Json Request(Json command) {
std::unique_lock lock(bridge_mutex);
if (stopping || services_cancelled) return Json::object();
const auto id = ++request_id;
command["request"] = id; outgoing.push_back(std::move(command));
// Picker dismissal and shutdown both reply/cancel; never block ArkUI.
wake.wait(lock, [id] { return stopping || services_cancelled || replies.count(id); });
if (stopping || services_cancelled) return Json::object();
auto result = std::move(replies.at(id)); replies.erase(id); return result;
}
namespace {
struct Surface;
class DesktopWindow final : public slint::platform::WindowAdapter {
public:
int id;
slint::platform::SoftwareRenderer software{slint::platform::SoftwareRenderer::RepaintBufferType::ReusedBuffer};
slint::PhysicalSize extent;
slint::PhysicalPosition origin{{0, 0}};
bool dirty = true, visible = false, active = false, explicitly_sized = false;
bool positioned = false;
float density = 1;
int decor = 0;
bool no_frame = false, topmost = false;
KeyboardState keyboard;
PointerState pointer;
const void* ime_adapter = nullptr;
uint64_t ime_session = 0;
std::string surface_id;
WindowMode window_mode;
uint64_t state_revision = 0;
bool scaled = false;
std::unique_ptr<Surface> surface;
explicit DesktopWindow(int value);
~DesktopWindow() override;
slint::PhysicalSize size() override { return extent; }
slint::platform::AbstractRenderer& renderer() override { return software; }
void request_redraw() override { dirty = true; }
void set_visible(bool value) override {
if (!scaled) SetDensity(Environment().value("density", 1.f));
if (!value) ReleaseInput();
visible = value;
Json command{{"op", "visible"}, {"id", id}, {"visible", value}, {"width", extent.width}, {"height", extent.height}};
if (positioned) { command["x"] = origin.x; command["y"] = origin.y; }
Emit(std::move(command));
}
void set_size(slint::PhysicalSize value) override {
explicitly_sized = true; extent = value;
Emit({{"op", "resize"}, {"id", id}, {"width", value.width}, {"height", value.height}});
}
std::optional<slint::PhysicalPosition> position() override { return origin; }
void set_position(slint::PhysicalPosition value) override {
positioned = true; origin = value;
Emit({{"op", "move"}, {"id", id}, {"x", value.x}, {"y", value.y}});
}
void update_window_properties(const WindowProperties& properties) override {
const auto constraints = properties.layout_constraints();
if (!scaled) SetDensity(Environment().value("density", 1.f));
if (!explicitly_sized && !visible)
extent = slint::PhysicalSize({uint32_t(std::max(1.f, constraints.preferred.width * density)),
uint32_t(std::max(1.f, constraints.preferred.height * density))});
Json command{{"op", "properties"}, {"id", id}, {"title", std::string(properties.title())},
{"stateRevision", state_revision}, {"noFrame", no_frame}, {"topmost", topmost}};
const Json requested{{"fullscreen", properties.is_fullscreen()}, {"maximized", properties.is_maximized()},
{"minimized", properties.is_minimized()}};
// Layout/title updates must not reapply an old window mode while the OS
// is asynchronously reporting a titlebar maximize/restore transition.
command.update(window_mode.Request(requested));
if (constraints.min) { command["minWidth"] = constraints.min->width * density; command["minHeight"] = constraints.min->height * density; }
if (constraints.max) { command["maxWidth"] = constraints.max->width * density; command["maxHeight"] = constraints.max->height * density; }
Emit(std::move(command));
}
void SetDensity(float value);
void ReleaseInput();
};
std::map<int, DesktopWindow*> windows;
#include "surface.inc"
DesktopWindow::DesktopWindow(int value) : id(value), extent{{1216, 903}}, surface(std::make_unique<Surface>(*this)) { windows[id] = this; }
DesktopWindow::~DesktopWindow() { windows.erase(id); Emit({{"op", "destroy"}, {"id", id}}); }
void DesktopWindow::SetDensity(float value) {
if (!std::isfinite(value) || value <= 0 || (scaled && density == value)) return;
density = value; scaled = true;
surface->density = value;
surface->rendered_width = surface->rendered_height = 0;
window().dispatch_scale_factor_change_event(value);
dirty = true;
}
slint::PointerEventButton PointerButton(int button) {
return button == 1 ? slint::PointerEventButton::Right : button == 2 ? slint::PointerEventButton::Middle : slint::PointerEventButton::Left;
}
void DesktopWindow::ReleaseInput() {
pointer.Cancel([this](int button, float x, float y) {
window().dispatch_pointer_release_event(slint::LogicalPosition({x, y}), PointerButton(button));
});
window().dispatch_pointer_exit_event();
for (const auto& [code, key] : keyboard.Pressed()) if (!key.empty()) window().dispatch_key_release_event(slint::SharedString(key));
keyboard.Reset();
std::lock_guard lock(bridge_mutex);
environment["buttons"] = 0;
}
void OsRequest(void* data, const void* token, int kind, const uint8_t* text, size_t length,
size_t cursor, size_t anchor, float x, float y, float width, float height, int type) {
auto& adapter = *static_cast<DesktopWindow*>(data);
if (kind == 4) { adapter.no_frame = type & 1; adapter.topmost = type & 2; return; }
if (kind == 3) Emit({{"op", "cursor"}, {"id", adapter.id}, {"kind", type}});
else {
if (kind == 0 || kind == 1) ++adapter.ime_session;
adapter.ime_adapter = kind == 0 ? nullptr : token;
Emit({{"op", "ime"}, {"id", adapter.id}, {"imeSession", adapter.ime_session}, {"kind", kind}, {"text", std::string(reinterpret_cast<const char*>(text), length)},
{"cursor", cursor}, {"anchor", anchor}, {"x", x}, {"y", y}, {"width", width}, {"height", height}, {"inputType", type}});
}
}
void Key(DesktopWindow& adapter, const Json& command) {
const bool down = command.value("down", false);
const int code = command.value("code", 0);
const bool repeat = down && adapter.keyboard.IsPressed(code);
const auto lock = [&command](const char* name) -> std::optional<bool> {
return command.contains(name) ? std::optional<bool>{command.at(name).get<bool>()} : std::nullopt;
};
const auto key = adapter.keyboard.Translate(code, down, command.value("unicode", 0u), lock("capsLock"), lock("numLock"));
if (key.empty()) return;
if (repeat) adapter.window().dispatch_key_press_repeat_event(slint::SharedString(key));
else if (down) adapter.window().dispatch_key_press_event(slint::SharedString(key));
else adapter.window().dispatch_key_release_event(slint::SharedString(key));
}
void Dispatch(const Json& command) {
if (command.value("op", "") == "tray") { HarmonyTray::Dispatch(command.value("text", "")); return; }
const auto found = windows.find(command.value("id", 0));
if (found == windows.end()) return;
auto& adapter = *found->second;
const auto op = command.value("op", "");
// ArkUI can finish a callback after Slint has focused another editor in the
// same window. Window identity alone is not an input-session identity.
if (command.contains("imeSession") && command.value("imeSession", uint64_t{0}) != adapter.ime_session) return;
if (op != "attach" && command.contains("surface") && command.value("surface", "") != adapter.surface_id) return;
if (op == "attach") {
adapter.surface_id = command.at("surface").get<std::string>();
if (!adapter.surface->AttachSurface(std::stoull(command.at("surface").get<std::string>()), command.at("density")))
Emit({{"op", "error"}, {"message", "Slint surface initialization failed"}});
adapter.dirty = true;
} else if (op == "detach") { adapter.surface_id.clear(); adapter.ReleaseInput(); adapter.surface->DetachSurface(); }
else if (op == "redraw") adapter.dirty = true;
else if (op == "restore") { adapter.window().set_minimized(false); adapter.window().show(); adapter.dirty = true; }
else if (op == "close") adapter.window().dispatch_close_requested_event();
else if (op == "active") {
adapter.active = command.value("active", false);
if (!adapter.active) adapter.ReleaseInput();
if (command.value("active", false) && adapter.scaled && !adapter.visible) {
adapter.window().set_minimized(false);
adapter.window().show();
}
adapter.window().dispatch_window_active_changed_event(command.value("active", false));
} else if (op == "state") {
adapter.state_revision = command.value("stateRevision", uint64_t{0});
auto actual = Json::object();
for (const auto* name : {"fullscreen", "maximized", "minimized"})
if (command.contains(name)) actual[name] = command.at(name);
// Each setter synchronously calls update_window_properties. These are OS
// acknowledgements, not three independent requests back to the compositor.
adapter.window_mode.Acknowledge(actual, [&] {
if (command.contains("fullscreen")) adapter.window().set_fullscreen(command.at("fullscreen").get<bool>());
if (command.contains("maximized")) adapter.window().set_maximized(command.at("maximized").get<bool>());
if (command.contains("minimized")) adapter.window().set_minimized(command.at("minimized").get<bool>());
});
} else if (op == "metrics") {
adapter.positioned = true;
adapter.origin = slint::PhysicalPosition({command.value("x", 0), command.value("y", 0)});
adapter.decor = command.value("decor", 0);
adapter.SetDensity(command.value("density", 1.f));
adapter.dirty = true;
} else if (op == "pointerExit") {
adapter.window().dispatch_pointer_exit_event();
} else if (op == "pointer") {
const int kind = command.value("kind", 2), button = command.value("button", 0);
const slint::LogicalPosition point{{command.value("x", 0.f), command.value("y", 0.f)}};
{
std::lock_guard lock(bridge_mutex);
const auto density = adapter.density;
environment["pointerX"] = adapter.origin.x + point.x * density;
environment["pointerY"] = adapter.origin.y + adapter.decor + point.y * density;
adapter.pointer.Update(kind, button, point.x, point.y);
environment["buttons"] = adapter.pointer.Buttons();
}
const auto b = PointerButton(button);
if (kind == 0) adapter.window().dispatch_pointer_press_event(point, b);
else if (kind == 1) adapter.window().dispatch_pointer_release_event(point, b);
else if (kind == 2) adapter.window().dispatch_pointer_move_event(point);
else adapter.ReleaseInput();
} else if (op == "scroll") adapter.window().dispatch_pointer_scroll_event(
slint::LogicalPosition({command.value("x", 0.f), command.value("y", 0.f)}), command.value("dx", 0.f), command.value("dy", 0.f));
else if (op == "key") Key(adapter, command);
else if (op == "imeKey" && adapter.ime_adapter) {
KeyboardState symbols;
const auto text = symbols.Translate(command.value("code", 0), true);
const int modifiers = (command.value("shift", false) ? 1 : 0) | (command.value("control", false) ? 2 : 0);
slint::cbindgen_private::crossdesk_slint_edit(adapter.ime_adapter,
reinterpret_cast<const uint8_t*>(text.data()), text.size(), modifiers);
} else if (op == "imeSelection" && adapter.ime_adapter) {
slint::cbindgen_private::crossdesk_slint_selection(adapter.ime_adapter,
command.value("anchor", 0), command.value("cursor", 0));
}
else if (op == "composition" && adapter.ime_adapter) {
const auto text = command.value("text", "");
slint::cbindgen_private::crossdesk_slint_composition(adapter.ime_adapter, command.value("commit", true), reinterpret_cast<const uint8_t*>(text.data()), text.size());
} else if (op == "text") {
const slint::SharedString text(command.value("text", ""));
adapter.window().dispatch_key_press_event(text); adapter.window().dispatch_key_release_event(text);
}
}
class DesktopPlatform final : public slint::platform::Platform {
int next_id = 0;
public:
std::unique_ptr<slint::platform::WindowAdapter> create_window_adapter() override { return std::make_unique<DesktopWindow>(++next_id); }
void run_in_event_loop(Task task) override {
std::lock_guard lock(bridge_mutex); tasks.push_back(std::move(task)); wake.notify_all();
}
void quit_event_loop() override { stopping = true; wake.notify_all(); }
void set_clipboard_text(const slint::SharedString& text, Clipboard kind) override {
if (kind == Clipboard::DefaultClipboard) SDL_SetClipboardText(text.data());
}
std::optional<slint::SharedString> clipboard_text(Clipboard kind) override {
if (kind != Clipboard::DefaultClipboard) return {};
const auto result = Request({{"op", "clipboardPaste"}});
if (!result.value("ok", false)) return {};
return slint::SharedString(result.value("text", ""));
}
void run_event_loop() override {
while (!stopping) {
std::deque<Json> commands;
std::deque<Task> pending;
{
std::lock_guard lock(bridge_mutex); commands.swap(incoming); pending.swap(tasks);
}
for (auto& task : pending) std::move(task).run();
for (auto& command : commands) {
try { Dispatch(command); }
catch (const std::exception& error) { Emit({{"op", "error"}, {"message", error.what()}}); }
}
slint::platform::update_timers_and_animations();
for (const auto& [id, adapter] : windows) if (adapter->visible) adapter->surface->Render();
std::unique_lock lock(bridge_mutex);
wake.wait_for(lock, std::chrono::milliseconds(8), [] { return stopping || !incoming.empty() || !tasks.empty(); });
}
}
};
std::string String(napi_env env, napi_value value) {
size_t length = 0;
if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok || length > 1024 * 1024) return {};
std::string result(length + 1, '\0');
napi_get_value_string_utf8(env, value, result.data(), result.size(), &length);
result.resize(length); return result;
}
napi_value Undefined(napi_env env) { napi_value out; napi_get_undefined(env, &out); return out; }
napi_value Start(napi_env env, napi_callback_info info) {
if (gui_thread.joinable()) return Undefined(env);
napi_value arg; size_t count = 1; napi_get_cb_info(env, info, &count, &arg, nullptr, nullptr);
try {
environment = Json::parse(String(env, arg));
if (environment.value("filesDir", "").empty()) throw std::runtime_error("Missing application storage directory");
} catch (const std::exception& error) { napi_throw_error(env, nullptr, error.what()); return nullptr; }
stopping = false;
gui_thread = std::thread([] {
try {
setenv("SLINT_DEFAULT_FONT", "/system/fonts/HarmonyOS_Sans.ttf", 1);
setenv("SLINT_FONT_PATH", "/system/fonts/HarmonyOS_Sans_SC.ttf", 1);
const auto certificates = Environment().value("certificates", "");
if (certificates.empty()) throw std::runtime_error("Missing system CA certificate directory");
setenv("SSL_CERT_DIR", certificates.c_str(), 1);
slint::cbindgen_private::crossdesk_slint_set_os_callback(OsRequest);
slint::platform::set_platform(std::make_unique<DesktopPlatform>());
// GuiRuntime constructs worker controllers before Run(). They may log
// immediately, so select an app-writable directory before construction.
crossdesk::InitLogger(crossdesk::platform::GetLogPath("CrossDesk").string());
crossdesk::GuiApplication app;
app.Run();
Emit({{"op", "exit"}});
} catch (const std::exception& error) {
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Application failed: %{public}s", error.what());
Emit({{"op", "error"}, {"message", error.what()}});
}
});
return Undefined(env);
}
napi_value Send(napi_env env, napi_callback_info info) {
napi_value arg; size_t count = 1; napi_get_cb_info(env, info, &count, &arg, nullptr, nullptr);
try {
auto command = Json::parse(String(env, arg));
if (command.value("op", "") == "autostartState") {
SetHarmonyAutostartStatus(command.value("pending", false) ? HarmonyAutostartStatus::checking :
!command.value("ok", false) ? HarmonyAutostartStatus::failed :
!command.value("supported", false) ? HarmonyAutostartStatus::unsupported :
command.value("active", false) ? HarmonyAutostartStatus::enabled : HarmonyAutostartStatus::disabled);
return Undefined(env);
}
if (command.value("op", "") == "active")
SetKeyboardFocus(command.value("id", 0), command.value("active", false));
else if (command.value("op", "") == "keyboardPermission")
SetKeyboardHookPermission(command.value("ok", false));
else if (command.value("op", "") == "hostPermissions") {
SetHostPermissions(command.value("controlDevice", false), command.value("screenRecording", false));
return Undefined(env);
}
else if (command.value("op", "") == "clipboardChanged") {
SDL_Event event{SDL_EVENT_CLIPBOARD_UPDATE}; SDL_PushEvent(&event);
return Undefined(env);
}
std::lock_guard lock(bridge_mutex);
// A synchronous native picker can be pending on the GUI thread when the
// system tray requests exit. Unblock it so the shared exit callback runs.
if (command.value("op", "") == "tray" && command.value("text", "") == "exit")
services_cancelled = true;
if (command.value("op", "") == "displays") environment["displays"] = command.at("displays");
else if (command.value("op", "") == "reply") replies[command.at("request").get<uint64_t>()] = command;
else incoming.push_back(std::move(command));
wake.notify_all();
} catch (...) { napi_throw_error(env, nullptr, "Invalid desktop event"); return nullptr; }
return Undefined(env);
}
napi_value Poll(napi_env env, napi_callback_info) {
Json result = Json::array();
{ std::lock_guard lock(bridge_mutex); for (auto& command : outgoing) result.push_back(std::move(command)); outgoing.clear(); }
const auto text = result.dump(); napi_value out; napi_create_string_utf8(env, text.c_str(), text.size(), &out); return out;
}
napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor properties[] = {
{"start", nullptr, Start, nullptr, nullptr, nullptr, napi_default, nullptr},
{"send", nullptr, Send, nullptr, nullptr, nullptr, napi_default, nullptr},
{"poll", nullptr, Poll, nullptr, nullptr, nullptr, napi_default, nullptr}};
napi_define_properties(env, exports, 3, properties);
napi_add_env_cleanup_hook(env, [](void*) { stopping = true; wake.notify_all(); if (gui_thread.joinable()) gui_thread.join(); }, nullptr);
return exports;
}
napi_module module = {1, 0, nullptr, Init, "slint_desktop", nullptr, {0}};
}
bool WindowActive(const slint::Window& window) {
const auto& handle = window.window_handle().handle();
for (const auto& [id, adapter] : windows) {
const auto& candidate = adapter->window().window_handle().handle();
if (handle._0 == candidate._0 && handle._1 == candidate._1)
return adapter->active && adapter->visible && !window.is_minimized();
}
return false;
}
}
namespace crossdesk {
bool IsHarmonyWindowActive(const slint::Window& window) {
return harmony::desktop::WindowActive(window);
}
}
extern "C" __attribute__((constructor)) void RegisterSlintDesktop() { napi_module_register(&crossdesk::harmony::desktop::module); }
+403
View File
@@ -0,0 +1,403 @@
#include "bridge.h"
#include "displays.h"
#include "capture.h"
#include "input.h"
#include "keyboard_hook.h"
#include "host_access.h"
#include "screen_capturer_factory.h"
#include "speaker_capturer_factory.h"
#include "device_controller_factory.h"
#include "platform/video_renderer.h"
#include "runtime/cursor_state_provider.h"
#include "runtime/cursor_position.h"
#include "cursor_shape.h"
#include "platform/harmony/input/system_shortcuts.h"
#include <display_stream_id.h>
#include <multimodalinput/oh_input_manager.h>
#include <hilog/log.h>
#include <atomic>
#include <cmath>
#include <mutex>
namespace crossdesk {
namespace {
std::mutex input_mutex;
harmony::Input input;
std::atomic<bool> capture_active{false};
std::atomic<harmony::CaptureCursorMode> cursor_mode{harmony::CaptureCursorMode::unknown};
std::atomic<bool> control_device_granted{false};
std::atomic<bool> screen_recording_granted{false};
std::mutex cursor_monitor_lifecycle, cursor_monitor_mutex;
bool cursor_monitor_requested = false, cursor_monitor_installed = false;
bool cursor_monitor_accepting = false;
struct MonitoredCursorPosition {
bool valid = false;
int32_t display = -1;
double x = 0, y = 0;
};
MonitoredCursorPosition monitored_cursor;
void ReceiveCursorPosition(const Input_MouseEvent* event) {
if (!event) return;
std::lock_guard lock(cursor_monitor_mutex);
if (!cursor_monitor_accepting || !capture_active) return;
if (OH_Input_GetMouseEventAction(event) == MOUSE_ACTION_CANCEL) {
monitored_cursor = {};
return;
}
const auto display = OH_Input_GetMouseEventDisplayId(event);
const auto x = OH_Input_GetMouseEventDisplayX(event);
const auto y = OH_Input_GetMouseEventDisplayY(event);
monitored_cursor = {display >= 0 && x >= 0 && y >= 0, display, double(x), double(y)};
}
void SetCursorMonitoring(bool active) {
std::lock_guard operation(cursor_monitor_lifecycle);
if (cursor_monitor_requested == active) return;
cursor_monitor_requested = active;
{
std::lock_guard lock(cursor_monitor_mutex);
cursor_monitor_accepting = false;
monitored_cursor = {};
}
// The callback only uses process-owned state. A failed removal remains
// inert, and must be removed before the next session can register again.
if (cursor_monitor_installed) {
const auto result = OH_Input_RemoveMouseEventMonitor(ReceiveCursorPosition);
// With this non-null callback, PARAMETER_ERROR means no listener remains
// (for example, the recording service already removed it on shutdown).
if (result != INPUT_SUCCESS && result != INPUT_PARAMETER_ERROR) {
OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "Cursor monitor removal failed: %{public}d", int(result));
return;
}
cursor_monitor_installed = false;
}
if (!active) return;
// Input Kit allows this listener only during recording. INPUT_MONITORING
// provides background positions without INPUT_DEVICE_CONFIGURATOR.
{
std::lock_guard lock(cursor_monitor_mutex);
cursor_monitor_accepting = true;
}
const auto result = OH_Input_AddMouseEventMonitor(ReceiveCursorPosition);
if (result == INPUT_SUCCESS) {
cursor_monitor_installed = true;
} else {
{
std::lock_guard lock(cursor_monitor_mutex);
cursor_monitor_accepting = false;
monitored_cursor = {};
}
OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "Cursor monitor unavailable: %{public}d", int(result));
}
}
bool InjectionAuthorized() {
if (!capture_active) return false;
// API 26's dialog status does not reflect CONTROL_DEVICE authorization.
if (control_device_granted) return true;
Input_InjectionStatus status = UNAUTHORIZED;
return OH_Input_QueryAuthorizedStatus(&status) == INPUT_SUCCESS && status == AUTHORIZED;
}
std::mutex speaker_mutex;
SpeakerCapturer::speaker_data_cb speaker_callback;
bool speaker_running = false;
std::vector<DisplayInfo> Displays() {
std::vector<DisplayInfo> result;
for (const auto& d : harmony::desktop::ReadDisplays(harmony::desktop::Environment()))
result.emplace_back(reinterpret_cast<void*>(uintptr_t(d.id)), d.name, d.primary,
d.x, d.y, d.x + d.width, d.y + d.height);
return result;
}
class HarmonyScreen final : public ScreenCapturer {
cb_desktop_data callback_;
harmony::Capture capture_;
mutable std::mutex mutex_;
std::vector<DisplayInfo> displays_;
DisplayInfo current_{"", 0, 0, 0, 0};
int selected_ = 0, fps_ = 60;
bool running_ = false, requested_ = false;
bool show_cursor_ = false;
void StopCapture() {
capture_active = false;
cursor_mode = harmony::CaptureCursorMode::unknown;
SetCursorMonitoring(false);
capture_.Stop();
{ std::lock_guard lock(input_mutex); input.Release(); }
OH_Input_CancelInjection();
}
void Refresh() {
const auto latest = Displays();
// MiniRTC negotiates DisplayN at initialization. Preserve its slot/physical
// identity across hot-unplug; new displays become available on reinit.
for (auto& known : displays_) {
const auto found = std::find_if(latest.begin(), latest.end(), [&](const auto& d) { return d.handle == known.handle; });
if (found != latest.end()) known = *found;
else { known.width = known.height = 0; known.right = known.left; known.bottom = known.top; }
}
int target = selected_;
if (target < 0 || target >= int(displays_.size()) || displays_[target].width <= 0 || displays_[target].height <= 0) {
const auto first = std::find_if(displays_.begin(), displays_.end(), [](const auto& d) { return d.width > 0 && d.height > 0; });
target = first == displays_.end() ? -1 : int(first - displays_.begin());
}
const bool changed = target != selected_ || (target >= 0 &&
(displays_[target].width != current_.width || displays_[target].height != current_.height));
selected_ = target;
if (requested_ && changed) {
StopCapture();
running_ = StartCapture() == 0;
}
}
int StartCapture() {
if (selected_ < 0 || selected_ >= int(displays_.size()) ||
displays_[selected_].width <= 0 || displays_[selected_].height <= 0) return -1;
current_ = displays_[selected_];
const auto stream = MakeDisplayStreamId(selected_);
const bool started = capture_.Start(current_.width, current_.height, uintptr_t(current_.handle),
[this, stream](const MiniRtcVideoFrame& frame) {
if (callback_) callback_(reinterpret_cast<unsigned char*>(const_cast<char*>(frame.data)),
frame.size, frame.width, frame.height, stream.c_str(), nullptr);
}, [this](int state) {
const bool was_active = capture_active.exchange(harmony::CaptureActiveForState(state, capture_active));
cursor_mode = capture_active ? capture_.CursorMode() : harmony::CaptureCursorMode::unknown;
SetCursorMonitoring(capture_active && cursor_mode == harmony::CaptureCursorMode::separate);
if (capture_active && !was_active && !InjectionAuthorized()) {
OH_Input_RequestInjection([](Input_InjectionStatus status) {
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Host input authorization: %{public}d", int(status));
// Retry releases retained while authorization was unavailable
// before accepting a newly authorized controller's presses.
{ std::lock_guard lock(input_mutex); input.Release(); }
});
} else if (!capture_active) { std::lock_guard lock(input_mutex); input.Release(); }
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Host capture state: %{public}d", state);
},
[](const char* data, size_t size) {
std::lock_guard lock(speaker_mutex);
if (speaker_running && speaker_callback)
speaker_callback(reinterpret_cast<unsigned char*>(const_cast<char*>(data)), size, "audio");
}, fps_, screen_recording_granted, show_cursor_);
cursor_mode = started ? capture_.CursorMode() : harmony::CaptureCursorMode::unknown;
return started ? 0 : -1;
}
int Select(int index) {
if (index < 0 || index >= int(displays_.size()) || displays_[index].width <= 0 || displays_[index].height <= 0) return -1;
if (index == selected_) return 0;
const int previous = selected_;
if (running_) StopCapture();
selected_ = index;
if (!requested_ || StartCapture() == 0) { running_ = requested_; return 0; }
// Do not acknowledge a failed switch or send input to the failed target.
selected_ = previous;
running_ = StartCapture() == 0;
return -1;
}
public:
~HarmonyScreen() override { Stop(); }
int Init(int fps, cb_desktop_data cb) override {
std::lock_guard lock(mutex_);
fps_ = std::clamp(fps, 1, 60); callback_ = std::move(cb); displays_ = Displays(); return 0;
}
int Destroy() override { return Stop(); }
int Start(bool show_cursor) override {
std::lock_guard lock(mutex_);
if (running_ && show_cursor_ == show_cursor) return 0;
if (running_) StopCapture();
requested_ = running_ = false;
show_cursor_ = show_cursor;
Refresh();
running_ = StartCapture() == 0;
requested_ = running_;
return running_ ? 0 : -1;
}
int Stop() override {
std::lock_guard lock(mutex_);
requested_ = running_ = false; StopCapture(); return 0;
}
int Pause(int) override { return Stop(); }
int Resume(int index) override {
std::lock_guard lock(mutex_);
Refresh();
if (Select(index) != 0) return -1;
if (!running_) running_ = StartCapture() == 0;
requested_ = running_;
return running_ ? 0 : -1;
}
std::vector<DisplayInfo> GetDisplayInfoList() override {
std::lock_guard lock(mutex_);
Refresh();
return displays_;
}
int SwitchTo(int index) override { std::lock_guard lock(mutex_); return Select(index); }
int ResetToInitialMonitor() override { return SwitchTo(0); }
int GetCurrentMonitorIndex() const override { std::lock_guard lock(mutex_); return selected_; }
};
class HarmonyMouse final : public MouseController {
std::vector<DisplayInfo> displays_;
void* display_ = nullptr;
int width_ = 0, height_ = 0;
bool Configure(int index) {
const auto& d = displays_[index];
if (d.handle == display_ && d.width == width_ && d.height == height_) return true;
if (!input.Configure(d.width, d.height, uintptr_t(d.handle))) return false;
display_ = d.handle; width_ = d.width; height_ = d.height;
return true;
}
public:
int Init(std::vector<DisplayInfo> displays) override {
if (displays.empty()) return -1;
std::lock_guard lock(input_mutex);
displays_ = std::move(displays); return Configure(0) ? 0 : -1;
}
void UpdateDisplayInfoList(const std::vector<DisplayInfo>& displays) override {
std::lock_guard lock(input_mutex);
if (!std::any_of(displays.begin(), displays.end(), [this](const auto& d) {
return d.handle == display_ && d.width > 0 && d.height > 0;
})) input.Release();
displays_ = displays;
}
int Destroy() override {
std::lock_guard lock(input_mutex); input.Release(); displays_.clear(); width_ = height_ = 0; return 0;
}
int SendMouseCommand(RemoteAction action, int index) override {
std::lock_guard lock(input_mutex);
if (index < 0 || index >= int(displays_.size()) || displays_[index].width <= 0 || displays_[index].height <= 0 || !InjectionAuthorized()) return -1;
if (!Configure(index)) return -1;
return input.Apply(action) ? 0 : -1;
}
};
class HarmonyKeyboard final : public KeyboardCapturer {
harmony::desktop::KeyboardHook hook_;
int last_hook_result_ = -1;
public:
int Hook(OnKeyAction callback, void* context) override {
const int result = hook_.Start(callback, context);
if (result != last_hook_result_) {
last_hook_result_ = result;
OH_LOG_Print(LOG_APP, result == INPUT_SUCCESS ? LOG_INFO : LOG_WARN, 0, "SlintDesktop",
"Global keyboard hook result: %{public}d", result);
}
if (result == INPUT_PERMISSION_DENIED)
harmony::desktop::Emit({{"op", "keyboardPermission"}});
return result;
}
// Local keyboard capture and remote input injection are independent. A
// controller-window focus change must not release another peer's host keys.
int Unhook() override { return hook_.Stop(); }
bool IsHookActive() const override { return hook_.Active(); }
bool ShouldRetryHook() const override {
return last_hook_result_ != INPUT_DEVICE_NOT_SUPPORTED &&
(last_hook_result_ != INPUT_PERMISSION_DENIED || harmony::desktop::KeyboardHookPermissionGranted());
}
int SendKeyboardCommand(int key, bool down, uint32_t scan, bool extended) override {
RemoteAction action{}; action.type = keyboard;
action.k.key_value = key; action.k.flag = down ? key_down : key_up;
action.k.scan_code = scan; action.k.extended = extended;
std::lock_guard lock(input_mutex); return InjectionAuthorized() && input.Apply(action) ? 0 : -1;
}
};
// The common presenter owns its CPU image fallback. A GPU renderer can replace
// this without introducing a separate session, input, or window implementation.
class SoftwareVideo final : public VideoRenderer {
std::string selected_;
public:
bool IsReady() const override { return false; }
bool IsActive() const override { return false; }
bool SetSelectedStream(std::string id) override { bool changed = id != selected_; selected_ = std::move(id); return changed; }
void DiscardStream(std::string_view id) override { if (id == selected_) selected_.clear(); }
SubmitResult SubmitNv12(std::string_view, const uint8_t*, size_t, int, int, VideoLatencyFrame) override { return SubmitResult::failed; }
SubmitResult SubmitCachedNv12(std::string_view, const uint8_t*, size_t, int, int, VideoLatencyFrame) override { return SubmitResult::failed; }
bool CopyLatestNv12(std::string_view, std::vector<unsigned char>*, int*, int*) const override { return false; }
};
class HarmonySpeaker final : public SpeakerCapturer {
public:
int Init(speaker_data_cb callback) override { std::lock_guard lock(speaker_mutex); speaker_callback = std::move(callback); return 0; }
int Destroy() override { std::lock_guard lock(speaker_mutex); speaker_running = false; speaker_callback = {}; return 0; }
int Start() override { std::lock_guard lock(speaker_mutex); speaker_running = true; return 0; }
int Stop() override { std::lock_guard lock(speaker_mutex); speaker_running = false; return 0; }
bool IsRunning() const override { std::lock_guard lock(speaker_mutex); return speaker_running; }
};
}
void harmony::desktop::SetHostPermissions(bool control_device, bool screen_recording) {
std::lock_guard lock(input_mutex);
if (control_device_granted.exchange(control_device) != control_device) input.Release();
screen_recording_granted = screen_recording;
}
ScreenCapturer* ScreenCapturerFactory::Create() { return new HarmonyScreen(); }
bool SendHarmonySystemShortcut(ServiceCommandFlag command) {
// Windows secure attention has no public Harmony equivalent. Do not claim
// it succeeded, or turn a SAS request into an unrelated physical chord.
if (command != ServiceCommandFlag::lock_workstation) return false;
std::lock_guard lock(input_mutex);
return InjectionAuthorized() && input.LockWorkstation();
}
SpeakerCapturer* SpeakerCapturerFactory::Create() { return new HarmonySpeaker(); }
DeviceController* DeviceControllerFactory::Create(Device device) {
return device == Mouse ? static_cast<DeviceController*>(new HarmonyMouse()) : new HarmonyKeyboard();
}
std::unique_ptr<VideoRenderer> CreateVideoRenderer() { return std::make_unique<SoftwareVideo>(); }
struct CursorStateProvider::Impl {
Input_CursorInfo* cursor = OH_Input_CursorInfo_Create();
~Impl() { if (cursor) OH_Input_CursorInfo_Destroy(&cursor); }
};
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
CursorStateProvider::~CursorStateProvider() = default;
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays, int, CursorState* state) {
if (!state) return false;
*state = {};
ResetCursorPosition(state);
state->shape = RemoteCursorShape::none;
if (!capture_active) return false;
const auto mode = cursor_mode.load();
if (mode == harmony::CaptureCursorMode::embedded) {
state->render_mode = CursorRenderMode::embedded;
return true;
}
if (mode != harmony::CaptureCursorMode::separate || !impl_->cursor ||
OH_Input_GetCursorInfo(impl_->cursor, nullptr) != INPUT_SUCCESS ||
OH_Input_CursorInfo_IsVisible(impl_->cursor, &state->visible) != INPUT_SUCCESS) return false;
if (state->visible) {
Input_PointerStyle style = DEFAULT;
if (OH_Input_CursorInfo_GetStyle(impl_->cursor, &style) != INPUT_SUCCESS) return false;
state->shape = harmony::desktop::ShapeFromPointerStyle(style);
}
state->render_mode = state->visible ? CursorRenderMode::separate : CursorRenderMode::hidden;
state->hidden_reason = state->visible ? CursorHiddenReason::unspecified : CursorHiddenReason::system_hidden;
int32_t display = -1;
double x = 0, y = 0;
auto location = OH_Input_GetPointerLocation(&display, &x, &y);
if (location != INPUT_SUCCESS) {
std::lock_guard lock(cursor_monitor_mutex);
if (location == INPUT_DEVICE_NO_POINTER) {
monitored_cursor = {};
} else if (cursor_monitor_accepting && monitored_cursor.valid) {
display = monitored_cursor.display;
x = monitored_cursor.x;
y = monitored_cursor.y;
location = INPUT_SUCCESS;
}
}
if (location == INPUT_SUCCESS && display >= 0 && std::isfinite(x) && std::isfinite(y)) {
// SDK coordinates are relative to a physical display; the protocol uses
// negotiated display slots. Do not confuse a physical ID with a slot or
// select a different monitor when desktop rectangles overlap.
for (size_t index = 0; index < displays.size(); ++index) {
const auto& d = displays[index];
if (uintptr_t(d.handle) != static_cast<uintptr_t>(display) || d.width <= 0 || d.height <= 0 ||
x < 0 || y < 0 || x >= d.width || y >= d.height) continue;
state->display_id = static_cast<int>(index);
state->x = static_cast<float>(x / d.width);
state->y = static_cast<float>(y / d.height);
state->position_valid = true;
break;
}
} else if (location == INPUT_DEVICE_NO_POINTER && !state->visible) {
state->hidden_reason = CursorHiddenReason::no_pointing_device;
}
// Without a successful query or a monitored event, report no position.
// This must not discard a successfully sampled shape/visibility.
return true;
}
}
+59
View File
@@ -0,0 +1,59 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DISPLAYS_H_
#define _DISPLAYS_H_
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdint>
#include <limits>
#include <string>
#include <vector>
namespace crossdesk::harmony::desktop {
struct DesktopDisplay {
int id;
std::string name;
bool primary;
int x, y, width, height;
int usable_x, usable_y, usable_width, usable_height;
// SDL reserves zero for failure; Harmony's primary display can be zero.
uint32_t SdlId() const { return uint32_t(id) + 1; }
};
inline std::vector<DesktopDisplay> ReadDisplays(const nlohmann::json& environment) {
std::vector<DesktopDisplay> result;
for (const auto& item : environment.value("displays", nlohmann::json::array())) {
const int64_t id = item.value("id", int64_t(-1));
const int width = item.value("width", 0), height = item.value("height", 0);
if (id < 0 || id > INT32_MAX || width <= 0 || height <= 0) continue;
if (std::any_of(result.begin(), result.end(), [id](const auto& d) { return d.id == id; })) continue;
const int x = item.value("x", 0), y = item.value("y", 0);
result.push_back({int(id), item.value("name", "Harmony Display"), item.value("primary", false),
x, y, width, height, item.value("usableX", x), item.value("usableY", y),
item.value("usableWidth", width), item.value("usableHeight", height)});
}
std::stable_sort(result.begin(), result.end(), [](const auto& a, const auto& b) {
return a.primary != b.primary ? a.primary : a.id < b.id;
});
return result;
}
inline const DesktopDisplay* DisplayAt(const std::vector<DesktopDisplay>& displays, int x, int y) {
const DesktopDisplay* nearest = nullptr;
int64_t distance = std::numeric_limits<int64_t>::max();
for (const auto& d : displays) {
const int64_t dx = int64_t(x) - std::clamp<int64_t>(x, d.x, int64_t(d.x) + d.width - 1);
const int64_t dy = int64_t(y) - std::clamp<int64_t>(y, d.y, int64_t(d.y) + d.height - 1);
const int64_t next = dx * dx + dy * dy;
if (next < distance) { distance = next; nearest = &d; }
}
return nearest;
}
}
#endif
@@ -0,0 +1,41 @@
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -200,7 +200,7 @@
wgpu-29 = { package = "wgpu", version = "29.0.4", default-features = false }
input = { version = "0.10.0", default-features = false }
tr = { version = "0.1", default-features = false }
-fontique = { version = "0.10.0" }
+fontique = { version = "0.10.0", default-features = false, features = ["std"] }
# Pinned to match parley's version
read-fonts = { version = "0.39" }
# Pinned to match parley's version
--- a/internal/core/Cargo.toml
+++ b/internal/core/Cargo.toml
@@ -130,7 +130,7 @@
unicode-script = { version = "0.5.7", optional = true }
icu_normalizer = { workspace = true, optional = true }
sys-locale = { version = "0.3.2", optional = true, features = ["js"] }
-parley = { version = "0.10.0", optional = true, features = ["complex-scripts"] }
+parley = { version = "0.10.0", optional = true, default-features = false, features = ["std", "complex-scripts"] }
image = { workspace = true, optional = true, default-features = false }
clru = { workspace = true, optional = true }
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3960,16 +3960,9 @@
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
- "objc2 0.6.4",
- "objc2-core-foundation",
- "objc2-core-text",
- "objc2-foundation 0.3.2",
"parlance",
"read-fonts",
- "roxmltree",
"smallvec",
- "windows 0.62.2",
- "windows-core 0.62.2",
"yeslogic-fontconfig-sys",
]
+15
View File
@@ -0,0 +1,15 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _HOST_ACCESS_H_
#define _HOST_ACCESS_H_
namespace crossdesk::harmony::desktop {
// Values come from AbilityKit's actual permission checks, never saved settings.
void SetHostPermissions(bool control_device, bool screen_recording);
}
#endif
@@ -0,0 +1,57 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SDL_H_
#define _SDL_H_
// The desktop's small SDL service surface, implemented with Harmony APIs.
// This header is private to the Harmony target; desktop SDL builds are unchanged.
#include <cstdint>
#include <cstdlib>
using Uint8 = uint8_t;
using SDL_DisplayID = uint32_t;
struct SDL_Point { int x, y; };
struct SDL_Rect { int x, y, w, h; };
struct SDL_DisplayMode { int w, h; };
struct SDL_Event { uint32_t type; };
struct SDL_AudioSpec { int format, channels, freq; };
struct SDL_AudioStream;
constexpr uint32_t SDL_INIT_VIDEO = 1, SDL_INIT_AUDIO = 2;
constexpr uint32_t SDL_EVENT_FIRST = 0, SDL_EVENT_LAST = UINT32_MAX;
constexpr uint32_t SDL_EVENT_QUIT = 0x100, SDL_EVENT_CLIPBOARD_UPDATE = 0x900;
constexpr int SDL_GETEVENT = 2, SDL_AUDIO_S16 = 0x8010;
constexpr uint32_t SDL_BUTTON_LMASK = 1, SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK = 0;
bool SDL_Init(uint32_t);
bool SDL_InitSubSystem(uint32_t);
void SDL_Quit();
void SDL_QuitSubSystem(uint32_t);
const char* SDL_GetError();
uint64_t SDL_GetTicks();
void SDL_Delay(uint32_t);
bool SDL_PollEvent(SDL_Event*);
int SDL_PeepEvents(SDL_Event*, int, int, uint32_t, uint32_t);
bool SDL_PushEvent(SDL_Event*);
char* SDL_GetClipboardText();
bool SDL_SetClipboardText(const char*);
bool SDL_HasClipboardText();
inline void SDL_free(void* p) { std::free(p); }
const char* SDL_GetCurrentVideoDriver();
const SDL_DisplayMode* SDL_GetCurrentDisplayMode(SDL_DisplayID);
SDL_DisplayID SDL_GetPrimaryDisplay();
SDL_DisplayID SDL_GetDisplayForPoint(const SDL_Point*);
SDL_DisplayID* SDL_GetDisplays(int*);
bool SDL_GetDisplayUsableBounds(SDL_DisplayID, SDL_Rect*);
uint32_t SDL_GetGlobalMouseState(float*, float*);
bool SDL_OpenURL(const char*);
uint32_t SDL_OpenAudioDevice(uint32_t, const SDL_AudioSpec*);
bool SDL_ResumeAudioDevice(uint32_t);
void SDL_CloseAudioDevice(uint32_t);
SDL_AudioStream* SDL_CreateAudioStream(const SDL_AudioSpec*, const SDL_AudioSpec*);
void SDL_DestroyAudioStream(SDL_AudioStream*);
bool SDL_BindAudioStream(uint32_t, SDL_AudioStream*);
bool SDL_PutAudioStreamData(SDL_AudioStream*, const void*, int);
#endif
@@ -0,0 +1,12 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SDL_AUDIO_H_
#define _SDL_AUDIO_H_
#include "SDL.h"
#endif
@@ -0,0 +1,12 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SDL_CLIPBOARD_H_
#define _SDL_CLIPBOARD_H_
#include "SDL.h"
#endif
@@ -0,0 +1,12 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SDL_ERROR_H_
#define _SDL_ERROR_H_
#include "SDL.h"
#endif
@@ -0,0 +1,12 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SDL_EVENTS_H_
#define _SDL_EVENTS_H_
#include "SDL.h"
#endif
@@ -0,0 +1,12 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SDL_INIT_H_
#define _SDL_INIT_H_
#include "SDL.h"
#endif
@@ -0,0 +1,12 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SDL_TIMER_H_
#define _SDL_TIMER_H_
#include "SDL.h"
#endif
@@ -0,0 +1,14 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TINYFILEDIALOGS_H_
#define _TINYFILEDIALOGS_H_
const char* tinyfd_selectFolderDialog(const char* title, const char* path);
const char* tinyfd_openFileDialog(const char* title, const char* path, int,
const char* const*, const char*, int);
#endif
+3
View File
@@ -0,0 +1,3 @@
export const start: (environment: string) => void;
export const send: (event: string) => void;
export const poll: () => string;
+93
View File
@@ -0,0 +1,93 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-14
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _KEYBOARD_H_
#define _KEYBOARD_H_
#include <slint-platform.h>
#include <map>
#include <optional>
#include <string>
namespace crossdesk::harmony::desktop {
// Remember the symbol chosen on key-down. Releasing Shift before a letter must
// not turn the matching key-up into a different remote key.
class KeyboardState {
std::map<int, std::string> pressed_;
bool caps_ = false;
bool num_ = true;
static std::string Utf8(std::u8string_view key) { return {reinterpret_cast<const char*>(key.data()), key.size()}; }
public:
void Reset() { pressed_.clear(); }
bool IsPressed(int code) const { return pressed_.count(code); }
const std::map<int, std::string>& Pressed() const { return pressed_; }
std::string Translate(int code, bool down, unsigned unicode = 0,
std::optional<bool> caps = {}, std::optional<bool> num = {}) {
// Lock keys may have changed in another application. Prefer the OS event
// snapshot, while keeping a down/up pair's original symbol unchanged.
if (caps) caps_ = *caps;
if (num) num_ = *num;
if (auto it = pressed_.find(code); it != pressed_.end()) {
auto key = it->second;
if (!down) pressed_.erase(it);
return key;
}
using namespace slint::platform::key_codes;
const bool shift = IsPressed(2047) || IsPressed(2048);
std::string key;
if (!num_ && code >= 2103 && code <= 2112) {
const std::u8string_view navigation[] = {Insert, End, DownArrow, PageDown, LeftArrow, u8"", RightArrow, Home, UpArrow, PageUp};
key = Utf8(navigation[code - 2103]);
} else if (!num_ && code == 2117) key = Utf8(Delete);
else if (unicode >= 0x21 && unicode <= 0x7e &&
((code >= 2000 && code <= 2009) || (code >= 2017 && code <= 2044) ||
(code >= 2056 && code <= 2064) || (code >= 2103 && code <= 2122)))
key = std::string(1, char(unicode));
else if (code >= 2000 && code <= 2009) key = std::string(1, shift ? ")!@#$%^&*("[code - 2000] : char('0' + code - 2000));
else if (code >= 2017 && code <= 2042) key = std::string(1, char(((shift != caps_) ? 'A' : 'a') + code - 2017));
else if (code >= 2103 && code <= 2112) key = std::string(1, char('0' + code - 2103));
else if (code >= 2090 && code <= 2101) {
const std::u8string_view functions[] = {F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12};
key = Utf8(functions[code - 2090]);
} else if (code >= 2816 && code <= 2827) {
const std::u8string_view functions[] = {F13, F14, F15, F16, F17, F18, F19, F20, F21, F22, F23, F24};
key = Utf8(functions[code - 2816]);
} else switch (code) {
case 2055: key = Utf8(Backspace); break; case 2054: case 2119: key = Utf8(Return); break;
case 2049: key = Utf8(Tab); break; case 2070: key = Utf8(Escape); break;
case 2012: key = Utf8(UpArrow); break; case 2013: key = Utf8(DownArrow); break;
case 2014: key = Utf8(LeftArrow); break; case 2015: key = Utf8(RightArrow); break;
case 2072: key = Utf8(Control); break; case 2073: key = Utf8(ControlR); break;
case 2047: key = Utf8(Shift); break; case 2048: key = Utf8(ShiftR); break;
case 2045: key = Utf8(Alt); break; case 2046: key = Utf8(AltGr); break;
case 2076: key = Utf8(Meta); break; case 2077: key = Utf8(MetaR); break;
case 2067: key = Utf8(Menu); break;
case 2050: key = " "; break; case 2071: key = Utf8(Delete); break;
case 2081: key = Utf8(Home); break; case 2082: key = Utf8(End); break;
case 2068: key = Utf8(PageUp); break; case 2069: key = Utf8(PageDown); break;
case 2083: key = Utf8(Insert); break;
case 2074: if (down && !caps) caps_ = !caps_; key = Utf8(CapsLock); break;
case 2102: if (down && !num) num_ = !num_; break;
case 2075: key = Utf8(ScrollLock); break; case 2080: key = Utf8(Pause); break;
case 2079: key = Utf8(SysReq); break; case 2113: key = "/"; break;
case 2114: key = "*"; break; case 2115: key = "-"; break;
case 2116: key = "+"; break; case 2117: key = "."; break;
case 2118: key = ","; break; case 2120: key = "="; break;
case 2121: key = "("; break; case 2122: key = ")"; break;
case 2043: key = shift ? "<" : ","; break; case 2044: key = shift ? ">" : "."; break;
case 2056: key = shift ? "~" : "`"; break; case 2057: key = shift ? "_" : "-"; break;
case 2058: key = shift ? "+" : "="; break; case 2059: key = shift ? "{" : "["; break;
case 2060: key = shift ? "}" : "]"; break; case 2061: key = shift ? "|" : "\\"; break;
case 2062: key = shift ? ":" : ";"; break; case 2063: key = shift ? "\"" : "'"; break;
case 2064: key = shift ? "?" : "/"; break;
}
if (down && (!key.empty() || code == 2102)) pressed_[code] = key;
return key;
}
};
}
#endif
+115
View File
@@ -0,0 +1,115 @@
#include "keyboard_hook.h"
#include "key_mapping.h"
#include "keyboard_state.h"
#include "windows_key_metadata.h"
#include <mutex>
namespace crossdesk::harmony::desktop {
namespace {
std::mutex lifecycle, delivery;
KeyboardHook* owner = nullptr;
OnKeyAction sink = nullptr;
void* sink_context = nullptr;
int focused_window = 0, capture_window = 0;
bool installed = false, permission = false;
PressedKeys pressed;
void ReleaseCaptured() {
if (sink) for (const auto& key : ReconcileKeyboardState(pressed, {}))
sink(key.key_code, false, key.scan_code, key.extended, sink_context);
pressed.clear();
}
}
void SetKeyboardFocus(int window, bool active) {
std::lock_guard lock(delivery);
if (active) focused_window = window;
else if (focused_window == window) focused_window = 0;
if (capture_window != focused_window) ReleaseCaptured();
}
void SetKeyboardHookPermission(bool granted) {
std::lock_guard lock(delivery);
permission = granted;
if (!granted) ReleaseCaptured();
}
bool KeyboardHookPermissionGranted() { std::lock_guard lock(delivery); return permission; }
KeyboardHook::~KeyboardHook() { Stop(); }
int KeyboardHook::Start(OnKeyAction callback, void* context) {
if (!callback) return INPUT_PARAMETER_ERROR;
std::lock_guard operation(lifecycle);
int target_window = 0;
{
std::lock_guard lock(delivery);
if (owner) return owner == this ? INPUT_SUCCESS : INPUT_REPEAT_INTERCEPTOR;
if (!focused_window) return INPUT_APP_NOT_FOCUSED;
target_window = focused_window;
}
// A failed removal leaves a pass-through callback installed. Retry removal
// before registering again; never interpret REPEAT_INTERCEPTOR as success.
if (installed) {
const auto result = OH_Input_RemoveKeyEventHook(Receive);
if (result != INPUT_SUCCESS) return result;
installed = false;
}
const auto result = OH_Input_AddKeyEventHook(Receive);
if (result != INPUT_SUCCESS) return result;
installed = true;
{
std::lock_guard lock(delivery);
owner = this; sink = callback; sink_context = context;
capture_window = target_window; permission = true; pressed.clear();
}
return INPUT_SUCCESS;
}
int KeyboardHook::Stop() {
std::lock_guard operation(lifecycle);
{
std::lock_guard lock(delivery);
if (owner && owner != this) return INPUT_SUCCESS;
// Wait for an in-flight delivery, then detach the context before invoking
// the OS. Failed removal or a queued callback cannot access a dead owner.
owner = nullptr; sink = nullptr; sink_context = nullptr;
capture_window = 0; pressed.clear();
}
if (!installed) return INPUT_SUCCESS;
const auto result = OH_Input_RemoveKeyEventHook(Receive);
if (result == INPUT_SUCCESS) installed = false;
return result;
}
bool KeyboardHook::Active() const {
std::lock_guard lock(delivery);
return owner == this && permission && capture_window != 0 && capture_window == focused_window;
}
void KeyboardHook::Receive(const Input_KeyEvent* event) {
if (!event) return;
bool consumed = false;
{
std::lock_guard lock(delivery);
if (owner && sink && permission && capture_window != 0 && capture_window == focused_window) {
const auto action = OH_Input_GetKeyEventAction(event);
const int code = OH_Input_GetKeyEventKeyCode(event);
const int key = ToVirtualKey(code);
if (action == KEY_ACTION_CANCEL) {
consumed = !pressed.empty(); ReleaseCaptured();
} else if (key && (action == KEY_ACTION_DOWN || action == KEY_ACTION_UP)) {
const bool down = action == KEY_ACTION_DOWN;
// Do not swallow a release whose press belonged to the local app.
if (down || pressed.count(key)) {
uint32_t scan = 0; bool extended = false;
LookupWindowsKeyMetadataFromVk(key, &scan, &extended);
if (code == 2119) { scan = 0x1C; extended = true; }
if (down) pressed[key] = {key, scan, extended};
else pressed.erase(key);
sink(key, down, scan, extended, sink_context); consumed = true;
}
}
}
}
// The SDK owns event storage. Return unhandled events in original order,
// without reinjecting them (which would re-enter the hook).
if (!consumed) {
int32_t id = 0;
if (OH_Input_GetKeyEventId(event, &id) == INPUT_SUCCESS)
OH_Input_DispatchToNextHandler(id);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _KEYBOARD_HOOK_H_
#define _KEYBOARD_HOOK_H_
#include "device_controller.h"
#include <multimodalinput/oh_input_manager.h>
namespace crossdesk::harmony::desktop {
// Process-wide OS hook; the shared SessionDeviceManager owns when it runs.
// ArkUI updates focus immediately, even while the Slint thread waits for an OS
// service. A late callback must never capture input for another application.
void SetKeyboardFocus(int window, bool active);
void SetKeyboardHookPermission(bool granted);
bool KeyboardHookPermissionGranted();
class KeyboardHook {
public:
~KeyboardHook();
int Start(OnKeyAction callback, void* context);
int Stop();
bool Active() const;
private:
static void Receive(const Input_KeyEvent* event);
};
}
#endif
+118
View File
@@ -0,0 +1,118 @@
--- a/api/cpp/platform.rs
+++ b/api/cpp/platform.rs
@@ -47,6 +47,11 @@
}
impl WindowAdapter for CppWindowAdapter {
+ #[cfg(target_env = "ohos")]
+ fn internal(&self, _: i_slint_core::InternalToken) -> Option<&dyn i_slint_core::window::WindowAdapterInternal> {
+ Some(self)
+ }
+
fn window(&self) -> &Window {
&self.window
}
@@ -186,6 +191,103 @@
}
}
+
+// Harmony's native editor and cursor services are provided by the C++ host.
+// Keep this extension target-local; the public C++ adapter ABI stays unchanged.
+#[cfg(target_env = "ohos")]
+mod harmony_services {
+ use super::*;
+ use core::cell::Cell;
+ use i_slint_core::window::{InputMethodRequest, WindowAdapterInternal, WindowInner};
+ type HarmonyOsCallback = unsafe extern "C" fn(*mut c_void, *const c_void, i32,
+ *const u8, usize, usize, usize, f32, f32, f32, f32, i32);
+ std::thread_local! { static CALLBACK: Cell<Option<HarmonyOsCallback>> = const { Cell::new(None) }; }
+ #[unsafe(no_mangle)]
+ pub extern "C" fn crossdesk_slint_set_os_callback(callback: HarmonyOsCallback) {
+ CALLBACK.set(Some(callback));
+ }
+ impl WindowAdapterInternal for CppWindowAdapter {
+ fn input_method_request(&self, request: InputMethodRequest) {
+ let Some(callback) = CALLBACK.get() else { return };
+ let (kind, properties) = match request {
+ InputMethodRequest::Enable(p) => (1, Some(p)),
+ InputMethodRequest::Update(p) => (2, Some(p)),
+ InputMethodRequest::Disable => (0, None),
+ _ => return,
+ };
+ let p = properties.unwrap_or_default();
+ let cursor = p.text[..p.cursor_position].encode_utf16().count();
+ let anchor = p.text[..p.anchor_position.unwrap_or(p.cursor_position)].encode_utf16().count();
+ unsafe { callback(self.user_data, self as *const Self as *const c_void, kind, p.text.as_ptr(), p.text.len(), cursor, anchor,
+ p.cursor_rect_origin.x, p.cursor_rect_origin.y, p.cursor_rect_size.width,
+ p.cursor_rect_size.height, p.input_type as i32); }
+ }
+ fn set_mouse_cursor(&self, cursor: i_slint_core::items::MouseCursor) {
+ if let Some(callback) = CALLBACK.get() {
+ unsafe { callback(self.user_data, self as *const Self as *const c_void, 3, core::ptr::null(), 0, 0, 0,
+ 0., 0., 0., 0., cursor as i32); }
+ }
+ }
+ }
+ // IME commands target the editor that owns the native input session. Use
+ // Slint's normal key handling (selection, clipboard, validation and undo)
+ // without mutating the window's physical modifier state.
+ #[unsafe(no_mangle)]
+ pub unsafe extern "C" fn crossdesk_slint_edit(adapter: *const c_void,
+ text: *const u8, length: usize, modifiers: i32) {
+ use i_slint_core::input::{InternalKeyEvent, KeyEvent, KeyEventType};
+ let Ok(text) = core::str::from_utf8(unsafe { core::slice::from_raw_parts(text, length) }) else { return };
+ let window = WindowInner::from_pub(&unsafe { &*(adapter as *const CppWindowAdapter) }.window);
+ let Some(item) = window.focus_item.borrow().upgrade() else { return };
+ if item.downcast::<i_slint_core::items::TextInput>().is_none() { return; }
+ let mut key_event = KeyEvent::default();
+ key_event.text = text.into();
+ key_event.modifiers.shift = modifiers & 1 != 0;
+ key_event.modifiers.control = modifiers & 2 != 0;
+ for event_type in [KeyEventType::KeyPressed, KeyEventType::KeyReleased] {
+ item.borrow().as_ref().key_event(&InternalKeyEvent {
+ key_event: key_event.clone(), event_type, ..Default::default()
+ }, &window.window_adapter(), &item);
+ }
+ }
+ #[unsafe(no_mangle)]
+ pub unsafe extern "C" fn crossdesk_slint_selection(adapter: *const c_void, anchor: i32, cursor: i32) {
+ let window = WindowInner::from_pub(&unsafe { &*(adapter as *const CppWindowAdapter) }.window);
+ let Some(item) = window.focus_item.borrow().upgrade() else { return };
+ let Some(input) = item.downcast::<i_slint_core::items::TextInput>() else { return };
+ let input = input.as_pin_ref();
+ let text = input.text();
+ // Harmony uses UTF-16 indices; Slint uses UTF-8 byte offsets. Clamp
+ // malformed ranges to character boundaries, including surrogate pairs.
+ let byte_offset = |offset: i32| -> i32 {
+ let mut utf16 = 0;
+ for (byte, ch) in text.char_indices() {
+ if utf16 + ch.len_utf16() > offset.max(0) as usize { return byte as i32; }
+ utf16 += ch.len_utf16();
+ }
+ text.len() as i32
+ };
+ input.set_selection_offsets(&window.window_adapter(), &item, byte_offset(anchor), byte_offset(cursor));
+ }
+ // Called only on the Slint thread while the owning C++ window is alive.
+ #[unsafe(no_mangle)]
+ pub unsafe extern "C" fn crossdesk_slint_composition(adapter: *const c_void,
+ commit: bool, text: *const u8, length: usize) {
+ use i_slint_core::input::{InternalKeyEvent, KeyEvent, KeyEventType};
+ let Ok(text) = core::str::from_utf8(unsafe { core::slice::from_raw_parts(text, length) }) else { return };
+ let mut key_event = KeyEvent::default();
+ key_event.text = text.into();
+ let event = if commit {
+ InternalKeyEvent { event_type: KeyEventType::CommitComposition,
+ key_event, ..Default::default() }
+ } else {
+ InternalKeyEvent { event_type: KeyEventType::UpdateComposition, preedit_text: text.into(),
+ preedit_selection: Some(text.len() as i32..text.len() as i32), ..Default::default() }
+ };
+ WindowInner::from_pub(&unsafe { &*(adapter as *const CppWindowAdapter) }.window).process_key_input(event);
+ }
+}
+
type PlatformUserData = *mut c_void;
struct CppPlatform {
+34
View File
@@ -0,0 +1,34 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _POINTER_H_
#define _POINTER_H_
#include <array>
#include <utility>
namespace crossdesk::harmony::desktop {
// Preserve each button's last position. Cancellation/focus loss must release
// every held button, while merely leaving the surface must preserve a drag.
class PointerState {
std::array<bool, 3> pressed_{};
float x_ = 0, y_ = 0;
public:
void Update(int kind, int button, float x, float y) {
x_ = x; y_ = y;
if (button >= 0 && button < 3 && (kind == 0 || kind == 1)) pressed_[button] = kind == 0;
}
unsigned Buttons() const {
return (pressed_[0] ? 1u : 0u) | (pressed_[1] ? 4u : 0u) | (pressed_[2] ? 2u : 0u);
}
template<class Release> void Cancel(Release release) {
const auto pressed = std::exchange(pressed_, {});
for (int i = 0; i < 3; ++i) if (pressed[i]) release(i, x_, y_);
}
};
}
#endif
+181
View File
@@ -0,0 +1,181 @@
#include "bridge.h"
#include "displays.h"
#include "SDL3/SDL.h"
#include "audio.h"
#include "platform/path_backend.h"
#include "platform/autostart_backend.h"
#include "platform/harmony/harmony_autostart.h"
#include "platform.h"
#include <multimodalinput/oh_input_manager.h>
#include <chrono>
#include <atomic>
#include <cstring>
#include <deque>
#include <mutex>
#include <thread>
using namespace crossdesk::harmony::desktop;
namespace {
std::mutex event_mutex;
std::deque<SDL_Event> events;
thread_local std::string service_error;
std::atomic<crossdesk::HarmonyAutostartStatus> autostart_status{crossdesk::HarmonyAutostartStatus::checking};
bool AudioFormat(const SDL_AudioSpec* spec) {
return spec && spec->freq == 48000 && spec->channels == 1 && spec->format == SDL_AUDIO_S16;
}
}
bool SDL_Init(uint32_t) { return true; }
bool SDL_InitSubSystem(uint32_t) { return true; }
void SDL_Quit() { std::lock_guard lock(event_mutex); events.clear(); }
void SDL_QuitSubSystem(uint32_t) {}
const char* SDL_GetError() { return service_error.c_str(); }
uint64_t SDL_GetTicks() {
static const auto start = std::chrono::steady_clock::now();
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
}
void SDL_Delay(uint32_t delay) { std::this_thread::sleep_for(std::chrono::milliseconds(delay)); }
bool SDL_PushEvent(SDL_Event* event) {
if (!event) return false;
std::lock_guard lock(event_mutex); events.push_back(*event); return true;
}
bool SDL_PollEvent(SDL_Event* event) { return SDL_PeepEvents(event, 1, SDL_GETEVENT, 0, UINT32_MAX) == 1; }
int SDL_PeepEvents(SDL_Event* out, int count, int, uint32_t first, uint32_t last) {
std::lock_guard lock(event_mutex); int copied = 0;
for (auto it = events.begin(); it != events.end() && copied < count;) {
if (it->type >= first && it->type <= last) { out[copied++] = *it; it = events.erase(it); }
else ++it;
}
return copied;
}
char* SDL_GetClipboardText() {
auto result = Request({{"op", "clipboardRead"}});
if (!result.value("ok", false)) {
service_error = result.value("message", "Harmony clipboard read failed");
return nullptr;
}
return strdup(result.value("text", "").c_str());
}
bool SDL_SetClipboardText(const char* text) {
auto result = Request({{"op", "clipboardWrite"}, {"text", text ? text : ""}});
if (result.value("ok", false)) return true;
service_error = result.value("message", "Harmony clipboard write failed");
return false;
}
bool SDL_HasClipboardText() { return Request({{"op", "clipboardHas"}}).value("ok", false); }
const char* SDL_GetCurrentVideoDriver() { return "harmony"; }
const SDL_DisplayMode* SDL_GetCurrentDisplayMode(SDL_DisplayID id) {
static thread_local SDL_DisplayMode mode;
for (const auto& d : ReadDisplays(Environment())) if (d.SdlId() == id) {
mode = {d.width, d.height}; return &mode;
}
return nullptr;
}
SDL_DisplayID SDL_GetPrimaryDisplay() {
const auto displays = ReadDisplays(Environment());
return displays.empty() ? 0 : displays.front().SdlId();
}
SDL_DisplayID SDL_GetDisplayForPoint(const SDL_Point* point) {
if (!point) return 0;
const auto displays = ReadDisplays(Environment());
const auto* found = DisplayAt(displays, point->x, point->y);
return found ? found->SdlId() : 0;
}
SDL_DisplayID* SDL_GetDisplays(int* count) {
const auto displays = ReadDisplays(Environment());
auto* ids = static_cast<SDL_DisplayID*>(std::calloc(displays.size() + 1, sizeof(SDL_DisplayID)));
if (count) *count = ids ? int(displays.size()) : 0;
if (ids) for (size_t i = 0; i < displays.size(); ++i) ids[i] = displays[i].SdlId();
return ids;
}
bool SDL_GetDisplayUsableBounds(SDL_DisplayID id, SDL_Rect* bounds) {
if (!bounds) return false;
for (const auto& d : ReadDisplays(Environment())) if (d.SdlId() == id) {
*bounds = {d.usable_x, d.usable_y, d.usable_width, d.usable_height}; return true;
}
return false;
}
uint32_t SDL_GetGlobalMouseState(float* x, float* y) {
auto env = Environment();
double px = env.value("pointerX", 0.), py = env.value("pointerY", 0.);
// Touch-only devices and unfocused apps may not expose a global pointer.
// Keep the last window event coordinates intact and retry after hot-plug or
// focus changes without issuing a failing system call every render frame.
static thread_local std::chrono::steady_clock::time_point retry_after;
const auto now = std::chrono::steady_clock::now();
if (now >= retry_after) {
int32_t display = 0;
double native_x = px, native_y = py;
if (OH_Input_GetPointerLocation(&display, &native_x, &native_y) == INPUT_SUCCESS) {
px = native_x; py = native_y;
for (const auto& d : ReadDisplays(env)) if (d.id == display) { px += d.x; py += d.y; break; }
} else retry_after = now + std::chrono::seconds(1);
}
if (x) *x = px;
if (y) *y = py;
return env.value("buttons", 0u);
}
bool SDL_OpenURL(const char* url) { return Request({{"op", "openUrl"}, {"url", url}}).value("ok", false); }
struct SDL_AudioStream { crossdesk::harmony::AudioPlayer player; };
uint32_t SDL_OpenAudioDevice(uint32_t, const SDL_AudioSpec* spec) {
if (AudioFormat(spec)) return 1;
service_error = "Harmony playback requires 48 kHz mono PCM16"; return 0;
}
bool SDL_ResumeAudioDevice(uint32_t id) { return id == 1; }
void SDL_CloseAudioDevice(uint32_t) {}
SDL_AudioStream* SDL_CreateAudioStream(const SDL_AudioSpec* in, const SDL_AudioSpec* out) {
if (!AudioFormat(in) || !AudioFormat(out)) return nullptr;
return new SDL_AudioStream();
}
void SDL_DestroyAudioStream(SDL_AudioStream* stream) { delete stream; }
bool SDL_BindAudioStream(uint32_t id, SDL_AudioStream* stream) {
if (id == 1 && stream && stream->player.Start()) return true;
service_error = "OH_AudioRenderer initialization failed"; return false;
}
bool SDL_PutAudioStreamData(SDL_AudioStream* stream, const void* data, int size) {
if (!stream || !data || size <= 0) return false;
stream->player.Push(static_cast<const char*>(data), size); return true;
}
const char* tinyfd_selectFolderDialog(const char* title, const char* path) {
static thread_local std::string result;
result = Request({{"op", "selectFolder"}, {"title", title ? title : ""},
{"path", path ? path : ""}}).value("path", "");
return result.empty() ? nullptr : result.c_str();
}
const char* tinyfd_openFileDialog(const char* title, const char*, int, const char* const*, const char*, int) {
static thread_local std::string result;
result = Request({{"op", "selectFile"}, {"title", title ? title : ""}}).value("path", "");
return result.empty() ? nullptr : result.c_str();
}
namespace crossdesk {
HarmonyAutostartStatus GetHarmonyAutostartStatus() { return autostart_status.load(); }
void SetHarmonyAutostartStatus(HarmonyAutostartStatus status) { autostart_status = status; }
void RefreshHarmonyAutostartStatus() {
autostart_status = HarmonyAutostartStatus::checking;
Emit({{"op", "autostartRefresh"}});
}
std::string GetHostName() { return Environment().value("name", "Harmony PC"); }
std::string GetMac() { return {}; } // App sandbox does not expose a hardware MAC.
bool IsWaylandSession() { return false; }
namespace platform {
std::filesystem::path GetExecutableDirectory() { return Environment().value("filesDir", ""); }
std::filesystem::path GetConfigPath(const std::string&) { return GetCachePath(""); }
std::filesystem::path GetCachePath(const std::string&) {
auto path = GetExecutableDirectory() / "desktop";
std::filesystem::create_directories(path); return path;
}
std::filesystem::path GetLogPath(const std::string&) {
auto path = GetCachePath("") / "logs";
std::filesystem::create_directories(path); return path;
}
std::string GetAutostartExecutablePath() { return {}; }
bool IsAutostartEnabled(const std::string&) {
return GetHarmonyAutostartStatus() == HarmonyAutostartStatus::enabled;
}
// The public Harmony API can query this setting, but only the user can change
// it in system settings. Never report a registration change we did not make.
bool EnableAutostart(const std::string& app, const std::string&) { return IsAutostartEnabled(app); }
bool DisableAutostart(const std::string& app) {
return GetHarmonyAutostartStatus() == HarmonyAutostartStatus::disabled;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
// GLES presentation only; all UI and callbacks belong to GuiApplication.
struct Surface {
DesktopWindow& owner;
explicit Surface(DesktopWindow& window) : owner(window) {}
~Surface() { DetachSurface(); }
EGLDisplay display = EGL_NO_DISPLAY;
EGLContext context = EGL_NO_CONTEXT;
EGLSurface surface = EGL_NO_SURFACE;
OHNativeWindow* native_window = nullptr;
GLuint program = 0, texture = 0, vao = 0;
float density = 1;
std::vector<slint::Rgb8Pixel> pixels;
uint64_t frames = 0;
uint32_t rendered_width = 0, rendered_height = 0;
void DetachSurface() {
if (display != EGL_NO_DISPLAY) {
if (surface != EGL_NO_SURFACE && context != EGL_NO_CONTEXT) {
eglMakeCurrent(display, surface, surface, context);
glDeleteTextures(1, &texture); glDeleteProgram(program); glDeleteVertexArrays(1, &vao);
}
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (surface != EGL_NO_SURFACE) eglDestroySurface(display, surface);
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
// EGL display is shared by the application windows.
}
if (native_window) OH_NativeWindow_DestroyNativeWindow(native_window);
display = EGL_NO_DISPLAY; context = EGL_NO_CONTEXT; surface = EGL_NO_SURFACE;
native_window = nullptr; program = texture = vao = 0;
// ReusedBuffer's retained contents belong to the Slint window, not the EGL
// surface. Keep them when a hidden window gets a new XComponent; otherwise
// Slint repaints only dirty regions over a cleared (black) buffer.
frames = 0;
rendered_width = rendered_height = 0;
}
GLuint Shader(GLenum type, const char* source) {
GLuint value = glCreateShader(type);
glShaderSource(value, 1, &source, nullptr); glCompileShader(value);
GLint ok = 0; glGetShaderiv(value, GL_COMPILE_STATUS, &ok);
if (!ok) {
char log[1024]{}; glGetShaderInfoLog(value, sizeof(log), nullptr, log);
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Shader failed: %{public}s", log);
glDeleteShader(value); return 0;
}
return value;
}
bool AttachSurface(uint64_t id, float scale) {
DetachSurface();
if (!id || !std::isfinite(scale) || scale <= 0) return false;
owner.SetDensity(scale);
density = scale;
if (OH_NativeWindow_CreateNativeWindowFromSurfaceId(id, &native_window) != 0) return false;
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (!eglInitialize(display, nullptr, nullptr) || !eglBindAPI(EGL_OPENGL_ES_API)) return false;
const EGLint attributes[] = {EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE,
EGL_OPENGL_ES3_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
EGLConfig config; EGLint count = 0;
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false;
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
surface = eglCreateWindowSurface(display, config, reinterpret_cast<EGLNativeWindowType>(native_window), nullptr);
if (context == EGL_NO_CONTEXT || surface == EGL_NO_SURFACE ||
!eglMakeCurrent(display, surface, surface, context)) return false;
const char* vertex = "#version 300 es\n"
"out vec2 uv; void main(){ vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);"
"uv=vec2(p.x,1.-p.y); gl_Position=vec4(p*2.-1.,0.,1.);}";
const char* fragment = "#version 300 es\nprecision mediump float;"
"in vec2 uv; uniform sampler2D frame; out vec4 color; void main(){color=texture(frame,uv);}";
GLuint v = Shader(GL_VERTEX_SHADER, vertex), f = Shader(GL_FRAGMENT_SHADER, fragment);
if (!v || !f) { glDeleteShader(v); glDeleteShader(f); return false; }
program = glCreateProgram(); glAttachShader(program, v); glAttachShader(program, f);
glLinkProgram(program); glDeleteShader(v); glDeleteShader(f);
GLint linked = 0; glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked) return false;
glGenVertexArrays(1, &vao); glBindVertexArray(vao);
glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Surface attached scale=%{public}.3f", density);
return true;
}
void Render() {
if (surface == EGL_NO_SURFACE) return;
EGLint width = 0, height = 0;
eglQuerySurface(display, surface, EGL_WIDTH, &width);
eglQuerySurface(display, surface, EGL_HEIGHT, &height);
if (width <= 0 || height <= 0 || width > 8192 || height > 8192) return;
if (rendered_width != uint32_t(width) || rendered_height != uint32_t(height)) {
rendered_width = width; rendered_height = height;
owner.extent = slint::PhysicalSize({uint32_t(width), uint32_t(height)});
owner.window().dispatch_resize_event(slint::LogicalSize({width / density, height / density}));
pixels.resize(size_t(width) * height); owner.dirty = true;
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Window %{public}d resize %{public}dx%{public}d", owner.id, width, height);
}
if (!owner.dirty && !owner.window().has_active_animations()) return;
if (pixels.size() != size_t(width) * height) pixels.resize(size_t(width) * height);
const auto started = std::chrono::steady_clock::now();
owner.dirty = false;
owner.software.render(pixels, width);
if (!eglMakeCurrent(display, surface, surface, context)) return;
glViewport(0, 0, width, height); glUseProgram(program);
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, pixels.data());
glUniform1i(glGetUniformLocation(program, "frame"), 0);
glBindVertexArray(vao); glDrawArrays(GL_TRIANGLES, 0, 3);
const GLenum error = glGetError();
const bool swapped = eglSwapBuffers(display, surface);
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (error || !swapped) {
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Present failed gl=%{public}x egl=%{public}x", error, eglGetError());
} else if (++frames == 1 || frames % 120 == 0) {
double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - started).count();
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Presented frames=%{public}llu frame_ms=%{public}.2f", (unsigned long long)frames, ms);
}
}
};
+39
View File
@@ -0,0 +1,39 @@
#include "platform/harmony/gui/tray/harmony_tray.h"
#include "bridge.h"
#include "localization.h"
#include "rd_log.h"
namespace crossdesk {
namespace { HarmonyTray* current = nullptr; }
HarmonyTray::HarmonyTray(std::function<void()> show, std::function<void()> hide,
std::function<void()> settings, std::function<void()> exit,
const std::string& tooltip, int language)
: show_(std::move(show)), hide_(std::move(hide)),
settings_(std::move(settings)), exit_(std::move(exit)) {
current = this;
language = localization::detail::ClampLanguageIndex(language);
auto result = harmony::desktop::Request({{"op", "trayCreate"}, {"title", tooltip},
{"show", localization::show_main_window[language]},
{"settings", localization::settings[language]},
{"exit", localization::exit_program[language]}});
active_ = result.value("ok", false);
if (!active_) LOG_WARN("Harmony tray unavailable: {}", result.value("message", "unsupported"));
}
HarmonyTray::~HarmonyTray() { RemoveTrayIcon(); if (current == this) current = nullptr; }
bool HarmonyTray::MinimizeToTray() {
if (!active_) return false;
hide_();
return true;
}
void HarmonyTray::RemoveTrayIcon() {
if (!active_) return;
active_ = false;
harmony::desktop::Emit({{"op", "trayRemove"}});
}
void HarmonyTray::Dispatch(const std::string& action) {
if (!current) return;
if (action == "show") current->show_();
else if (action == "settings") current->settings_();
else if (action == "exit") current->exit_();
}
}
@@ -0,0 +1,28 @@
--- a/api/cpp/platform.rs
+++ b/api/cpp/platform.rs
@@ -98,6 +98,8 @@
}
fn update_window_properties(&self, properties: WindowProperties<'_>) {
+ #[cfg(target_env = "ohos")]
+ harmony_services::window_chrome(self);
unsafe { (self.update_window_properties)(self.user_data, &properties) }
}
}
@@ -206,6 +208,16 @@
pub extern "C" fn crossdesk_slint_set_os_callback(callback: HarmonyOsCallback) {
CALLBACK.set(Some(callback));
}
+ // Read these properties inside the normal property tracker so subsequent
+ // Slint changes propagate without a separate UI/window state machine.
+ pub(super) fn window_chrome(adapter: &CppWindowAdapter) {
+ let Some(callback) = CALLBACK.get() else { return };
+ let Some(item) = WindowInner::from_pub(&adapter.window).window_item() else { return };
+ let item = item.as_pin_ref();
+ let flags = i32::from(item.no_frame()) | (i32::from(item.always_on_top()) << 1);
+ unsafe { callback(adapter.user_data, adapter as *const _ as *const c_void, 4,
+ core::ptr::null(), 0, 0, 0, 0., 0., 0., 0., flags); }
+ }
impl WindowAdapterInternal for CppWindowAdapter {
fn input_method_request(&self, request: InputMethodRequest) {
let Some(callback) = CALLBACK.get() else { return };
+38
View File
@@ -0,0 +1,38 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOW_MODE_H_
#define _WINDOW_MODE_H_
#include <nlohmann/json.hpp>
namespace crossdesk::harmony::desktop {
// Slint setters synchronously notify the platform one property at a time.
// A native state snapshot must be acknowledged atomically without echoing
// those intermediate combinations back to the native compositor.
class WindowMode {
nlohmann::json known_ = nlohmann::json::object();
bool acknowledging_ = false;
public:
nlohmann::json Request(const nlohmann::json& desired) {
auto changes = nlohmann::json::object();
if (acknowledging_) return changes;
for (const auto& [name, value] : desired.items())
if (!known_.contains(name) || known_.at(name) != value) changes[name] = value;
known_ = desired;
return changes;
}
template <typename Apply>
void Acknowledge(const nlohmann::json& actual, Apply apply) {
known_.update(actual);
struct Reset { bool& flag; ~Reset() { flag = false; } } reset{acknowledging_};
acknowledging_ = true;
apply();
}
};
}
#endif