[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
+116
View File
@@ -0,0 +1,116 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require(process.argv[2]);
const path = require('node:path');
const source = process.argv[3];
const sent = [], queries = [], timers = new Map();
let supported = true, capabilityError = false, nextTimer = 0;
const sdk = {
isAutoStartupSupported() { if (capabilityError) throw Error('service unavailable'); return supported; },
getAutoStartupStatusForSelf() { return new Promise((resolve, reject) => queries.push({ resolve, reject })); }
};
const flush = () => new Promise(resolve => setImmediate(resolve));
function load(file, imports, extra = {}) {
const module = {};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, {
exports: module, require: name => imports[name] ?? {},
console: { warn() {}, error: e => { throw Error(e); } },
setTimeout: fn => { timers.set(++nextTimer, fn); return nextTimer; },
clearTimeout: id => timers.delete(id), ...extra
});
return module;
}
const { AutostartHost: host } = load(source, { '@kit.AbilityKit': { autoStartupManager: sdk } });
const notify = e => sent.push(JSON.parse(JSON.stringify(e)));
const latest = () => sent.at(-1);
const check = value => assert.deepEqual(latest(), { op: 'autostartState', ...value });
(async () => {
host.refresh(); assert.equal(sent.length, 0, 'no queries before startup');
supported = false; host.start(notify);
check({ ok: true, supported: false }); assert.equal(queries.length, 0);
assert.equal(timers.size, 0);
supported = true; host.refresh(); check({ pending: true });
queries.at(-1).resolve(true); await flush();
check({ ok: true, supported: true, active: true });
host.refresh(); queries.at(-1).resolve(false); await flush();
check({ ok: true, supported: true, active: false });
host.refresh(); queries.at(-1).reject(Error('permission/service failure')); await flush();
check({ ok: false }); // Failure must never be displayed as "Disabled".
capabilityError = true; host.refresh(); check({ ok: false });
capabilityError = false;
host.refresh(); const old = queries.at(-1);
host.refresh(); const fresh = queries.at(-1);
assert.equal(timers.size, 1, 'superseding a query clears its timeout');
fresh.resolve(true); await flush(); const count = sent.length;
old.resolve(false); await flush(); assert.equal(sent.length, count);
check({ ok: true, supported: true, active: true });
host.refresh(); const timedOut = queries.at(-1);
[...timers.values()][0](); check({ ok: false });
const afterTimeout = sent.length;
timedOut.resolve(true); await flush(); assert.equal(sent.length, afterTimeout);
host.refresh(); queries.at(-1).resolve(false); await flush();
check({ ok: true, supported: true, active: false });
host.refresh(); const beforeStop = queries.at(-1);
host.stop(); const stopped = sent.length;
beforeStop.resolve(true); await flush(); host.refresh();
assert.equal(sent.length, stopped); assert.equal(timers.size, 0);
host.start(notify); const previousRun = queries.at(-1);
host.start(notify); queries.at(-1).resolve(false); await flush();
const restarted = sent.length;
previousRun.reject(Error('late failure')); await flush(); assert.equal(sent.length, restarted);
host.stop();
// Exercise the real desktop startup, window activation, EntryAbility and
// native-command routing, not only the standalone query helper.
const handlers = new Map(), commands = [], order = [];
const target = {
on: (name, fn) => handlers.set(name, fn),
getWindowDecorVisible: () => false,
getWindowProperties: () => ({ id: 1, windowRect: { left: 0, top: 0 } })
};
const { DesktopHost: desktop } = load(path.join(path.dirname(source), 'DesktopHost.ets'), {
'libslint_desktop.so': { default: {
start: () => order.push('native'), send: text => notify(JSON.parse(text)),
poll: () => JSON.stringify(commands.splice(0))
} },
'./AutostartHost': { AutostartHost: host },
'./FileHost': { FileHost: { prepare: async () => {} } },
'./DesktopAccess': { DesktopAccess: { start() {}, refresh() {}, stop() {} } },
'./DisplayHost': { DisplayHost: {
snapshot: async () => [], observe() {}, global: () => ({ x: 0, y: 0 }), screen: () => ({ densityPixels: 1 })
} },
'@kit.ArkUI': { display: { getDefaultDisplaySync: () => ({ width: 1280, height: 800, densityPixels: 1, id: 0 }) },
window: { WindowEventType: { WINDOW_ACTIVE: 1, WINDOW_INACTIVE: 2 } } },
'@kit.BasicServicesKit': { pasteboard: { getSystemPasteboard: () => ({ on() {}, off() {} }) } },
'@kit.DeviceCertificateKit': { certificateManager: { getCertificateStorePath: () => '/test/ca', CertType: {} } }
}, { LocalStorage: class { setOrCreate() {} }, setInterval: () => 1, clearInterval() {} });
desktop.foreground(); assert.equal(timers.size, 0);
desktop.start({ getMainWindowSync: () => target, loadContent: async () => {} },
{ filesDir: '/test/files', cacheDir: '/test/cache', terminateSelf: () => order.push('exit') });
await flush(); assert.deepEqual(order, ['native']); check({ pending: true });
queries.at(-1).resolve(false); await flush();
check({ ok: true, supported: true, active: false });
const { default: Ability } = load(path.join(path.dirname(source), 'EntryAbility.ets'), {
'@kit.AbilityKit': { UIAbility: class {} }, '../pages/DesktopHost': { DesktopHost: desktop }
});
new Ability().onForeground(); check({ pending: true });
queries.at(-1).resolve(true); await flush(); check({ ok: true, supported: true, active: true });
handlers.get('windowEvent')(1); // Returning from system Settings refreshes.
queries.at(-1).resolve(false); await flush(); check({ ok: true, supported: true, active: false });
commands.push({ op: 'autostartRefresh' }); desktop.poll(); check({ pending: true });
const last = queries.at(-1);
commands.push({ op: 'exit' }); desktop.poll(); await flush();
const exited = sent.length;
last.resolve(true); await flush(); new Ability().onForeground();
assert.equal(sent.length, exited); assert.equal(timers.size, 0);
assert.deepEqual(order, ['native', 'exit']);
console.log('Autostart system state, failures, timeout, stale replies, startup/foreground and exit tests passed');
})().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
"""Build the opt-in synthetic host after building the Harmony native core."""
import importlib.util
from pathlib import Path
APP=Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('harmony_build',APP/'scripts/build.py')
build=importlib.util.module_from_spec(spec)
spec.loader.exec_module(build)
_, _, xmake, env=build.environment()
build.run([xmake,'-P',APP/'native','-y','crossdesk_harmony_mobile_fixture'],cwd=APP/'native',env=env)
+330
View File
@@ -0,0 +1,330 @@
#include "capture.h"
#include <native_buffer/native_buffer.h>
#include <native_buffer/buffer_common.h>
#include <multimedia/player_framework/native_avbuffer.h>
#include <hilog/log.h>
#include <libyuv.h>
#include <cassert>
#include <cstring>
#include <deque>
#include <fcntl.h>
#include <unistd.h>
// Test the production capture adapter with asynchronous SDK callbacks, without
// opening a recorder or granting access to the workstation's screen.
struct OH_AVScreenCapture {
OH_AVScreenCapture_OnStateChange state{};
OH_AVScreenCapture_OnError error{};
OH_AVScreenCapture_OnBufferAvailable data{};
void *state_user{}, *error_user{}, *data_user{};
bool started = false;
};
struct OH_NativeBuffer {};
struct OH_AVScreenCapture_CaptureStrategy { bool picker = true; };
struct OH_AVBuffer {};
struct OH_NativeImage {
OH_OnFrameAvailableListener listener{};
std::mutex mutex;
std::deque<int> fences;
};
OH_NativeImage* surface{};
int window_token = 0, buffer_token = 0;
bool surface_available = false, reject_surface_listener = false;
int surface_starts = 0, surface_destroys = 0;
std::atomic<int> surface_releases{0}, released_fence{-2};
OH_AVScreenCapture* current{};
OH_NativeBuffer native_buffer;
OH_AVBuffer av_buffer;
OH_NativeBuffer_Config buffer_config{4, 2, NATIVEBUFFER_PIXEL_FMT_RGBA_8888, 0, 16};
OH_AVCodecBufferAttr audio_attr{};
unsigned char pixels[32]{};
int rate_calls = 0, rate = 0, starts = 0, releases = 0, unrefs = 0;
std::atomic<int> unmaps{0};
bool reject_init = false, no_native = false, reject_map = false;
bool gpu_available = false;
int gpu_stops = 0;
int strategy_creates = 0, strategy_releases = 0, strategy_applies = 0;
bool reject_strategy_create = false, reject_picker = false, reject_strategy_apply = false;
bool picker_disabled = false;
bool cursor_shown = false, reject_cursor_hide = false, reject_cursor_show = false;
crossdesk::harmony::CaptureSurface::Frame gpu_frame;
// Only replace EGL/driver ownership here. Capture's authorization, conversion,
// SDK calls, fallback and cleanup remain the production implementation.
namespace crossdesk::harmony {
OHNativeWindow* CaptureSurface::Start(int width, int height, Frame frame) {
assert(width == 4 && height == 2);
if (!gpu_available) return nullptr;
gpu_frame = std::move(frame);
return reinterpret_cast<OHNativeWindow*>(&window_token);
}
void CaptureSurface::Stop() {
if (gpu_frame) { ++gpu_stops; gpu_frame = {}; }
}
}
extern "C" {
OH_NativeImage* OH_ConsumerSurface_Create() { return surface_available ? surface = new OH_NativeImage : nullptr; }
int32_t OH_ConsumerSurface_SetDefaultUsage(OH_NativeImage*, uint64_t usage) {
assert((usage & NATIVEBUFFER_USAGE_CPU_READ) && (usage & NATIVEBUFFER_USAGE_HW_RENDER)); return 0;
}
int32_t OH_ConsumerSurface_SetDefaultSize(OH_NativeImage*, int32_t width, int32_t height) { assert(width == 4 && height == 2); return 0; }
OHNativeWindow* OH_NativeImage_AcquireNativeWindow(OH_NativeImage*) { return reinterpret_cast<OHNativeWindow*>(&window_token); }
int32_t OH_NativeImage_SetOnFrameAvailableListener(OH_NativeImage* image, OH_OnFrameAvailableListener listener) {
if (reject_surface_listener) return -1;
image->listener = listener; return 0;
}
int32_t OH_NativeImage_UnsetOnFrameAvailableListener(OH_NativeImage* image) { image->listener = {}; return 0; }
void OH_NativeImage_Destroy(OH_NativeImage** image) {
assert(!(*image)->listener.onFrameAvailable);
delete *image; *image = nullptr; surface = nullptr; ++surface_destroys;
}
int32_t OH_NativeImage_AcquireNativeWindowBuffer(OH_NativeImage* image, OHNativeWindowBuffer** buffer, int* fence) {
std::lock_guard lock(image->mutex);
if (image->fences.empty()) return 40601000;
*fence = image->fences.front(); image->fences.pop_front();
*buffer = reinterpret_cast<OHNativeWindowBuffer*>(&buffer_token); return 0;
}
int32_t OH_NativeImage_ReleaseNativeWindowBuffer(OH_NativeImage*, OHNativeWindowBuffer*, int fence) {
released_fence = fence;
if (fence >= 0) close(fence);
++surface_releases; return 0;
}
int32_t OH_NativeBuffer_FromNativeWindowBuffer(OHNativeWindowBuffer*, OH_NativeBuffer** buffer) {
*buffer = &native_buffer; return 0; // Borrowed; must not decrement its reference count.
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_StartScreenCaptureWithSurface(OH_AVScreenCapture*, OHNativeWindow* window) {
assert(window == reinterpret_cast<OHNativeWindow*>(&window_token));
++starts; ++surface_starts; return AV_SCREEN_CAPTURE_ERR_OK;
}
int OH_LOG_Print(LogType, LogLevel, unsigned int, const char*, const char*, ...) { return 0; }
OH_AVScreenCapture* OH_AVScreenCapture_Create() { return current = new OH_AVScreenCapture; }
OH_AVScreenCapture_CaptureStrategy* OH_AVScreenCapture_CreateCaptureStrategy() {
++strategy_creates;
return reject_strategy_create ? nullptr : new OH_AVScreenCapture_CaptureStrategy;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_ReleaseCaptureStrategy(OH_AVScreenCapture_CaptureStrategy* strategy) {
++strategy_releases; delete strategy; return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_StrategyForPickerPopUp(OH_AVScreenCapture_CaptureStrategy* strategy, bool value) {
assert(!value); strategy->picker = value;
return reject_picker ? AV_SCREEN_CAPTURE_ERR_INVALID_VAL : AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_SetCaptureStrategy(OH_AVScreenCapture* p, OH_AVScreenCapture_CaptureStrategy* strategy) {
assert(p == current && !p->started);
++strategy_applies;
if (reject_strategy_apply) return AV_SCREEN_CAPTURE_ERR_INVALID_VAL;
picker_disabled = !strategy->picker;
return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_Release(OH_AVScreenCapture* p) {
++releases; delete p; current = nullptr; return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_SetStateCallback(OH_AVScreenCapture* p, OH_AVScreenCapture_OnStateChange cb, void* user) {
p->state = cb; p->state_user = user; return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_SetErrorCallback(OH_AVScreenCapture* p, OH_AVScreenCapture_OnError cb, void* user) {
p->error = cb; p->error_user = user; return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_SetDataCallback(OH_AVScreenCapture* p, OH_AVScreenCapture_OnBufferAvailable cb, void* user) {
p->data = cb; p->data_user = user; return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_Init(OH_AVScreenCapture*, OH_AVScreenCaptureConfig config) {
assert(config.captureMode == OH_CAPTURE_SPECIFIED_SCREEN);
assert(config.videoInfo.videoCapInfo.displayId == 42);
assert(config.videoInfo.videoCapInfo.videoFrameWidth == 4 && config.videoInfo.videoCapInfo.videoFrameHeight == 2);
return reject_init ? AV_SCREEN_CAPTURE_ERR_INVALID_VAL : AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_SetMicrophoneEnabled(OH_AVScreenCapture*, bool enabled) {
assert(!enabled); return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_StartScreenCapture(OH_AVScreenCapture*) {
++starts; return AV_SCREEN_CAPTURE_ERR_OK; // Consent is still pending.
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_StopScreenCapture(OH_AVScreenCapture* p) {
p->started = false; p->state(p, OH_SCREEN_CAPTURE_STATE_STOPPED_BY_USER, p->state_user);
return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_SetMaxVideoFrameRate(OH_AVScreenCapture* p, int32_t fps) {
assert(p->started); ++rate_calls; rate = fps; return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_AVSCREEN_CAPTURE_ErrCode OH_AVScreenCapture_ShowCursor(OH_AVScreenCapture* p, bool visible) {
assert(p->started);
if (visible ? reject_cursor_show : reject_cursor_hide) return AV_SCREEN_CAPTURE_ERR_OPERATE_NOT_PERMIT;
cursor_shown = visible;
return AV_SCREEN_CAPTURE_ERR_OK;
}
OH_NativeBuffer* OH_AVBuffer_GetNativeBuffer(OH_AVBuffer*) { return no_native ? nullptr : &native_buffer; }
void OH_NativeBuffer_GetConfig(OH_NativeBuffer*, OH_NativeBuffer_Config* config) { *config = buffer_config; }
int32_t OH_NativeBuffer_Map(OH_NativeBuffer*, void** out) { *out = pixels; return reject_map ? -1 : 0; }
int32_t OH_NativeBuffer_Unmap(OH_NativeBuffer*) { ++unmaps; return 0; }
int32_t OH_NativeBuffer_Unreference(OH_NativeBuffer*) { ++unrefs; return 0; }
uint8_t* OH_AVBuffer_GetAddr(OH_AVBuffer*) { return pixels; }
int32_t OH_AVBuffer_GetCapacity(OH_AVBuffer*) { return sizeof(pixels); }
OH_AVErrCode OH_AVBuffer_GetBufferAttr(OH_AVBuffer*, OH_AVCodecBufferAttr* out) { *out = audio_attr; return AV_ERR_OK; }
}
namespace libyuv {
extern "C" int ABGRToNV12(const uint8_t* src, int stride, uint8_t* y, int y_stride,
uint8_t* uv, int uv_stride, int width, int height) {
assert(src == pixels && stride == 16 && width == 4 && height == 2);
assert(y_stride == 4 && uv_stride == 4 && uv == y + 8);
std::memset(y, 128, 12); return 0;
}
}
void State(OH_AVScreenCaptureStateCode state) {
if (state == OH_SCREEN_CAPTURE_STATE_STARTED) current->started = true;
current->state(current, state, current->state_user);
}
void Data(OH_AVScreenCaptureBufferType type = OH_SCREEN_CAPTURE_BUFFERTYPE_VIDEO) {
current->data(current, &av_buffer, type, 0, current->data_user);
}
void SurfaceData(std::initializer_list<int> fences) {
{ std::lock_guard lock(surface->mutex); surface->fences.insert(surface->fences.end(), fences); }
surface->listener.onFrameAvailable(surface->listener.context);
}
template<class F> void Wait(F condition) {
const auto limit = std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (!condition() && std::chrono::steady_clock::now() < limit)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
assert(condition());
}
int main() {
crossdesk::harmony::Capture capture;
std::atomic<int> frames{0};
int audio_bytes = 0, last_state = -1;
const auto frame = [&](const MiniRtcVideoFrame& value) {
assert(value.width == 4 && value.height == 2 && value.size == 12);
assert(static_cast<unsigned char>(value.data[0]) == 128); ++frames;
};
const auto state = [&](int code) { last_state = code; };
const auto audio = [&](const char*, size_t size) { audio_bytes += int(size); };
assert(capture.Start(4, 2, 42, frame, state, audio, 30));
assert(starts == 1 && rate_calls == 0 && !capture.CursorEmbedded());
Data(); assert(frames == 0); // Do not send data before consent completes.
State(OH_SCREEN_CAPTURE_STATE_STARTED);
assert(rate == 30 && rate_calls == 1 && capture.CursorEmbedded());
Data(); assert(frames == 1 && unrefs == 1 && unmaps == 1);
State(OH_SCREEN_CAPTURE_STATE_ENTER_PRIVATE_SCENE); Data(); assert(frames == 1);
State(OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE); Data(); assert(frames == 2);
State(OH_SCREEN_CAPTURE_STATE_PAUSED_BY_USER); Data(); assert(frames == 2);
State(OH_SCREEN_CAPTURE_STATE_RESUMED_BY_USER); Data(); assert(frames == 3);
State(OH_SCREEN_CAPTURE_STATE_MIC_MUTED_BY_USER); Data(); assert(frames == 4);
audio_attr.offset = 2; audio_attr.size = 4;
Data(OH_SCREEN_CAPTURE_BUFFERTYPE_AUDIO_INNER); assert(audio_bytes == 4);
audio_attr.offset = 31; audio_attr.size = 4;
Data(OH_SCREEN_CAPTURE_BUFFERTYPE_AUDIO_INNER); assert(audio_bytes == 4);
no_native = true; Data(); no_native = false; assert(frames == 4 && unrefs == 4);
reject_map = true; Data(); reject_map = false; assert(frames == 4 && unrefs == 5 && unmaps == 4);
buffer_config.width = 3; Data(); buffer_config.width = 4; assert(frames == 4 && unrefs == 6);
current->error(current, -10, current->error_user); Data(); assert(frames == 4 && last_state == -1);
capture.Stop(); assert(current == nullptr && releases == 1 && !capture.CursorEmbedded());
capture.Stop(); assert(releases == 1);
reject_init = true;
assert(!capture.Start(4, 2, 42, frame, state, audio, 120));
assert(starts == 1 && releases == 2 && current == nullptr);
reject_init = false;
assert(capture.Start(4, 2, 42, frame, state, audio, 120));
State(OH_SCREEN_CAPTURE_STATE_STARTED); assert(rate == 60);
State(OH_SCREEN_CAPTURE_STATE_CANCELED); Data(); assert(frames == 4);
capture.Stop();
surface_available = true;
assert(capture.Start(4, 2, 42, frame, state, audio, 30));
assert(surface_starts == 1);
SurfaceData({-1}); Wait([] { return surface_releases == 1; }); assert(frames == 4);
State(OH_SCREEN_CAPTURE_STATE_STARTED);
const int references_before = unrefs;
SurfaceData({-1, -1, -1}); // One callback for three queued buffers must drain all three.
Wait([&] { return frames == 7 && surface_releases == 4; });
assert(unrefs == references_before);
State(OH_SCREEN_CAPTURE_STATE_ENTER_PRIVATE_SCENE);
SurfaceData({-1}); Wait([] { return surface_releases == 5; }); assert(frames == 7);
State(OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE);
int pipe_fd[2]; assert(pipe(pipe_fd) == 0);
assert(write(pipe_fd[1], "x", 1) == 1);
SurfaceData({pipe_fd[0]}); Wait([&] { return frames == 8 && surface_releases == 6; });
Wait([&] { return fcntl(pipe_fd[0], F_GETFD) == -1; }); close(pipe_fd[1]);
assert(released_fence == -1);
assert(pipe(pipe_fd) == 0);
SurfaceData({pipe_fd[0]}); // Timeout must return the outstanding fence to its owner.
Wait([] { return surface_releases == 7; });
assert(frames == 8 && released_fence == pipe_fd[0] && fcntl(pipe_fd[0], F_GETFD) == -1);
close(pipe_fd[1]);
capture.Stop(); assert(surface == nullptr && surface_destroys == 1);
capture.Stop(); assert(surface_destroys == 1);
reject_surface_listener = true;
assert(capture.Start(4, 2, 42, frame, state, audio, 30));
assert(surface == nullptr && surface_destroys == 2 && surface_starts == 1);
State(OH_SCREEN_CAPTURE_STATE_STARTED); Data(); assert(frames == 9);
capture.Stop();
reject_surface_listener = false;
reject_init = true;
assert(!capture.Start(4, 2, 42, frame, state, audio, 30));
assert(surface == nullptr && surface_destroys == 3); // Worker exits even when capture never starts.
reject_init = false;
gpu_available = true;
assert(capture.Start(4, 2, 42, frame, state, audio, 30));
assert(surface == nullptr && surface_starts == 2); // Prefer GPU over CPU mapping.
gpu_frame(pixels, 4, 2, 16); assert(frames == 9);
State(OH_SCREEN_CAPTURE_STATE_STARTED);
gpu_frame(pixels, 4, 2, 16); assert(frames == 10);
State(OH_SCREEN_CAPTURE_STATE_ENTER_PRIVATE_SCENE);
gpu_frame(pixels, 4, 2, 16); assert(frames == 10);
State(OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE);
gpu_frame(pixels, 4, 2, 16); assert(frames == 11);
State(OH_SCREEN_CAPTURE_STATE_STOPPED_BY_USER);
gpu_frame(pixels, 4, 2, 16); assert(frames == 11);
capture.Stop(); capture.Stop(); assert(gpu_stops == 1 && !gpu_frame);
reject_init = true;
assert(!capture.Start(4, 2, 42, frame, state, audio, 30));
assert(gpu_stops == 2 && !gpu_frame && current == nullptr);
assert(strategy_creates == 0); // Ordinary sessions keep the system's default authorization UI.
reject_init = false;
assert(capture.Start(4, 2, 42, frame, state, audio, 30, true));
assert(picker_disabled && strategy_applies == 1 && strategy_creates == strategy_releases);
gpu_frame(pixels, 4, 2, 16); assert(frames == 11); // A permission hint cannot replace STARTED.
State(OH_SCREEN_CAPTURE_STATE_STARTED);
gpu_frame(pixels, 4, 2, 16); assert(frames == 12);
capture.Stop();
assert(capture.Start(4, 2, 42, frame, state, audio, 30, true));
assert(strategy_applies == 2 && strategy_creates == strategy_releases);
State(OH_SCREEN_CAPTURE_STATE_CANCELED); // The OS can still reject a revoked grant.
gpu_frame(pixels, 4, 2, 16); assert(frames == 12);
capture.Stop();
for (bool* failure : {&reject_strategy_create, &reject_picker, &reject_strategy_apply}) {
*failure = true; picker_disabled = false;
assert(capture.Start(4, 2, 42, frame, state, audio, 30, true));
assert(!picker_disabled); // Failed optional configuration falls back to system authorization.
capture.Stop(); *failure = false;
}
assert(strategy_creates == strategy_releases + 1); // Failed allocation has no resource to release.
using crossdesk::harmony::CaptureCursorMode;
assert(capture.Start(4, 2, 42, frame, state, {}, 30, false, false));
assert(capture.CursorMode() == CaptureCursorMode::unknown);
State(OH_SCREEN_CAPTURE_STATE_STARTED);
assert(!cursor_shown && capture.CursorMode() == CaptureCursorMode::separate);
State(OH_SCREEN_CAPTURE_STATE_ENTER_PRIVATE_SCENE);
cursor_shown = true;
State(OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE);
assert(!cursor_shown && capture.CursorMode() == CaptureCursorMode::separate);
State(OH_SCREEN_CAPTURE_STATE_PAUSED_BY_USER);
cursor_shown = true;
State(OH_SCREEN_CAPTURE_STATE_RESUMED_BY_USER);
assert(!cursor_shown && capture.CursorMode() == CaptureCursorMode::separate);
capture.Stop();
assert(capture.CursorMode() == CaptureCursorMode::unknown);
reject_cursor_hide = true;
assert(capture.Start(4, 2, 42, frame, state, {}, 30, false, false));
State(OH_SCREEN_CAPTURE_STATE_STARTED);
assert(cursor_shown && capture.CursorMode() == CaptureCursorMode::embedded);
capture.Stop();
reject_cursor_show = true;
assert(capture.Start(4, 2, 42, frame, state, {}, 30, false, false));
State(OH_SCREEN_CAPTURE_STATE_STARTED);
assert(capture.CursorMode() == CaptureCursorMode::unknown);
capture.Stop();
}
+413
View File
@@ -0,0 +1,413 @@
#include "bridge.h"
#include "capture.h"
#include "input.h"
#include "keyboard_hook.h"
#include "host_access.h"
#include "keyboard_hook_mock.h"
#include "platform/harmony/input/system_shortcuts.h"
#include "screen_capturer_factory.h"
#include "device_controller_factory.h"
#include "runtime/cursor_state_provider.h"
#include "runtime/cursor_presentation.h"
#include <multimodalinput/oh_input_manager.h>
#include <hilog/log.h>
#include <cassert>
#include <memory>
#include <tuple>
#include <limits>
// Exercise the production desktop adapter and SDK declarations without
// recording the workstation or requesting real input-injection permissions.
using crossdesk::harmony::desktop::Json;
Json environment;
crossdesk::harmony::Capture::Frame frame_callback;
crossdesk::harmony::Capture::State state_callback;
std::tuple<int, int, int, int> capture_parameters;
int starts = 0, stops = 0, fail_display = -1, input_display = -1, releases = 0;
bool release_ok = true;
bool persistent_capture = false;
bool capture_show_cursor = true;
crossdesk::harmony::CaptureCursorMode captured_cursor_mode = crossdesk::harmony::CaptureCursorMode::separate;
struct Input_CursorInfo {};
bool pointer_visible = true;
Input_PointerStyle pointer_style = DEFAULT;
Input_Result cursor_result = INPUT_SUCCESS, location_result = INPUT_SUCCESS, style_result = INPUT_SUCCESS;
int32_t pointer_display = 0;
double pointer_x = 960, pointer_y = 270;
struct Input_MouseEvent { int32_t action, display, x, y; };
Input_MouseEventCallback mouse_monitor = nullptr;
Input_Result monitor_add_result = INPUT_PERMISSION_DENIED, monitor_remove_result = INPUT_SUCCESS;
int monitor_adds = 0, monitor_removes = 0;
const Input_MouseEvent* initial_monitor_event = nullptr;
int injection_requests = 0;
Input_InjectionStatus authorization = UNAUTHORIZED;
namespace crossdesk::harmony::desktop {
Json Environment() { return environment; }
void Emit(Json) {}
}
namespace crossdesk::harmony {
void CaptureSurface::Stop() {}
bool Capture::Start(int width, int height, uint64_t display, Frame frame, State state, Audio, int fps, bool persistent_permission, bool show_cursor) {
++starts;
persistent_capture = persistent_permission;
capture_show_cursor = show_cursor;
if (int(display) == fail_display) return false;
capture_parameters = {width, height, int(display), fps};
frame_callback = std::move(frame);
state_callback = state;
cursor_mode_ = show_cursor ? CaptureCursorMode::embedded : captured_cursor_mode;
state(OH_SCREEN_CAPTURE_STATE_STARTED);
return true;
}
void Capture::Stop() { ++stops; frame_callback = {}; }
bool Input::Configure(int, int, int display) { if (!Release()) return false; input_display = display; return true; }
bool Input::Release() { ++releases; return release_ok; }
bool Input::Apply(const RemoteAction&) { return true; }
bool Input::LockWorkstation() { return true; }
}
extern "C" {
int OH_LOG_Print(LogType, LogLevel, unsigned int, const char*, const char*, ...) { return 0; }
Input_Result OH_Input_QueryAuthorizedStatus(Input_InjectionStatus* result) { *result = authorization; return INPUT_SUCCESS; }
Input_Result OH_Input_RequestInjection(Input_InjectAuthorizeCallback callback) {
++injection_requests;
authorization = AUTHORIZED; callback(authorization); return INPUT_SUCCESS;
}
void OH_Input_CancelInjection() { authorization = UNAUTHORIZED; }
Input_CursorInfo* OH_Input_CursorInfo_Create() { return new Input_CursorInfo; }
void OH_Input_CursorInfo_Destroy(Input_CursorInfo** info) { delete *info; *info = nullptr; }
Input_Result OH_Input_GetCursorInfo(Input_CursorInfo*, OH_PixelmapNative** pixels) {
assert(pixels == nullptr); return cursor_result;
}
Input_Result OH_Input_CursorInfo_IsVisible(Input_CursorInfo*, bool* visible) {
*visible = pointer_visible; return INPUT_SUCCESS;
}
Input_Result OH_Input_CursorInfo_GetStyle(Input_CursorInfo*, Input_PointerStyle* style) {
assert(pointer_visible); *style = pointer_style; return style_result;
}
Input_Result OH_Input_GetPointerLocation(int32_t* display, double* x, double* y) {
*display = pointer_display; *x = pointer_x; *y = pointer_y; return location_result;
}
Input_Result OH_Input_AddMouseEventMonitor(Input_MouseEventCallback callback) {
++monitor_adds;
assert(!mouse_monitor);
if (monitor_add_result == INPUT_SUCCESS) {
mouse_monitor = callback;
if (initial_monitor_event) callback(initial_monitor_event);
}
return monitor_add_result;
}
Input_Result OH_Input_RemoveMouseEventMonitor(Input_MouseEventCallback callback) {
++monitor_removes;
assert(mouse_monitor == callback || (!mouse_monitor && monitor_remove_result == INPUT_PARAMETER_ERROR));
if (monitor_remove_result == INPUT_SUCCESS || monitor_remove_result == INPUT_PARAMETER_ERROR) mouse_monitor = nullptr;
return monitor_remove_result;
}
int32_t OH_Input_GetMouseEventAction(const Input_MouseEvent* event) { return event->action; }
int32_t OH_Input_GetMouseEventDisplayId(const Input_MouseEvent* event) { return event->display; }
int32_t OH_Input_GetMouseEventDisplayX(const Input_MouseEvent* event) { return event->x; }
int32_t OH_Input_GetMouseEventDisplayY(const Input_MouseEvent* event) { return event->y; }
}
int main() {
using namespace crossdesk;
const auto primary = Json::parse(R"({"id":0,"primary":true,"width":1920,"height":1080})");
const auto secondary = Json::parse(R"({"id":42,"x":-2560,"width":2560,"height":1440})");
environment["displays"] = Json::array({secondary, primary});
ScreenCapturerFactory factory;
std::unique_ptr<ScreenCapturer> screen(factory.Create());
std::string stream;
screen->Init(30, [&](unsigned char*, int, int, int, const char* id, const MiniRtcNativeVideoFrame*) { stream = id; });
assert(screen->GetDisplayInfoList().size() == 2);
assert(screen->Start(false) == 0);
assert((capture_parameters == std::tuple{1920, 1080, 0, 30}));
MiniRtcVideoFrame frame{}; frame_callback(frame); assert(stream == "Display1");
CursorStateProvider cursor;
CursorState cursor_state{};
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(!capture_show_cursor && cursor_state.render_mode == CursorRenderMode::separate);
assert(cursor_state.visible && cursor_state.shape == RemoteCursorShape::default_cursor);
assert(cursor_state.position_update && cursor_state.position_valid && cursor_state.display_id == 0);
assert(cursor_state.x == 0.5f && cursor_state.y == 0.25f);
const std::pair<Input_PointerStyle, RemoteCursorShape> shapes[] = {
{TEXT_CURSOR, RemoteCursorShape::text}, {HORIZONTAL_TEXT_CURSOR, RemoteCursorShape::text},
{HAND_POINTING, RemoteCursorShape::pointer}, {HAND_OPEN, RemoteCursorShape::grab},
{HAND_GRABBING, RemoteCursorShape::grabbing}, {CURSOR_FORBID, RemoteCursorShape::not_allowed},
{NORTH_WEST_SOUTH_EAST, RemoteCursorShape::nwse_resize}, {NORTH_EAST_SOUTH_WEST, RemoteCursorShape::nesw_resize},
{WEST_EAST, RemoteCursorShape::ew_resize}, {NORTH_SOUTH, RemoteCursorShape::ns_resize},
{RESIZE_LEFT_RIGHT, RemoteCursorShape::col_resize}, {RESIZE_UP_DOWN, RemoteCursorShape::row_resize},
{NORTH, RemoteCursorShape::n_resize}, {SOUTH, RemoteCursorShape::s_resize},
{EAST, RemoteCursorShape::e_resize}, {WEST, RemoteCursorShape::w_resize},
{NORTH_EAST, RemoteCursorShape::ne_resize}, {NORTH_WEST, RemoteCursorShape::nw_resize},
{SOUTH_EAST, RemoteCursorShape::se_resize}, {SOUTH_WEST, RemoteCursorShape::sw_resize},
{LOADING, RemoteCursorShape::wait}, {RUNNING, RemoteCursorShape::progress},
{HELP, RemoteCursorShape::help}, {MOVE, RemoteCursorShape::move},
{CROSS, RemoteCursorShape::crosshair}, {CURSOR_COPY, RemoteCursorShape::copy},
{DEVELOPER_DEFINED_ICON, RemoteCursorShape::default_cursor},
{static_cast<Input_PointerStyle>(999), RemoteCursorShape::default_cursor}
};
for (const auto& [native, remote] : shapes) {
pointer_style = native;
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(cursor_state.shape == remote);
RemoteAction outgoing{}, incoming{};
outgoing.type = ControlType::cursor_state; outgoing.cs = cursor_state;
assert(incoming.from_json(outgoing.to_json()));
assert(incoming.type == ControlType::cursor_state && incoming.cs.position_valid);
assert(incoming.cs.display_id == 0 && incoming.cs.x == 0.5f && incoming.cs.y == 0.25f);
// Mac and Windows both use this presentation policy for the Harmony host.
CursorPresentation presentation;
presentation.Observe(incoming.cs, 100);
assert(presentation.Resolve(101, HostPlatform::HarmonyOS) == remote);
}
pointer_visible = false;
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(!cursor_state.visible && cursor_state.shape == RemoteCursorShape::none);
assert(cursor_state.render_mode == CursorRenderMode::hidden && cursor_state.hidden_reason == CursorHiddenReason::system_hidden);
location_result = INPUT_DEVICE_NO_POINTER;
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(!cursor_state.position_valid && cursor_state.hidden_reason == CursorHiddenReason::no_pointing_device);
pointer_visible = true; pointer_style = TEXT_CURSOR;
location_result = INPUT_APP_NOT_FOCUSED;
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(cursor_state.visible && cursor_state.shape == RemoteCursorShape::text && !cursor_state.position_valid);
cursor_result = INPUT_SERVICE_EXCEPTION;
assert(!cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
cursor_result = INPUT_SUCCESS; style_result = INPUT_SERVICE_EXCEPTION;
assert(!cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
style_result = INPUT_SUCCESS; location_result = INPUT_SUCCESS;
pointer_display = 42; pointer_x = 1280; pointer_y = 720;
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(cursor_state.position_valid && cursor_state.display_id == 1 && cursor_state.x == 0.5f && cursor_state.y == 0.5f);
auto overlapping = screen->GetDisplayInfoList();
overlapping[1].left = overlapping[1].top = 0;
overlapping[1].right = 2560; overlapping[1].bottom = 1440;
assert(cursor.Sample(overlapping, 0, &cursor_state) && cursor_state.display_id == 1);
for (double invalid : {-1., 2560., std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
pointer_x = invalid;
assert(cursor.Sample(overlapping, 0, &cursor_state) && !cursor_state.position_valid);
}
pointer_x = 1280; pointer_display = 99;
assert(cursor.Sample(overlapping, 0, &cursor_state) && !cursor_state.position_valid);
pointer_display = 42;
overlapping[1].width = 0;
assert(cursor.Sample(overlapping, 0, &cursor_state) && !cursor_state.position_valid);
assert(screen->SwitchTo(1) == 0 && screen->GetCurrentMonitorIndex() == 1);
assert(!capture_show_cursor); // Separate cursor policy survives a physical display switch.
assert((capture_parameters == std::tuple{2560, 1440, 42, 30}));
frame_callback(frame); assert(stream == "Display2");
DeviceControllerFactory input_factory;
std::unique_ptr<DeviceController> device(input_factory.Create(DeviceControllerFactory::Mouse));
auto* mouse = static_cast<MouseController*>(device.get());
mouse->Init(screen->GetDisplayInfoList());
std::unique_ptr<DeviceController> keyboard_device(input_factory.Create(DeviceControllerFactory::Keyboard));
auto* keyboard = static_cast<KeyboardCapturer*>(keyboard_device.get());
const int before_unhook = releases;
assert(keyboard->Unhook() == 0 && releases == before_unhook);
harmony::desktop::SetKeyboardFocus(2, true);
harmony::desktop::SetKeyboardHookPermission(false);
hook_add_result = INPUT_PERMISSION_DENIED;
assert(keyboard->Hook([](int, bool, uint32_t, bool, void*) {}, nullptr) == INPUT_PERMISSION_DENIED);
assert(!keyboard->ShouldRetryHook()); // Do not interrupt a held fallback chord every second.
harmony::desktop::SetKeyboardHookPermission(true);
assert(keyboard->ShouldRetryHook());
hook_add_result = INPUT_SUCCESS;
RemoteAction action{}; action.type = ControlType::mouse;
release_ok = false;
assert(mouse->SendMouseCommand(action, 1) != 0 && input_display == 0);
release_ok = true;
const int before_input_switch = releases;
assert(mouse->SendMouseCommand(action, 1) == 0 && input_display == 42);
assert(releases == before_input_switch + 1);
assert(mouse->SendMouseCommand(action, 1) == 0 && releases == before_input_switch + 1);
assert(mouse->SendMouseCommand(action, -1) != 0 && mouse->SendMouseCommand(action, 2) != 0);
state_callback(OH_SCREEN_CAPTURE_STATE_MIC_MUTED_BY_USER);
assert(mouse->SendMouseCommand(action, 1) == 0);
state_callback(OH_SCREEN_CAPTURE_STATE_ENTER_PRIVATE_SCENE);
assert(mouse->SendMouseCommand(action, 1) != 0);
assert(!cursor.Sample(screen->GetDisplayInfoList(), 1, &cursor_state));
state_callback(OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE);
assert(mouse->SendMouseCommand(action, 1) == 0);
authorization = UNAUTHORIZED;
assert(mouse->SendMouseCommand(action, 1) != 0);
authorization = AUTHORIZED;
assert(SendHarmonySystemShortcut(ServiceCommandFlag::lock_workstation));
assert(!SendHarmonySystemShortcut(ServiceCommandFlag::send_sas));
fail_display = 0;
assert(screen->SwitchTo(0) != 0 && screen->GetCurrentMonitorIndex() == 1);
frame_callback(frame); assert(stream == "Display2"); // Failed switch rolls back.
fail_display = -1;
environment["displays"] = Json::array({secondary});
const int before_removal = starts;
auto displays = screen->GetDisplayInfoList();
assert(displays.size() == 2 && displays[0].width == 0 && displays[1].width == 2560);
assert(screen->GetCurrentMonitorIndex() == 1 && starts == before_removal);
mouse->UpdateDisplayInfoList(displays);
assert(mouse->SendMouseCommand(action, 0) != 0);
assert(screen->SwitchTo(0) != 0);
environment["displays"] = Json::array({primary});
screen->GetDisplayInfoList();
assert(screen->GetCurrentMonitorIndex() == 0);
frame_callback(frame); assert(stream == "Display1");
environment["displays"].push_back(Json::parse(R"({"id":91,"width":1280,"height":720})"));
assert(screen->GetDisplayInfoList().size() == 2); // Unnegotiated streams must not be advertised as usable.
environment["displays"] = Json::array();
screen->GetDisplayInfoList();
assert(screen->GetCurrentMonitorIndex() == -1 && !frame_callback);
const int before_repeat = stops;
screen->GetDisplayInfoList(); assert(stops == before_repeat);
environment["displays"] = Json::array({secondary});
screen->GetDisplayInfoList();
assert(screen->GetCurrentMonitorIndex() == 1 && frame_callback);
frame_callback(frame); assert(stream == "Display2");
assert(screen->Pause(1) == 0 && !frame_callback);
assert(screen->Resume(1) == 0 && frame_callback);
assert(screen->Stop() == 0);
assert(mouse->SendMouseCommand(action, 1) != 0); // Authorization alone is insufficient after capture ends.
assert(!SendHarmonySystemShortcut(ServiceCommandFlag::lock_workstation));
// A persistent grant is independent of dialog authorization and survives
// disconnect/reconnect. Capture must still be active before accepting input.
harmony::desktop::SetHostPermissions(true, true);
const int before_persistent = injection_requests;
assert(authorization == UNAUTHORIZED);
assert(screen->Resume(1) == 0 && persistent_capture);
assert(injection_requests == before_persistent && authorization == UNAUTHORIZED);
mouse->UpdateDisplayInfoList(screen->GetDisplayInfoList());
assert(mouse->SendMouseCommand(action, 1) == 0);
assert(keyboard->SendKeyboardCommand(65, true) == 0);
assert(SendHarmonySystemShortcut(ServiceCommandFlag::lock_workstation));
state_callback(OH_SCREEN_CAPTURE_STATE_ENTER_PRIVATE_SCENE);
assert(mouse->SendMouseCommand(action, 1) != 0);
assert(keyboard->SendKeyboardCommand(65, false) != 0);
state_callback(OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE);
assert(injection_requests == before_persistent);
assert(screen->Stop() == 0 && mouse->SendMouseCommand(action, 1) != 0);
assert(screen->Resume(1) == 0 && persistent_capture && injection_requests == before_persistent);
const int before_revoke = releases;
harmony::desktop::SetHostPermissions(false, false);
assert(releases == before_revoke + 1);
assert(mouse->SendMouseCommand(action, 1) != 0);
assert(keyboard->SendKeyboardCommand(65, true) != 0);
assert(!SendHarmonySystemShortcut(ServiceCommandFlag::lock_workstation));
assert(injection_requests == before_persistent); // Revocation never triggers a prompt loop.
assert(screen->Stop() == 0 && screen->Resume(1) == 0);
assert(!persistent_capture && injection_requests == before_persistent + 1);
assert(mouse->SendMouseCommand(action, 1) == 0); // Ordinary consent remains usable.
screen->Stop();
assert(!cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(screen->Start(true) == 0 && capture_show_cursor);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(cursor_state.render_mode == CursorRenderMode::embedded && !cursor_state.visible);
assert(cursor_state.shape == RemoteCursorShape::none && !cursor_state.position_valid);
CursorPresentation embedded;
embedded.Observe(cursor_state, 100);
assert(embedded.Resolve(101, HostPlatform::HarmonyOS) == RemoteCursorShape::none);
assert(screen->Start(false) == 0 && !capture_show_cursor);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(cursor_state.render_mode == CursorRenderMode::separate);
screen->Stop();
captured_cursor_mode = harmony::CaptureCursorMode::embedded; // Recorder refused to remove its cursor.
assert(screen->Start(false) == 0);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(cursor_state.render_mode == CursorRenderMode::embedded);
screen->Stop();
captured_cursor_mode = harmony::CaptureCursorMode::unknown;
assert(screen->Start(false) == 0);
assert(!cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
screen->Stop();
// INPUT_MONITORING supplies screen-relative positions while the direct
// query is rejected in the background. No INPUT_DEVICE_CONFIGURATOR grant.
environment["displays"] = Json::array({primary, secondary});
captured_cursor_mode = harmony::CaptureCursorMode::separate;
location_result = INPUT_APP_NOT_FOCUSED;
monitor_add_result = INPUT_SUCCESS;
assert(screen->Start(false) == 0 && mouse_monitor);
assert(cursor.Sample(screen->GetDisplayInfoList(), 1, &cursor_state) && !cursor_state.position_valid);
Input_MouseEvent motion{MOUSE_ACTION_MOVE, 42, 640, 720};
mouse_monitor(&motion);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(cursor_state.position_valid && cursor_state.display_id == 1);
assert(cursor_state.x == 0.25f && cursor_state.y == 0.5f && cursor_state.shape == RemoteCursorShape::text);
RemoteAction monitored_out{}, monitored_in{};
monitored_out.type = ControlType::cursor_state; monitored_out.cs = cursor_state;
assert(monitored_in.from_json(monitored_out.to_json()));
assert(monitored_in.cs.position_valid && monitored_in.cs.display_id == 1 && monitored_in.cs.x == 0.25f);
const int before_notification = monitor_adds;
state_callback(OH_SCREEN_CAPTURE_STATE_MIC_MUTED_BY_USER);
assert(monitor_adds == before_notification);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && cursor_state.position_valid);
const auto queued_monitor = mouse_monitor;
state_callback(OH_SCREEN_CAPTURE_STATE_ENTER_PRIVATE_SCENE);
assert(!mouse_monitor && !cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
queued_monitor(&motion); // Late events cannot repopulate a stopped session.
state_callback(OH_SCREEN_CAPTURE_STATE_EXIT_PRIVATE_SCENE);
assert(mouse_monitor && cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(!cursor_state.position_valid);
mouse_monitor(&motion);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && cursor_state.position_valid);
motion.action = MOUSE_ACTION_CANCEL;
mouse_monitor(&motion);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && !cursor_state.position_valid);
motion.action = MOUSE_ACTION_MOVE; motion.x = -1;
mouse_monitor(&motion);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && !cursor_state.position_valid);
motion.x = 640;
mouse_monitor(&motion);
assert(screen->SwitchTo(0) == 0 && mouse_monitor);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && !cursor_state.position_valid);
motion.display = 0; motion.x = 480; motion.y = 540;
mouse_monitor(&motion);
assert(cursor.Sample(screen->GetDisplayInfoList(), 1, &cursor_state));
assert(cursor_state.position_valid && cursor_state.display_id == 0 && cursor_state.x == 0.25f && cursor_state.y == 0.5f);
location_result = INPUT_DEVICE_NO_POINTER; pointer_visible = false;
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(!cursor_state.position_valid && cursor_state.hidden_reason == CursorHiddenReason::no_pointing_device);
location_result = INPUT_APP_NOT_FOCUSED; pointer_visible = true;
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && !cursor_state.position_valid);
monitor_remove_result = INPUT_SERVICE_EXCEPTION;
screen->Stop();
assert(mouse_monitor);
mouse_monitor(&motion); // Failed removal stays inert.
const int before_failed_restart = monitor_adds;
assert(screen->Start(false) == 0);
assert(monitor_adds == before_failed_restart); // Never install a duplicate callback.
mouse_monitor(&motion);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && !cursor_state.position_valid);
monitor_remove_result = INPUT_SUCCESS;
screen->Stop();
assert(!mouse_monitor);
assert(screen->Start(false) == 0 && mouse_monitor);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && !cursor_state.position_valid);
mouse_monitor(&motion);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && cursor_state.position_valid);
// Capture can remove the monitor itself; "not registered" is clean removal.
mouse_monitor = nullptr;
monitor_remove_result = INPUT_PARAMETER_ERROR;
state_callback(OH_SCREEN_CAPTURE_STATE_PAUSED_BY_USER);
monitor_remove_result = INPUT_SUCCESS;
initial_monitor_event = &motion;
state_callback(OH_SCREEN_CAPTURE_STATE_RESUMED_BY_USER);
initial_monitor_event = nullptr;
assert(mouse_monitor);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state) && cursor_state.position_valid);
screen->Stop();
assert(!mouse_monitor);
monitor_add_result = INPUT_PERMISSION_DENIED;
assert(screen->Start(false) == 0 && !mouse_monitor);
const int before_denied_notification = monitor_adds;
state_callback(OH_SCREEN_CAPTURE_STATE_MIC_MUTED_BY_USER);
assert(monitor_adds == before_denied_notification);
assert(cursor.Sample(screen->GetDisplayInfoList(), 0, &cursor_state));
assert(cursor_state.visible && cursor_state.shape == RemoteCursorShape::text && !cursor_state.position_valid);
screen->Stop();
const int before_embedded = monitor_adds;
assert(screen->Start(true) == 0 && !mouse_monitor && monitor_adds == before_embedded);
screen->Stop();
}
@@ -0,0 +1,64 @@
#include "keyboard.h"
#include <cassert>
#include <iostream>
using crossdesk::harmony::desktop::KeyboardState;
int main() {
KeyboardState keys;
assert(keys.Translate(2017, true) == "a");
assert(keys.Translate(2017, false) == "a");
keys.Translate(2047, true);
assert(keys.Translate(2017, true) == "A");
assert(keys.Translate(2017, true) == "A"); // repeat retains the original symbol
keys.Translate(2047, false);
assert(keys.Translate(2017, false) == "A"); // Shift was released first
keys.Translate(2047, true); keys.Translate(2048, true); keys.Translate(2047, false);
assert(keys.Translate(2001, true) == "!"); // the other Shift is still pressed
keys.Translate(2001, false); keys.Translate(2048, false);
assert(keys.Translate(2057, true) == "-"); keys.Translate(2057, false);
keys.Translate(2074, true); keys.Translate(2074, false);
assert(keys.Translate(2042, true) == "Z"); keys.Translate(2042, false);
keys.Translate(2047, true);
assert(keys.Translate(2042, true) == "z"); keys.Translate(2042, false);
keys.Translate(2072, true); keys.Reset();
assert(keys.Pressed().empty());
for (int code=2090; code<=2101; ++code) {
auto press=keys.Translate(code,true); assert(!press.empty()); assert(keys.Translate(code,false)==press);
}
assert(keys.Translate(2117, true) == ".");
assert(keys.Translate(99999, true).empty());
keys.Reset();
for (auto pair : {std::pair{2047, 2048}, std::pair{2072, 2073}, std::pair{2076, 2077}}) {
const auto left = keys.Translate(pair.first, true);
const auto right = keys.Translate(pair.second, true);
assert(!left.empty() && !right.empty() && left != right);
assert(keys.Translate(pair.first, false) == left && keys.IsPressed(pair.second));
assert(keys.Translate(pair.second, false) == right);
}
for (int code = 2816; code <= 2827; ++code) {
const auto press = keys.Translate(code, true);
assert(!press.empty() && keys.Translate(code, false) == press);
}
assert(!keys.Translate(2067, true).empty());
keys.Reset();
// The lock state changed while another application had keyboard focus.
assert(keys.Translate(2017, true, 0, true) == "A");
assert(keys.Translate(2017, false, 0, false) == "A");
assert(keys.Translate(2017, true, 0, false) == "a");
keys.Reset();
// Prefer the OS layout result to the US fallback, preserving paired releases.
assert(keys.Translate(2042, true, 'y') == "y");
assert(keys.Translate(2042, true, 'Y') == "y");
assert(keys.Translate(2042, false, 'Y') == "y");
assert(keys.Translate(2057, true, '?') == "?"); keys.Reset();
using namespace slint::platform::key_codes;
const auto utf8 = [](std::u8string_view key) { return std::string(reinterpret_cast<const char*>(key.data()), key.size()); };
assert(keys.Translate(2104, true, '1', {}, false) == utf8(End));
assert(keys.Translate(2104, false, '1', {}, true) == utf8(End));
assert(keys.Translate(2104, true, '1', {}, true) == "1"); keys.Reset();
assert(keys.Translate(2117, true, '.', {}, false) == utf8(Delete)); keys.Reset();
assert(keys.Translate(2045, true) == utf8(Alt));
assert(keys.Translate(2046, true) == utf8(AltGr));
assert(keys.Translate(2045, false) == utf8(Alt) && keys.IsPressed(2046));
assert(keys.Translate(2046, false) == utf8(AltGr));
std::cout << "Slint keyboard chords, repeat, focus release, punctuation and function keys passed\n";
}
@@ -0,0 +1,69 @@
#include "displays.h"
#include "pointer.h"
#include "window_mode.h"
#include <cassert>
#include <tuple>
int main() {
using namespace crossdesk::harmony::desktop;
using Json = nlohmann::json;
const auto displays = ReadDisplays(Json::parse(R"({"displays":[
{"id":42,"name":"Left","x":-2560,"y":-300,"width":2560,"height":1440},
{"id":0,"name":"Main","primary":true,"width":1920,"height":1080,"usableY":30,"usableHeight":990},
{"id":42,"width":2560,"height":1440},
{"id":7,"width":0,"height":1080},
{"id":-1,"width":1920,"height":1080}
]})"));
assert(displays.size() == 2 && displays[0].id == 0 && displays[1].id == 42);
assert(displays[0].SdlId() == 1 && displays[1].SdlId() == 43);
assert(displays[0].usable_y == 30 && displays[0].usable_height == 990);
assert(displays[1].usable_x == -2560 && displays[1].usable_y == -300);
assert(DisplayAt(displays, -1, 0)->id == 42);
assert(DisplayAt(displays, 0, 0)->id == 0);
assert(DisplayAt(displays, -1280, -800)->id == 42); // Outside monitors: nearest display.
assert(DisplayAt(displays, 3000, 100)->id == 0);
const auto removed = ReadDisplays(Json::parse(R"({"displays":[{"id":42,"primary":true,"width":2560,"height":1440}]})"));
assert(removed[0].SdlId() == displays[1].SdlId()); // SDL identity survives primary changes.
assert(ReadDisplays(Json::object()).empty());
assert(DisplayAt({}, 0, 0) == nullptr);
PointerState pointer;
std::vector<std::tuple<int, float, float>> releases;
const auto release = [&](int b, float x, float y) { releases.emplace_back(b, x, y); };
pointer.Update(0, 0, 1, 2);
pointer.Update(0, 1, 10, 20);
pointer.Update(0, 2, 10, 20);
assert(pointer.Buttons() == 7);
pointer.Update(1, 1, 12, 22); // Right release does not clear left/middle drag.
assert(pointer.Buttons() == 3);
pointer.Update(2, 0, -15, 130); // Drag beyond the surface preserves held state.
pointer.Cancel(release);
assert(pointer.Buttons() == 0 && releases.size() == 2);
assert((releases[0] == std::tuple{0, -15.f, 130.f}));
assert((releases[1] == std::tuple{2, -15.f, 130.f}));
pointer.Cancel(release);
assert(releases.size() == 2); // Repeated focus/detach/cancel has no duplicate releases.
WindowMode modes;
Json slint{{"fullscreen", false}, {"maximized", false}, {"minimized", false}};
assert(modes.Request(slint) == slint);
assert(modes.Request(slint).empty());
// Native titlebar maximize/restore: all three Slint setters synchronously
// call back, including the first one before the other flags have changed.
for (bool maximized : {true, false, true, false}) {
Json actual = slint; actual["maximized"] = maximized;
modes.Acknowledge(actual, [&] {
for (auto& [name, value] : actual.items()) {
slint[name] = value;
assert(modes.Request(slint).empty());
}
});
assert(modes.Request(slint).empty());
}
slint["fullscreen"] = true;
assert(modes.Request(slint) == Json({{"fullscreen", true}}));
modes.Acknowledge(Json{{"minimized", true}}, [&] { slint["minimized"] = true; });
assert(modes.Request(slint).empty()); // Minimize preserves fullscreen.
slint["minimized"] = false;
assert(modes.Request(slint) == Json({{"minimized", false}}));
}
@@ -0,0 +1,144 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require(process.argv[2]);
// Exercise the production bridge with asynchronous native windows, including
// callbacks that outlive a destroyed XComponent/window with the same Slint id.
const states = { FLOATING: 1, MAXIMIZE: 2, FULL_SCREEN: 3, MINIMIZE: 4 };
const modes = { FOLLOW_APP_IMMERSIVE_SETTING: 0, EXIT_IMMERSIVE: 1, ENTER_IMMERSIVE: 2 };
const events = { WINDOW_ACTIVE: 1, WINDOW_INACTIVE: 2 };
const sent = [], released = [], moduleExports = {};
let commands = [], nextNativeId = 10;
class NativeWindow {
constructor(status = states.FLOATING) {
this.id = ++nextNativeId; this.status = status; this.previous = states.FLOATING;
this.handlers = new Map(); this.calls = []; this.decor = true;
}
getWindowStatus() { return this.status; }
getUIContext() { return {}; }
getWindowProperties() { return { id: this.id, windowRect: { left: 20, top: 30 } }; }
getWindowDecorVisible() { return this.decor; }
getWindowDecorHeight() { return 32; }
setWindowDecorVisible(value) { this.decor = value; }
async setWindowTitle() { if (this.rejectTitle) throw Error('unsupported'); }
async setSubWindowZLevel() { if (this.rejectStack) throw Error('unsupported'); }
async setWindowLimits(limits) { this.limits = limits; this.calls.push('limits'); }
async loadContent() { this.calls.push('load'); }
async resize(w, h) { this.calls.push(['resize', w, h]); }
async destroyWindow() { this.calls.push('destroy'); }
async minimize() { this.calls.push('minimize'); this.previous = this.status; this.status = states.MINIMIZE; }
async restore() { this.calls.push('restore'); this.status = this.previous; }
async showWindow() {
this.calls.push('show');
// showWindow does not restore a minimized 2in1 main window.
if (this !== main && this.status === states.MINIMIZE) this.status = this.previous;
}
async recover() { this.calls.push('recover'); this.status = states.FLOATING; }
async maximize(mode) {
this.calls.push(['maximize', mode]);
this.status = mode === modes.EXIT_IMMERSIVE ? states.MAXIMIZE : states.FULL_SCREEN;
}
on(name, callback) { if (name === this.unsupportedEvent) throw Error('unsupported'); this.handlers.set(name, callback); }
off(name) { this.handlers.delete(name); }
async createSubWindowWithOptions() { return new NativeWindow(); }
}
const main = new NativeWindow();
vm.runInNewContext(ts.transpileModule(fs.readFileSync(process.argv[3], 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, {
exports: moduleExports, console: { warn: () => {}, error: e => { throw Error(e); } },
LocalStorage: class { setOrCreate() {} },
require: name => {
if (name === 'libslint_desktop.so') return { default: {
send: text => sent.push(JSON.parse(text)), poll: () => JSON.stringify(commands.splice(0))
} };
if (name === '@kit.ArkUI') return { window: { WindowStatusType: states, MaximizePresentation: modes, WindowEventType: events } };
if (name === './DesktopAccess') return { DesktopAccess: { refresh() {}, requestHost() {} } };
if (name === './TextInputHost') return { TextInputHost: { release: id => released.push(id) } };
if (name === './DisplayHost') return { DisplayHost: {
screen: () => ({ densityPixels: 2 }), global: () => ({ x: 20, y: 30 }),
move: async (target, x, y) => target.calls.push(['move', x, y])
} };
return {};
}
});
const host = moduleExports.DesktopHost;
host.stage = { getMainWindowSync: () => main };
host.windows.set(1, main);
const props = (id, changes = {}) => ({ op: 'properties', id, stateRevision: host.stateRevisions.get(id) ?? 0,
fullscreen: false, maximized: false, minimized: false, ...changes });
(async () => {
main.status = states.FULL_SCREEN;
await host.windowCommand(props(1, { maximized: true }));
assert.equal(main.status, states.MAXIMIZE, 'leaving fullscreen must explicitly exit immersive mode');
await host.windowCommand(props(1, { fullscreen: true, minimized: true }));
assert.equal(main.status, states.MINIMIZE, 'minimized takes precedence over fullscreen');
await host.windowCommand(props(1));
assert.equal(main.status, states.FLOATING, 'unminimize must finish the requested mode transition');
assert(main.calls.includes('restore')); assert(main.calls.includes('recover'));
await host.windowCommand({ op: 'visible', id: 1, visible: true, width: 900, height: 600 });
main.calls = []; main.status = states.MAXIMIZE;
await host.windowCommand({ op: 'visible', id: 1, visible: true, width: 3000, height: 1800 });
assert.deepEqual(main.calls, ['show'], 'showing a visible maximized main window must not reset its restore size');
main.rejectTitle = true;
await host.windowCommand(props(1, { title: 'new title', fullscreen: true }));
assert.equal(main.status, states.FULL_SCREEN, 'optional chrome failure cannot abort fullscreen');
await host.windowCommand(props(2, { topmost: true, noFrame: true, title: 'Shared panel' }));
await host.windowCommand({ op: 'visible', id: 2, visible: true, width: 400, height: 250, x: -100, y: 40 });
const old = host.windows.get(2);
assert(old.calls.some(c => Array.isArray(c) && c[0] === 'move' && c[1] === -100));
assert(!old.decor);
const late = new Map(old.handlers);
old.handlers.get('windowStatusDidChange')(states.MINIMIZE);
assert.deepEqual(sent.at(-1), { op: 'state', id: 2, stateRevision: 1, minimized: true });
await host.windowCommand({ op: 'visible', id: 2, visible: false });
assert.equal(old.handlers.size, 0); assert(released.includes(2));
await host.windowCommand({ op: 'visible', id: 2, visible: true, width: 400, height: 250 });
assert.notEqual(host.windows.get(2), old);
const before = sent.length;
late.get('windowEvent')(events.WINDOW_INACTIVE);
late.get('windowRectChange')(); late.get('displayIdChange')();
late.get('windowStatusDidChange')(states.FULL_SCREEN);
assert.equal(await late.get('windowWillClose')(), false);
assert.equal(sent.length, before, 'destroyed native windows cannot update their replacement');
const child = host.windows.get(2);
child.rejectStack = true;
await host.windowCommand(props(2, { topmost: true, maximized: true }));
assert.equal(child.status, states.MAXIMIZE);
child.status = states.FLOATING;
child.handlers.get('windowStatusDidChange')(states.FLOATING);
await host.windowCommand(props(2, { maximized: true, stateRevision: 0 }));
assert.equal(child.status, states.FLOATING, 'an old mode command cannot undo a native titlebar restore');
let finishLimits;
child.setWindowLimits = async () => { await new Promise(resolve => { finishLimits = resolve; }); };
const pendingMode = host.windowCommand(props(2, { maximized: true }));
await new Promise(resolve => setImmediate(resolve));
child.handlers.get('windowStatusDidChange')(states.FLOATING);
finishLimits(); await pendingMode;
assert.equal(child.status, states.FLOATING, 'a native transition during an awaited property operation also wins');
child.setWindowLimits = async () => {};
child.status = states.MAXIMIZE;
await host.windowCommand({ op: 'properties', id: 2, title: 'layout update' });
assert.equal(child.status, states.MAXIMIZE, 'an unrelated property change must not recover the native window');
child.unsupportedEvent = 'displayIdChange';
host.observe(2, child);
assert(child.handlers.has('windowWillClose'), 'one unavailable observer must not disable close interception');
commands = [{ op: 'destroy', id: 2 }, { op: 'destroy', id: 9 }];
host.poll();
await Promise.all([...host.queues.values()]);
await new Promise(resolve => setImmediate(resolve));
assert.equal(host.queues.size, 0);
assert.equal(host.properties.has(2), false);
assert.equal(host.windows.has(2), false);
const broken = new NativeWindow();
broken.loadContent = async () => { throw Error('creation failed'); };
main.createSubWindowWithOptions = async () => broken;
await assert.rejects(host.windowCommand({ op: 'visible', id: 3, visible: true }), /creation failed/);
assert.equal(host.windows.has(3), false); assert(broken.calls.includes('destroy'));
console.log('Slint window restore/fullscreen, optional services, recreation, stale callbacks and queue cleanup passed');
})().catch(error => { console.error(error); process.exitCode = 1; });
+134
View File
@@ -0,0 +1,134 @@
#include "input.h"
#include "key_mapping.h"
#include <multimodalinput/oh_input_manager.h>
#include <cassert>
#include <cmath>
#include <set>
#include <vector>
#include <hilog/log.h>
// Execute the production input adapter against an in-memory Input Kit. These
// tests exercise releases and coordinate bounds without injecting real input.
struct Input_KeyEvent { int code = 0, action = 0; };
struct Input_MouseEvent { int action = 0, button = -1, x = 0, y = 0, display = 0, axis = 0; double value = 0; };
std::set<int> pressed_keys;
std::set<int> pressed_buttons;
std::vector<Input_MouseEvent> pointer_events;
std::vector<Input_KeyEvent> key_events;
bool allow = true;
int rejected_key = -1;
extern "C" {
int OH_LOG_Print(LogType, LogLevel, unsigned int, const char*, const char*, ...) { return 0; }
Input_KeyEvent* OH_Input_CreateKeyEvent() { return new Input_KeyEvent; }
void OH_Input_DestroyKeyEvent(Input_KeyEvent** e) { delete *e; *e = nullptr; }
void OH_Input_SetKeyEventKeyCode(Input_KeyEvent* e, int32_t code) { e->code = code; }
void OH_Input_SetKeyEventAction(Input_KeyEvent* e, int32_t action) { e->action = action; }
int32_t OH_Input_InjectKeyEvent(const Input_KeyEvent* e) {
if (!allow || e->code == rejected_key) return INPUT_PERMISSION_DENIED;
key_events.push_back(*e);
if (e->action == KEY_ACTION_DOWN) pressed_keys.insert(e->code); else pressed_keys.erase(e->code);
return INPUT_SUCCESS;
}
Input_MouseEvent* OH_Input_CreateMouseEvent() { return new Input_MouseEvent; }
void OH_Input_DestroyMouseEvent(Input_MouseEvent** e) { delete *e; *e = nullptr; }
void OH_Input_SetMouseEventAction(Input_MouseEvent* e, int32_t action) { e->action = action; }
void OH_Input_SetMouseEventButton(Input_MouseEvent* e, int32_t button) { e->button = button; }
void OH_Input_SetMouseEventDisplayId(Input_MouseEvent* e, int32_t display) { e->display = display; }
void OH_Input_SetMouseEventDisplayX(Input_MouseEvent* e, int32_t x) { e->x = x; }
void OH_Input_SetMouseEventDisplayY(Input_MouseEvent* e, int32_t y) { e->y = y; }
void OH_Input_SetMouseEventAxisType(Input_MouseEvent* e, int32_t axis) { e->axis = axis; }
void OH_Input_SetMouseEventAxisValue(Input_MouseEvent* e, float value) { e->value = value; }
int32_t OH_Input_InjectMouseEvent(const Input_MouseEvent* e) {
if (!allow) return INPUT_PERMISSION_DENIED;
pointer_events.push_back(*e);
if (e->action == MOUSE_ACTION_BUTTON_DOWN) pressed_buttons.insert(e->button);
if (e->action == MOUSE_ACTION_BUTTON_UP) pressed_buttons.erase(e->button);
return INPUT_SUCCESS;
}
}
int main() {
using namespace crossdesk;
using namespace crossdesk::harmony;
for (auto [native, wire] : kSpecialKeys) {
assert(ToVirtualKey(native) == wire); assert(FromVirtualKey(wire) == native);
}
assert(ToVirtualKey(2017) == 'A' && FromVirtualKey('Z') == 2042);
assert(ToVirtualKey(2068) == 0x21 && ToVirtualKey(2069) == 0x22);
assert(ToVirtualKey(0) == 0 && FromVirtualKey(0xFFFF) == 0);
Input input;
input.Configure(1920, 1080, 7);
RemoteAction mouse_action{}; mouse_action.type = mouse;
mouse_action.m = {-2, 3, 0, left_down};
assert(input.Apply(mouse_action));
assert(pointer_events.back().x == 0 && pointer_events.back().y == 1079);
assert(pointer_events.back().display == 7 && pressed_buttons.count(MOUSE_BUTTON_LEFT));
RemoteAction key{}; key.type = keyboard; key.k = {0xA2, 0, false, key_down};
assert(input.Apply(key) && pressed_keys.count(2072));
RemoteAction state{}; state.type = keyboard_state; state.ks.seq = 10;
state.ks.pressed_count = 1; state.ks.pressed_keys[0] = {0x41, 0, false};
assert(input.Apply(state) && !pressed_keys.count(2072) && pressed_keys.count(2017));
state.ks.seq = 9; state.ks.pressed_count = 0;
assert(input.Apply(state) && pressed_keys.count(2017)); // Ignore reordered snapshots.
input.Release();
assert(pressed_keys.empty() && pressed_buttons.empty());
input.Release(); // Repeated teardown is safe.
auto count = pointer_events.size();
mouse_action.m.x = NAN;
assert(!input.Apply(mouse_action) && pointer_events.size() == count);
mouse_action.m = {.5f, .5f, 9000, wheel_vertical};
assert(input.Apply(mouse_action));
assert(pointer_events.size() == count + 3 && pointer_events.back().action == MOUSE_ACTION_AXIS_END);
assert(pointer_events[count].value == 0 && pointer_events[count + 1].value == 1200 && pointer_events[count + 2].value == 0);
mouse_action.m = {.25f, .75f, 0, left_down};
assert(input.Apply(mouse_action));
assert(input.Apply(key));
input.Configure(2560, 1440, 42); // Switching screens releases the old screen first.
assert(pressed_keys.empty() && pressed_buttons.empty());
assert(pointer_events.back().display == 7 && pointer_events.back().action == MOUSE_ACTION_BUTTON_UP);
mouse_action.m = {1.f, 1.f, 0, move};
assert(input.Apply(mouse_action));
assert(pointer_events.back().display == 42 && pointer_events.back().x == 2559 && pointer_events.back().y == 1439);
allow = false;
assert(!input.Apply(key) && pressed_keys.empty());
allow = true;
assert(FromVirtualKey(0x10, 0x36) == 2048);
assert(FromVirtualKey(0x11, 0x1D, true) == 2073);
assert(FromVirtualKey(0x12, 0x38, true) == 2046);
assert(FromVirtualKey(0x0D, 0x1C, true) == 2119);
assert(FromVirtualKey(0x23, 0x4F, false) == 2104);
assert(FromVirtualKey(0x23, 0x4F, true) == 2082);
assert(FromVirtualKey(0x7C) == 2816 && ToVirtualKey(2827) == 0x87);
mouse_action.m = {.5f, .5f, 0, left_down}; assert(input.Apply(mouse_action));
mouse_action.m.flag = move; assert(input.Apply(mouse_action));
assert(pointer_events.back().button == MOUSE_BUTTON_LEFT); // Required by WMS for a drag.
mouse_action.m.flag = right_down; assert(input.Apply(mouse_action));
mouse_action.m.flag = left_up; assert(input.Apply(mouse_action));
mouse_action.m.flag = move; assert(input.Apply(mouse_action));
assert(pointer_events.back().button == MOUSE_BUTTON_RIGHT);
assert(input.Release());
state.ks.seq = 11; state.ks.pressed_count = 2;
state.ks.pressed_keys[0] = {0x41, 0x1E, false};
state.ks.pressed_keys[1] = {0x11, 0x1D, true};
key_events.clear(); assert(input.Apply(state));
assert(key_events.size() == 2 && key_events[0].code == 2073 && key_events[1].code == 2017);
allow = false; assert(!input.Release());
assert(!input.Configure(800, 600, 8)); // Do not change screen while a release is still pending.
allow = true; key_events.clear(); assert(input.Release());
assert(key_events.size() == 2 && key_events[0].code == 2017 && key_events[1].code == 2073);
assert(pressed_keys.empty() && pressed_buttons.empty());
assert(input.Release());
state.ks.pressed_count = kMaxKeyboardStateKeys + 1; assert(!input.Apply(state));
key_events.clear();
assert(input.LockWorkstation());
assert(key_events.size() == 4 && key_events[0].code == 2076 && key_events[1].code == 2028);
assert(key_events[2].action == KEY_ACTION_UP && key_events[3].action == KEY_ACTION_UP);
assert(pressed_keys.empty());
allow = false; assert(!input.LockWorkstation());
assert(pressed_keys.empty());
allow = true; rejected_key = 2028; key_events.clear();
assert(!input.LockWorkstation()); // Letter fails after Meta was already pressed.
assert(key_events.size() == 2 && key_events.back().code == 2076 && key_events.back().action == KEY_ACTION_UP);
assert(pressed_keys.empty());
return 0;
}
+35
View File
@@ -0,0 +1,35 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _KEYBOARD_HOOK_MOCK_H_
#define _KEYBOARD_HOOK_MOCK_H_
#include <multimodalinput/oh_input_manager.h>
#include <vector>
struct Input_KeyEvent { int code = 0, action = 0, id = 0; };
inline Input_KeyEventCallback hook_callback = nullptr;
inline Input_Result hook_add_result = INPUT_SUCCESS, hook_remove_result = INPUT_SUCCESS;
inline int hook_adds = 0, hook_removes = 0, focus_during_add = 0;
inline std::vector<int> forwarded_events;
extern "C" {
Input_Result OH_Input_AddKeyEventHook(Input_KeyEventCallback cb) {
++hook_adds;
if (focus_during_add) crossdesk::harmony::desktop::SetKeyboardFocus(focus_during_add, true);
if (hook_add_result == INPUT_SUCCESS) hook_callback = cb;
return hook_add_result;
}
Input_Result OH_Input_RemoveKeyEventHook(Input_KeyEventCallback) {
++hook_removes;
if (hook_remove_result == INPUT_SUCCESS) hook_callback = nullptr;
return hook_remove_result;
}
int32_t OH_Input_GetKeyEventAction(const Input_KeyEvent* event) { return event->action; }
int32_t OH_Input_GetKeyEventKeyCode(const Input_KeyEvent* event) { return event->code; }
Input_Result OH_Input_GetKeyEventId(const Input_KeyEvent* event, int32_t* id) { *id = event->id; return INPUT_SUCCESS; }
Input_Result OH_Input_DispatchToNextHandler(int32_t id) { forwarded_events.push_back(id); return INPUT_SUCCESS; }
}
#endif
+83
View File
@@ -0,0 +1,83 @@
#include "keyboard_hook.h"
#include "keyboard_hook_mock.h"
#include "keyboard_state.h"
#include <cassert>
#include <memory>
using namespace crossdesk;
using namespace crossdesk::harmony::desktop;
std::vector<KeyboardInput> delivered;
void Sink(int key, bool down, uint32_t scan, bool extended, void* context) {
assert(context == &delivered); delivered.push_back({key, down, scan, extended});
}
void Event(int code, int action) {
static int id = 0;
const Input_KeyEvent event{code, action, ++id};
assert(hook_callback); hook_callback(&event);
}
int main() {
KeyboardHook hook;
assert(hook.Start(Sink, &delivered) == INPUT_APP_NOT_FOCUSED);
SetKeyboardFocus(2, true);
hook_add_result = INPUT_PERMISSION_DENIED;
assert(hook.Start(Sink, &delivered) == INPUT_PERMISSION_DENIED);
assert(!hook.Active() && hook.Stop() == INPUT_SUCCESS);
hook_add_result = INPUT_SUCCESS;
assert(hook.Start(Sink, &delivered) == INPUT_SUCCESS && hook.Active());
const int installed_count = hook_adds;
assert(hook.Start(Sink, &delivered) == INPUT_SUCCESS && hook_adds == installed_count);
KeyboardHook other;
assert(other.Start(Sink, &delivered) == INPUT_REPEAT_INTERCEPTOR);
assert(other.Stop() == INPUT_SUCCESS && hook.Active());
// Alt+Tab and Meta+L reach the same shared input queue as ordinary keys.
for (auto [modifier, key] : {std::pair{2045, 2049}, std::pair{2076, 2028}}) {
Event(modifier, KEY_ACTION_DOWN); Event(key, KEY_ACTION_DOWN);
Event(key, KEY_ACTION_UP); Event(modifier, KEY_ACTION_UP);
}
assert(delivered.size() == 8 && forwarded_events.empty());
assert(delivered[0].key_code == 0xA4 && delivered[1].key_code == 0x09);
assert(delivered[4].key_code == 0x5B && delivered[4].extended && delivered[5].key_code == 'L');
Event(2073, KEY_ACTION_DOWN); Event(2119, KEY_ACTION_DOWN); Event(2119, KEY_ACTION_DOWN);
assert(delivered.back().key_code == 0x0D && delivered.back().scan_code == 0x1C && delivered.back().extended);
Event(2119, KEY_ACTION_CANCEL);
assert(!delivered.back().is_down && delivered.back().key_code == 0xA3);
assert(delivered[delivered.size()-2].key_code == 0x0D);
const auto count = delivered.size();
Event(9999, KEY_ACTION_DOWN); Event(2017, KEY_ACTION_UP);
assert(delivered.size() == count && forwarded_events.size() == 2);
Event(2072, KEY_ACTION_DOWN);
SetKeyboardFocus(2, false);
assert(!hook.Active() && !delivered.back().is_down);
Event(2017, KEY_ACTION_DOWN);
assert(forwarded_events.size() == 3);
SetKeyboardFocus(1, true); Event(2017, KEY_ACTION_DOWN);
assert(!hook.Active() && forwarded_events.size() == 4);
hook.Stop(); SetKeyboardFocus(2, true);
hook.Start(Sink, &delivered); Event(2076, KEY_ACTION_DOWN);
SetKeyboardHookPermission(false);
assert(!hook.Active() && !delivered.back().is_down);
Event(2017, KEY_ACTION_DOWN); assert(forwarded_events.size() == 5);
// Failed unregistration must leave a pass-through callback, never a dangling sink.
hook_remove_result = INPUT_SERVICE_EXCEPTION;
assert(hook.Stop() == INPUT_SERVICE_EXCEPTION);
const auto after_stop = delivered.size();
Event(2017, KEY_ACTION_DOWN); assert(delivered.size() == after_stop);
assert(hook.Start(Sink, &delivered) == INPUT_SERVICE_EXCEPTION);
hook_remove_result = INPUT_SUCCESS;
assert(hook.Start(Sink, &delivered) == INPUT_SUCCESS && hook.Active());
hook.Stop();
// A focus change inside the OS registration call cannot bind the hook to
// a local dialog that replaced the control window during installation.
focus_during_add = 1;
hook.Start(Sink, &delivered); assert(!hook.Active());
Event(2017, KEY_ACTION_DOWN); assert(delivered.size() == after_stop);
hook.Stop();
focus_during_add = 0;
{
auto transient = std::make_unique<KeyboardHook>();
transient->Start(Sink, &delivered);
hook_remove_result = INPUT_SERVICE_EXCEPTION;
}
Event(2017, KEY_ACTION_DOWN); assert(delivered.size() == after_stop);
hook_remove_result = INPUT_SUCCESS; hook.Stop();
}
@@ -0,0 +1,54 @@
#include <mobile_file_transfer_session.h>
#include <cassert>
#include <deque>
#include <iostream>
#include <unistd.h>
using namespace crossdesk;
using mobile_controller::FileTransferSession;
int main() {
char pattern[] = "/tmp/crossdesk-mobile-files-XXXXXX";
const std::filesystem::path root = mkdtemp(pattern);
std::deque<std::string> chunks, acks;
std::string received; bool complete = false, rejected = false;
FileTransferSession sender([&](const char*, const char* p, size_t n) { chunks.emplace_back(p,n); return 0; },
[&](const std::string&, double p, bool, const std::string&) { complete = p == 1; rejected |= p < 0; });
FileTransferSession receiver([&](const char*, const char* p, size_t n) { acks.emplace_back(p,n); return 0; },
[&](const std::string&, double p, bool, const std::string& path) { if (p == 1) received = path; });
receiver.SetDirectory(root / "received");
const std::string data(3*kFileChunkSize+37,'x');
const auto staged = root / "outgoing";
{ std::ofstream out(staged,std::ios::binary); out << data; }
assert(sender.Start(staged,"../../test.bin"));
for(int i=0;i<4;i++) sender.Pump();
assert(chunks.size()==4);
// Real KCP callbacks may be reordered: do not expose holes as a finished file.
for(int i : {3,1,0,2}) { const auto& c=chunks[i]; receiver.Receive(c.data(),c.size()); if(i!=2) assert(received.empty()); }
assert(!received.empty() && std::filesystem::path(received).filename()=="test.bin");
for(auto& a:acks) sender.Ack(a.data(),a.size());
assert(complete && !sender.Sending() && !std::filesystem::exists(staged));
std::ifstream input(received,std::ios::binary); const std::string result((std::istreambuf_iterator<char>(input)),{});
assert(result == data);
// Duplicate ranges must be rejected and their partial file removed.
const std::string name="duplicate"; auto chunk=EncodeFileChunk(50,0,4,"ab",2,&name,true,false);
receiver.Receive(chunk.data(),chunk.size()); receiver.Receive(chunk.data(),chunk.size());
FileTransferAck ack{}; assert(DecodeFileTransferAck(acks.back().data(),acks.back().size(),&ack)); assert(ack.flags==2);
// Sender timeout removes only the disposable staging copy.
{ std::ofstream out(staged); out << "timeout"; }
const auto now=FileTransferSession::Clock::now(); assert(sender.Start(staged,"timeout",now));
sender.Pump(now+std::chrono::seconds(31)); assert(rejected && !sender.Sending() && !std::filesystem::exists(staged));
// Empty files still require terminal acknowledgement.
chunks.clear(); acks.clear(); complete=false; received.clear();
{ std::ofstream out(staged); } assert(sender.Start(staged,"empty")); sender.Pump();
receiver.Receive(chunks[0].data(),chunks[0].size()); assert(!received.empty());
sender.Ack(acks[0].data(),acks[0].size()); assert(complete && std::filesystem::file_size(received)==0);
const auto previous=received;
{
FileTransferSession fresh([](const char*,const char*,size_t){return 0;},
[&](const std::string&,double p,bool,const std::string&path){if(p==1)received=path;});
fresh.SetDirectory(root/"received");
fresh.Receive(chunks[0].data(),chunks[0].size());
assert(received!=previous && std::filesystem::exists(previous) && std::filesystem::exists(received));
}
std::filesystem::remove_all(root);
std::cout << "PASS: shared Android/Harmony file transfer, reordered chunks, safe names, duplicate rejection, timeout cleanup and empty files\n";
}
@@ -0,0 +1,84 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const ts = require(process.argv[2]), root = process.argv[3];
const commands = [], events = [], timers = new Map(), intervals = new Map(); let timerId = 0, time = 100;
let preferences = new Map(), secrets = new Map(), records = new Map(), saves = [], platforms = [];
class Store {
constructor(){this.directory='/private/mobile';}
flag(k,f=false){return preferences.get(k)??f;} number(k,f){return preferences.get(k)??f;} text(k,f=''){return preferences.get(k)??f;}
put(k,v){preferences.set(k,v);} static scope(s,p){return `${s.trim().toLowerCase().replace(/\.$/,'')}:${p}`;}
async secret(k){return secrets.get(k)??'';} async saveSecret(k,v){secrets.set(k,v);} async removeSecret(k){secrets.delete(k);}
recent(s){return records.get(s)??[];} clearPreviews(){this.cleared=true;}
async saveRecent(s,d,p){saves.push({scope:s,device:{...d},password:p});records.set(s,[d]);if(d.remember)secrets.set(`password:${s}:${d.id}`,p);}
metadata(s,id,n,p){for(const d of this.recent(s))if(d.id===id){d.name=n;d.platform=p;}}
async delete(s,id){records.set(s,this.recent(s).filter(d=>d.id!==id));secrets.delete(`password:${s}:${id}`);}
async savePreview(){this.previewSaved=true;} discardRaw(){} discardOutgoing(){}
}
class Platform {
constructor(c,end,release,feedback){this.context=c;this.end=end;this.release=release;platforms.push(this);this.choosing=false;}
resume(){} async connected(){this.active=true;} async background(){this.release();}
disconnect(){this.active=false;} dispose(){this.disconnect();} setMuted(){} cancelRequests(){} orientation(){} videoSize(){} async surface(){}
async version(){this.versionCalls=(this.versionCalls??0)+1;return undefined;}
async choose(){return undefined;} save(){} openLink(){}
}
const modules={};
function load(name){
if(modules[name])return modules[name];const exports={}; modules[name]=exports;
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root,name+'.ets'),'utf8'),{
compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,experimentalDecorators:true}
}).outputText,{exports,console,Observed:c=>c,Date,Map,Set,Number,Math,JSON,Uint8Array,ArrayBuffer,
setTimeout:fn=>{timers.set(++timerId,fn);return timerId;},clearTimeout:id=>timers.delete(id),
setInterval:fn=>{intervals.set(++timerId,fn);return timerId;},clearInterval:id=>intervals.delete(id),
require:name=>{
if(name==='libcrossdesk.so')return {default:{mobileCommand:s=>commands.push(JSON.parse(s)),mobilePoll:()=>JSON.stringify(events.splice(0))}};
if(name==='@kit.AbilityKit')return {bundleManager:{BundleFlag:{GET_BUNDLE_INFO_DEFAULT:0},getBundleInfoForSelfSync:()=>({versionName:'1.6.1'})}};
if(name==='@kit.ArkUI')return {display:{getDefaultDisplaySync:()=>({width:1080,height:2400})},promptAction:{showToast(){}}};
if(name==='@kit.ArkTS')return {util:{TextDecoder:{create:()=>({decodeWithStream:()=> 'Privacy policy'})},TextEncoder:class{encodeInto(s){return Buffer.from(s);}}}};
if(name==='@kit.BasicServicesKit')return {systemDateTime:{TimeType:{STARTUP:0},getUptime:()=>time},pasteboard:{}};
if(name==='./MobileStore')return {MobileStore:Store};
if(name==='./MobilePlatform')return {MobilePlatform:Platform,MobileLifecycle:{}};
if(name.startsWith('./'))return load(name.slice(2));throw Error(name);
}});return exports;
}
const {MobileController}=load('MobileController');
const context={cacheDir:'/private/cache',filesDir:'/private',resourceManager:{getRawFileContentSync:()=>new Uint8Array([1])}};
const flush=async()=>{for(let i=0;i<8;i++)await Promise.resolve();};
function feed(c,event){events.push({session:c.session,...event});c.poll();}
(async()=>{
const c=new MobileController(context);await flush();assert.equal(commands.length,0);assert.equal(platforms[0].versionCalls,undefined);
assert.equal(c.state.modal,'consent');await c.consent();await flush();
assert(commands.some(c=>c.op==='start'));const start=commands.find(c=>c.op==='start');assert.equal(start.identity,'');
feed(c,{type:'identity',identity:'123456789@secret'});await flush();
assert.equal(secrets.get('identity:api.crossdesk.cn:9099'),'123456789@secret');
feed(c,{type:'identity',identity:'123456789'});await flush();
assert.equal(secrets.get('identity:api.crossdesk.cn:9099'),'123456789@secret'); // Public reconnect report cannot erase the password.
feed(c,{type:'signal',status:1,controller:false,generation:1,id:'123456789'});
assert.equal(c.state.signal,1);assert(commands.some(c=>c.op==='signal'&&c.message.type==='announcements_list'));
await c.requestConnection('987654321');assert.equal(c.state.modal,'password');c.state.remember=true;c.begin('correct');
const connect=commands.filter(c=>c.op==='connect').at(-1);assert.equal(connect.password,'correct');assert.equal(c.state.password,'');
feed(c,{type:'data',connection:connect.connection,message:{type:3,host_info:{host_name:'Early desktop',platform:'windows',app_version:'1.6.1',supports_video_settings:true,displays:[{name:'Early display'}]}}});
assert.equal(c.state.displays.length,1);assert(!commands.some(c=>c.op==='videoSettings'));
feed(c,{type:'connection',connection:connect.connection,status:1});await flush();assert(c.state.ready);assert.equal(saves.length,1);assert.equal(saves[0].password,'correct');
assert(commands.some(c=>c.op==='videoSettings'));assert.equal(saves[0].device.name,'Early desktop');
feed(c,{type:'connection',connection:connect.connection,status:1});await flush();assert.equal(saves.length,1);
feed(c,{type:'data',connection:connect.connection,message:{type:3,host_info:{host_name:'Desktop',platform:'windows',app_version:'1.6.1',supports_video_settings:true,displays:[{name:'Display1'}]}}});
assert(c.video.supported);assert(commands.some(c=>c.op==='videoSettings'));assert.equal(c.state.displays.length,1);
c.updateVideo(1,60);const settings=commands.filter(c=>c.op==='videoSettings').at(-1);
feed(c,{type:'data',connection:connect.connection,message:{type:12,video_settings:{quality:settings.quality,frame_rate:60,preference:settings.preference,request_id:settings.request,accepted:false}}});
assert.equal(c.state.videoRate,30);assert(c.video.failed);
c.background();await flush();assert(c.state.ready);assert(!commands.slice(-2).some(c=>c.op==='disconnect'));assert(commands.some(c=>c.op==='release'));
c.resume();await flush();c.end('');await c.requestConnection('987654321');
assert.equal(c.state.modal,'connecting'); // Encrypted remembered password, no prompt.
const second=commands.filter(c=>c.op==='connect').at(-1);
feed(c,{type:'connection',connection:connect.connection,status:1});assert(!c.state.ready); // Superseded session.
feed(c,{type:'connection',connection:second.connection,status:6});assert.equal(c.state.modal,'password');assert.match(c.state.notice,/密码错误/);
const previousSession=c.session;c.saveSettings('private.example','9443');await flush();
assert.equal(c.scope(),'private.example:9443');assert.equal(c.state.recent.length,0);
feed(c,{session:previousSession,type:'identity',identity:'000000000@stale'});assert.notEqual(c.state.identity,'000000000');
c.setPreviews(true);assert(c.state.previews);c.revoke();await flush();assert(!c.state.consent);assert(c.store.cleared);
const calls=platforms[0].versionCalls;c.resume();await c.checkLocal(true);assert.equal(platforms[0].versionCalls,calls);
c.destroy();assert.equal(intervals.size,0);
console.log('PASS: consent-before-network, encrypted identity/password use, duplicate/stale callbacks, controller-only lifecycle, ACK rollback, background retention, server isolation and revocation');
})().catch(e=>{console.error(e);process.exitCode=1;});
+97
View File
@@ -0,0 +1,97 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require(process.argv[2]);
const root = process.argv[3];
let timers = new Map(), nextTimer = 0;
function load(name) {
const exports = {};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, name + '.ets'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, { exports, console, setTimeout: fn => { timers.set(++nextTimer, fn); return nextTimer; },
clearTimeout: id => timers.delete(id), Date, Map, Set, Number, Math, JSON,
require: name => { if (name.startsWith('./')) return load(name.slice(2)); throw Error(name); } });
return exports;
}
const { RemoteViewport, MouseInput, TouchInput, VideoSettings, NetworkStatistics, RecentPresence, asciiKey } = load('ControllerModels');
const viewport = new RemoteViewport(); viewport.fit(320, 200, 1920, 1080);
assert.equal(viewport.baseHeight, 180); assert.equal(viewport.top(), 10);
const before = viewport.x(100); viewport.zoom(2, 100, 100); assert.equal(viewport.x(100), before);
viewport.zoom(100, 160, 100); assert.equal(viewport.scale, 10);
viewport.pan(1e6, -1e6); assert.equal(viewport.offsetX, viewport.baseWidth * 4.5);
viewport.zoom(.00001, 160, 100); assert.equal(viewport.scale, 1); assert.equal(viewport.offsetX, 0);
assert(!viewport.contains(100, 0)); assert.equal(viewport.x(-100), 0);
const packets = [];
const mouse = new MouseInput((x, y, flag, wheel) => packets.push({ x, y, flag, wheel }));
const touch = new TouchInput(viewport, mouse);
function input(kind, points = []) { if (kind === 'down') touch.begin(); touch.touch(points.map(([x,y]) => ({x,y})), kind); }
input('down', [[100,100]]); input('move', [[120,100]]); input('up');
assert(packets.every(p => p.flag === 0)); // Relative movement never drags.
packets.length = 0; input('down', [[100,100]]); input('up'); assert.deepEqual(packets.map(p => p.flag), [1,2]);
packets.length = 0; input('down', [[100,100]]); touch.hold(); input('up'); assert.deepEqual(packets.map(p => p.flag), [3,4]);
touch.relative = false; packets.length = 0;
input('down', [[100,100]]); input('move', [[120,100]]); input('move', [[400,300]]); input('up');
assert.deepEqual(packets.map(p => p.flag), [0,1,0,0,2]); assert.equal(packets.at(-2).x, 1);
packets.length = 0;
input('down', [[100,100]]); input('down', [[100,100],[200,100]]); input('move', [[80,100],[220,100]]);
input('up', [[80,100]]); input('move', [[90,110]]); input('up');
assert(viewport.scale > 1); assert(packets.every(p => p.flag === 0));
viewport.reset(); packets.length = 0;
input('down', [[100,100]]); input('down', [[100,100],[200,100]]); input('move', [[100,125],[200,125]]); input('up');
assert.equal(packets.filter(p => p.flag === 7).length, 1);
mouse.button(true, true); const revision = mouse.revision;
packets.length = 0; input('down', [[100,100]]); input('move', [[110,100]]); input('up');
assert(mouse.left); assert(packets.every(p => p.flag === 0)); assert.equal(mouse.revision, revision);
mouse.button(false, true); mouse.drag(32,0,320,180); mouse.release();
assert.equal(packets.at(-2).flag, 2); assert.equal(packets.at(-1).flag, 4);
assert.equal(asciiKey('a'), 65); assert.equal(asciiKey('A'), 321); assert.equal(asciiKey('!'), 305); assert.equal(asciiKey('中'), 0);
const settings = new VideoSettings(1, 1280); assert.equal(settings.selection.quality, 0);
assert.equal(settings.begin({quality:1,frameRate:30,preference:1}), -1); settings.support(true);
const one = {quality:2,frameRate:60,preference:0}, two = {quality:1,frameRate:30,preference:2};
const id1 = settings.begin(one), id2 = settings.begin(two);
settings.receive(one, id1, true); assert(settings.pending); assert.equal(settings.applied.quality, 2);
settings.receive(two, id2, false); assert(!settings.pending); assert.equal(settings.selection.quality, 2);
const id3 = settings.begin(two); settings.expire(id2); assert(settings.pending); settings.expire(id3); assert.equal(settings.selection.quality, 2);
settings.receive(two,id3+1,true); assert.equal(settings.selection.quality,2);
settings.requestId = 0xffffffff; settings.appliedRequestId = 0xffffffff;
const wrapped = settings.begin(two); assert.equal(wrapped,0); settings.receive(two,wrapped,true); assert.equal(settings.selection.preference,2);
settings.support(false); assert(!settings.pending);
const network = new NetworkStatistics();
const traffic = {inbound:8000000,outbound:10000,loss:.02};
network.receive({rtt:40,mode:0,srtp:true,video:traffic,audio:traffic,data:traffic,total:traffic}, 100);
network.receive({rtt:80,mode:1,srtp:false,video:traffic,audio:traffic,data:traffic,total:traffic}, 200);
assert.equal(network.rtt,50); assert.equal(network.mode(201),'TURN 中继'); assert.equal(network.mode(3200),'—');
network.frame(6,7000,1920,1080,200); network.frame(6,1,1920,1080,250); assert.equal(network.fps(250),6); assert.equal(network.latency(250),7000);
assert.equal(network.fps(1200),0); network.reset(); assert.equal(network.width,0);
const subscriptions = []; const presence = new RecentPresence(ids => subscriptions.push([...ids]));
presence.watch(['111111111'],0); presence.connect(true,10); assert.equal(subscriptions.length,1);
presence.receive({type:'presence_update',id:'111111111',online:true},20); assert(!presence.online('111111111',21));
presence.receive({type:'presence',devices:[{id:'111111111',online:true},{id:'222222222',online:true}]},30);
assert(presence.online('111111111',31)); assert(!presence.online('222222222',31)); assert(!presence.online('111111111',60030));
presence.tick(30010); assert.equal(subscriptions.length,2); presence.connect(false,40000); presence.connect(true,41000); assert(!presence.online('111111111',41001));
const { AnnouncementInbox } = load('AnnouncementInbox');
const { announcementSpans, announcementByteLength, isAnnouncementURL } = load('AnnouncementText');
const spans = announcementSpans('说明\r\n[下载](HTTP://example.com/a(b));https://example.com/end. <b>普通文本</b>');
assert.equal(spans.map(p=>p.text).join(''),'说明\n下载;https://example.com/end. <b>普通文本</b>');
assert.deepEqual(Array.from(spans.filter(p=>p.url),p=>p.url),['HTTP://example.com/a(b)','https://example.com/end']);
assert(!isAnnouncementURL('javascript:alert(1)'));assert(!isAnnouncementURL('https://a\\b'));assert(!isAnnouncementURL('https://'));
assert.equal(announcementByteLength('中'.repeat(80)),240);assert.equal(announcementByteLength('😀'),4);
const requests = []; const saved = new Map(); let failSave = false;
const inbox = new AnnouncementInbox(r => requests.push({...r}), s => saved.get(s) ?? '{}', (s,v) => { if(failSave)throw Error(); saved.set(s,v); });
inbox.configure('server:9099:identity'); inbox.connect(true);
function answer(request, entries, total, revision=1) { inbox.receive({type:'announcements',request_id:request.request_id,summary_only:request.summary_only,offset:request.offset,total,catalog_revision:revision,items:entries}); }
const entries = Array.from({length:21},(_,i)=>({id:i+1,revision:1,title:`Title ${i+1}`,body:'Body',updated_at:1700000000}));
const first = requests.at(-1); answer(first,entries,21); assert.equal(requests.at(-1).summary_only,false);
answer(requests.at(-1),entries.slice(0,20),21); assert.equal(inbox.items.length,20); assert.equal(inbox.unread,21);
inbox.markRead(inbox.items[0]); assert.equal(inbox.unread,20);
failSave = true; inbox.dismiss(inbox.items[0]); assert.equal(inbox.total,21); assert(inbox.saveFailed);
failSave = false; inbox.dismiss(inbox.items[0]); answer(requests.at(-1),entries.slice(20),21); assert.equal(inbox.items.length,20); assert.equal(inbox.total,20);
const stale = requests.at(-1); inbox.refresh(); answer(stale,[],0); assert(inbox.loading); answer(requests.at(-1),entries.map(e=>({...e,revision:2})),21,2);
answer(requests.at(-1),entries.slice(0,20).map(e=>({...e,revision:2})),21,2); assert.equal(inbox.unread,21);
inbox.more(); answer(requests.at(-1),entries.slice(20).map(e=>({...e,revision:2})),21,2); assert.equal(inbox.items.length,21);
inbox.connect(false); const count=requests.length; inbox.more(); assert.equal(requests.length,count);
inbox.connect(true); for(const timer of [...timers.values()])timer(); assert(inbox.failed);
inbox.configure('utf8');inbox.connect(true);answer(requests.at(-1),[entries[0]],1);
answer(requests.at(-1),[{...entries[0],title:'中'.repeat(81)}],1);assert(inbox.failed);assert.equal(inbox.items.length,0);
console.log('PASS: mobile viewport/gestures, shared button ownership, keyboard, video ACK ordering, network expiry, presence and announcement pagination/revisions');
@@ -0,0 +1,39 @@
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
const ts=require(process.argv[2]),root=process.argv[3], exports_={};
const textExports={};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root,'AnnouncementText.ets'),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports:textExports});
let agentResolve, startResolve, pipResolve, creating=0, activeTasks=0, calls=[], ended=[], releases=0;
const flush=async()=>{for(let i=0;i<12;i++)await Promise.resolve();};
const pip={setAutoStartEnabled:v=>calls.push(['auto',v]),on:(type,fn)=>pip[type]=fn,updatePiPControlStatus:(t,s)=>calls.push(['mute',s]),
updateContentSize(){},startPiP:async()=>calls.push(['pip']),stopPiP:async()=>calls.push(['stopPip'])};
const background={BackgroundMode:{DATA_TRANSFER:1},startBackgroundRunning:()=>new Promise(r=>{calls.push(['start']);startResolve=()=>{activeTasks++;r();};}),
stopBackgroundRunning:async()=>{calls.push(['stop']);activeTasks--;}};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root,'MobilePlatform.ets'),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,
{exports:exports_,console,Promise,Date,Math,require:name=>{
if(name==='./AnnouncementText')return textExports;
if(name==='@kit.BasicServicesKit')return {deviceInfo:{deviceType:'phone'}};
if(name==='@kit.AbilityKit')return {wantAgent:{OperationType:{START_ABILITY:1},WantAgentFlags:{UPDATE_PRESENT_FLAG:1},getWantAgent:()=>new Promise(r=>agentResolve=r)}};
if(name==='@kit.BackgroundTasksKit')return {backgroundTaskManager:background};
if(name==='@kit.CoreFileKit')return {picker:{},fileIo:{},fileUri:{}};
if(name==='@kit.NetworkKit')return {http:{}};
if(name==='@kit.ArkUI')return {display:{getDefaultDisplaySync:()=>({densityPixels:1})},window:{Orientation:{PORTRAIT:1,AUTO_ROTATION:2},
getLastWindow:async()=>({setWindowKeepScreenOn:async()=>{},getWindowProperties:()=>({windowRect:{width:360}}),setPreferredOrientation:async()=>{}})},
PiPWindow:{PiPTemplateType:{VIDEO_LIVE:1},VideoLiveControlGroup:{MUTE_SWITCH:402},PiPControlType:{MUTE_SWITCH:8},PiPControlStatus:{PAUSE:0,PLAY:1},
PiPState:{ABOUT_TO_START:1,STARTED:2,ABOUT_TO_RESTORE:3,STOPPED:4},isPiPEnabled:()=>true,create:()=>{creating++;return new Promise(r=>pipResolve=r);}}};
throw Error(name);
}});
(async()=>{
const p=new exports_.MobilePlatform({abilityInfo:{name:'Entry',bundleName:'test'}},r=>ended.push(r),()=>releases++,()=>{});
const first=p.connected();await flush();agentResolve({});await flush();
// Disconnect during system start, then reconnect before it resolves.
p.disconnect();const second=p.connected();await flush();assert.equal(calls.filter(c=>c[0]==='start').length,1);
startResolve();await flush();assert.equal(activeTasks,0);agentResolve({});await flush();startResolve();await second;assert.equal(activeTasks,1);
const a=p.surface({},640,360), b=p.surface({},640,360);assert.equal(creating,1);pipResolve(pip);await Promise.all([a,b]);
let muted=false;p.onMute=v=>muted=v;pip.controlEvent({controlType:8,status:0});assert(muted);p.setMuted(true);assert(calls.some(c=>c[0]==='mute'&&c[1]===0));
p.setChoosing(true);await p.background();assert(!calls.some(c=>c[0]==='pip'));assert.equal(ended.length,0);
p.resume();p.setChoosing(false);await p.background();assert(calls.some(c=>c[0]==='pip'));assert(releases>0);
pip.stateChange(2,'');assert(p.pipActive);p.resume();await flush();assert(!p.pipActive);assert.equal(ended.length,0);
assert(calls.some(c=>c[0]==='stopPip'));await p.background();pip.stateChange(2,'');pip.stateChange(4,'close');assert.equal(ended.length,1);
p.disconnect();await flush();assert.equal(activeTasks,0);
console.log('PASS: rapid reconnect serializes background tasks, PiP creation deduplicates, mute control, picker guard, background release and close cleanup');
})().catch(e=>{console.error(e);process.exitCode=1;});
+45
View File
@@ -0,0 +1,45 @@
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),path=require('node:path'),crypto=require('node:crypto');
const ts=require(process.argv[2]),root=process.argv[3],exports_={};
const values=new Map(),secrets=new Map(),files=new Map(),opened=new Map();let nextFd=0,finishPacking,cacheMissing=false;
const Tag={ALIAS:1,SECRET:2,RETURN_TYPE:3,ACCESSIBILITY:4,SYNC_TYPE:5,CONFLICT_RESOLUTION:6};
const key=q=>Buffer.from(q.get(Tag.ALIAS)).toString();
const asset={Tag,ReturnType:{ALL:1},Accessibility:{DEVICE_FIRST_UNLOCKED:2},SyncType:{NEVER:0},ConflictResolution:{OVERWRITE:1},ErrorCode:{NOT_FOUND:404},
query:async q=>{const v=secrets.get(key(q));if(!v)throw {code:404};return [v];},
add:async q=>{assert.equal(q.get(Tag.ACCESSIBILITY),2);assert.equal(q.get(Tag.SYNC_TYPE),0);secrets.set(key(q),new Map(q));},
remove:async q=>{secrets.delete(key(q));}};
const io={OpenMode:{READ_ONLY:1,WRITE_ONLY:2,CREATE:4,TRUNC:8},mkdirSync(){},
accessSync:p=>{if(!files.has(p))throw Error('missing');return true;},
listFileSync:p=>{if(cacheMissing)throw Error('cache removed');return [...files.keys()].filter(k=>k.startsWith(p+'/')).map(k=>k.slice(p.length+1));},
unlinkSync:p=>files.delete(p),renameSync:(a,b)=>{assert(files.has(a));files.set(b,files.get(a));files.delete(a);},
openSync:(p,mode)=>{if(mode&4)files.set(p,Buffer.alloc(0));if(!files.has(p))throw Error('missing');const file={fd:++nextFd};opened.set(file.fd,p);return file;},
closeSync:f=>opened.delete(f.fd),readSync:(fd,b)=>{const content=files.get(opened.get(fd));new Uint8Array(b).set(content);return content.length;}};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root,'MobileStore.ets'),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,
{exports:exports_,Uint8Array,ArrayBuffer,Map,console,require:name=>{
if(name==='@kit.ArkData')return {preferences:{getPreferencesSync:()=>({getSync:(k,d)=>values.get(k)??d,putSync:(k,v)=>values.set(k,v),flushSync(){}})}};
if(name==='@kit.AssetStoreKit')return {asset};
if(name==='@kit.CryptoArchitectureKit')return {cryptoFramework:{createMd:()=>{const hash=crypto.createHash('sha256');return {update:async b=>hash.update(b.data),digest:async()=>({data:hash.digest()})};}}};
if(name==='@kit.ArkTS')return {util:{TextEncoder:class{encodeInto(v){return Buffer.from(v);}},TextDecoder:{create:()=>({decodeWithStream:v=>Buffer.from(v).toString()})}}};
if(name==='@kit.CoreFileKit')return {fileIo:io};
if(name==='@kit.ImageKit')return {image:{PixelMapFormat:{RGBA_8888:1},createPixelMap:async()=>({release(){}}),createImagePacker:()=>({packToFile:()=>new Promise(r=>finishPacking=r),release(){}})}};
throw Error(name);
}});
const flush=async()=>{for(let i=0;i<12;i++)await Promise.resolve();};
(async()=>{
const Store=exports_.MobileStore,s=new Store({filesDir:'/private',cacheDir:'/cache'}),scope=Store.scope('EXAMPLE.COM.',9099);
assert.equal(scope,'example.com:9099');
await s.saveSecret('identity:'+scope,'123456789@private');assert.equal(await s.secret('identity:'+scope),'123456789@private');
await s.saveRecent(scope,{id:'987654321',name:'Desktop',platform:'windows',remember:true,preview:''},'password');
assert(!JSON.stringify([...values]).includes('password'));assert(!JSON.stringify([...values]).includes('@private'));
assert.equal(await s.secret('password:'+scope+':987654321'),'password');
s.put('consent',true);s.put('previews',true);
let raw='/cache/mobile-previews/preview-1-1.rgba';files.set(raw,Buffer.alloc(640*360*4));
let save=s.savePreview(scope,'987654321',raw);await flush();assert(finishPacking);s.clearPreviews();finishPacking();await save;
assert.equal(files.size,0);assert.equal(s.recent(scope)[0].preview,'');assert.equal(opened.size,0);
raw='/cache/mobile-previews/preview-2-1.rgba';files.set(raw,Buffer.alloc(640*360*4));
save=s.savePreview(scope,'987654321',raw);await flush();finishPacking();await save;
const preview=s.recent(scope)[0].preview;assert(preview.endsWith('.jpg'));assert(files.has(preview));assert(!files.has(raw));
files.delete(preview);assert.equal(s.recent(scope).length,1);assert.equal(s.recent(scope)[0].preview,'');
cacheMissing=true;assert.doesNotThrow(()=>s.clearPreviews());
await s.delete(scope,'987654321');assert.equal(s.recent(scope).length,0);assert.equal(await s.secret('password:'+scope+':987654321'),'');
console.log('PASS: Asset Store-only secrets without sync, private preview cache, asynchronous revocation, evicted cache and credential deletion');
})().catch(e=>{console.error(e);process.exitCode=1;});
@@ -0,0 +1,70 @@
// Manual simulator integration fixture. Generates pixels/PCM only; never captures a device.
// Run in a disposable directory: <binary> <server> <port> [av1]. Login is saved in fixture-login.
#include <minirtc.h>
#include <remote_action.h>
#include <stream_names.h>
#include <mobile_file_transfer_session.h>
#include <atomic>
#include <chrono>
#include <fstream>
#include <mutex>
#include <thread>
#include <vector>
#include <sys/stat.h>
using namespace crossdesk;
struct Fixture {
PeerPtr* peer=nullptr; std::atomic<bool> connected{false}; std::atomic<int> selected{0};
std::mutex mutex; std::vector<std::pair<std::string,std::string>> messages;
std::string remote; std::atomic<int> signal{-1}; int keys=0,mouse=0,settings=0,clipboard=0;
void Send(RemoteAction& a) {const auto s=a.to_json();SendReliableDataFrame(peer,s.data(),s.size(),kControlStream);}
};
int main(int argc,char**argv) {
if(argc<3)return 2;umask(0077);
#if defined(__OHOS__)
setenv("SSL_CERT_DIR","/etc/security/certificates",1);
#endif
std::string login;{std::ifstream input("fixture-login");input>>login;}
Fixture f;Params p{};snprintf(p.signal_server_ip,sizeof(p.signal_server_ip),"%s",argv[1]);p.signal_server_port=atoi(argv[2]);p.user_id=login.c_str();p.user_data=&f;
p.turn_mode=TurnAutoUdpTcp;p.enable_srtp=true;p.av1_encoding=argc>3;p.video_frame_rate=30;p.video_quality=QualityMedium;
p.video_content_type=VideoContentType::ScreenContent;p.video_degradation_preference=VideoDegradationPreference::MaintainResolution;
p.on_signal_status=[](SignalStatus s,const char*,size_t,void*u){static_cast<Fixture*>(u)->signal=s;};
p.on_net_status_report=[](const char*id,size_t n,TraversalMode mode,const MiniRtcNetTrafficStats*,const char*,size_t,void*){
if(mode==UnknownMode&&id&&n&&std::string(id,n).find('@')!=std::string::npos){std::ofstream out("fixture-login");out.write(id,n);}
};
p.on_connection_status=[](ConnectionStatus s,const char*id,size_t n,void*u){auto*f=static_cast<Fixture*>(u);std::lock_guard lock(f->mutex);f->remote.assign(id,n);f->connected=s==Connected;if(s==Connected)f->selected=0;};
p.on_receive_data_buffer=[](const char*d,size_t n,const char*,size_t,const char*s,size_t l,void*u){auto*f=static_cast<Fixture*>(u);std::lock_guard lock(f->mutex);f->messages.emplace_back(std::string(s,l),std::string(d,n));};
f.peer=CreatePeer(&p);AddVideoStream(f.peer,"Display1");AddVideoStream(f.peer,"Display2");AddAudioStream(f.peer,kAudioStream);AddDataStream(f.peer,kDataStream,false);
for(auto s:{kKeyboardStream,kMouseStream,kControlStream,kClipboardStream,kFileStream,kFileFeedbackStream})AddDataStream(f.peer,s,true);
if(Init(f.peer))return 3;
mobile_controller::FileTransferSession files([&](const char*s,const char*b,size_t n){return SendReliableDataFrame(f.peer,b,n,s);},
[](const std::string&,double p,bool,const std::string&path){if(p==1&&!path.empty())std::ofstream("received-result")<<path;});
files.SetDirectory("received");bool info=false;int iteration=0;
std::vector<uint8_t> pixels(640*360*3/2,128);int16_t pcm[480]{};
const auto end=std::chrono::steady_clock::now()+std::chrono::minutes(15);
while(std::chrono::steady_clock::now()<end){
std::vector<std::pair<std::string,std::string>> messages;{std::lock_guard lock(f.mutex);messages.swap(f.messages);}
if(f.connected&&!info){auto a=MakeHostInformation("Synthetic mobile fixture",{{"Test pattern",0,0,640,360},{"Second display",0,0,640,360}},true,"1.6.1",HostPlatform::Windows);a.i.supports_video_settings=true;f.Send(a);FreeRemoteAction(a);info=true;
const std::string clip="CrossDesk synthetic clipboard";SendReliableDataFrame(f.peer,clip.data(),clip.size(),kClipboardStream);
{std::ofstream out("sample.txt");out<<"Synthetic mobile file transfer\n";}files.Start("sample.txt","fixture.txt");}
if(!f.connected)info=false;
for(auto&[stream,bytes]:messages){
if(stream==kFileStream)files.Receive(bytes.data(),bytes.size());else if(stream==kFileFeedbackStream)files.Ack(bytes.data(),bytes.size());
else if(stream==kClipboardStream){f.clipboard++;SendReliableDataFrame(f.peer,bytes.data(),bytes.size(),kClipboardStream);}
else {RemoteAction a{};if(!a.from_json(bytes))continue;
if(a.type==host_infomation)info=false;
if(a.type==keyboard)f.keys++;if(a.type==mouse)f.mouse++;if(a.type==display_id)f.selected=a.d;
if(a.type==video_settings){f.settings++;a.type=video_settings_status;a.vs.accepted=true;f.Send(a);}FreeRemoteAction(a);}
}
if(f.connected){
files.Pump();MiniRtcAudioFrame audio{};audio.data=reinterpret_cast<const char*>(pcm);audio.size=sizeof(pcm);SendAudioFrame(f.peer,&audio,kAudioStream);
if(iteration%3==0){for(int y=0;y<360;y++)for(int x=0;x<640;x++)pixels[y*640+x]=uint8_t(35+(x/80)*24+((y+iteration)%180<12?30:0));
std::fill(pixels.begin()+640*360,pixels.end(),128);for(size_t i=640*360;i<pixels.size();i+=2){pixels[i]=f.selected?80:170;pixels[i+1]=f.selected?170:80;}
MiniRtcVideoFrame frame{};frame.width=640;frame.height=360;frame.size=pixels.size();frame.data=reinterpret_cast<const char*>(pixels.data());
frame.captured_timestamp=std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
SendVideoFrame(f.peer,&frame,f.selected?"Display2":"Display1");}
}
if(iteration++%100==0)std::ofstream("fixture-status")<<"signal="<<f.signal<<" connected="<<f.connected<<" mouse="<<f.mouse<<" keys="<<f.keys<<" settings="<<f.settings<<" clipboard="<<f.clipboard<<" display="<<f.selected<<"\n";
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
DestroyPeer(&f.peer);
}
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Run host-side protocol/input tests against declarations from the installed SDK."""
from pathlib import Path
import argparse
import os
import subprocess
import tempfile
APP = Path(__file__).resolve().parents[1]
ROOT = APP.parents[1]
SDK = Path(os.environ.get('DEVECO_SDK_HOME', '/Applications/DevEco-Studio.app/Contents/sdk'))
HEADERS = SDK / 'default/openharmony/native/sysroot/usr/include'
def run(args):
subprocess.run([str(arg) for arg in args], check=True, cwd=ROOT)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--desktop', action='store_true', help='Also test the Slint desktop keyboard adapter')
parser.add_argument('--mobile', action='store_true', help='Test mobile controller models, lifecycle and shared Android protocol')
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix='crossdesk-harmony-test-') as temporary:
work = Path(temporary)
for directory in ['multimodalinput', 'info', 'multimedia', 'ohaudio', 'hilog', 'native_window', 'native_buffer', 'native_image']:
(work / directory).symlink_to(HEADERS / directory, target_is_directory=True)
common = ['c++', '-std=c++17', '-Wno-availability', '-Wno-unknown-attributes',
'-I' + str(work), '-I' + str(ROOT / 'libs/wire/include'), '-I' + str(APP / 'native'),
'-I' + str(ROOT / 'apps/desktop/src/platform/common/input')]
run(common + [APP / 'tests/input_test.cpp', APP / 'native/input.cpp', '-o', work / 'input_test'])
run([work / 'input_test'])
packages = APP / '.native/packages'
headers = list(packages.glob('n/nlohmann_json/*/*/include'))
if not headers:
raise SystemExit('Build the native dependencies first to obtain nlohmann_json.')
yuv_headers = list(packages.glob('l/libyuv/*/*/include'))
if not yuv_headers:
raise SystemExit('Build the native dependencies first to obtain libyuv.')
run([*common, '-I' + str(yuv_headers[0]), '-I' + str(ROOT / 'deps/submodules/minirtc/src/api'),
APP / 'tests/capture_test.cpp', APP / 'native/capture.cpp', '-o', work / 'capture_test'])
run([work / 'capture_test'])
print('Capture authorization timing, frames/audio, privacy pause/resume and cleanup tests passed.')
sources = list((ROOT / 'libs/wire/src').glob('*.cpp'))
for name in ['wire_test', 'keyboard_state_wire_test']:
run(common + ['-I' + str(headers[0]), *sources,
ROOT / 'libs/wire/tests' / (name + '.cpp'), '-o', work / name])
run([work / name])
if args.mobile:
studio = Path(os.environ.get('DEVECO_STUDIO_HOME', '/Applications/DevEco-Studio.app/Contents'))
for test in ['mobile_models_test.cjs', 'mobile_lifecycle_test.cjs', 'mobile_platform_test.cjs', 'mobile_store_test.cjs']:
run([studio / 'tools/node/bin/node', APP / 'tests' / test,
studio / 'tools/ohpm/node_modules/typescript/lib/typescript.js',
APP / 'entry/src/main/ets/mobile'])
android = ROOT / 'apps/android'
for test in ['controller_protocol_test', 'video_pipeline_test']:
run([*common, '-pthread', '-I' + str(headers[0]), '-I' + str(android / 'native'),
'-I' + str(ROOT / 'deps/submodules/minirtc/src/api'),
android / 'test/native' / (test + '.cpp'), ROOT / 'libs/wire/src/remote_action.cpp', '-o', work / test])
run([work / test])
run([*common, '-I' + str(ROOT / 'libs/controller/include'),
APP / 'tests/mobile_file_transfer_test.cpp', ROOT / 'libs/wire/src/file_transfer.cpp',
'-o', work / 'mobile_file_transfer_test'])
run([work / 'mobile_file_transfer_test'])
if args.desktop:
studio = Path(os.environ.get('DEVECO_STUDIO_HOME', '/Applications/DevEco-Studio.app/Contents'))
run([studio / 'tools/node/bin/node', APP / 'tests/text_input_host_test.cjs',
studio / 'tools/ohpm/node_modules/typescript/lib/typescript.js',
APP / 'desktop-port/TextInputHost.ets', APP / 'desktop-port/KeyboardHost.ets',
APP / 'desktop-port/DesktopHost.ets', APP / 'desktop-port/DesktopAccess.ets'])
run([studio / 'tools/node/bin/node', APP / 'tests/desktop_window_host_test.cjs',
studio / 'tools/ohpm/node_modules/typescript/lib/typescript.js',
APP / 'desktop-port/DesktopHost.ets'])
run([studio / 'tools/node/bin/node', APP / 'tests/autostart_host_test.cjs',
studio / 'tools/ohpm/node_modules/typescript/lib/typescript.js',
APP / 'desktop-port/AutostartHost.ets'])
cache = APP / '.native/slint-poc'
run(['c++', '-std=c++20', '-I' + str(APP / 'desktop-port'),
'-I' + str(cache / 'slint-1.17.1/api/cpp/include'), '-I' + str(cache / 'include'),
APP / 'tests/desktop_keyboard_test.cpp', '-o', work / 'desktop_keyboard_test'])
run([work / 'desktop_keyboard_test'])
run(['c++', '-std=c++20', '-I' + str(APP / 'desktop-port'), '-I' + str(headers[0]),
APP / 'tests/desktop_platform_test.cpp', '-o', work / 'desktop_platform_test'])
run([work / 'desktop_platform_test'])
desktop = ROOT / 'apps/desktop/src'
includes = [APP / 'desktop-port', headers[0], desktop, desktop / 'screen_capturer',
desktop / 'speaker_capturer', desktop / 'device_controller',
desktop / 'common', desktop / 'gui', ROOT / 'deps/submodules/minirtc/src/api']
run([*common, '-std=c++20', *['-I' + str(p) for p in includes],
APP / 'tests/desktop_devices_test.cpp', APP / 'desktop-port/devices.cpp',
APP / 'desktop-port/keyboard_hook.cpp', *sources,
'-o', work / 'desktop_devices_test'])
run([work / 'desktop_devices_test'])
run([*common, '-std=c++20', *['-I' + str(p) for p in includes],
APP / 'tests/keyboard_hook_test.cpp', APP / 'desktop-port/keyboard_hook.cpp',
'-o', work / 'keyboard_hook_test'])
run([work / 'keyboard_hook_test'])
print('Slint displays, cursor shapes/position/wire/presentation, pointer cancellation, capture switching/rollback and authorization tests passed.')
print('HarmonyOS input adapter and shared wire tests passed.')
+320
View File
@@ -0,0 +1,320 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require(process.argv[2]);
// Run the production ArkTS service with a fake IME. In particular, commands
// consumed by the OS must reach Slint exactly once after field reattachment.
const handlers = new Map();
let attached = 0, detached = 0, selection;
const editor = {
attachWithUIContext: async (context, config, options) => {
assert.equal(context, 17); assert.equal(options.showKeyboard, false);
assert.equal(config.inputAttribute.consumeKeyEvents, true); ++attached;
},
detach: async () => { ++detached; },
on: (type, callback) => { assert(!handlers.has(type)); handlers.set(type, callback); },
off: type => handlers.delete(type),
changeSelection: async (...value) => { selection = value; }, updateCursor: async () => {}
};
const inputMethod = {
getController: () => editor,
TextInputType: { TEXT: 0, VISIBLE_PASSWORD: 1, NUMBER: 2, NUMBER_DECIMAL: 3 },
EnterKeyType: { DONE: 1 },
Direction: { CURSOR_UP: 1, CURSOR_DOWN: 2, CURSOR_LEFT: 3, CURSOR_RIGHT: 4 },
ExtendAction: { SELECT_ALL: 0, CUT: 3, COPY: 4, PASTE: 5 }
};
const source = fs.readFileSync(process.argv[3], 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020
}}).outputText;
const exportsObject = {};
vm.runInNewContext(compiled, { exports: exportsObject, console, require: name => {
if (name === '@kit.IMEKit') return { inputMethod };
if (name === './DisplayHost') return { DisplayHost: { screen: () => ({ densityPixels: 2 }) } };
throw Error(`Unexpected import ${name}`);
}});
const host = exportsObject.TextInputHost;
const target = { getWindowProperties: () => ({ id: 17, windowRect: { left: 20, top: 30 } }),
getWindowDecorVisible: () => false, getWindowDecorHeight: () => 0, getUIContext: () => 17 };
const sent = [];
const request = async event => {
host.request(event, target, value => sent.push(value));
await new Promise(resolve => setImmediate(resolve));
};
const take = () => JSON.parse(JSON.stringify(sent.splice(0)));
const keyboardModule = {};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(process.argv[4], 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, { exports: keyboardModule, KeyType: { Down: 0, Up: 1 } });
const keyboard = new keyboardModule.KeyboardHost();
const key = (code, down, ctrl = false) => keyboard.preIme({ keyCode: code,
type: down ? 0 : 1, getModifierKeyState: keys => {
assert(['Ctrl', 'Alt', 'Shift'].includes(keys[0]));
return keys[0] === 'Ctrl' && ctrl;
} }, 1, e => sent.push(e));
assert(key(2072, true, true)); // Slint gets the modifier even if the IME consumes it.
assert(key(2017, true, true));
assert(key(2017, true, true)); // Repeat is retained.
assert(key(2072, false));
assert(key(2017, false)); // Ctrl was released first.
assert.equal(take().length, 5);
assert(!key(2017, true)); // Plain typing remains with the IME.
assert(!key(2050, true, true)); // Ctrl+Space can switch input methods.
assert(key(2019, true, true)); take(); keyboard.reset();
assert(!key(2019, true)); // Lost focus must not consume the next plain letter.
assert(key(2076, true)); assert(key(2017, true));
assert(key(2076, false)); assert(key(2017, false));
assert.equal(take().length, 4);
async function verifyClipboardQueue() {
const module = {}, replies = [], order = [];
let clipboardPermission = true;
let pending = [], releaseRead, clipboardText = 'old';
const native = { poll: () => JSON.stringify(pending.splice(0)),
send: value => replies.push(JSON.parse(value)) };
const read = new Promise(resolve => { releaseRead = resolve; });
let readWait = read;
const board = {
hasDataType: () => true,
getData: async () => { order.push('read'); const text = clipboardText; await readWait; return { getPrimaryText: () => text }; },
setData: async data => { order.push(data); if (data === 'fail') throw Error('denied'); }
};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(process.argv[5], 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, { exports: module, console, require: name => {
if (name === 'libslint_desktop.so') return { default: native };
if (name === './DesktopAccess') return { DesktopAccess: { requestClipboard: () => {} } };
if (name === '@kit.BasicServicesKit') return { pasteboard: {
getSystemPasteboard: () => board, MIMETYPE_TEXT_PLAIN: 'text/plain', createData: (_, text) => text
} };
if (name === '@kit.AbilityKit') return { abilityAccessCtrl: {
GrantStatus: { PERMISSION_GRANTED: 0 }, createAtManager: () => ({
checkAccessTokenSync: () => clipboardPermission ? 0 : -1
})
} };
return {}; // Other OS services are not entered by this test.
}});
const host = module.DesktopHost;
host.context = { applicationInfo: { accessTokenId: 17 } };
pending = [{ op: 'clipboardRead', request: 1 }, { op: 'clipboardWrite', request: 2, text: 'new' }];
host.poll();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(order, ['read']); // Write must wait for the outstanding read.
pending = [{ op: 'unknown', request: 3 }]; host.poll();
await new Promise(resolve => setImmediate(resolve));
assert.equal(replies[0].request, 3); // Unrelated services remain responsive.
releaseRead(); await host.clipboardQueue;
assert.deepEqual(order, ['read', 'new']);
assert.deepEqual(replies.slice(1).map(r => r.request), [1, 2]);
pending = [{ op: 'clipboardWrite', request: 4, text: 'fail' }, { op: 'clipboardWrite', request: 5, text: 'recovered' }];
host.poll(); await host.clipboardQueue;
assert.equal(replies[3].ok, false); assert.equal(replies[4].ok, true);
clipboardPermission = false;
const before = order.length;
pending = [{ op: 'clipboardRead', request: 6 }, { op: 'clipboardHas', request: 7 }];
host.poll(); await host.clipboardQueue;
assert.equal(order.length, before); // Polling cannot consume Ctrl+V's one-read grant.
assert.equal(replies[5].ok, false); assert.equal(replies[6].ok, false);
pending = [{ op: 'clipboardPaste', request: 8 }];
host.poll(); await host.clipboardQueue;
assert.equal(order.length, before + 1); assert.equal(replies[7].text, 'old');
// No active window is required: an OS update invalidates the cache and
// enters the same SDL notification path while the application is hidden.
host.started = true; host.clipboardObserved = true; clipboardPermission = true;
const query = async id => {
pending = [{ op: 'clipboardRead', request: id }]; host.poll(); await host.clipboardQueue;
return replies.find(r => r.request === id);
};
host.clipboardChanged();
assert.equal((await query(9)).text, 'old');
const afterFirst = order.length;
assert.equal((await query(10)).text, 'old'); assert.equal(order.length, afterFirst);
clipboardText = '外部应用复制 😀'; host.clipboardChanged();
assert.equal((await query(11)).text, clipboardText); assert.equal(order.length, afterFirst + 1);
// A change during an asynchronous read must not be cached as the new value.
let releaseRace;
readWait = new Promise(resolve => { releaseRace = resolve; });
clipboardText = 'first'; host.clipboardChanged();
const racingRead = query(12);
await new Promise(resolve => setImmediate(resolve));
clipboardText = 'second'; host.clipboardChanged(); releaseRace();
assert.equal((await racingRead).text, 'first');
assert.equal((await query(13)).text, 'second');
// Revocation during getData must not expose or cache the result.
readWait = new Promise(resolve => { releaseRace = resolve; });
host.clipboardChanged(); const revokedRead = query(14);
await new Promise(resolve => setImmediate(resolve));
clipboardPermission = false; host.clipboardChanged(); releaseRace();
assert.equal((await revokedRead).ok, false);
const afterRevocation = order.length;
assert.equal((await query(15)).ok, false); assert.equal(order.length, afterRevocation);
assert.equal(host.clipboardCache, '');
host.terminating = true;
const notifications = replies.length; host.clipboardChanged();
assert.equal(replies.length, notifications); // Late callbacks after shutdown are ignored.
}
async function verifyDesktopAccess() {
const module = {}, notifications = [], calls = [], grants = new Set();
let change, releasePrompt, watched;
const manager = {
checkAccessTokenSync: (_, permission) => grants.has(permission) ? 0 : -1,
on: (type, permissions, callback) => { assert.equal(type, 'selfPermissionStateChange'); watched = Array.from(permissions); change = callback; },
off: (type, permissions, callback) => { assert.equal(callback, change); change = undefined; },
requestPermissionsFromUser: async () => { calls.push('clipboard'); await new Promise(resolve => { releasePrompt = resolve; }); },
openPermissionOnSetting: async () => { calls.push('keyboard'); throw Error('user dismissed'); }
};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(process.argv[6], 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, { exports: module, console: { warn: () => {} }, require: name => {
assert.equal(name, '@kit.AbilityKit'); return { abilityAccessCtrl: {
GrantStatus: { PERMISSION_GRANTED: 0 }, createAtManager: () => manager
} };
}});
let selection = 1, blockGuide = false, failSettings = false;
const dialogs = [], routes = [];
const ui = { getPromptAction: () => ({ showDialog: async options => {
calls.push('guide'); dialogs.push(options);
if (blockGuide) return await new Promise(resolve => { releasePrompt = resolve; });
return { index: selection };
} }) };
const access = module.DesktopAccess, context = {
applicationInfo: { accessTokenId: 17, name: 'com.chunkdash.crossdesk' },
startAbility: async want => {
calls.push('settings'); routes.push(JSON.parse(JSON.stringify(want)));
if (failSettings) throw Error('settings unavailable');
}
};
access.start(context, false, e => notifications.push(e));
access.requestKeyboard(); access.requestClipboard(); access.requestHost(); await access.requests;
assert.equal(calls.length, 0); assert.equal(change, undefined); // Ordinary HAP never opens unusable prompts.
access.stop(); access.start(context, true, e => notifications.push(e));
access.requestClipboard(); access.requestClipboard(); access.requestKeyboard();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(calls, ['clipboard']); // Permission UIs must not overlap.
releasePrompt(); await access.requests;
assert.deepEqual(calls, ['clipboard', 'keyboard']);
access.requestKeyboard(); access.requestClipboard(); await access.requests;
assert.equal(calls.length, 2); // Denial/dismissal is not a retry loop.
grants.add('ohos.permission.HOOK_KEY_EVENT'); change({});
assert.equal(notifications.filter(e => e.op === 'keyboardPermission').at(-1).ok, true);
grants.clear(); change({});
assert.equal(notifications.filter(e => e.op === 'keyboardPermission').at(-1).ok, false);
const late = change; access.stop();
const before = notifications.length; late({});
assert.equal(notifications.length, before);
calls.length = 0;
const screen = 'ohos.permission.CUSTOM_SCREEN_RECORDING', control = 'ohos.permission.CONTROL_DEVICE';
blockGuide = true;
access.start(context, false, e => notifications.push(e), true);
assert.deepEqual(watched, [screen, control]); // Host-only builds do not require controller ACLs.
access.requestHost(ui); access.requestHost(ui); access.requestKeyboard(); access.requestClipboard();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(calls, ['guide']);
assert.match(dialogs.at(-1).message, /屏幕共享/); assert.match(dialogs.at(-1).message, /辅助操作/);
releasePrompt({ index: 1 }); await access.requests;
assert.deepEqual(calls, ['guide', 'settings']);
assert.deepEqual(routes[0], {
bundleName: 'com.huawei.hmos.settings', abilityName: 'com.huawei.hmos.settings.MainAbility',
uri: 'application_info_entry', parameters: { pushParams: 'com.chunkdash.crossdesk' }
});
const latest = () => notifications.filter(e => e.op === 'hostPermissions').at(-1);
// Navigating to Settings is not a grant. Only system state changes enable access.
assert.equal(latest().controlDevice, false); assert.equal(latest().screenRecording, false);
grants.add(screen); grants.add(control); change({});
assert.equal(latest().controlDevice, true); assert.equal(latest().screenRecording, true);
access.stop(); access.start(context, false, e => notifications.push(e), true);
access.requestHost(ui); await access.requests;
assert.deepEqual(calls, ['guide', 'settings']); // Existing system grants survive an app restart.
grants.delete(control); change({});
assert.equal(latest().controlDevice, false); assert.equal(latest().screenRecording, true);
access.stop();
// Only explain the missing switch. Declining never launches Settings or loops.
calls.length = 0; blockGuide = false; selection = 0;
access.start(context, false, e => notifications.push(e), true);
access.requestHost(ui); await access.requests;
access.requestHost(ui); await access.requests;
assert.deepEqual(calls, ['guide']);
assert.match(dialogs.at(-1).message, /辅助操作/); assert.doesNotMatch(dialogs.at(-1).message, /屏幕共享/);
assert.equal(latest().controlDevice, false); assert.equal(latest().screenRecording, true);
grants.delete(screen); change({}); assert.equal(latest().screenRecording, false);
access.stop();
// A response from an old guide cannot navigate or publish state after restart.
calls.length = 0; blockGuide = true; selection = 1;
access.start(context, false, e => notifications.push(e), true);
access.requestHost(ui); await new Promise(resolve => setImmediate(resolve));
access.stop(); access.start(context, false, e => notifications.push(e), false);
const beforeCompletion = notifications.length;
releasePrompt({ index: 1 }); await access.requests;
assert.deepEqual(calls, ['guide']); assert.equal(notifications.length, beforeCompletion);
access.stop();
// A failed Settings route gives manual guidance without granting access or
// falling back to the system dialog with the misleading navigation path.
calls.length = 0; blockGuide = false; failSettings = true;
access.start(context, false, e => notifications.push(e), true);
access.requestHost(ui); await access.requests;
assert.deepEqual(calls, ['guide', 'settings', 'guide']);
assert.equal(dialogs.at(-1).title, '未能打开应用设置');
assert.match(dialogs.at(-1).message, /屏幕共享/); assert.match(dialogs.at(-1).message, /辅助操作/);
assert.equal(latest().controlDevice, false); assert.equal(latest().screenRecording, false);
access.stop();
}
(async () => {
await verifyClipboardQueue();
await verifyDesktopAccess();
await request({ op: 'ime', id: 1, kind: 1, text: 'a😀中', anchor: 1, cursor: 4 });
assert.deepEqual(selection, ['a😀中', 1, 4]);
for (const [action, code] of [[0, 2017], [3, 2040], [4, 2019], [5, 2038]]) {
handlers.get('handleExtendAction')(action);
assert.deepEqual(take(), [{ op: 'imeKey', id: 1, imeSession: 0, code, control: true }]);
}
handlers.get('moveCursor')(3);
handlers.get('selectByMovement')({ direction: 4 });
handlers.get('selectByRange')({ start: 1, end: 3 });
assert.deepEqual(take(), [
{ op: 'imeKey', id: 1, imeSession: 0, code: 2014, shift: false },
{ op: 'imeKey', id: 1, imeSession: 0, code: 2015, shift: true },
{ op: 'imeSelection', id: 1, imeSession: 0, anchor: 1, cursor: 3 }
]);
handlers.get('insertText')('你好');
assert.deepEqual(take(), [{ op: 'composition', id: 1, imeSession: 0, text: '你好', commit: true }]);
const oldInsert = handlers.get('insertText'), oldPreview = handlers.get('setPreviewText');
const oldFinish = handlers.get('finishTextPreview');
await request({ op: 'ime', id: 1, kind: 1 });
oldInsert('must not enter the new field'); oldPreview('stale'); oldFinish();
assert.equal(take().length, 0);
assert.equal(host.preview, '');
assert.equal(attached, 2); assert.equal(detached, 1);
handlers.get('handleExtendAction')(0);
assert.equal(take().length, 1);
const lateInsert = handlers.get('insertText');
await request({ op: 'ime', id: 1, kind: 0 });
lateInsert('late'); handlers.get('deleteLeft')(1);
assert.equal(take().length, 0);
// A native attach may finish after the window is destroyed. No editor or
// callbacks may be installed after release, even with the same window id.
let finishAttach;
editor.attachWithUIContext = async () => { await new Promise(resolve => { finishAttach = resolve; }); };
host.request({ op: 'ime', id: 2, kind: 1, imeSession: 7 }, target, e => sent.push(e));
await new Promise(resolve => setImmediate(resolve));
host.release(2); finishAttach(); await host.queue;
assert.equal(host.id, 0);
editor.attachWithUIContext = async () => {};
await request({ op: 'ime', id: 2, kind: 1, imeSession: 9 });
handlers.get('insertText')('current');
assert.deepEqual(take(), [{ op: 'composition', id: 2, imeSession: 9, text: 'current', commit: true }]);
const currentSelection = selection;
await request({ op: 'ime', id: 2, kind: 2, imeSession: 7, text: 'old update' });
assert.equal(selection, currentSelection);
host.release(1); await host.queue; // Destroying another window keeps this editor.
assert.equal(host.id, 2);
host.release(2); await host.queue;
assert.equal(host.id, 0);
console.log('Slint IME editing, selection, composition, detached callbacks and clipboard serialization passed');
})().catch(error => { console.error(error); process.exitCode = 1; });
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Check the packaged app, rather than only the files in the source project."""
import argparse
import json
import hashlib
import os
from pathlib import Path
import re
import subprocess
import tempfile
import zipfile
APP = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('hap', nargs='?', type=Path)
parser.add_argument('--mobile', action='store_true')
parser.add_argument('--bundle-name', default=json.loads((APP / 'AppScope/app.json5').read_text())['app']['bundleName'])
parser.add_argument('--desktop-permissions', action='store_true')
parser.add_argument('--host-permissions', action='store_true')
parser.add_argument('--cursor-position-permission', action='store_true')
args = parser.parse_args()
if args.hap is None:
project = 'mobile-project' if args.mobile else 'slint-poc/desktop-project'
args.hap = APP / '.native' / project / 'entry/build/default/outputs/default/entry-default-unsigned.hap'
sdk = Path(os.environ.get('DEVECO_SDK_HOME', '/Applications/DevEco-Studio.app/Contents/sdk'))
native = sdk / 'default/openharmony/native'
readelf = native / 'llvm/bin/llvm-readelf'
system_libraries = native / 'sysroot/usr/lib/aarch64-linux-ohos'
def require(condition, message):
if not condition:
raise SystemExit(message)
with zipfile.ZipFile(args.hap) as hap, tempfile.TemporaryDirectory(prefix='crossdesk-hap-') as temporary:
require(hap.testzip() is None, 'Corrupt HAP archive')
manifest = json.loads(hap.read('module.json'))
expected_app = (json.loads((APP / 'AppScope/app.json5').read_text())['app']['bundleName']
if args.mobile else args.bundle_name)
require(manifest['app']['bundleName'] == expected_app, 'Unexpected bundle name')
require(set(manifest['module']['deviceTypes']) == ({'phone', 'tablet'} if args.mobile else {'2in1'}), 'Unexpected device types')
permissions = {item['name'] for item in manifest['module'].get('requestPermissions', [])}
if args.mobile:
require(permissions == {'ohos.permission.INTERNET', 'ohos.permission.KEEP_BACKGROUND_RUNNING'}, 'Mobile must not declare screen capture or input-injection permissions')
require(all('dataTransfer' in a.get('backgroundModes', []) for a in manifest['module']['abilities']), 'Missing mobile background mode')
else:
expected_permissions = {'ohos.permission.INTERNET', 'ohos.permission.FILE_ACCESS_PERSIST',
'ohos.permission.PREPARE_APP_TERMINATE'}
for enabled, names in [
(args.desktop_permissions, ['ohos.permission.HOOK_KEY_EVENT', 'ohos.permission.READ_PASTEBOARD']),
(args.host_permissions, ['ohos.permission.CUSTOM_SCREEN_RECORDING', 'ohos.permission.CONTROL_DEVICE']),
(args.cursor_position_permission, ['ohos.permission.INPUT_MONITORING'])]:
if enabled:
expected_permissions.update(names)
require(permissions == expected_permissions, 'Desktop permissions do not match the selected build options')
require(all(not a.get('backgroundModes') for a in manifest['module']['abilities']),
'Desktop must not inherit mobile background modes')
arkts = hap.read('ets/modules.abc')
require(len(arkts) > 0, 'Missing compiled ArkTS')
if args.mobile:
require(b'pages/Mobile' in arkts and b'pages/Index' not in arkts and b'NativeSession' not in arkts,
'Mobile package must contain only the mobile entry and controller')
prefix = 'resources/rawfile/'
require(prefix + 'THIRD_PARTY_NOTICES.txt' not in hap.namelist(), 'Redundant aggregate license file')
for name in ['PRIVACY.md', 'LICENSE', 'OPEN_SOURCE_NOTICE.txt', 'SourceMetadata.json']:
require(len(hap.read(prefix + name)) > 0, 'Missing mobile document: ' + name)
catalog = json.loads(hap.read(prefix + 'ThirdPartyLicenses.json'))
require({'minirtc', 'openh264', 'dav1d', 'openfec', 'ohos-llvm'} <= {c['id'] for c in catalog['components']}, 'Incomplete legal catalog')
for component in catalog['components']:
for document in component['documents']:
require(hashlib.sha256(hap.read(prefix + document['asset'])).hexdigest() == document['sha256'], 'License content mismatch')
metadata = json.loads(hap.read(prefix + 'SourceMetadata.json'))
for document in metadata['buildDocuments']:
require(len(hap.read(prefix + document['asset'])) > 0, 'Missing offline build document')
else:
require(b'pages/Index' in arkts and b'pages/Mobile' not in arkts and b'NativeSession' not in arkts,
'Desktop must contain only the shared Slint entry')
require('resources/rawfile/crossdesk-icons.ttf' not in hap.namelist(),
'Unused ArkUI desktop icon font must not be packaged')
libraries = [name for name in hap.namelist() if name.endswith('.so')]
require(all(name.startswith('libs/arm64-v8a/') for name in libraries), 'Unexpected library ABI')
packaged = {Path(name).name for name in libraries}
expected_libraries = ({'libcrossdesk.so', 'libcrossdesk_harmony_core.so', 'libc++_shared.so'} if args.mobile
else {'libslint_desktop.so', 'libslint_cpp.so', 'libc++_shared.so'})
require(packaged == expected_libraries, 'Missing native library or stale library from a retired entry')
for name in libraries:
data = hap.read(name)
require(data[:6] == b'\x7fELF\x02\x01', f'{name}: expected ELF64 little endian')
require(int.from_bytes(data[18:20], 'little') == 183, f'{name}: expected AArch64')
require(int.from_bytes(data[16:18], 'little') == 3, f'{name}: expected shared object')
path = Path(temporary) / Path(name).name
path.write_bytes(data)
dynamic = subprocess.check_output([readelf, '-d', path], text=True)
for dependency in re.findall(r'\(NEEDED\).*?\[(.*?)\]', dynamic):
# An absolute dependency could exist on this build machine and
# still be impossible to resolve inside the device's app sandbox.
require(dependency and '/' not in dependency and '\\' not in dependency,
f'{name}: runtime dependency must be a library name, not a path: {dependency}')
require(dependency in packaged or (system_libraries / dependency).is_file(),
f'{name}: dependency is neither packaged nor in the HarmonyOS SDK: {dependency}')
if args.mobile:
require(dependency not in {'libnative_avscreen_capture.so', 'libohinput.so', 'libnative_image.so'},
f'{name}: unexpected PC capture/input dependency: {dependency}')
if args.mobile and path.name == 'libcrossdesk_harmony_core.so':
symbols = subprocess.check_output([readelf, '--dyn-syms', '--demangle', '--wide', path], text=True)
for api in ['Command(', 'Poll(', 'Shutdown(', 'Capture::', 'Input::']:
require('crossdesk::harmony::' + api not in symbols, 'Mobile core exports PC API: ' + api)
for api in ['MobileCommand(', 'MobilePoll(', 'MobileShutdown(']:
require('crossdesk::harmony::' + api in symbols, 'Missing mobile bridge API: ' + api)
if not args.mobile and path.name == 'libslint_desktop.so':
symbols = subprocess.check_output([readelf, '--dyn-syms', '--demangle', '--wide', path], text=True)
for api in ['Renderer::', 'MobileCommand(', 'MobilePoll(']:
require('crossdesk::harmony::' + api not in symbols, 'Desktop bundles unused mobile API: ' + api)
sonames = re.findall(r'\(SONAME\).*?\[(.*?)\]', dynamic)
require(sonames == [path.name], f'{name}: expected SONAME {path.name}, got {sonames}')
for search_path in re.findall(r'\((?:RUNPATH|RPATH)\).*?\[(.*?)\]', dynamic):
require(all(not item.startswith('/') for item in search_path.split(':')),
f'{name}: absolute runtime search path: {search_path}')
print(f'HAP verified: {args.hap}')
print(f'{"phone / tablet controller only" if args.mobile else "2in1 shared Slint desktop"}, compiled ArkTS, {len(libraries)} AArch64 libraries, resolved dependencies')