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