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

This commit is contained in:
kunkundi
2026-10-16 00:42:55 +08:00
parent 43a0789c1e
commit 45bf6ec4ee
149 changed files with 11587 additions and 93 deletions
+320
View File
@@ -0,0 +1,320 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require(process.argv[2]);
// Run the production ArkTS service with a fake IME. In particular, commands
// consumed by the OS must reach Slint exactly once after field reattachment.
const handlers = new Map();
let attached = 0, detached = 0, selection;
const editor = {
attachWithUIContext: async (context, config, options) => {
assert.equal(context, 17); assert.equal(options.showKeyboard, false);
assert.equal(config.inputAttribute.consumeKeyEvents, true); ++attached;
},
detach: async () => { ++detached; },
on: (type, callback) => { assert(!handlers.has(type)); handlers.set(type, callback); },
off: type => handlers.delete(type),
changeSelection: async (...value) => { selection = value; }, updateCursor: async () => {}
};
const inputMethod = {
getController: () => editor,
TextInputType: { TEXT: 0, VISIBLE_PASSWORD: 1, NUMBER: 2, NUMBER_DECIMAL: 3 },
EnterKeyType: { DONE: 1 },
Direction: { CURSOR_UP: 1, CURSOR_DOWN: 2, CURSOR_LEFT: 3, CURSOR_RIGHT: 4 },
ExtendAction: { SELECT_ALL: 0, CUT: 3, COPY: 4, PASTE: 5 }
};
const source = fs.readFileSync(process.argv[3], 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020
}}).outputText;
const exportsObject = {};
vm.runInNewContext(compiled, { exports: exportsObject, console, require: name => {
if (name === '@kit.IMEKit') return { inputMethod };
if (name === './DisplayHost') return { DisplayHost: { screen: () => ({ densityPixels: 2 }) } };
throw Error(`Unexpected import ${name}`);
}});
const host = exportsObject.TextInputHost;
const target = { getWindowProperties: () => ({ id: 17, windowRect: { left: 20, top: 30 } }),
getWindowDecorVisible: () => false, getWindowDecorHeight: () => 0, getUIContext: () => 17 };
const sent = [];
const request = async event => {
host.request(event, target, value => sent.push(value));
await new Promise(resolve => setImmediate(resolve));
};
const take = () => JSON.parse(JSON.stringify(sent.splice(0)));
const keyboardModule = {};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(process.argv[4], 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, { exports: keyboardModule, KeyType: { Down: 0, Up: 1 } });
const keyboard = new keyboardModule.KeyboardHost();
const key = (code, down, ctrl = false) => keyboard.preIme({ keyCode: code,
type: down ? 0 : 1, getModifierKeyState: keys => {
assert(['Ctrl', 'Alt', 'Shift'].includes(keys[0]));
return keys[0] === 'Ctrl' && ctrl;
} }, 1, e => sent.push(e));
assert(key(2072, true, true)); // Slint gets the modifier even if the IME consumes it.
assert(key(2017, true, true));
assert(key(2017, true, true)); // Repeat is retained.
assert(key(2072, false));
assert(key(2017, false)); // Ctrl was released first.
assert.equal(take().length, 5);
assert(!key(2017, true)); // Plain typing remains with the IME.
assert(!key(2050, true, true)); // Ctrl+Space can switch input methods.
assert(key(2019, true, true)); take(); keyboard.reset();
assert(!key(2019, true)); // Lost focus must not consume the next plain letter.
assert(key(2076, true)); assert(key(2017, true));
assert(key(2076, false)); assert(key(2017, false));
assert.equal(take().length, 4);
async function verifyClipboardQueue() {
const module = {}, replies = [], order = [];
let clipboardPermission = true;
let pending = [], releaseRead, clipboardText = 'old';
const native = { poll: () => JSON.stringify(pending.splice(0)),
send: value => replies.push(JSON.parse(value)) };
const read = new Promise(resolve => { releaseRead = resolve; });
let readWait = read;
const board = {
hasDataType: () => true,
getData: async () => { order.push('read'); const text = clipboardText; await readWait; return { getPrimaryText: () => text }; },
setData: async data => { order.push(data); if (data === 'fail') throw Error('denied'); }
};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(process.argv[5], 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, { exports: module, console, require: name => {
if (name === 'libslint_desktop.so') return { default: native };
if (name === './DesktopAccess') return { DesktopAccess: { requestClipboard: () => {} } };
if (name === '@kit.BasicServicesKit') return { pasteboard: {
getSystemPasteboard: () => board, MIMETYPE_TEXT_PLAIN: 'text/plain', createData: (_, text) => text
} };
if (name === '@kit.AbilityKit') return { abilityAccessCtrl: {
GrantStatus: { PERMISSION_GRANTED: 0 }, createAtManager: () => ({
checkAccessTokenSync: () => clipboardPermission ? 0 : -1
})
} };
return {}; // Other OS services are not entered by this test.
}});
const host = module.DesktopHost;
host.context = { applicationInfo: { accessTokenId: 17 } };
pending = [{ op: 'clipboardRead', request: 1 }, { op: 'clipboardWrite', request: 2, text: 'new' }];
host.poll();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(order, ['read']); // Write must wait for the outstanding read.
pending = [{ op: 'unknown', request: 3 }]; host.poll();
await new Promise(resolve => setImmediate(resolve));
assert.equal(replies[0].request, 3); // Unrelated services remain responsive.
releaseRead(); await host.clipboardQueue;
assert.deepEqual(order, ['read', 'new']);
assert.deepEqual(replies.slice(1).map(r => r.request), [1, 2]);
pending = [{ op: 'clipboardWrite', request: 4, text: 'fail' }, { op: 'clipboardWrite', request: 5, text: 'recovered' }];
host.poll(); await host.clipboardQueue;
assert.equal(replies[3].ok, false); assert.equal(replies[4].ok, true);
clipboardPermission = false;
const before = order.length;
pending = [{ op: 'clipboardRead', request: 6 }, { op: 'clipboardHas', request: 7 }];
host.poll(); await host.clipboardQueue;
assert.equal(order.length, before); // Polling cannot consume Ctrl+V's one-read grant.
assert.equal(replies[5].ok, false); assert.equal(replies[6].ok, false);
pending = [{ op: 'clipboardPaste', request: 8 }];
host.poll(); await host.clipboardQueue;
assert.equal(order.length, before + 1); assert.equal(replies[7].text, 'old');
// No active window is required: an OS update invalidates the cache and
// enters the same SDL notification path while the application is hidden.
host.started = true; host.clipboardObserved = true; clipboardPermission = true;
const query = async id => {
pending = [{ op: 'clipboardRead', request: id }]; host.poll(); await host.clipboardQueue;
return replies.find(r => r.request === id);
};
host.clipboardChanged();
assert.equal((await query(9)).text, 'old');
const afterFirst = order.length;
assert.equal((await query(10)).text, 'old'); assert.equal(order.length, afterFirst);
clipboardText = '外部应用复制 😀'; host.clipboardChanged();
assert.equal((await query(11)).text, clipboardText); assert.equal(order.length, afterFirst + 1);
// A change during an asynchronous read must not be cached as the new value.
let releaseRace;
readWait = new Promise(resolve => { releaseRace = resolve; });
clipboardText = 'first'; host.clipboardChanged();
const racingRead = query(12);
await new Promise(resolve => setImmediate(resolve));
clipboardText = 'second'; host.clipboardChanged(); releaseRace();
assert.equal((await racingRead).text, 'first');
assert.equal((await query(13)).text, 'second');
// Revocation during getData must not expose or cache the result.
readWait = new Promise(resolve => { releaseRace = resolve; });
host.clipboardChanged(); const revokedRead = query(14);
await new Promise(resolve => setImmediate(resolve));
clipboardPermission = false; host.clipboardChanged(); releaseRace();
assert.equal((await revokedRead).ok, false);
const afterRevocation = order.length;
assert.equal((await query(15)).ok, false); assert.equal(order.length, afterRevocation);
assert.equal(host.clipboardCache, '');
host.terminating = true;
const notifications = replies.length; host.clipboardChanged();
assert.equal(replies.length, notifications); // Late callbacks after shutdown are ignored.
}
async function verifyDesktopAccess() {
const module = {}, notifications = [], calls = [], grants = new Set();
let change, releasePrompt, watched;
const manager = {
checkAccessTokenSync: (_, permission) => grants.has(permission) ? 0 : -1,
on: (type, permissions, callback) => { assert.equal(type, 'selfPermissionStateChange'); watched = Array.from(permissions); change = callback; },
off: (type, permissions, callback) => { assert.equal(callback, change); change = undefined; },
requestPermissionsFromUser: async () => { calls.push('clipboard'); await new Promise(resolve => { releasePrompt = resolve; }); },
openPermissionOnSetting: async () => { calls.push('keyboard'); throw Error('user dismissed'); }
};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(process.argv[6], 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, { exports: module, console: { warn: () => {} }, require: name => {
assert.equal(name, '@kit.AbilityKit'); return { abilityAccessCtrl: {
GrantStatus: { PERMISSION_GRANTED: 0 }, createAtManager: () => manager
} };
}});
let selection = 1, blockGuide = false, failSettings = false;
const dialogs = [], routes = [];
const ui = { getPromptAction: () => ({ showDialog: async options => {
calls.push('guide'); dialogs.push(options);
if (blockGuide) return await new Promise(resolve => { releasePrompt = resolve; });
return { index: selection };
} }) };
const access = module.DesktopAccess, context = {
applicationInfo: { accessTokenId: 17, name: 'com.chunkdash.crossdesk' },
startAbility: async want => {
calls.push('settings'); routes.push(JSON.parse(JSON.stringify(want)));
if (failSettings) throw Error('settings unavailable');
}
};
access.start(context, false, e => notifications.push(e));
access.requestKeyboard(); access.requestClipboard(); access.requestHost(); await access.requests;
assert.equal(calls.length, 0); assert.equal(change, undefined); // Ordinary HAP never opens unusable prompts.
access.stop(); access.start(context, true, e => notifications.push(e));
access.requestClipboard(); access.requestClipboard(); access.requestKeyboard();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(calls, ['clipboard']); // Permission UIs must not overlap.
releasePrompt(); await access.requests;
assert.deepEqual(calls, ['clipboard', 'keyboard']);
access.requestKeyboard(); access.requestClipboard(); await access.requests;
assert.equal(calls.length, 2); // Denial/dismissal is not a retry loop.
grants.add('ohos.permission.HOOK_KEY_EVENT'); change({});
assert.equal(notifications.filter(e => e.op === 'keyboardPermission').at(-1).ok, true);
grants.clear(); change({});
assert.equal(notifications.filter(e => e.op === 'keyboardPermission').at(-1).ok, false);
const late = change; access.stop();
const before = notifications.length; late({});
assert.equal(notifications.length, before);
calls.length = 0;
const screen = 'ohos.permission.CUSTOM_SCREEN_RECORDING', control = 'ohos.permission.CONTROL_DEVICE';
blockGuide = true;
access.start(context, false, e => notifications.push(e), true);
assert.deepEqual(watched, [screen, control]); // Host-only builds do not require controller ACLs.
access.requestHost(ui); access.requestHost(ui); access.requestKeyboard(); access.requestClipboard();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(calls, ['guide']);
assert.match(dialogs.at(-1).message, /屏幕共享/); assert.match(dialogs.at(-1).message, /辅助操作/);
releasePrompt({ index: 1 }); await access.requests;
assert.deepEqual(calls, ['guide', 'settings']);
assert.deepEqual(routes[0], {
bundleName: 'com.huawei.hmos.settings', abilityName: 'com.huawei.hmos.settings.MainAbility',
uri: 'application_info_entry', parameters: { pushParams: 'com.chunkdash.crossdesk' }
});
const latest = () => notifications.filter(e => e.op === 'hostPermissions').at(-1);
// Navigating to Settings is not a grant. Only system state changes enable access.
assert.equal(latest().controlDevice, false); assert.equal(latest().screenRecording, false);
grants.add(screen); grants.add(control); change({});
assert.equal(latest().controlDevice, true); assert.equal(latest().screenRecording, true);
access.stop(); access.start(context, false, e => notifications.push(e), true);
access.requestHost(ui); await access.requests;
assert.deepEqual(calls, ['guide', 'settings']); // Existing system grants survive an app restart.
grants.delete(control); change({});
assert.equal(latest().controlDevice, false); assert.equal(latest().screenRecording, true);
access.stop();
// Only explain the missing switch. Declining never launches Settings or loops.
calls.length = 0; blockGuide = false; selection = 0;
access.start(context, false, e => notifications.push(e), true);
access.requestHost(ui); await access.requests;
access.requestHost(ui); await access.requests;
assert.deepEqual(calls, ['guide']);
assert.match(dialogs.at(-1).message, /辅助操作/); assert.doesNotMatch(dialogs.at(-1).message, /屏幕共享/);
assert.equal(latest().controlDevice, false); assert.equal(latest().screenRecording, true);
grants.delete(screen); change({}); assert.equal(latest().screenRecording, false);
access.stop();
// A response from an old guide cannot navigate or publish state after restart.
calls.length = 0; blockGuide = true; selection = 1;
access.start(context, false, e => notifications.push(e), true);
access.requestHost(ui); await new Promise(resolve => setImmediate(resolve));
access.stop(); access.start(context, false, e => notifications.push(e), false);
const beforeCompletion = notifications.length;
releasePrompt({ index: 1 }); await access.requests;
assert.deepEqual(calls, ['guide']); assert.equal(notifications.length, beforeCompletion);
access.stop();
// A failed Settings route gives manual guidance without granting access or
// falling back to the system dialog with the misleading navigation path.
calls.length = 0; blockGuide = false; failSettings = true;
access.start(context, false, e => notifications.push(e), true);
access.requestHost(ui); await access.requests;
assert.deepEqual(calls, ['guide', 'settings', 'guide']);
assert.equal(dialogs.at(-1).title, '未能打开应用设置');
assert.match(dialogs.at(-1).message, /屏幕共享/); assert.match(dialogs.at(-1).message, /辅助操作/);
assert.equal(latest().controlDevice, false); assert.equal(latest().screenRecording, false);
access.stop();
}
(async () => {
await verifyClipboardQueue();
await verifyDesktopAccess();
await request({ op: 'ime', id: 1, kind: 1, text: 'a😀中', anchor: 1, cursor: 4 });
assert.deepEqual(selection, ['a😀中', 1, 4]);
for (const [action, code] of [[0, 2017], [3, 2040], [4, 2019], [5, 2038]]) {
handlers.get('handleExtendAction')(action);
assert.deepEqual(take(), [{ op: 'imeKey', id: 1, imeSession: 0, code, control: true }]);
}
handlers.get('moveCursor')(3);
handlers.get('selectByMovement')({ direction: 4 });
handlers.get('selectByRange')({ start: 1, end: 3 });
assert.deepEqual(take(), [
{ op: 'imeKey', id: 1, imeSession: 0, code: 2014, shift: false },
{ op: 'imeKey', id: 1, imeSession: 0, code: 2015, shift: true },
{ op: 'imeSelection', id: 1, imeSession: 0, anchor: 1, cursor: 3 }
]);
handlers.get('insertText')('你好');
assert.deepEqual(take(), [{ op: 'composition', id: 1, imeSession: 0, text: '你好', commit: true }]);
const oldInsert = handlers.get('insertText'), oldPreview = handlers.get('setPreviewText');
const oldFinish = handlers.get('finishTextPreview');
await request({ op: 'ime', id: 1, kind: 1 });
oldInsert('must not enter the new field'); oldPreview('stale'); oldFinish();
assert.equal(take().length, 0);
assert.equal(host.preview, '');
assert.equal(attached, 2); assert.equal(detached, 1);
handlers.get('handleExtendAction')(0);
assert.equal(take().length, 1);
const lateInsert = handlers.get('insertText');
await request({ op: 'ime', id: 1, kind: 0 });
lateInsert('late'); handlers.get('deleteLeft')(1);
assert.equal(take().length, 0);
// A native attach may finish after the window is destroyed. No editor or
// callbacks may be installed after release, even with the same window id.
let finishAttach;
editor.attachWithUIContext = async () => { await new Promise(resolve => { finishAttach = resolve; }); };
host.request({ op: 'ime', id: 2, kind: 1, imeSession: 7 }, target, e => sent.push(e));
await new Promise(resolve => setImmediate(resolve));
host.release(2); finishAttach(); await host.queue;
assert.equal(host.id, 0);
editor.attachWithUIContext = async () => {};
await request({ op: 'ime', id: 2, kind: 1, imeSession: 9 });
handlers.get('insertText')('current');
assert.deepEqual(take(), [{ op: 'composition', id: 2, imeSession: 9, text: 'current', commit: true }]);
const currentSelection = selection;
await request({ op: 'ime', id: 2, kind: 2, imeSession: 7, text: 'old update' });
assert.equal(selection, currentSelection);
host.release(1); await host.queue; // Destroying another window keeps this editor.
assert.equal(host.id, 2);
host.release(2); await host.queue;
assert.equal(host.id, 0);
console.log('Slint IME editing, selection, composition, detached callbacks and clipboard serialization passed');
})().catch(error => { console.error(error); process.exitCode = 1; });