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