chore(release): prepare 2.0.0-Beta.2 build 20260924
Integrate the reviewed 2.0 PRs and local fixes from 03136853. Preserve the verified subtitle behavior and model/runtime corrections. Keep the numeric Beta tag compatible with released clients while exposing the date as application build metadata. Co-authored-by: DepengWang <2818245+DepengWang@users.noreply.github.com> Co-authored-by: HKLHaoBin <we3q@qq.com> Co-authored-by: Nahida <1139500183@qq.com> Co-authored-by: TRIP <1933142963@qq.com> Co-authored-by: chyuwei <weichangyuwcy@gmail.com> Co-authored-by: leonoxo <leonoxo@gmail.com> Co-authored-by: tayir <arnoldincredible@126.com>
This commit is contained in:
1 parent
a8ebcf6ffa
commit
16c3f841b5
261 files changed
+618156
-4281
No files matched your search
@@ -0,0 +1,27 @@
|
||||
# Linux 桌面集成
|
||||
|
||||
桥协议为 `org.openless.Desktop1` v1,与 Core 业务数据版本独立。X11 直接使用 X11 接口;Wayland 使用桌面组件提供前台窗口、屏幕工作区、焦点恢复、浮窗定位和全局快捷键。
|
||||
|
||||
安装包附带 `openless-desktop-install`。在用户会话中运行:
|
||||
|
||||
```sh
|
||||
openless-desktop-install install
|
||||
openless-desktop-install enable
|
||||
openless-desktop-install uninstall
|
||||
```
|
||||
|
||||
GNOME 42–44 和 45+ 使用不同模块入口。首次安装 GNOME 扩展后可能需要注销并重新登录,再执行 `enable`。KDE 使用 KWin 脚本与 KGlobalAccel 辅助程序;安装时写入用户的脚本、D-Bus 服务和登录启动项。安装后重启 OpenLess,使当前设置重新注册到桌面桥。
|
||||
|
||||
组件只运行在当前用户的会话中,不要求 root,不读取密码输入框。fcitx5 输入目标使用 UUID 与会话票据;桌面窗口身份用于定位与恢复焦点,不代替写入前的文本目标校验。
|
||||
|
||||
开发构建:
|
||||
|
||||
```sh
|
||||
node gnome/build.mjs
|
||||
cmake -S kde -B kde/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build kde/build --parallel
|
||||
```
|
||||
|
||||
KDE 构建支持 Qt 5 / KF5 与 Qt 6 / KF6。桌面组件的实际交互验收见 Linux 交接清单;编译通过不代表已经在相应桌面版本上完成验收。
|
||||
|
||||
接口参考:[GNOME 扩展](https://gjs.guide/extensions/)、[KWin](https://develop.kde.org/docs/plasma/kwin/api/)、[KGlobalAccel](https://api.kde.org/kglobalaccel.html)、[AT-SPI Text](https://gnome.pages.gitlab.gnome.org/at-spi2-core/libatspi/iface.Text.html)。
|
||||
@@ -0,0 +1,99 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* This body is shared by the GNOME 42–44 and 45+ entry points at packaging.
|
||||
*/
|
||||
const IFACE = `<node><interface name="org.openless.Desktop1">
|
||||
<method name="Version"><arg direction="out" type="u"/></method>
|
||||
<method name="Snapshot"><arg direction="out" type="s"/></method>
|
||||
<method name="Bind"><arg direction="in" type="s"/><arg direction="out" type="s"/></method>
|
||||
<method name="Restore"><arg direction="in" type="s"/><arg direction="out" type="b"/></method>
|
||||
<method name="Place"><arg direction="in" type="s"/><arg direction="in" type="i"/><arg direction="in" type="i"/><arg direction="out" type="b"/></method>
|
||||
<signal name="Hotkey"><arg type="s"/><arg type="u"/><arg type="u"/><arg type="b"/></signal>
|
||||
</interface></node>`;
|
||||
|
||||
class DesktopBridge {
|
||||
constructor() {
|
||||
this.bindings = new Map(); this.held = new Map();
|
||||
this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this);
|
||||
this.object.export(Gio.DBus.session, '/org/openless/Desktop1');
|
||||
this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null);
|
||||
this.activation = global.display.connect('accelerator-activated', (_display, id) => {
|
||||
const binding = this.bindings.get(id);
|
||||
if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); }
|
||||
});
|
||||
this.release = global.stage.connect('captured-event', (_actor, event) => {
|
||||
if (event.type() === Clutter.EventType.KEY_RELEASE) {
|
||||
for (const [id, binding] of this.held) {
|
||||
if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); }
|
||||
}
|
||||
}
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
// Older Mutter versions expose modifier state outside shell focus but
|
||||
// no accelerator-deactivated signal. It also releases a held modifier
|
||||
// after focus changes or a compositor modal operation.
|
||||
this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => {
|
||||
const state = global.get_pointer()[2];
|
||||
for (const [id, binding] of this.held) {
|
||||
let mask = binding.states;
|
||||
if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4;
|
||||
if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1;
|
||||
if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8;
|
||||
if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64;
|
||||
if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); }
|
||||
}
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
});
|
||||
try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => {
|
||||
const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); }
|
||||
}); } catch (_) { this.deactivation = 0; }
|
||||
}
|
||||
Version() { return 1; }
|
||||
windows() { return global.get_window_actors().map(actor => actor.meta_window); }
|
||||
Snapshot() {
|
||||
const window = global.display.focus_window;
|
||||
if (!window) return '{}';
|
||||
const rect = window.get_work_area_for_monitor(window.get_monitor());
|
||||
return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '',
|
||||
x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1});
|
||||
}
|
||||
emit(binding, pressed) {
|
||||
this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed]));
|
||||
}
|
||||
Bind(json) {
|
||||
let requested;
|
||||
try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); }
|
||||
catch (error) { return String(error); }
|
||||
const next = new Map(); const created = [];
|
||||
for (const binding of requested) {
|
||||
const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator);
|
||||
const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE);
|
||||
if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; }
|
||||
if (!existing) created.push(id);
|
||||
next.set(id, binding);
|
||||
}
|
||||
for (const [id, binding] of this.held) this.emit(binding, false);
|
||||
this.held.clear();
|
||||
for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id);
|
||||
for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL);
|
||||
this.bindings = next;
|
||||
return '';
|
||||
}
|
||||
Restore(target) {
|
||||
const window = this.windows().find(window => String(window.get_stable_sequence()) === target);
|
||||
if (!window) return false;
|
||||
window.activate(global.get_current_time()); return true;
|
||||
}
|
||||
Place(title, x, y) {
|
||||
if (!title.startsWith('OpenLess ')) return false;
|
||||
const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless'));
|
||||
if (!window) return false;
|
||||
window.move_frame(true, x, y); return true;
|
||||
}
|
||||
destroy() {
|
||||
for (const binding of this.held.values()) this.emit(binding, false);
|
||||
for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id);
|
||||
global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation);
|
||||
global.stage.disconnect(this.release); GLib.Source.remove(this.watch);
|
||||
this.object.unexport(); Gio.bus_unown_name(this.owner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const here=path.dirname(fileURLToPath(import.meta.url));
|
||||
const body=fs.readFileSync(path.join(here,'bridge.js'),'utf8');
|
||||
for(const [name,versions,imports,entry] of [
|
||||
['legacy',['42','43','44'], 'const {Gio, GLib, Meta, Shell, Clutter} = imports.gi;\nconst Main = imports.ui.main;\n', 'let bridge;\nfunction init() {}\nfunction enable() { bridge = new DesktopBridge(); }\nfunction disable() { bridge?.destroy(); bridge = null; }\n'],
|
||||
['modern',['45','46','47','48','49','50'], `import Gio from 'gi://Gio';\nimport GLib from 'gi://GLib';\nimport Meta from 'gi://Meta';\nimport Shell from 'gi://Shell';\nimport Clutter from 'gi://Clutter';\nimport * as Main from 'resource:///org/gnome/shell/ui/main.js';\n`, 'export default class OpenLessExtension { enable() { this.bridge = new DesktopBridge(); } disable() { this.bridge?.destroy(); this.bridge = null; } }\n'],
|
||||
]) {
|
||||
const out=path.join(here,name);fs.mkdirSync(out,{recursive:true});
|
||||
fs.writeFileSync(path.join(out,'metadata.json'),JSON.stringify({uuid:'openless@openless.app',name:'OpenLess Desktop Bridge',description:'Native OpenLess hotkeys and window placement',version:1,'shell-version':versions},null,2)+'\n','utf8');
|
||||
fs.writeFileSync(path.join(out,'extension.js'),imports+body+'\n'+entry,'utf8');
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
const {Gio, GLib, Meta, Shell, Clutter} = imports.gi;
|
||||
const Main = imports.ui.main;
|
||||
/* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* This body is shared by the GNOME 42–44 and 45+ entry points at packaging.
|
||||
*/
|
||||
const IFACE = `<node><interface name="org.openless.Desktop1">
|
||||
<method name="Version"><arg direction="out" type="u"/></method>
|
||||
<method name="Snapshot"><arg direction="out" type="s"/></method>
|
||||
<method name="Bind"><arg direction="in" type="s"/><arg direction="out" type="s"/></method>
|
||||
<method name="Restore"><arg direction="in" type="s"/><arg direction="out" type="b"/></method>
|
||||
<method name="Place"><arg direction="in" type="s"/><arg direction="in" type="i"/><arg direction="in" type="i"/><arg direction="out" type="b"/></method>
|
||||
<signal name="Hotkey"><arg type="s"/><arg type="u"/><arg type="u"/><arg type="b"/></signal>
|
||||
</interface></node>`;
|
||||
|
||||
class DesktopBridge {
|
||||
constructor() {
|
||||
this.bindings = new Map(); this.held = new Map();
|
||||
this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this);
|
||||
this.object.export(Gio.DBus.session, '/org/openless/Desktop1');
|
||||
this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null);
|
||||
this.activation = global.display.connect('accelerator-activated', (_display, id) => {
|
||||
const binding = this.bindings.get(id);
|
||||
if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); }
|
||||
});
|
||||
this.release = global.stage.connect('captured-event', (_actor, event) => {
|
||||
if (event.type() === Clutter.EventType.KEY_RELEASE) {
|
||||
for (const [id, binding] of this.held) {
|
||||
if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); }
|
||||
}
|
||||
}
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
// Older Mutter versions expose modifier state outside shell focus but
|
||||
// no accelerator-deactivated signal. It also releases a held modifier
|
||||
// after focus changes or a compositor modal operation.
|
||||
this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => {
|
||||
const state = global.get_pointer()[2];
|
||||
for (const [id, binding] of this.held) {
|
||||
let mask = binding.states;
|
||||
if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4;
|
||||
if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1;
|
||||
if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8;
|
||||
if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64;
|
||||
if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); }
|
||||
}
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
});
|
||||
try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => {
|
||||
const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); }
|
||||
}); } catch (_) { this.deactivation = 0; }
|
||||
}
|
||||
Version() { return 1; }
|
||||
windows() { return global.get_window_actors().map(actor => actor.meta_window); }
|
||||
Snapshot() {
|
||||
const window = global.display.focus_window;
|
||||
if (!window) return '{}';
|
||||
const rect = window.get_work_area_for_monitor(window.get_monitor());
|
||||
return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '',
|
||||
x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1});
|
||||
}
|
||||
emit(binding, pressed) {
|
||||
this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed]));
|
||||
}
|
||||
Bind(json) {
|
||||
let requested;
|
||||
try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); }
|
||||
catch (error) { return String(error); }
|
||||
const next = new Map(); const created = [];
|
||||
for (const binding of requested) {
|
||||
const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator);
|
||||
const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE);
|
||||
if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; }
|
||||
if (!existing) created.push(id);
|
||||
next.set(id, binding);
|
||||
}
|
||||
for (const [id, binding] of this.held) this.emit(binding, false);
|
||||
this.held.clear();
|
||||
for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id);
|
||||
for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL);
|
||||
this.bindings = next;
|
||||
return '';
|
||||
}
|
||||
Restore(target) {
|
||||
const window = this.windows().find(window => String(window.get_stable_sequence()) === target);
|
||||
if (!window) return false;
|
||||
window.activate(global.get_current_time()); return true;
|
||||
}
|
||||
Place(title, x, y) {
|
||||
if (!title.startsWith('OpenLess ')) return false;
|
||||
const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless'));
|
||||
if (!window) return false;
|
||||
window.move_frame(true, x, y); return true;
|
||||
}
|
||||
destroy() {
|
||||
for (const binding of this.held.values()) this.emit(binding, false);
|
||||
for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id);
|
||||
global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation);
|
||||
global.stage.disconnect(this.release); GLib.Source.remove(this.watch);
|
||||
this.object.unexport(); Gio.bus_unown_name(this.owner);
|
||||
}
|
||||
}
|
||||
|
||||
let bridge;
|
||||
function init() {}
|
||||
function enable() { bridge = new DesktopBridge(); }
|
||||
function disable() { bridge?.destroy(); bridge = null; }
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"uuid": "openless@openless.app",
|
||||
"name": "OpenLess Desktop Bridge",
|
||||
"description": "Native OpenLess hotkeys and window placement",
|
||||
"version": 1,
|
||||
"shell-version": [
|
||||
"42",
|
||||
"43",
|
||||
"44"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import Meta from 'gi://Meta';
|
||||
import Shell from 'gi://Shell';
|
||||
import Clutter from 'gi://Clutter';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
/* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* This body is shared by the GNOME 42–44 and 45+ entry points at packaging.
|
||||
*/
|
||||
const IFACE = `<node><interface name="org.openless.Desktop1">
|
||||
<method name="Version"><arg direction="out" type="u"/></method>
|
||||
<method name="Snapshot"><arg direction="out" type="s"/></method>
|
||||
<method name="Bind"><arg direction="in" type="s"/><arg direction="out" type="s"/></method>
|
||||
<method name="Restore"><arg direction="in" type="s"/><arg direction="out" type="b"/></method>
|
||||
<method name="Place"><arg direction="in" type="s"/><arg direction="in" type="i"/><arg direction="in" type="i"/><arg direction="out" type="b"/></method>
|
||||
<signal name="Hotkey"><arg type="s"/><arg type="u"/><arg type="u"/><arg type="b"/></signal>
|
||||
</interface></node>`;
|
||||
|
||||
class DesktopBridge {
|
||||
constructor() {
|
||||
this.bindings = new Map(); this.held = new Map();
|
||||
this.object = Gio.DBusExportedObject.wrapJSObject(IFACE, this);
|
||||
this.object.export(Gio.DBus.session, '/org/openless/Desktop1');
|
||||
this.owner = Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.openless.Desktop1', Gio.BusNameOwnerFlags.NONE, null, null);
|
||||
this.activation = global.display.connect('accelerator-activated', (_display, id) => {
|
||||
const binding = this.bindings.get(id);
|
||||
if (binding && !this.held.has(id)) { this.held.set(id, binding); this.emit(binding, true); }
|
||||
});
|
||||
this.release = global.stage.connect('captured-event', (_actor, event) => {
|
||||
if (event.type() === Clutter.EventType.KEY_RELEASE) {
|
||||
for (const [id, binding] of this.held) {
|
||||
if (event.get_key_symbol() === binding.symbol) { this.held.delete(id); this.emit(binding, false); }
|
||||
}
|
||||
}
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
// Older Mutter versions expose modifier state outside shell focus but
|
||||
// no accelerator-deactivated signal. It also releases a held modifier
|
||||
// after focus changes or a compositor modal operation.
|
||||
this.watch = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 20, () => {
|
||||
const state = global.get_pointer()[2];
|
||||
for (const [id, binding] of this.held) {
|
||||
let mask = binding.states;
|
||||
if ([0xffe3, 0xffe4].includes(binding.symbol)) mask |= 4;
|
||||
if ([0xffe1, 0xffe2].includes(binding.symbol)) mask |= 1;
|
||||
if ([0xffe9, 0xffea].includes(binding.symbol)) mask |= 8;
|
||||
if ([0xffeb, 0xffec].includes(binding.symbol)) mask |= 64;
|
||||
if (mask && (state & mask) !== mask) { this.held.delete(id); this.emit(binding, false); }
|
||||
}
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
});
|
||||
try { this.deactivation = global.display.connect('accelerator-deactivated', (_d, id) => {
|
||||
const binding = this.held.get(id); if (binding) { this.held.delete(id); this.emit(binding, false); }
|
||||
}); } catch (_) { this.deactivation = 0; }
|
||||
}
|
||||
Version() { return 1; }
|
||||
windows() { return global.get_window_actors().map(actor => actor.meta_window); }
|
||||
Snapshot() {
|
||||
const window = global.display.focus_window;
|
||||
if (!window) return '{}';
|
||||
const rect = window.get_work_area_for_monitor(window.get_monitor());
|
||||
return JSON.stringify({version: 1, target: String(window.get_stable_sequence()), application: window.get_wm_class() || '',
|
||||
x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1});
|
||||
}
|
||||
emit(binding, pressed) {
|
||||
this.object.emit_signal('Hotkey', new GLib.Variant('(suub)', [binding.action, binding.symbol, binding.states, pressed]));
|
||||
}
|
||||
Bind(json) {
|
||||
let requested;
|
||||
try { requested = JSON.parse(json); if (!Array.isArray(requested) || requested.length > 64) throw new Error('Invalid bindings'); }
|
||||
catch (error) { return String(error); }
|
||||
const next = new Map(); const created = [];
|
||||
for (const binding of requested) {
|
||||
const existing = [...this.bindings].find(([, previous]) => previous.accelerator === binding.accelerator);
|
||||
const id = existing ? existing[0] : global.display.grab_accelerator(binding.accelerator, Meta.KeyBindingFlags.NONE);
|
||||
if (!id) { for (const newId of created) global.display.ungrab_accelerator(newId); return `Shortcut conflict: ${binding.accelerator}`; }
|
||||
if (!existing) created.push(id);
|
||||
next.set(id, binding);
|
||||
}
|
||||
for (const [id, binding] of this.held) this.emit(binding, false);
|
||||
this.held.clear();
|
||||
for (const id of this.bindings.keys()) if (!next.has(id)) global.display.ungrab_accelerator(id);
|
||||
for (const id of next.keys()) Main.wm.allowKeybinding(Meta.external_binding_name_for_action(id), Shell.ActionMode.ALL);
|
||||
this.bindings = next;
|
||||
return '';
|
||||
}
|
||||
Restore(target) {
|
||||
const window = this.windows().find(window => String(window.get_stable_sequence()) === target);
|
||||
if (!window) return false;
|
||||
window.activate(global.get_current_time()); return true;
|
||||
}
|
||||
Place(title, x, y) {
|
||||
if (!title.startsWith('OpenLess ')) return false;
|
||||
const window = this.windows().find(window => window.get_title() === title && (window.get_wm_class() || '').toLowerCase().includes('openless'));
|
||||
if (!window) return false;
|
||||
window.move_frame(true, x, y); return true;
|
||||
}
|
||||
destroy() {
|
||||
for (const binding of this.held.values()) this.emit(binding, false);
|
||||
for (const id of this.bindings.keys()) global.display.ungrab_accelerator(id);
|
||||
global.display.disconnect(this.activation); if (this.deactivation) global.display.disconnect(this.deactivation);
|
||||
global.stage.disconnect(this.release); GLib.Source.remove(this.watch);
|
||||
this.object.unexport(); Gio.bus_unown_name(this.owner);
|
||||
}
|
||||
}
|
||||
|
||||
export default class OpenLessExtension { enable() { this.bridge = new DesktopBridge(); } disable() { this.bridge?.destroy(); this.bridge = null; } }
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"uuid": "openless@openless.app",
|
||||
"name": "OpenLess Desktop Bridge",
|
||||
"description": "Native OpenLess hotkeys and window placement",
|
||||
"version": 1,
|
||||
"shell-version": [
|
||||
"45",
|
||||
"46",
|
||||
"47",
|
||||
"48",
|
||||
"49",
|
||||
"50"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
MODE=${1:-install}
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
DATA=${XDG_DATA_HOME:-"$HOME/.local/share"}
|
||||
CONFIG=${XDG_CONFIG_HOME:-"$HOME/.config"}
|
||||
BIN="$HOME/.local/bin"
|
||||
UUID=openless@openless.app
|
||||
GNOME_DIR="$DATA/gnome-shell/extensions/$UUID"
|
||||
KWIN_DIR="$DATA/kwin/scripts/openless-desktop"
|
||||
HELPER="$BIN/openless-desktop-bridge"
|
||||
RUNTIME="$HOME/.local/lib/openless-desktop-bridge"
|
||||
case "$MODE" in install|enable|uninstall) ;; *) echo 'Usage: install.sh [install|enable|uninstall]' >&2; exit 2;; esac
|
||||
|
||||
is_gnome=false
|
||||
desktop=${XDG_CURRENT_DESKTOP:-}
|
||||
case "${desktop,,}" in *gnome*|*ubuntu*) is_gnome=true;; esac
|
||||
|
||||
if [ "$MODE" = uninstall ]; then
|
||||
if command -v gnome-extensions >/dev/null; then gnome-extensions disable "$UUID" || true; fi
|
||||
for tool in kwriteconfig6 kwriteconfig5; do
|
||||
if command -v "$tool" >/dev/null; then "$tool" --file kwinrc --group Plugins --key openless-desktopEnabled false; break; fi
|
||||
done
|
||||
# Only remove these package-owned directories and files under the user's
|
||||
# configured roots. Never enumerate arbitrary home files or use shell globs.
|
||||
test "$GNOME_DIR" = "$DATA/gnome-shell/extensions/openless@openless.app"
|
||||
test "$KWIN_DIR" = "$DATA/kwin/scripts/openless-desktop"
|
||||
rm -rf -- "$GNOME_DIR" "$KWIN_DIR" "$RUNTIME"
|
||||
rm -f -- "$HELPER" "$DATA/dbus-1/services/org.openless.Desktop1.service" "$CONFIG/autostart/openless-desktop-bridge.desktop"
|
||||
echo 'Desktop integration removed. Log out and back in to unload the running component.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$MODE" = install ]; then
|
||||
if $is_gnome; then
|
||||
version=$(gnome-shell --version | sed -n 's/.* \([0-9][0-9]*\).*/\1/p')
|
||||
test -n "$version"
|
||||
variant=modern; if [ "$version" -lt 45 ]; then variant=legacy; fi
|
||||
install -Dm644 "$ROOT/gnome/$variant/metadata.json" "$GNOME_DIR/metadata.json"
|
||||
install -Dm644 "$ROOT/gnome/$variant/extension.js" "$GNOME_DIR/extension.js"
|
||||
else
|
||||
install -Dm644 "$ROOT/kwin/metadata.json" "$KWIN_DIR/metadata.json"
|
||||
install -Dm644 "$ROOT/kwin/metadata.desktop" "$KWIN_DIR/metadata.desktop"
|
||||
install -Dm644 "$ROOT/kwin/contents/code/main.js" "$KWIN_DIR/contents/code/main.js"
|
||||
install -Dm755 "$ROOT/openless-desktop-bridge" "$RUNTIME/openless-desktop-bridge"
|
||||
if [ -d "$ROOT/lib" ]; then mkdir -p "$RUNTIME/lib"; cp -a "$ROOT/lib/." "$RUNTIME/lib/"; fi
|
||||
if [ -d "$ROOT/plugins" ]; then mkdir -p "$RUNTIME/plugins"; cp -a "$ROOT/plugins/." "$RUNTIME/plugins/"; fi
|
||||
if [ -d "$ROOT/licenses" ]; then mkdir -p "$RUNTIME/licenses"; cp -a "$ROOT/licenses/." "$RUNTIME/licenses/"; fi
|
||||
mkdir -p "$BIN"
|
||||
# Keep the helper and its Qt runtime after the AppImage mount disappears.
|
||||
printf '#!/usr/bin/env bash\nexec %q "$@"\n' "$RUNTIME/openless-desktop-bridge" > "$HELPER"
|
||||
chmod +x "$HELPER"
|
||||
mkdir -p "$DATA/dbus-1/services" "$CONFIG/autostart"
|
||||
escaped=${HELPER//\\/\\\\}; escaped=${escaped//\"/\\\"}
|
||||
printf '[D-BUS Service]\nName=org.openless.Desktop1\nExec="%s"\n' "$escaped" > "$DATA/dbus-1/services/org.openless.Desktop1.service"
|
||||
printf '[Desktop Entry]\nType=Application\nName=OpenLess Desktop Bridge\nExec="%s"\nOnlyShowIn=KDE;\nNoDisplay=true\n' "$escaped" > "$CONFIG/autostart/openless-desktop-bridge.desktop"
|
||||
fi
|
||||
fi
|
||||
|
||||
if $is_gnome; then
|
||||
if ! gnome-extensions enable "$UUID"; then
|
||||
echo 'The extension is installed. Log out and back in, then run this command with enable.' >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
enabled=false
|
||||
for tool in kwriteconfig6 kwriteconfig5; do
|
||||
if command -v "$tool" >/dev/null; then "$tool" --file kwinrc --group Plugins --key openless-desktopEnabled true; enabled=true; break; fi
|
||||
done
|
||||
$enabled || { echo 'KDE configuration tools are missing' >&2; exit 1; }
|
||||
for tool in qdbus6 qdbus qdbus-qt5; do
|
||||
if command -v "$tool" >/dev/null; then "$tool" org.kde.KWin /KWin reconfigure; break; fi
|
||||
done
|
||||
# The service is started by D-Bus on demand and on the next KDE login.
|
||||
fi
|
||||
echo 'OpenLess desktop integration enabled. Restart OpenLess to bind shortcuts.'
|
||||
@@ -0,0 +1,18 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(openless-desktop-bridge LANGUAGES CXX)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
find_package(Qt6 QUIET COMPONENTS Core Gui DBus Widgets)
|
||||
if(Qt6_FOUND)
|
||||
find_package(KF6GlobalAccel REQUIRED)
|
||||
set(OPENLESS_QT Qt6)
|
||||
set(OPENLESS_KF KF6)
|
||||
else()
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Gui DBus Widgets)
|
||||
find_package(KF5GlobalAccel REQUIRED)
|
||||
set(OPENLESS_QT Qt5)
|
||||
set(OPENLESS_KF KF5)
|
||||
endif()
|
||||
add_executable(openless-desktop-bridge bridge.cpp)
|
||||
target_link_libraries(openless-desktop-bridge PRIVATE ${OPENLESS_QT}::Core ${OPENLESS_QT}::Gui ${OPENLESS_QT}::DBus ${OPENLESS_QT}::Widgets ${OPENLESS_KF}::GlobalAccel)
|
||||
install(TARGETS openless-desktop-bridge RUNTIME DESTINATION bin)
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
#include <QApplication>
|
||||
#include <QAction>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusContext>
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusInterface>
|
||||
#include <QDBusReply>
|
||||
#include <QDBusObjectPath>
|
||||
#include <QDateTime>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QKeySequence>
|
||||
#include <QQueue>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <KGlobalAccel>
|
||||
|
||||
class Bridge : public QObject, protected QDBusContext {
|
||||
Q_OBJECT
|
||||
Q_CLASSINFO("D-Bus Interface", "org.openless.Desktop1")
|
||||
QMap<QString,QAction*> actions;
|
||||
QMap<QString,QJsonObject> bindings;
|
||||
QMap<QString,QDBusMessage> acknowledgements;
|
||||
QQueue<QJsonObject> commands;
|
||||
QDBusMessage waiting;
|
||||
quint64 waiterGeneration=0;
|
||||
QString snapshot="{}";
|
||||
qint64 snapshotTime=0;
|
||||
QString releasePath;
|
||||
static QString encode(const QJsonObject &value) { return QString::fromUtf8(QJsonDocument(value).toJson(QJsonDocument::Compact)); }
|
||||
void replyCommand() {
|
||||
if (waiting.type()!=QDBusMessage::MethodCallMessage || commands.isEmpty()) return;
|
||||
QDBusConnection::sessionBus().send(waiting.createReply(encode(commands.dequeue())));
|
||||
waiting=QDBusMessage(); ++waiterGeneration;
|
||||
}
|
||||
bool queue(QJsonObject command) {
|
||||
if (!calledFromDBus() || QDateTime::currentMSecsSinceEpoch()-snapshotTime>5000) return false;
|
||||
const QString id=QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
command["id"]=id;
|
||||
setDelayedReply(true); acknowledgements[id]=message(); commands.enqueue(command); replyCommand();
|
||||
QTimer::singleShot(1500,this,[this,id](){ Ack(id,false); });
|
||||
return false; // delayed reply contains the actual KWin acknowledgement
|
||||
}
|
||||
static QKeySequence key(const QJsonObject &binding) {
|
||||
QString accelerator=binding["accelerator"].toString();
|
||||
accelerator.replace("<Control>","Ctrl+").replace("<Shift>","Shift+").replace("<Alt>","Alt+").replace("<Super>","Meta+");
|
||||
accelerator.replace("Control_R","Ctrl").replace("Control_L","Ctrl").replace("Alt_R","Alt").replace("Alt_L","Alt");
|
||||
return QKeySequence(accelerator);
|
||||
}
|
||||
public:
|
||||
Bridge() = default;
|
||||
public slots:
|
||||
void Released(const QString &name,qlonglong) {const auto binding=bindings.value(name);if(!binding.isEmpty())emit Hotkey(binding["action"].toString(),binding["symbol"].toInt(),binding["states"].toInt(),false);}
|
||||
uint Version() const { return 1; }
|
||||
QString Snapshot() const { return QDateTime::currentMSecsSinceEpoch()-snapshotTime<5000?snapshot:"{}"; }
|
||||
void Update(const QString &json) {
|
||||
const auto document=QJsonDocument::fromJson(json.toUtf8());
|
||||
if (document.isObject() && document.object()["version"].toInt()==1) { snapshot=json; snapshotTime=QDateTime::currentMSecsSinceEpoch(); }
|
||||
}
|
||||
QString Bind(const QString &json) {
|
||||
const auto document=QJsonDocument::fromJson(json.toUtf8());
|
||||
if (!document.isArray() || document.array().size()>64) return "Invalid desktop binding document";
|
||||
QMap<QString,QJsonObject> next;
|
||||
for (const auto value:document.array()) {
|
||||
const auto binding=value.toObject(); const auto sequence=key(binding);
|
||||
const QString id=binding["action"].toString()+":"+QString::number(binding["symbol"].toInt())+":"+QString::number(binding["states"].toInt());
|
||||
bool owned=false; for (auto action:actions) if (KGlobalAccel::self()->shortcut(action).contains(sequence)) owned=true;
|
||||
if (sequence.isEmpty() || (!owned&&!KGlobalAccel::isGlobalShortcutAvailable(sequence,"openless-desktop-bridge"))) return "Shortcut conflict: "+binding["accelerator"].toString();
|
||||
next[id]=binding;
|
||||
}
|
||||
const auto previous=bindings;
|
||||
for (auto action:actions) KGlobalAccel::self()->removeAllShortcuts(action);
|
||||
bool success=true;
|
||||
for (auto it=next.begin();it!=next.end();++it) {
|
||||
auto action=actions.value(it.key());
|
||||
if (!action) {
|
||||
action=new QAction(it.key(),this);action->setObjectName(it.key());actions[it.key()]=action;
|
||||
connect(action,&QAction::triggered,this,[this,action](){const auto binding=bindings.value(action->objectName());if(!binding.isEmpty())emit Hotkey(binding["action"].toString(),binding["symbol"].toInt(),binding["states"].toInt(),true);});
|
||||
}
|
||||
if (!KGlobalAccel::self()->setShortcut(action,{key(it.value())},KGlobalAccel::NoAutoloading)) { success=false;break; }
|
||||
}
|
||||
if (!success) {
|
||||
for (auto action:actions) KGlobalAccel::self()->removeAllShortcuts(action);
|
||||
for (auto it=previous.begin();it!=previous.end();++it) KGlobalAccel::self()->setShortcut(actions[it.key()],{key(it.value())},KGlobalAccel::NoAutoloading);
|
||||
return "Shortcut registration failed; previous bindings restored";
|
||||
}
|
||||
bindings=next;
|
||||
if(releasePath.isEmpty()) {
|
||||
QDBusInterface service("org.kde.kglobalaccel","/kglobalaccel","org.kde.KGlobalAccel");
|
||||
QDBusReply<QDBusObjectPath> component=service.call("getComponent","openless-desktop-bridge");
|
||||
if(component.isValid()) {
|
||||
releasePath=component.value().path();
|
||||
QDBusConnection::sessionBus().connect("org.kde.kglobalaccel",releasePath,"org.kde.kglobalaccel.Component","globalShortcutReleased",this,SLOT(Released(QString,qlonglong)));
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
bool Restore(const QString &target) { return queue({{"op","restore"},{"target",target}}); }
|
||||
bool Place(const QString &title,int x,int y) { if(!title.startsWith("OpenLess "))return false;return queue({{"op","place"},{"title",title},{"x",x},{"y",y}}); }
|
||||
QString Next() {
|
||||
if (!commands.isEmpty()) return encode(commands.dequeue());
|
||||
if (waiting.type()==QDBusMessage::MethodCallMessage) return "{}";
|
||||
setDelayedReply(true);waiting=message();const auto generation=++waiterGeneration;
|
||||
QTimer::singleShot(1000,this,[this,generation](){if(waiterGeneration==generation&&waiting.type()==QDBusMessage::MethodCallMessage){QDBusConnection::sessionBus().send(waiting.createReply(QString("{}")));waiting=QDBusMessage();}});
|
||||
return {};
|
||||
}
|
||||
void Ack(const QString &id,bool success) {
|
||||
if (!acknowledgements.contains(id)) return;
|
||||
QDBusConnection::sessionBus().send(acknowledgements.take(id).createReply(success));
|
||||
}
|
||||
signals:
|
||||
void Hotkey(const QString &action,uint symbol,uint states,bool pressed);
|
||||
};
|
||||
|
||||
int main(int argc,char **argv) {
|
||||
// This helper only uses D-Bus; a private offscreen platform plugin avoids
|
||||
// loading Qt plugins from an incompatible Plasma installation.
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
const auto plugins=QFileInfo(QString::fromLocal8Bit(argv[0])).absoluteDir().filePath("plugins/platforms");
|
||||
if(QDir(plugins).exists())qputenv("QT_QPA_PLATFORM_PLUGIN_PATH",plugins.toUtf8());
|
||||
QApplication app(argc,argv);app.setApplicationName("openless-desktop-bridge");app.setQuitOnLastWindowClosed(false);
|
||||
Bridge bridge;
|
||||
auto bus=QDBusConnection::sessionBus();
|
||||
if(!bus.registerService("org.openless.Desktop1") || !bus.registerObject("/org/openless/Desktop1",&bridge,QDBusConnection::ExportAllSlots|QDBusConnection::ExportAllSignals))return 1;
|
||||
return app.exec();
|
||||
}
|
||||
#include "bridge.moc"
|
||||
@@ -0,0 +1,40 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-only */
|
||||
var bus = 'org.openless.Desktop1', path = '/org/openless/Desktop1';
|
||||
var polling = false;
|
||||
function windows() { return workspace.windowList ? workspace.windowList() : workspace.clientList(); }
|
||||
function active() { return workspace.activeWindow !== undefined ? workspace.activeWindow : workspace.activeClient; }
|
||||
function identity(window) { return String(window.internalId || window.windowId); }
|
||||
function update() {
|
||||
var window = active();
|
||||
if (!window) return;
|
||||
var area = workspace.clientArea(KWin.MaximizeArea, window);
|
||||
callDBus(bus, path, bus, 'Update', JSON.stringify({version: 1, target: identity(window), application: String(window.resourceClass), x: area.x, y: area.y, width: area.width, height: area.height, scale: 1}));
|
||||
}
|
||||
function next() {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
callDBus(bus, path, bus, 'Next', function (json) {
|
||||
polling = false;
|
||||
// A missing helper must not turn a failed D-Bus call into a busy loop.
|
||||
// A subsequent focus/screen event starts the connection again.
|
||||
if (typeof json !== 'string') return;
|
||||
var command, success = false;
|
||||
try {
|
||||
command = JSON.parse(json || '{}');
|
||||
if (command.op === 'restore') {
|
||||
var target = windows().filter(function (window) { return identity(window) === command.target; })[0];
|
||||
if (target) { if (workspace.activeWindow !== undefined) workspace.activeWindow = target; else workspace.activeClient = target; success = true; }
|
||||
} else if (command.op === 'place' && command.title.indexOf('OpenLess ') === 0) {
|
||||
var popup = windows().filter(function (window) { return window.caption === command.title && String(window.resourceClass).toLowerCase().indexOf('openless') >= 0; })[0];
|
||||
if (popup) { var rect = popup.frameGeometry; rect.x = command.x; rect.y = command.y; popup.frameGeometry = rect; success = true; }
|
||||
}
|
||||
} catch (error) { print('OpenLess desktop bridge: ' + error); }
|
||||
if (command && command.id) callDBus(bus, path, bus, 'Ack', command.id, success);
|
||||
update(); next();
|
||||
});
|
||||
}
|
||||
function changed() { update(); next(); }
|
||||
if (workspace.windowActivated) workspace.windowActivated.connect(changed);
|
||||
else workspace.clientActivated.connect(changed);
|
||||
if (workspace.screensChanged) workspace.screensChanged.connect(changed);
|
||||
update(); next();
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Name=OpenLess Desktop Bridge
|
||||
Type=Service
|
||||
X-KDE-ServiceTypes=KWin/Script
|
||||
X-KDE-PluginInfo-Name=openless-desktop
|
||||
X-KDE-PluginInfo-Version=1.0
|
||||
X-KDE-PluginInfo-License=AGPL-3.0-only
|
||||
X-Plasma-API=javascript
|
||||
X-Plasma-MainScript=code/main.js
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"KPlugin": {"Id": "openless-desktop", "Name": "OpenLess Desktop Bridge", "Description": "OpenLess focus and floating window integration", "Version": "1.0", "License": "AGPL-3.0-only"},
|
||||
"X-Plasma-API": "javascript",
|
||||
"X-Plasma-MainScript": "code/main.js",
|
||||
"KPackageStructure": "KWin/Script"
|
||||
}
|
||||
@@ -40,9 +40,18 @@ int main() {
|
||||
instance.initialize();
|
||||
fcitx::OpenLess plugin(&instance);
|
||||
RecordingInputContext first(instance.inputContextManager());
|
||||
first.focusIn();
|
||||
fcitx::OpenLessInputTargetContract::select(plugin, first);
|
||||
first.surroundingText().setText("foo foo", 3, 0);
|
||||
assert(plugin.captureSelectionTarget("selection") == "foo");
|
||||
const auto metadata=plugin.contextSnapshot("selection",false);
|
||||
assert(metadata.find("openless-contract")!=std::string::npos);
|
||||
assert(metadata.find("\"text\"")==std::string::npos);
|
||||
assert(plugin.contextSnapshot("selection",true).find("foo foo")!=std::string::npos);
|
||||
first.setCapabilityFlags(fcitx::CapabilityFlag::Password);
|
||||
assert(plugin.captureSelectionTarget("password").empty());
|
||||
assert(plugin.contextSnapshot("selection",true).find("\"text\"")==std::string::npos);
|
||||
first.setCapabilityFlags({});
|
||||
// Identical text at a different position is a different selection.
|
||||
// Comparing only the selected string would corrupt the wrong range.
|
||||
first.surroundingText().setCursor(7, 4);
|
||||
@@ -73,7 +82,12 @@ int main() {
|
||||
RecordingInputContext second(instance.inputContextManager());
|
||||
fcitx::OpenLessInputTargetContract::type(plugin, first);
|
||||
assert(plugin.captureDictationTarget("dictation"));
|
||||
first.focusOut();
|
||||
second.focusIn();
|
||||
fcitx::OpenLessInputTargetContract::type(plugin, second);
|
||||
assert(!plugin.commitDictationTarget("dictation", "focus changed"));
|
||||
second.focusOut();
|
||||
first.focusIn();
|
||||
assert(plugin.commitDictationTarget("dictation", "original target"));
|
||||
assert(first.committed.back() == "original target");
|
||||
assert(second.committed.empty());
|
||||
@@ -82,6 +96,8 @@ int main() {
|
||||
|
||||
{
|
||||
RecordingInputContext destroyed(instance.inputContextManager());
|
||||
first.focusOut();
|
||||
destroyed.focusIn();
|
||||
destroyed.surroundingText().setText("original", 8, 0);
|
||||
fcitx::OpenLessInputTargetContract::type(plugin, destroyed);
|
||||
fcitx::OpenLessInputTargetContract::select(plugin, destroyed);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* SetAuxDown(s: text) — 在候选词列表下方显示状态文本
|
||||
* ClearAuxDown() — 清除候选词列表下方文本
|
||||
* GetSelectionText() -> s — 读取当前 PRIMARY 选区文本(由 clipboard addon 维护)
|
||||
* SetClipboardText(s: text) -> b — 通过 clipboard addon 写入 CLIPBOARD
|
||||
* CaptureSelectionTarget(s: ticket) -> s — 捕获选区和原输入上下文
|
||||
* ApplySelectionTarget(sss: ticket, source, replacement) -> b — 校验后替换
|
||||
* RevertSelectionTarget(s: ticket) -> b — 校验光标前文本后撤销替换
|
||||
@@ -34,8 +35,12 @@
|
||||
* TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起
|
||||
*/
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
@@ -87,6 +92,14 @@ public:
|
||||
translationRawStates_(0),
|
||||
lessComputerRawSym_(0),
|
||||
lessComputerRawStates_(0),
|
||||
switchStyleRawSym_(0),
|
||||
switchStyleRawStates_(0),
|
||||
lessComputerPanelRawSym_(0),
|
||||
lessComputerPanelRawStates_(0),
|
||||
lessComputerQuickRawSym_(0),
|
||||
lessComputerQuickRawStates_(0),
|
||||
openAppRawSym_(0),
|
||||
openAppRawStates_(0),
|
||||
hasCustomDictationKey_(false),
|
||||
dictationTriggerHeld_(false),
|
||||
dictationTriggerCombined_(false),
|
||||
@@ -240,6 +253,31 @@ public:
|
||||
<< "Translation modifier: sym=" << sym;
|
||||
translationModifierEvent(sym, states, isPress);
|
||||
}
|
||||
if (switchStyleRawSym_ != 0 && sym == switchStyleRawSym_ &&
|
||||
states == switchStyleRawStates_) {
|
||||
switchStyleEvent(sym, states, isPress);
|
||||
keyEvent.filterAndAccept();
|
||||
return;
|
||||
}
|
||||
if (lessComputerPanelRawSym_ != 0 && sym == lessComputerPanelRawSym_ && states == lessComputerPanelRawStates_) {
|
||||
lessComputerPanelEvent(sym,states,isPress); keyEvent.filterAndAccept(); return;
|
||||
}
|
||||
if (lessComputerQuickRawSym_ != 0 && sym == lessComputerQuickRawSym_ && states == lessComputerQuickRawStates_) {
|
||||
lessComputerQuickEvent(sym,states,isPress); keyEvent.filterAndAccept(); return;
|
||||
}
|
||||
if (openAppRawSym_ != 0 && sym == openAppRawSym_ &&
|
||||
states == openAppRawStates_) {
|
||||
openAppEvent(sym, states, isPress);
|
||||
keyEvent.filterAndAccept();
|
||||
return;
|
||||
}
|
||||
for (const auto &[packId, packSym, packStates] : stylePackHotkeys_) {
|
||||
if (packSym != 0 && sym == packSym && states == packStates) {
|
||||
stylePackHotkeyEvent(sym, states, isPress);
|
||||
keyEvent.filterAndAccept();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 4. 监听 InputContext 销毁事件,自动清空 savedIc_ 避免野指针
|
||||
@@ -249,6 +287,10 @@ public:
|
||||
EventWatcherPhase::Default,
|
||||
[this](Event &event) {
|
||||
auto &icEvent = static_cast<InputContextEvent &>(event);
|
||||
for (auto it = contextTargets_.begin(); it != contextTargets_.end();) {
|
||||
if (it->second == icEvent.inputContext()) it = contextTargets_.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
if (icEvent.inputContext() == savedIc_) {
|
||||
savedIc_ = nullptr;
|
||||
}
|
||||
@@ -341,8 +383,57 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
// Independently versioned read-only bridge. A target UUID never aliases a
|
||||
// newly created input context, and includeText=false never reads its text.
|
||||
std::string contextSnapshot(const std::string &expected, bool includeText) {
|
||||
InputContext *ic = nullptr;
|
||||
bool selectionTicket = false;
|
||||
if (expected.empty()) {
|
||||
instance_->inputContextManager().foreachFocused([&ic](InputContext *candidate) {
|
||||
ic = candidate; return false;
|
||||
});
|
||||
} else {
|
||||
auto found = contextTargets_.find(expected);
|
||||
if (found != contextTargets_.end()) ic = found->second;
|
||||
auto selected = selectionTargets_.find(expected);
|
||||
if (selected != selectionTargets_.end()) { ic=selected->second.inputContext; selectionTicket=true; }
|
||||
}
|
||||
if (!ic || !ic->hasFocus()) return "{}";
|
||||
std::ostringstream id;
|
||||
for (auto byte : ic->uuid()) id << std::hex << std::setw(2) << std::setfill('0') << static_cast<unsigned>(byte);
|
||||
const auto target = selectionTicket ? expected : id.str();
|
||||
if (!expected.empty() && expected != target) return "{}";
|
||||
if (!selectionTicket) contextTargets_[target] = ic;
|
||||
const bool sensitive = ic->capabilityFlags().test(CapabilityFlag::Password);
|
||||
auto quote = [](const std::string &value) {
|
||||
std::ostringstream out; out << '"';
|
||||
for (unsigned char c : value) {
|
||||
if (c == '"' || c == '\\') out << '\\' << c;
|
||||
else if (c < 0x20) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast<unsigned>(c);
|
||||
else out << c;
|
||||
}
|
||||
out << '"'; return out.str();
|
||||
};
|
||||
std::ostringstream out;
|
||||
out << "{\"version\":1,\"target\":" << quote(target)
|
||||
<< ",\"application\":" << quote(ic->program())
|
||||
<< ",\"sensitive\":" << (sensitive ? "true" : "false");
|
||||
if (includeText && !sensitive && ic->surroundingText().isValid()) {
|
||||
const auto &text = ic->surroundingText();
|
||||
// Reject oversized documents rather than returning a moving window
|
||||
// that could be mistaken for a user edit by the observer.
|
||||
if (text.text().size() <= 65536)
|
||||
out << ",\"text\":" << quote(text.text()) << ",\"cursor\":" << text.cursor();
|
||||
}
|
||||
out << '}'; return out.str();
|
||||
}
|
||||
|
||||
std::string captureSelectionTarget(const std::string &ticket) {
|
||||
if (ticket.empty() || !selectionIc_) {
|
||||
selectionIc_ = nullptr;
|
||||
instance_->inputContextManager().foreachFocused([this](InputContext *ic) {
|
||||
selectionIc_ = ic; return false;
|
||||
});
|
||||
if (ticket.empty() || !selectionIc_ || selectionIc_->capabilityFlags().test(CapabilityFlag::Password)) {
|
||||
return std::string();
|
||||
}
|
||||
std::string source;
|
||||
@@ -363,6 +454,10 @@ public:
|
||||
}
|
||||
|
||||
bool captureDictationTarget(const std::string &ticket) {
|
||||
savedIc_ = nullptr;
|
||||
instance_->inputContextManager().foreachFocused([this](InputContext *ic) {
|
||||
savedIc_ = ic; return false;
|
||||
});
|
||||
if (ticket.empty() || !savedIc_) return false;
|
||||
// A session keeps its own native target even when later key events
|
||||
// update savedIc_. Destruction invalidates the ticket instead of
|
||||
@@ -372,7 +467,7 @@ public:
|
||||
|
||||
bool commitDictationTarget(const std::string &ticket, const std::string &text) {
|
||||
auto found = dictationTargets_.find(ticket);
|
||||
if (found == dictationTargets_.end()) return false;
|
||||
if (found == dictationTargets_.end() || !found->second->hasFocus()) return false;
|
||||
found->second->commitString(text);
|
||||
return true;
|
||||
}
|
||||
@@ -393,6 +488,7 @@ public:
|
||||
return false;
|
||||
}
|
||||
auto *ic = found->second.inputContext;
|
||||
if (!ic->hasFocus()) return false;
|
||||
const auto &surrounding = ic->surroundingText();
|
||||
const auto &captured = found->second;
|
||||
// PRIMARY can outlive the selection, and the same selected string may
|
||||
@@ -418,6 +514,7 @@ public:
|
||||
return false;
|
||||
}
|
||||
auto *ic = found->second.inputContext;
|
||||
if (!ic->hasFocus()) return false;
|
||||
const auto &replacement = found->second.replacement;
|
||||
const auto &surrounding = ic->surroundingText();
|
||||
if (!surrounding.isValid()) {
|
||||
@@ -658,6 +755,45 @@ public:
|
||||
safeSaveAsIni(raw, configFile());
|
||||
}
|
||||
|
||||
void setSwitchStyleHotkeyRaw(uint32_t sym, uint32_t states) {
|
||||
switchStyleRawSym_ = sym;
|
||||
switchStyleRawStates_ = states;
|
||||
persistRawHotkey("SwitchStyle", sym, states);
|
||||
}
|
||||
|
||||
void setLessComputerPanelHotkeyRaw(uint32_t sym,uint32_t states) {
|
||||
lessComputerPanelRawSym_=sym; lessComputerPanelRawStates_=states; persistRawHotkey("LessComputerPanel",sym,states);
|
||||
}
|
||||
|
||||
void setLessComputerQuickHotkeyRaw(uint32_t sym,uint32_t states) {
|
||||
lessComputerQuickRawSym_=sym; lessComputerQuickRawStates_=states; persistRawHotkey("LessComputerQuick",sym,states);
|
||||
}
|
||||
|
||||
void setOpenAppHotkeyRaw(uint32_t sym, uint32_t states) {
|
||||
openAppRawSym_ = sym;
|
||||
openAppRawStates_ = states;
|
||||
persistRawHotkey("OpenApp", sym, states);
|
||||
}
|
||||
|
||||
void setStylePackHotkeys(
|
||||
const std::vector<dbus::DBusStruct<std::string, uint32_t, uint32_t>> &bindings) {
|
||||
stylePackHotkeys_.clear();
|
||||
stylePackHotkeys_.reserve(bindings.size());
|
||||
for (const auto &binding : bindings) {
|
||||
stylePackHotkeys_.push_back(binding.data());
|
||||
}
|
||||
RawConfig raw;
|
||||
readAsIni(raw, configFile());
|
||||
raw.setValueByPath("StylePackHotkeyCount", std::to_string(bindings.size()));
|
||||
for (size_t index = 0; index < stylePackHotkeys_.size(); ++index) {
|
||||
const auto prefix = "StylePackHotkey" + std::to_string(index);
|
||||
raw.setValueByPath(prefix + "Id", std::get<0>(stylePackHotkeys_[index]));
|
||||
raw.setValueByPath(prefix + "Sym", std::to_string(std::get<1>(stylePackHotkeys_[index])));
|
||||
raw.setValueByPath(prefix + "States", std::to_string(std::get<2>(stylePackHotkeys_[index])));
|
||||
}
|
||||
safeSaveAsIni(raw, configFile());
|
||||
}
|
||||
|
||||
/// 读取当前 PRIMARY 选区文本。空字符串表示无选区或 clipboard addon 不可用。
|
||||
std::string getSelectionText() {
|
||||
auto *clipboard = instance_->addonManager().addon("clipboard");
|
||||
@@ -674,7 +810,19 @@ public:
|
||||
return text;
|
||||
}
|
||||
|
||||
bool setClipboardText(const std::string &text) {
|
||||
auto *clipboard = instance_->addonManager().addon("clipboard");
|
||||
if (!clipboard) {
|
||||
FCITX_LOGC(openless, Debug)
|
||||
<< "SetClipboardText: clipboard addon not loaded";
|
||||
return false;
|
||||
}
|
||||
clipboard->call<IClipboard::setClipboard>("openless", text);
|
||||
return true;
|
||||
}
|
||||
|
||||
FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", "b");
|
||||
FCITX_OBJECT_VTABLE_METHOD(contextSnapshot, "ContextSnapshot", "sb", "s");
|
||||
FCITX_OBJECT_VTABLE_METHOD(captureDictationTarget, "CaptureDictationTarget", "s", "b");
|
||||
FCITX_OBJECT_VTABLE_METHOD(commitDictationTarget, "CommitDictationTarget", "ss", "b");
|
||||
FCITX_OBJECT_VTABLE_METHOD(cancelDictationTarget, "CancelDictationTarget", "s", "b");
|
||||
@@ -692,7 +840,13 @@ public:
|
||||
FCITX_OBJECT_VTABLE_METHOD(setSelectionPolishHotkeyRaw, "SetSelectionPolishHotkeyRaw", "uu", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setLessComputerHotkeyRaw, "SetLessComputerHotkeyRaw", "uu", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setSwitchStyleHotkeyRaw, "SetSwitchStyleHotkeyRaw", "uu", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setLessComputerPanelHotkeyRaw,"SetLessComputerPanelHotkeyRaw","uu","");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setLessComputerQuickHotkeyRaw,"SetLessComputerQuickHotkeyRaw","uu","");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setOpenAppHotkeyRaw, "SetOpenAppHotkeyRaw", "uu", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setStylePackHotkeys, "SetStylePackHotkeys", "a(suu)", "");
|
||||
FCITX_OBJECT_VTABLE_METHOD(getSelectionText, "GetSelectionText", "", "s");
|
||||
FCITX_OBJECT_VTABLE_METHOD(setClipboardText, "SetClipboardText", "s", "b");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyCombined, "DictationKeyCombined", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(lessComputerKeyEvent, "LessComputerKeyEvent", "uub");
|
||||
@@ -700,6 +854,11 @@ public:
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(selectionPolishEvent, "SelectionPolishEvent", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(translationModifierEvent, "TranslationModifierEvent", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(switchStyleEvent, "SwitchStyleEvent", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(lessComputerPanelEvent,"LessComputerPanelEvent","uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(lessComputerQuickEvent,"LessComputerQuickEvent","uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(openAppEvent, "OpenAppEvent", "uub");
|
||||
FCITX_OBJECT_VTABLE_SIGNAL(stylePackHotkeyEvent, "StylePackHotkeyEvent", "uub");
|
||||
|
||||
Instance *instance() { return instance_; }
|
||||
|
||||
@@ -749,6 +908,26 @@ public:
|
||||
auto *v = raw.valueByPath("LessComputerRawStates");
|
||||
lessComputerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0;
|
||||
}
|
||||
loadRawHotkey(raw, "SwitchStyle", switchStyleRawSym_, switchStyleRawStates_);
|
||||
loadRawHotkey(raw,"LessComputerPanel",lessComputerPanelRawSym_,lessComputerPanelRawStates_);
|
||||
loadRawHotkey(raw,"LessComputerQuick",lessComputerQuickRawSym_,lessComputerQuickRawStates_);
|
||||
loadRawHotkey(raw, "OpenApp", openAppRawSym_, openAppRawStates_);
|
||||
stylePackHotkeys_.clear();
|
||||
if (auto *countValue = raw.valueByPath("StylePackHotkeyCount")) {
|
||||
const auto count = std::min<size_t>(
|
||||
std::stoul(*countValue, nullptr, 0), 128);
|
||||
for (size_t index = 0; index < count; ++index) {
|
||||
const auto prefix = "StylePackHotkey" + std::to_string(index);
|
||||
auto *id = raw.valueByPath(prefix + "Id");
|
||||
auto *sym = raw.valueByPath(prefix + "Sym");
|
||||
auto *states = raw.valueByPath(prefix + "States");
|
||||
if (id && sym && states && !id->empty()) {
|
||||
stylePackHotkeys_.emplace_back(
|
||||
*id, std::stoul(*sym, nullptr, 0),
|
||||
std::stoul(*states, nullptr, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
lessComputerTriggerHeld_ = false;
|
||||
lessComputerTriggerCombined_ = false;
|
||||
rebuildTriggerKeys();
|
||||
@@ -814,6 +993,23 @@ private:
|
||||
triggerKeyList_ = config_.triggerKey.value();
|
||||
}
|
||||
|
||||
void persistRawHotkey(const std::string &name, uint32_t sym,
|
||||
uint32_t states) {
|
||||
RawConfig raw;
|
||||
readAsIni(raw, configFile());
|
||||
raw.setValueByPath(name + "RawSym", std::to_string(sym));
|
||||
raw.setValueByPath(name + "RawStates", std::to_string(states));
|
||||
safeSaveAsIni(raw, configFile());
|
||||
}
|
||||
|
||||
static void loadRawHotkey(RawConfig &raw, const std::string &name,
|
||||
uint32_t &sym, uint32_t &states) {
|
||||
auto *symValue = raw.valueByPath(name + "RawSym");
|
||||
auto *statesValue = raw.valueByPath(name + "RawStates");
|
||||
sym = symValue ? std::stoul(*symValue, nullptr, 0) : 0;
|
||||
states = statesValue ? std::stoul(*statesValue, nullptr, 0) : 0;
|
||||
}
|
||||
|
||||
Instance *instance_;
|
||||
OpenLessConfig config_;
|
||||
KeyList triggerKeyList_;
|
||||
@@ -827,6 +1023,15 @@ private:
|
||||
uint32_t translationRawStates_;
|
||||
uint32_t lessComputerRawSym_;
|
||||
uint32_t lessComputerRawStates_;
|
||||
uint32_t switchStyleRawSym_;
|
||||
uint32_t switchStyleRawStates_;
|
||||
uint32_t lessComputerPanelRawSym_;
|
||||
uint32_t lessComputerPanelRawStates_;
|
||||
uint32_t lessComputerQuickRawSym_;
|
||||
uint32_t lessComputerQuickRawStates_;
|
||||
uint32_t openAppRawSym_;
|
||||
uint32_t openAppRawStates_;
|
||||
std::vector<std::tuple<std::string, uint32_t, uint32_t>> stylePackHotkeys_;
|
||||
Key customDictationKey_;
|
||||
bool hasCustomDictationKey_;
|
||||
bool dictationTriggerHeld_;
|
||||
@@ -837,6 +1042,7 @@ private:
|
||||
/// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。
|
||||
/// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。
|
||||
InputContext *savedIc_;
|
||||
std::unordered_map<std::string, InputContext *> contextTargets_;
|
||||
/// QA/Selection 快捷键按下时的原输入上下文。该指针只能由 fcitx5 主事件循环
|
||||
/// 访问,并在 InputContextDestroyed 中与所有关联 ticket 一起失效。
|
||||
InputContext *selectionIc_;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import {createRequire} from 'node:module';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../app');
|
||||
const require=createRequire(path.join(root,'package.json'));
|
||||
const ts=require('typescript');
|
||||
const result={};
|
||||
function flatten(value,prefix='',out={}){for(const [key,item] of Object.entries(value)){const name=prefix?prefix+'.'+key:key;if(typeof item==='string')out[name]=item;else if(item&&typeof item==='object')flatten(item,name,out);}return out;}
|
||||
const modules=new Map();
|
||||
function load(locale) {
|
||||
if(modules.has(locale))return modules.get(locale);
|
||||
const source=fs.readFileSync(path.join(root,'src/i18n',locale+'.ts'),'utf8');
|
||||
const output=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const exports={};modules.set(locale,exports);
|
||||
vm.runInNewContext(output,{exports,require:specifier=>{if(!/^\.\/[a-zA-Z-]+$/.test(specifier))throw new Error('Unexpected locale import');return load(specifier.slice(2));}},{timeout:5000});
|
||||
return exports;
|
||||
}
|
||||
for(const locale of ['zh-CN','zh-TW','en','ja','ko','es','fr','de'])result[locale]=flatten(Object.values(load(locale)).find(value=>value&&typeof value==='object'));
|
||||
fs.mkdirSync(path.join(root,'linux-egui/assets'),{recursive:true});
|
||||
fs.writeFileSync(path.join(root,'linux-egui/assets/ui-locales.json'),JSON.stringify(result)+'\n','utf8');
|
||||
Reference in new issue
Block a user