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:
committed 2026-09-24 17:28:24 +08:00
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');