diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index dd50aed..0a7bd4f 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -21,7 +21,8 @@ jobs: # which already attached its artifacts — the follow-up tag push must # not rebuild everything (it would also strip the dev version suffix). if: ${{ !contains(github.ref_name, '-dev.') }} - runs-on: ubuntu-22.04 + # libei-dev is available starting with Ubuntu 24.04. + runs-on: ubuntu-24.04 steps: - name: Checkout repository @@ -32,7 +33,9 @@ jobs: sudo apt-get update sudo apt-get install -y cmake extra-cmake-modules libfcitx5core-dev \ fcitx5 pkg-config gettext dpkg-dev apt-utils librsvg2-bin libdbus-1-dev \ - libgl1-mesa-dev libxkbcommon-dev libfontconfig1-dev libxcb1-dev qt6-base-dev + libgl1-mesa-dev libxkbcommon-dev libfontconfig1-dev libxcb1-dev libx11-dev libxtst-dev qt6-base-dev \ + libei-dev libportal-dev + pkg-config --print-errors --exists x11 xtst 'libei-1.0 >= 1.0' 'libportal >= 0.7.1' - name: Install Rust toolchain run: | @@ -77,8 +80,9 @@ jobs: pre_install: dnf install -y tar git install: | dnf install -y cmake extra-cmake-modules fcitx5-devel fcitx5 \ - dbus-devel libxcb-devel gettext gcc-c++ make \ + dbus-devel libxcb-devel libX11-devel libXtst-devel gettext gcc-c++ make \ qt6-qtbase-devel librsvg2-tools \ + 'pkgconfig(libei-1.0)' 'pkgconfig(libportal)' \ rpm-build rpm-sign gpg createrepo_c - distro: opensuse image: opensuse/tumbleweed @@ -87,8 +91,9 @@ jobs: install: | zypper --non-interactive install --force-resolution \ cmake extra-cmake-modules \ - fcitx5-devel fcitx5 dbus-1-devel libxcb-devel gettext gcc-c++ make \ + fcitx5-devel fcitx5 dbus-1-devel libxcb-devel libX11-devel libXtst-devel gettext gcc-c++ make \ qt6-base-devel rsvg-convert \ + 'pkgconfig(libei-1.0)' 'pkgconfig(libportal)' \ rpm-build gpg2 createrepo_c container: @@ -102,7 +107,9 @@ jobs: uses: actions/checkout@v4 - name: Install build dependencies - run: ${{ matrix.install }} + run: | + ${{ matrix.install }} + pkg-config --print-errors --exists x11 xtst 'libei-1.0 >= 1.0' 'libportal >= 0.7.1' - name: Import GPG key env: diff --git a/.github/workflows/clang.yml b/.github/workflows/clang.yml index 3e6252c..eeb16fd 100644 --- a/.github/workflows/clang.yml +++ b/.github/workflows/clang.yml @@ -17,9 +17,11 @@ jobs: run: | sudo apt-get update sudo apt-get install -y clang-18 clang-tools-18 cmake extra-cmake-modules \ - libfcitx5core-dev fcitx5 pkg-config gettext libdbus-1-dev libxcb1-dev \ + libfcitx5core-dev fcitx5 pkg-config gettext libdbus-1-dev libxcb1-dev libx11-dev libxtst-dev \ + libei-dev libportal-dev \ qt6-base-dev libgl1-mesa-dev libxkbcommon-dev libfontconfig1-dev \ dbus-daemon python3 + pkg-config --print-errors --exists x11 xtst 'libei-1.0 >= 1.0' 'libportal >= 0.7.1' - name: Build, test, and audit includes env: CC: clang-18 diff --git a/.github/workflows/ppa-upload.yml b/.github/workflows/ppa-upload.yml index 2e03641..8f1bff7 100644 --- a/.github/workflows/ppa-upload.yml +++ b/.github/workflows/ppa-upload.yml @@ -31,6 +31,8 @@ jobs: sudo sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' /etc/apt/sources.list /etc/apt/apt-mirrors.txt 2>/dev/null || true sudo sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true sudo apt-get update + # This runner only prepares source packages. Launchpad installs + # Build-Depends from debian/control in each target series. sudo apt-get install -y devscripts debhelper dpkg-dev git rsync dput - name: Resolve version diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f091a5..6f3af68 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(fcitx5-skey VERSION 0.8.8) +project(fcitx5-skey VERSION 0.8.9) # Version compiled into the settings GUI / updater. Dev builds append a # suffix (e.g. 0.7.5~dev.123) — project() VERSION must stay purely numeric, diff --git a/build.sh b/build.sh index 8b09122..c9b2581 100755 --- a/build.sh +++ b/build.sh @@ -60,7 +60,7 @@ Version: $PKG_VERSION Section: utils Priority: optional Architecture: $PKG_ARCH -Depends: fcitx5, systemd, hicolor-icon-theme, libqt6widgets6, libqt6svg6, libxcb1 +Depends: fcitx5, systemd, hicolor-icon-theme, libqt6widgets6, libqt6svg6, libxcb1, libx11-6, libxtst6, libei1, libportal1 Maintainer: Huy Description: Vietnamese SKey input method addon for Fcitx5 This package provides the skey input method engine for fcitx5, diff --git a/debian/control b/debian/control index c0aeb9a..95fbc54 100644 --- a/debian/control +++ b/debian/control @@ -12,6 +12,10 @@ Build-Depends: debhelper-compat (= 13), libfontconfig-dev, librsvg2-bin, libdbus-1-dev, + libx11-dev, + libxtst-dev, + libei-dev, + libportal-dev (>= 0.7.1), cargo, rustc, libstd-rust-dev, @@ -32,7 +36,9 @@ Depends: ${shlibs:Depends}, # without it the Icons tab (and system tray icon) render blank. libqt6svg6, acl, - udev + udev, + libei1, + libportal1 Description: Vietnamese SKey input method addon for Fcitx5 Skey is a Vietnamese input method engine for the Fcitx5 input method framework, supporting Telex, Telex W, and VNI diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 08ab7c5..e9aea4d 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -8,8 +8,8 @@ pkgdesc="Vietnamese SKey input method addon for Fcitx5 (skey-engine)" arch=('x86_64') url="https://github.com/collyn/skey" license=('GPL-3.0-or-later') -depends=('fcitx5' 'hicolor-icon-theme' 'qt6-base' 'qt6-svg' 'systemd' 'acl') -makedepends=('cmake' 'extra-cmake-modules' 'gettext' 'dbus' 'rust' 'gcc' 'libxcb' 'librsvg') +depends=('fcitx5' 'hicolor-icon-theme' 'qt6-base' 'qt6-svg' 'systemd' 'acl' 'libx11' 'libxtst' 'libei' 'libportal') +makedepends=('cmake' 'extra-cmake-modules' 'gettext' 'dbus' 'rust' 'gcc' 'libxcb' 'libx11' 'libxtst' 'librsvg' 'libei' 'libportal') # source is the git checkout itself — CI populates src/ before invoking makepkg -e source=() install=packaging/fcitx5-skey.install diff --git a/packaging/fcitx5-skey.spec b/packaging/fcitx5-skey.spec index f6a8e61..1e13606 100644 --- a/packaging/fcitx5-skey.spec +++ b/packaging/fcitx5-skey.spec @@ -27,6 +27,10 @@ BuildRequires: gcc-c++ BuildRequires: make BuildRequires: pkgconfig(dbus-1) BuildRequires: pkgconfig(xcb) +BuildRequires: pkgconfig(x11) +BuildRequires: pkgconfig(xtst) +BuildRequires: pkgconfig(libei-1.0) >= 1.0 +BuildRequires: pkgconfig(libportal) >= 0.7.1 # rust + cargo provided by rustup in CI; use distro packages for local builds: # BuildRequires: rust # BuildRequires: cargo @@ -51,6 +55,8 @@ Requires: fcitx5 Requires: hicolor-icon-theme Requires: systemd Requires: acl +# RPM generates shared-library requirements automatically, including the +# correct providers on openSUSE (libei1 / libportal1). %description SKey (Simple Key) is a Vietnamese input method engine for Fcitx5, diff --git a/packaging/nixos/default.nix b/packaging/nixos/default.nix index 44d3774..545b51e 100644 --- a/packaging/nixos/default.nix +++ b/packaging/nixos/default.nix @@ -17,6 +17,10 @@ librsvg, # rsvg-convert for PNG tray icons dbus, libxcb, # X11 WM_CLASS detection + libX11, + libXtst, + libei, + libportal, fcitx5, qt6, # settings GUI (qtbase/qtwayland/qtsvg) + wrapQtAppsHook rustPlatform, @@ -119,6 +123,10 @@ stdenv.mkDerivation (finalAttrs: { fcitx5 dbus libxcb + libX11 + libXtst + libei + libportal qt6.qtbase qt6.qtwayland # runtime platform plugin for the settings GUI on Wayland qt6.qtsvg # QIcon SVG loading at runtime diff --git a/po/en.po b/po/en.po index 609e5f3..84ae57b 100644 --- a/po/en.po +++ b/po/en.po @@ -1095,3 +1095,21 @@ msgstr "Update successful! The system has been rebuilt with nixos-rebuild." #: src/settings/updater.cpp:580 msgid "Cập nhật thất bại (mã %1): %2" msgstr "Update failed (code %1): %2" + +msgid "Thay thế Uinput bằng Native" +msgstr "Replace Uinput with Native" + +msgid "Khi bật, Native thay thế Uinput trong Auto và trong menu chọn mode." +msgstr "When enabled, Native replaces Uinput in Auto mode and the mode menu." + +msgid "Native" +msgstr "Native" + +msgid "Native (XTest/Libei)" +msgstr "Native (XTest/Libei)" + +msgid "Loại trừ ứng dụng" +msgstr "Exclude application" + +msgid "Output Mode: Native" +msgstr "Output Mode: Native" diff --git a/po/fcitx5-skey.pot b/po/fcitx5-skey.pot index 451cb93..fa386f3 100644 --- a/po/fcitx5-skey.pot +++ b/po/fcitx5-skey.pot @@ -986,3 +986,21 @@ msgstr "" #: src/settings/updater.cpp:580 msgid "Cập nhật thất bại (mã %1): %2" msgstr "" + +msgid "Thay thế Uinput bằng Native" +msgstr "" + +msgid "Khi bật, Native thay thế Uinput trong Auto và trong menu chọn mode." +msgstr "" + +msgid "Native" +msgstr "" + +msgid "Native (XTest/Libei)" +msgstr "" + +msgid "Loại trừ ứng dụng" +msgstr "" + +msgid "Output Mode: Native" +msgstr "" diff --git a/po/vi.po b/po/vi.po index d1ce5d0..b4aa225 100644 --- a/po/vi.po +++ b/po/vi.po @@ -1094,3 +1094,21 @@ msgstr "Cập nhật thành công! Hệ thống đã được build lại bằng #: src/settings/updater.cpp:580 msgid "Cập nhật thất bại (mã %1): %2" msgstr "Cập nhật thất bại (mã %1): %2" + +msgid "Thay thế Uinput bằng Native" +msgstr "Thay thế Uinput bằng Native" + +msgid "Khi bật, Native thay thế Uinput trong Auto và trong menu chọn mode." +msgstr "Khi bật, Native thay thế Uinput trong Auto và trong menu chọn mode." + +msgid "Native" +msgstr "Native" + +msgid "Native (XTest/Libei)" +msgstr "Native (XTest/Libei)" + +msgid "Loại trừ ứng dụng" +msgstr "Loại trừ ứng dụng" + +msgid "Output Mode: Native" +msgstr "Output Mode: Native" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d608957..a196dfa 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,7 @@ set(CMAKE_AUTORCC OFF) set(SKEY_SOURCES engine.cpp + native_injector.cpp vietnamese.cpp viet_recompose.cpp viet_undo.cpp @@ -20,6 +21,7 @@ set(SKEY_SOURCES find_package(PkgConfig REQUIRED) pkg_check_modules(DBUS REQUIRED dbus-1) pkg_check_modules(XCB REQUIRED xcb) +pkg_check_modules(NATIVE_X11 REQUIRED IMPORTED_TARGET x11 xtst) add_library(skey MODULE ${SKEY_SOURCES}) target_link_libraries(skey @@ -27,6 +29,7 @@ target_link_libraries(skey skey_engine ${DBUS_LIBRARIES} ${XCB_LIBRARIES} + PkgConfig::NATIVE_X11 pthread dl m @@ -66,11 +69,13 @@ target_include_directories(test_a11y_monitor PRIVATE ${DBUS_INCLUDE_DIRS}) target_compile_features(test_a11y_monitor PRIVATE cxx_std_17) add_test(NAME A11yMonitor COMMAND test_a11y_monitor) add_executable(test_engine_performance test_engine_performance.cpp ${SKEY_SOURCES}) -target_link_libraries(test_engine_performance Fcitx5::Core skey_engine ${DBUS_LIBRARIES} ${XCB_LIBRARIES} pthread dl m) +target_link_libraries(test_engine_performance Fcitx5::Core skey_engine ${DBUS_LIBRARIES} ${XCB_LIBRARIES} PkgConfig::NATIVE_X11 pthread dl m) target_include_directories(test_engine_performance PRIVATE ${SKEY_ENGINE_DIR} ${DBUS_INCLUDE_DIRS} ${XCB_INCLUDE_DIRS}) target_compile_features(test_engine_performance PRIVATE cxx_std_17) target_compile_definitions(test_engine_performance PRIVATE FCITX_SKEY_ICON_PATH="/tmp/skey-test.svg" FCITX_SKEY_ICON_DIR="/tmp") add_test(NAME EnginePerformance COMMAND test_engine_performance) +add_test(NAME InjectedBackspaceContract COMMAND ${CMAKE_COMMAND} -E env + SKEY_TEST_BACKSPACE_ONLY=1 $) set_tests_properties(EnginePerformance PROPERTIES TIMEOUT 15) find_program(DBUS_RUN_SESSION dbus-run-session) if(DBUS_RUN_SESSION) @@ -103,3 +108,19 @@ configure_file( ) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/skey-im.conf" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/inputmethod") + +# Optional experimental portal keyboard backend. Ordinary builds keep working. +pkg_check_modules(LIBEI QUIET IMPORTED_TARGET libei-1.0>=1.0) +pkg_check_modules(LIBPORTAL QUIET IMPORTED_TARGET libportal>=0.7.1) +if(LIBEI_FOUND AND LIBPORTAL_FOUND) + message(STATUS "Libei backend enabled (libei ${LIBEI_VERSION}, libportal ${LIBPORTAL_VERSION})") + foreach(target skey test_engine_performance) + target_compile_definitions(${target} PRIVATE SKEY_HAVE_LIBEI=1) + if(LIBPORTAL_VERSION VERSION_GREATER_EQUAL 0.8) + target_compile_definitions(${target} PRIVATE SKEY_LIBPORTAL_PERSIST=1) + endif() + target_link_libraries(${target} PkgConfig::LIBEI PkgConfig::LIBPORTAL) + endforeach() +else() + message(WARNING "Libei backend disabled: requires libei >= 1.0 and libportal >= 0.7.1") +endif() diff --git a/src/config.h b/src/config.h index 73bd86a..0f66bca 100644 --- a/src/config.h +++ b/src/config.h @@ -12,20 +12,21 @@ enum class SKeyInputMethod { Telex, VNI }; FCITX_CONFIG_ENUM_NAME_WITH_I18N(SKeyInputMethod, N_("Telex"), N_("VNI")); // Output mode -enum class SKeyOutputMode { Uinput, SurroundingText, Preedit, Auto }; +enum class SKeyOutputMode { Uinput, SurroundingText, Preedit, Auto, Native }; FCITX_CONFIG_ENUM_NAME_WITH_I18N(SKeyOutputMode, N_("Uinput"), N_("Surrounding Text"), N_("Preedit"), - N_("Auto")); + N_("Auto"), N_("Native")); // Output mode used only in Chromium-family browser address bars. enum class SKeyChromiumAddressBarMode { - Auto, Uinput, SurroundingText, Preedit, NoVietnamese + Auto, Uinput, SurroundingText, Preedit, Native, NoVietnamese }; FCITX_CONFIG_ENUM_NAME_WITH_I18N(SKeyChromiumAddressBarMode, N_("Auto"), N_("Uinput"), N_("Surrounding Text"), N_("Preedit"), + N_("Native"), N_("No Vietnamese")); // Character set / encoding — IDs match skey-engine VietCharset enum @@ -52,6 +53,9 @@ FCITX_CONFIGURATION( SKeyCharset::Unicode}; Option outputMode{this, "OutputMode", _("Chế độ xuất"), SKeyOutputMode::Auto}; + // Keep the on-disk key for compatibility with existing installations. + Option replaceUinputWithNative{this, "PreferLibeiAuto", + _("Thay thế Uinput bằng Native"), false}; Option freeMarking{this, "FreeMarking", _("Đánh dấu tự do"), false}; Option autoRestore{this, "AutoRestore", _("Tự động khôi phục"), true}; diff --git a/src/engine.cpp b/src/engine.cpp index 52ff53e..5425c8f 100644 --- a/src/engine.cpp +++ b/src/engine.cpp @@ -368,6 +368,8 @@ bool SKeyState::surroundingCacheEndsWith(const fcitx::SurroundingText &st, static std::string outputModeName(SKeyOutputMode mode) { switch (mode) { + case SKeyOutputMode::Native: + return "Native"; case SKeyOutputMode::SurroundingText: return "Surrounding Text"; case SKeyOutputMode::Preedit: @@ -831,6 +833,8 @@ class ModeCandidateWord : public CandidateWord { } void select(InputContext *) const override { + state_->appExcluded_ = false; + engine_->saveAppExcluded(state_->appProgram(), false); state_->appModeOverride_ = mode_; state_->hasAppModeOverride_ = true; state_->modeCacheValid_ = false; @@ -965,6 +969,14 @@ void SKeyEngine::setupTrayMenu() { omMenu_.addAction(&omUinput_); omMenu_.addAction(&omSurrounding_); omMenu_.addAction(&omPreedit_); + omNative_.setShortText(_("Native (XTest/Libei)")); + omNative_.setCheckable(true); + omNative_.registerAction("skey-om-libei", &uiManager); + omMenu_.addAction(&omNative_); + omNative_.connect([this](InputContext *) { + setOutputMode(SKeyOutputMode::Native); + native_.start(); + }); omAction_.setShortText(_("Output Mode")); omAction_.setMenu(&omMenu_); @@ -1159,14 +1171,26 @@ void SKeyEngine::updateMenuActions() { imAction_.setShortText(_("Input Method: Telex")); } + const bool replace = config_.replaceUinputWithNative.value(); + for (auto *action : omMenu_.actions()) omMenu_.removeAction(action); + omMenu_.addAction(&omAuto_); + omMenu_.addAction(replace ? &omNative_ : &omUinput_); + omMenu_.addAction(&omSurrounding_); + omMenu_.addAction(&omPreedit_); + if (!replace) omMenu_.addAction(&omNative_); + omNative_.setShortText(replace ? _("Native") : _("Native (XTest/Libei)")); auto om = config_.outputMode.value(); + if (replace && om == SKeyOutputMode::Uinput) om = SKeyOutputMode::Native; omAuto_.setChecked(om == SKeyOutputMode::Auto); omSurrounding_.setChecked(om == SKeyOutputMode::SurroundingText); + omNative_.setChecked(om == SKeyOutputMode::Native); omPreedit_.setChecked(om == SKeyOutputMode::Preedit); omUinput_.setChecked(om == SKeyOutputMode::Uinput); if (om == SKeyOutputMode::Auto) { omAction_.setShortText(_("Output Mode: Auto")); + } else if (om == SKeyOutputMode::Native) { + omAction_.setShortText(_("Output Mode: Native")); } else if (om == SKeyOutputMode::Preedit) { omAction_.setShortText(_("Output Mode: Preedit")); } else if (om == SKeyOutputMode::Uinput) { @@ -1398,21 +1422,24 @@ void SKeyEngine::maybeSaveAppDelays(bool force) { void SKeyEngine::reloadConfig() { const bool previousShowPreedit = config_.showPreedit.value(); - // Migrate legacy "Telex W" input method → Telex + ShortW=True. - // The TelexW enum value no longer exists, so peek the raw ini first. - { - RawConfig raw; - readAsIni(raw, "conf/skey.conf"); - auto *im = raw.valueByPath("InputMethod"); - if (im && (*im == "Telex W" || *im == "TelexW")) { - readAsIni(config_, "conf/skey.conf"); - config_.inputMethod.setValue(SKeyInputMethod::Telex); - config_.shortW.setValue(true); - safeSaveAsIni(config_, "conf/skey.conf"); - SKEY_INFO() << "Migrated legacy 'Telex W' → Telex + ShortW"; - } - } - readAsIni(config_, "conf/skey.conf"); + // Normalize retired enum names before parsing, preserving all other keys. + RawConfig raw; + readAsIni(raw, "conf/skey.conf"); + bool migrated = false; + if (const auto *im = raw.valueByPath("InputMethod"); + im && (*im == "Telex W" || *im == "TelexW")) { + raw.setValueByPath("InputMethod", "Telex"); + raw.setValueByPath("ShortW", "True"); + migrated = true; + } + for (const auto *key : {"OutputMode", "ChromiumAddressBarMode"}) { + if (const auto *mode = raw.valueByPath(key); mode && *mode == "Libei") { + raw.setValueByPath(key, "Native"); + migrated = true; + } + } + config_.load(raw); + if (migrated) safeSaveAsIni(raw, "conf/skey.conf"); g_skeyDebugEnabled = readDebugFromFile(); if (a11yMonitor_) { a11yMonitor_->setDebug(g_skeyDebugEnabled); @@ -1628,6 +1655,7 @@ void SKeyState::finishSurroundingMeasurement() { bool SKeyState::commitText(const std::string &utf8, bool uinputReplacement) { if (utf8.empty()) return false; + const uint64_t commitStartedAt = now(CLOCK_MONOTONIC); // A replacement timer may fire after a click but before the next key. // Never insert the previous cell's pending text into the newly selected // cell. Use the same synchronous boundary check as keyEvent(). @@ -1674,16 +1702,23 @@ bool SKeyState::commitText(const std::string &utf8, bool uinputReplacement) { preedit.setCursor(text.size()); ic_->inputPanel().setClientPreedit(preedit); ic_->updatePreedit(); + SKEY_DEBUG() << "Commit timing: updatePreedit=" + << (now(CLOCK_MONOTONIC) - commitStartedAt) / 1000 << "ms"; SKEY_DEBUG() << "Uinput: composition commit '" << text << (isX11Tabby() ? "' [tabby-x11]" : firefoxX11Replacement ? "' [firefox-x11]" : isWayland() ? "' [chromium-wayland]" : "' [chromium-x11]"); } ic_->commitString(text); + SKEY_DEBUG() << "Commit timing: commitString=" + << (now(CLOCK_MONOTONIC) - commitStartedAt) / 1000 << "ms" + << " textBytes=" << text.size(); if (composeReplacement) { // Protocol composition is immediately committed, independent of the // Show preedit preference. Never leave a visible/pending composition. ic_->inputPanel().setClientPreedit(Text()); ic_->updatePreedit(); + SKEY_DEBUG() << "Commit timing: clearPreedit=" + << (now(CLOCK_MONOTONIC) - commitStartedAt) / 1000 << "ms"; } return true; } @@ -1754,6 +1789,8 @@ void SKeyState::refreshAppMode() { else if (modeStr == "SurroundingTextSlow" || modeStr == "SurroundingText" || modeStr == "Surrounding Text") savedMode = SKeyOutputMode::SurroundingText; + else if (modeStr == "Native" || modeStr == "Libei") + savedMode = SKeyOutputMode::Native; else if (modeStr == "Uinput") savedMode = SKeyOutputMode::Uinput; appModeOverride_ = savedMode; @@ -1797,6 +1834,16 @@ bool SKeyState::inChromiumAddressBar() const { // Wayland would misclassify the Ctrl+F find bar as an address bar, causing // Escape-key autocomplete dismissal to close the find bar. if (!isWayland()) { + auto *mon = engine_->a11yMonitor(); + // A fresh web-content verdict is authoritative. Return before the + // cursor/DPI and active-window X11 queries below; those queries are + // synchronous and were needlessly run on every key in Chrome page + // editors when AT-SPI had already identified the focused document. + if (mon && mon->isFocusSnapshotFresh(5000000) && + mon->isWebContentFocused()) { + addrBarUiVerdictAtUsec_ = 0; + return false; + } // Caret-geometry gates below are scaled by the display DPI. Fixed // pixel thresholds assume 96 DPI and break on scaled displays // (125%/150%/200%), where Chrome's omnibox caret is proportionally @@ -1814,7 +1861,6 @@ bool SKeyState::inChromiumAddressBar() const { // X11 cursor rectangles are root-relative. Comparing their absolute Y // to the toolbar height misclassifies an omnibox when its window moves. const int caretTop = ic_->cursorRect().top() - x11ActiveWindowTop().value_or(0); - auto *mon = engine_->a11yMonitor(); // Fresh snapshot with WEB CONTENT focus means the user is in a web // page (Facebook chat, forms...) — never the omnibox. Must be // checked BEFORE the verdict latch and the cursor-rect fallback: a @@ -1823,11 +1869,6 @@ bool SKeyState::inChromiumAddressBar() const { // omnibox), would otherwise keep the address-bar machinery running // in web editors, adding a full BS round-trip + commit sleep to // every tone key. - if (mon && mon->isFocusSnapshotFresh(5000000) && - mon->isWebContentFocused()) { - addrBarUiVerdictAtUsec_ = 0; - return false; - } if (mon && mon->isBrowserUIFocused()) { // Only a FRESH browser-UI verdict counts, AND the caret must be // addrbar-shaped. Web-page buttons (FB's own UI) also produce @@ -1934,7 +1975,8 @@ SKeyOutputMode SKeyState::effectiveMode() const { case SKeyChromiumAddressBarMode::Auto: break; // fall through to normal Auto detection below case SKeyChromiumAddressBarMode::Uinput: - cachedMode_ = SKeyOutputMode::Uinput; + cachedMode_ = engine_->config().replaceUinputWithNative.value() + ? SKeyOutputMode::Native : SKeyOutputMode::Uinput; modeCacheValid_ = true; return cachedMode_; case SKeyChromiumAddressBarMode::SurroundingText: @@ -1945,6 +1987,10 @@ SKeyOutputMode SKeyState::effectiveMode() const { cachedMode_ = SKeyOutputMode::Preedit; modeCacheValid_ = true; return cachedMode_; + case SKeyChromiumAddressBarMode::Native: + cachedMode_ = SKeyOutputMode::Native; + modeCacheValid_ = true; + return cachedMode_; case SKeyChromiumAddressBarMode::NoVietnamese: break; } @@ -1956,7 +2002,8 @@ SKeyOutputMode SKeyState::effectiveMode() const { if (resolved == SKeyOutputMode::Auto) { cachedMode_ = detectAutoMode(); } else { - cachedMode_ = resolved; + cachedMode_ = resolved == SKeyOutputMode::Uinput && engine_->config().replaceUinputWithNative.value() + ? SKeyOutputMode::Native : resolved; } modeCacheValid_ = true; return cachedMode_; @@ -1964,12 +2011,21 @@ SKeyOutputMode SKeyState::effectiveMode() const { bool SKeyState::useSurroundingText() const { auto mode = effectiveMode(); - return mode == SKeyOutputMode::SurroundingText || + return mode == SKeyOutputMode::Native || + mode == SKeyOutputMode::SurroundingText || mode == SKeyOutputMode::Uinput; } +bool SKeyState::useNativeMode() const { + return effectiveMode() == SKeyOutputMode::Native; +} + bool SKeyState::useUinputMode() const { - return effectiveMode() == SKeyOutputMode::Uinput; + // Native is the same replacement backend as Uinput. It deliberately + // shares this path so address-bar handling, buffering, timing and state + // recovery cannot drift between the two transports. + auto mode = effectiveMode(); + return mode == SKeyOutputMode::Uinput || mode == SKeyOutputMode::Native; } // Content-hint capability bits that indicate a real, trusted editor in a @@ -2167,13 +2223,19 @@ void SKeyState::clearEngineBareCapsSticky() const { } SKeyOutputMode SKeyState::detectAutoMode() const { + const auto autoUinput = [this](SKeyOutputMode mode) { + if (mode == SKeyOutputMode::Uinput && + engine_->config().replaceUinputWithNative.value()) + return SKeyOutputMode::Native; + return mode; + }; // Runtime override: if the surrounding text API was verified as // non-functional during a previous replacement attempt (cache invalid), // stick with Uinput for this IC — the per-replacement fallback cannot // be verified and corrupts text in apps that drop forwarded keys. if (surroundingTextFailed_) { SKEY_DEBUG() << "Auto: surrounding text previously failed → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } // A Chromium browser tab whose a11y focus is NOT a text entry cannot @@ -2215,7 +2277,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { } if (nonEntry) { SKEY_DEBUG() << "Auto: focus is not a text entry → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } // Integrated terminal in a standalone Chromium app. Must run before the @@ -2224,7 +2286,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { // otherwise route it to SurroundingText. if (a11yChromiumTerminal()) { SKEY_DEBUG() << "Auto: Chromium integrated terminal (a11y) → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } // Google Sheets cell editor. Must run before the caps-based decisions: @@ -2240,7 +2302,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { if (mon && mon->sheetsEditorFocused() && mon->isFocusSnapshotFresh(5000000)) { SKEY_DEBUG() << "Auto: Google Sheets cell editor → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } } @@ -2252,7 +2314,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { // (0x72) fall through to SurroundingText here. if (isFirefoxOrSnap() && a11yGoogleDocsFocused()) { SKEY_DEBUG() << "Auto: Google Docs page (Firefox) → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } // Sticky Uinput: Chromium browsers (e.g. Google Sheets) may initially @@ -2265,7 +2327,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { if (chromiumBareCapsUinput_) { if (!a11yFreshWebEditor()) { SKEY_DEBUG() << "Auto: sticky bare caps → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } SKEY_DEBUG() << "Auto: sticky bypassed by fresh web-editor focus"; clearEngineBareCapsSticky(); @@ -2279,7 +2341,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { // Surr implementation, same result. if (isOfficeSuiteApp(appProgram())) { SKEY_DEBUG() << "Auto: office suite → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } auto caps = ic_->capabilityFlags(); @@ -2307,7 +2369,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { // (used by x11ChromiumSurrDelayUsec if Surr ever runs again via a // manual override). SKEY_DEBUG() << "Auto: no SurroundingText cap → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } // Terminal apps (Konsole, Alacritty, etc.) have their own internal @@ -2321,14 +2383,14 @@ SKeyOutputMode SKeyState::detectAutoMode() const { (isTerminalAppCached() && (!isChromiumCached() || isTerminalAppName(appProgram())))) { SKEY_DEBUG() << "Auto: terminal app → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } // Chromium address bar always needs Uinput — the omnibox autocomplete // races with SurroundingText replacements, corrupting text. if (inChromiumAddressBar() && caps.test(CapabilityFlag::SurroundingText)) { SKEY_DEBUG() << "Auto: address bar → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } // Chromium-family apps (Electron + full browsers) with truly bare caps @@ -2390,7 +2452,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { << std::hex << static_cast(caps.toInteger()) << std::dec << ")"; } - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } } @@ -2412,7 +2474,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { engine_->chromiumHadBareCaps_ = true; SKEY_DEBUG() << "Auto: deferred decision → sticky Uinput (weak hints only)"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } } @@ -2423,7 +2485,7 @@ SKeyOutputMode SKeyState::detectAutoMode() const { // safe for every app; the decision re-evaluates on the next focus. if (appProgram().empty() && !(caps & CapabilityFlags(kContentHints))) { SKEY_DEBUG() << "Auto: unresolved app + bare caps → Uinput"; - return SKeyOutputMode::Uinput; + return autoUinput(SKeyOutputMode::Uinput); } SKEY_DEBUG() << "Auto: SurroundingText cap → SurroundingText (caps=0x" @@ -2663,6 +2725,7 @@ void SKeyState::expireAddrBarCycle() { } void SKeyState::activate() { + nativeFailed_ = false; expireAddrBarCycle(); uinputAckUnavailable_ = false; // Re-sync input method from config (handles config changes at runtime) @@ -2820,6 +2883,8 @@ void SKeyState::activate() { else if (modeStr == "SurroundingTextSlow" || modeStr == "Surrounding Text") savedMode = SKeyOutputMode::SurroundingText; + else if (modeStr == "Native" || modeStr == "Libei") + savedMode = SKeyOutputMode::Native; else if (modeStr == "Uinput") savedMode = SKeyOutputMode::Uinput; else if (modeStr == "SurroundingText") @@ -2884,8 +2949,10 @@ void SKeyState::activate() { } auto mode = effectiveMode(); auto configuredMode = engine_->config().outputMode.value(); + if (useNativeMode() && !appExcluded_) engine_->native_.start(); SKEY_DEBUG() << "Activated: mode=" << outputModeName(mode) << " configured=" << outputModeName(configuredMode) + << " replaceUinputWithNative=" << engine_->config().replaceUinputWithNative.value() << " surroundingCap=" << caps.test(CapabilityFlag::SurroundingText) << " password=" << caps.test(CapabilityFlag::Password) @@ -3053,7 +3120,7 @@ void SKeyState::sendBackspaceUinput(int count, uint32_t flags, std::string_view if (count == 0 && flags == 0) { return; } - if (!connectUinputServer()) { + if (!useNativeMode() && !connectUinputServer()) { SKEY_DEBUG() << "Uinput: cannot send BS, server unavailable"; return; } @@ -3109,6 +3176,19 @@ void SKeyState::sendBackspaceUinput(int count, uint32_t flags, std::string_view oldWord, count - 1, bsSentAt_); } } + if (useNativeMode()) { + // Preserve the Uinput event contract: N deletions followed by one + // anchor. Fcitx must pass the real deletions to the application and + // consume only the anchor in handlePendingUinputBackspace(). + if (engine_->native_.backspaces(count, flags & 1, paceUsec)) { + uinputBsOutstanding_ += count; + SKEY_DEBUG() << "Native: sent BS=" << count << " (includes sync anchor) paceUs=" << paceUsec; + } else { + nativeFailed_ = true; + SKEY_DEBUG() << "Native: send rejected; no uinput fallback: " << engine_->native_.status(); + } + return; + } ssize_t n = send(uinputClientFd_, msg.data(), msg.size(), MSG_NOSIGNAL); if (n < 0) { SKEY_DEBUG() << "Uinput: send failed: " << strerror(errno); @@ -3853,6 +3933,10 @@ void SKeyState::deactivate() { CLOCK_MONOTONIC, now(CLOCK_MONOTONIC) + 500000, 0, [this](EventSourceTime *, uint64_t) { SKEY_DEBUG() << "Deactivate: no reactivate, genuine focus loss"; + if (useNativeMode()) { + engine_->native_.cancel(); + resetForCellChange(); + } addrBarExpectCycle_ = false; addrBarCycleTimer_.reset(); // Next word in a fresh context is the first word again @@ -3902,6 +3986,7 @@ void SKeyState::deactivate() { << "Deactivate: no reactivation, discarding mid-flight " "replacement"; uinputCycleTimer_.reset(); + if (useNativeMode()) engine_->native_.cancel(); pendingUinputCommit_.clear(); expectedUinputBackspaces_ = 0; seenUinputBackspaces_ = 0; @@ -3923,6 +4008,7 @@ void SKeyState::deactivate() { return; } + if (useNativeMode()) engine_->native_.cancel(); expectedUinputBackspaces_ = 0; seenUinputBackspaces_ = 0; pendingUinputCommit_.clear(); @@ -4107,6 +4193,7 @@ bool SKeyState::checkCellSelection() { } void SKeyState::resetForCellChange() { + if (useNativeMode()) engine_->native_.cancel(); x11BrowserFocusSettled_ = false; viet_.reset(); committedLen_ = 0; @@ -4309,6 +4396,21 @@ void SKeyState::keyEvent(KeyEvent &keyEvent) { return; } + if (useNativeMode() && !modeMenuActive_ && + !keyEvent.key().check(engine_->modeMenuKey())) { + engine_->native_.start(); + const auto status = engine_->native_.status(); + if (status != nativeStatus_) { + nativeStatus_ = status; + SKEY_INFO() << "Native: " << status; + } + if (!engine_->native_.ready() || nativeFailed_) { + if (uinputDeleting_ || uinputSettling_) resetForCellChange(); + viet_.reset(); committedLen_ = 0; clearLastWord(); + return; + } + } + if (handlePendingUinputBackspace(keyEvent)) { return; } @@ -4393,6 +4495,8 @@ void SKeyState::keyEvent(KeyEvent &keyEvent) { choice = 4; else if (sym == FcitxKey_5 || sym == FcitxKey_KP_5) choice = 5; + else if (sym == FcitxKey_6 || sym == FcitxKey_KP_6) + choice = 6; // Arrow keys move the highlight, Enter selects the highlighted item. // prevCandidate/nextCandidate wrap around at the edges. @@ -4423,67 +4527,9 @@ void SKeyState::keyEvent(KeyEvent &keyEvent) { } } - if (modeMenuForAddressBar_) { - SKeyChromiumAddressBarMode newMode; - switch (choice) { - case 1: - newMode = SKeyChromiumAddressBarMode::Auto; - break; - case 2: - newMode = SKeyChromiumAddressBarMode::Uinput; - break; - case 3: - newMode = SKeyChromiumAddressBarMode::SurroundingText; - break; - case 4: - newMode = SKeyChromiumAddressBarMode::Preedit; - break; - case 5: - newMode = SKeyChromiumAddressBarMode::NoVietnamese; - break; - default: - return; // unrecognized — dismiss below - } - engine_->setChromiumAddressBarMode(newMode); - SKEY_INFO() << "Address bar mode switched"; - dismissModeMenu(); - keyEvent.filterAndAccept(); - return; - } else if (choice > 0 && choice <= 4) { - SKeyOutputMode newMode; - switch (choice) { - case 1: - newMode = SKeyOutputMode::Auto; - break; - case 2: - newMode = SKeyOutputMode::Uinput; - break; - case 3: - newMode = SKeyOutputMode::SurroundingText; - break; - case 4: - newMode = SKeyOutputMode::Preedit; - break; - default: - return; // unreachable after the choice range check - } - appExcluded_ = false; - engine_->saveAppExcluded(appProgram(), false); - appModeOverride_ = newMode; - hasAppModeOverride_ = true; - modeCacheValid_ = false; - engine_->saveAppMode(appProgram(), newMode); - SKEY_INFO() << "Mode switched to " << outputModeName(newMode); - dismissModeMenu(); - keyEvent.filterAndAccept(); - return; - } else if (choice == 5) { - bool newExcluded = !appExcluded_; - appExcluded_ = newExcluded; - engine_->saveAppExcluded(appProgram(), newExcluded); - SKEY_INFO() << "App '" << appProgram() - << (newExcluded ? "' excluded" : "' included"); - dismissModeMenu(); + if (auto candList = ic_->inputPanel().candidateList(); + candList && choice > 0 && choice <= candList->size()) { + candList->candidate(choice - 1).select(ic_); keyEvent.filterAndAccept(); return; } @@ -5730,7 +5776,6 @@ void SKeyState::keyEvent(KeyEvent &keyEvent) { uinputPendingFinalLen_ = static_cast(utf8::length(newComposed)); uinputDeleting_ = true; - // Safety: force-commit if BS events are lost armUinputSafetyTimer(); } else if (!addPart.empty()) { SKEY_DEBUG() << "Uinput: consume '" << keyUtf8 << "' commit '" @@ -6636,7 +6681,7 @@ void SKeyState::surroundingCommit(const std::string &oldComposed, // into the terminal, so only the sync anchor proves the deletions // were dispatched before the commit is sent (kernel 7.0 PTY // interleaving — see the keyEvent terminal path). - bool usesUinputBs = + bool usesUinputBs = useNativeMode() || (useUinputMode() && (isWayland() || !isTerminalAppCached() || isChromiumCached())) || (!isWayland() && isTerminalAppCached()) || @@ -7108,9 +7153,10 @@ void SKeyState::showModeMenu() { // Build candidate list for dropdown menu auto candList = std::make_unique(); - candList->setPageSize(5); + candList->setPageSize(6); candList->setLayoutHint(CandidateLayoutHint::Vertical); + const bool replaceUinput = engine_->config().replaceUinputWithNative.value(); int cursorIdx = 0; if (modeMenuForAddressBar_) { auto addressBarMode = engine_->config().chromiumAddressBarMode.value(); @@ -7120,16 +7166,26 @@ void SKeyState::showModeMenu() { candList->append(std::make_unique( engine_, this, autoLabel, SKeyChromiumAddressBarMode::Auto)); candList->append(std::make_unique( - engine_, this, "2. Uinput", SKeyChromiumAddressBarMode::Uinput)); + engine_, this, replaceUinput ? "2. Native" : "2. Uinput", + replaceUinput ? SKeyChromiumAddressBarMode::Native + : SKeyChromiumAddressBarMode::Uinput)); candList->append(std::make_unique( engine_, this, "3. Surrounding Text", SKeyChromiumAddressBarMode::SurroundingText)); candList->append(std::make_unique( engine_, this, "4. Preedit", SKeyChromiumAddressBarMode::Preedit)); + if (!replaceUinput) { + candList->append(std::make_unique( + engine_, this, "5. Native", SKeyChromiumAddressBarMode::Native)); + } candList->append(std::make_unique( - engine_, this, "5. Không gõ tiếng Việt", + engine_, this, std::string(replaceUinput ? "5. " : "6. ") + _("Không gõ tiếng Việt"), SKeyChromiumAddressBarMode::NoVietnamese)); cursorIdx = static_cast(addressBarMode); + if (replaceUinput) { + if (addressBarMode == SKeyChromiumAddressBarMode::Native) cursorIdx = 1; + if (addressBarMode == SKeyChromiumAddressBarMode::NoVietnamese) cursorIdx = 4; + } } else { auto configured = hasAppModeOverride_ ? appModeOverride_ @@ -7140,23 +7196,29 @@ void SKeyState::showModeMenu() { candList->append(std::make_unique( engine_, this, autoLabel, SKeyOutputMode::Auto)); candList->append(std::make_unique( - engine_, this, "2. Uinput", SKeyOutputMode::Uinput)); + engine_, this, replaceUinput ? "2. Native" : "2. Uinput", + replaceUinput ? SKeyOutputMode::Native : SKeyOutputMode::Uinput)); candList->append(std::make_unique( engine_, this, "3. Surrounding Text", SKeyOutputMode::SurroundingText)); candList->append(std::make_unique( engine_, this, "4. Preedit", SKeyOutputMode::Preedit)); + if (!replaceUinput) + candList->append(std::make_unique( + engine_, this, std::string("5. ") + _("Native (XTest/Libei)"), SKeyOutputMode::Native)); std::string excludeLabel = - appExcluded_ ? "5. ✓ Loại trừ ứng dụng" : "5. Loại trừ ứng dụng"; + std::string(replaceUinput ? "5. " : "6. ") + + (appExcluded_ ? "✓ " : "") + _("Loại trừ ứng dụng"); candList->append( std::make_unique(engine_, this, excludeLabel)); // When configured to Auto, cursor stays on Auto (0). // Otherwise cursor follows the manually selected mode. - cursorIdx = appExcluded_ ? 4 + cursorIdx = appExcluded_ ? (replaceUinput ? 4 : 5) : (configured == SKeyOutputMode::Auto) ? 0 : (configured == SKeyOutputMode::Uinput) ? 1 : (configured == SKeyOutputMode::SurroundingText) ? 2 + : (configured == SKeyOutputMode::Native) ? (replaceUinput ? 1 : 4) : (configured == SKeyOutputMode::Preedit) ? 3 : 0; } diff --git a/src/engine.h b/src/engine.h index ce2f58c..7bfaafb 100644 --- a/src/engine.h +++ b/src/engine.h @@ -25,6 +25,7 @@ #include "app_delay_key.h" #include "surrounding_cursor.h" #include "uinput_delete_ack.h" +#include "native_injector.h" #include #include @@ -101,6 +102,9 @@ class SKeyState : public InputContextProperty { bool isWayland() const; const struct UinputTiming& uinputTiming() const; bool useUinputMode() const; + bool useNativeMode() const; + bool nativeFailed_ = false; + std::string nativeStatus_; bool isChromiumCached() const; /// The a11y-reported PID of the current app, when trustworthy: the /// focus snapshot is fresh and the pid's comm matches appProgram(). @@ -563,6 +567,8 @@ class SKeyEngine : public InputMethodEngineV2 { SimpleAction omAction_; Menu omMenu_; SimpleAction omSurrounding_; + SimpleAction omNative_; + skey::NativeInjector native_; SimpleAction omPreedit_; SimpleAction omUinput_; SimpleAction omAuto_; diff --git a/src/native_injector.cpp b/src/native_injector.cpp new file mode 100644 index 0000000..f397b52 --- /dev/null +++ b/src/native_injector.cpp @@ -0,0 +1,307 @@ +#include "native_injector.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef SKEY_HAVE_LIBEI +#include +#include +#include +#include +#include +#include +#endif + +namespace skey { +struct NativeInjector::Impl { + std::atomic ready{false}, stop{false}; + std::atomic generation{0}; + mutable std::mutex mutex; + std::string message = "not started"; + std::thread worker; + Display *x11Display = nullptr; + std::mutex x11Mutex; + void status(const std::string &s) { std::lock_guard lock(mutex); message = s; } +#ifdef SKEY_HAVE_LIBEI + int wakeFd = -1; + struct Batch { int count = 0; bool escape = false; unsigned pace = 1000, generation = 0; gint64 deadline = 0; } batch; + GMainContext *context = nullptr; + GCancellable *cancellable = nullptr; + XdpPortal *portal = nullptr; + XdpSession *session = nullptr; + std::string restoreToken; + ei *connection = nullptr; + ei_device *keyboard = nullptr; + GSource *inputSource = nullptr, *wakeSource = nullptr; + bool pending = false; + uint32_t sequence = 0; + static std::filesystem::path tokenPath() { + const char *xdg = std::getenv("XDG_CONFIG_HOME"); + const char *home = std::getenv("HOME"); + std::filesystem::path base = xdg && *xdg ? xdg : + (home && *home ? std::filesystem::path(home) / ".config" : "."); + return base / "fcitx5" / "skey-libei-restore-token"; + } + void loadToken() { + std::ifstream in(tokenPath()); + std::getline(in, restoreToken); + } + void saveToken() { + char *token = xdp_session_get_restore_token(session); + if (!token || !*token) { g_free(token); return; } + const auto path = tokenPath(); + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream out(path, std::ios::trunc); + if (out) { + out << token << '\n'; + out.close(); + chmod(path.c_str(), 0600); + } + g_free(token); + } + void wake() { uint64_t one = 1; if (wakeFd >= 0) (void)!write(wakeFd, &one, sizeof(one)); } + void fail(const std::string &s) { ready = false; ++generation; status(s); } + static void started(GObject *object, GAsyncResult *result, gpointer data) { + auto &self = *static_cast(data); + self.pending = false; + GError *error = nullptr; + if (!xdp_session_start_finish(XDP_SESSION(object), result, &error)) { + self.fail(error ? error->message : "portal start failed"); + g_clear_error(&error); return; + } + self.saveToken(); + if (self.stop) return; + if (!(xdp_session_get_devices(self.session) & XDP_DEVICE_KEYBOARD)) { + self.fail("portal did not grant keyboard access"); return; + } + const int fd = xdp_session_connect_to_eis(self.session, &error); + if (fd < 0) { + self.fail(error ? error->message : "portal has no ConnectToEIS"); + g_clear_error(&error); return; + } + self.connection = ei_new_sender(&self); + if (!self.connection) { close(fd); self.fail("ei_new_sender failed"); return; } + ei_configure_name(self.connection, "SKey Libei experimental"); + if (ei_setup_backend_fd(self.connection, fd) < 0) { + self.fail("EI handshake setup failed"); return; + } + self.inputSource = g_unix_fd_source_new(ei_get_fd(self.connection), + static_cast(G_IO_IN | G_IO_ERR | G_IO_HUP)); + g_source_set_callback(self.inputSource, G_SOURCE_FUNC(input), &self, nullptr); + g_source_attach(self.inputSource, self.context); + self.status("waiting for EIS keyboard"); + input(0, G_IO_IN, &self); + } + static void created(GObject *object, GAsyncResult *result, gpointer data) { + auto &self = *static_cast(data); + self.pending = false; + GError *error = nullptr; + self.session = xdp_portal_create_remote_desktop_session_finish(XDP_PORTAL(object), result, &error); + if (!self.session) { + self.fail(error ? error->message : "portal create failed"); + g_clear_error(&error); return; + } + g_signal_connect(self.session, "closed", G_CALLBACK(closed), &self); + if (self.stop) return; + self.pending = true; + self.status("waiting for keyboard permission"); + xdp_session_start(self.session, nullptr, self.cancellable, started, &self); + } + static void closed(XdpSession *, gpointer data) { + static_cast(data)->fail("portal session closed; restart Fcitx to retry"); + } + static gboolean input(gint, GIOCondition condition, gpointer data) { + auto &self = *static_cast(data); + ei_dispatch(self.connection); + while (auto *event = ei_get_event(self.connection)) { + const auto type = ei_event_get_type(event); + auto *device = ei_event_get_device(event); + if (type == EI_EVENT_SEAT_ADDED) { + ei_seat_bind_capabilities(ei_event_get_seat(event), EI_DEVICE_CAP_KEYBOARD, nullptr); + } else if (type == EI_EVENT_DEVICE_RESUMED && !self.keyboard && + ei_device_has_capability(device, EI_DEVICE_CAP_KEYBOARD)) { + self.keyboard = ei_device_ref(device); + ei_device_start_emulating(device, ++self.sequence); + self.ready = true; + self.status("ready"); + } else if ((type == EI_EVENT_DEVICE_PAUSED || type == EI_EVENT_DEVICE_REMOVED) && + device == self.keyboard) { + self.fail("EIS keyboard paused/removed"); + self.keyboard = ei_device_unref(self.keyboard); + } else if (type == EI_EVENT_DISCONNECT) { + self.fail("EIS disconnected; restart Fcitx to retry"); + } + ei_event_unref(event); + } + if (condition & (G_IO_ERR | G_IO_HUP)) { + self.fail("EIS connection closed"); return G_SOURCE_REMOVE; + } + return G_SOURCE_CONTINUE; + } + static gboolean commands(gint, GIOCondition, gpointer data) { + auto &self = *static_cast(data); + uint64_t value; (void)!read(self.wakeFd, &value, sizeof(value)); + if (self.stop) { g_cancellable_cancel(self.cancellable); return G_SOURCE_CONTINUE; } + Batch b; + { std::lock_guard lock(self.mutex); b = self.batch; self.batch = {}; } + if (!b.count && !b.escape) return G_SOURCE_CONTINUE; + if (!self.ready || b.generation != self.generation || g_get_monotonic_time() > b.deadline) + return G_SOURCE_CONTINUE; + auto tap = [&](uint32_t key) { + ei_device_keyboard_key(self.keyboard, key, true); + ei_device_frame(self.keyboard, ei_now(self.connection)); + ei_device_keyboard_key(self.keyboard, key, false); + ei_device_frame(self.keyboard, ei_now(self.connection)); + ei_dispatch(self.connection); + }; + if (b.escape) { tap(KEY_ESC); g_usleep(b.pace); } + for (int i = 0; i < b.count; ++i) { + if (self.stop || b.generation != self.generation || !self.ready) break; + tap(KEY_BACKSPACE); + if (i + 1 < b.count) g_usleep(b.pace); + } + return G_SOURCE_CONTINUE; + } + void run() { + context = g_main_context_new(); + g_main_context_push_thread_default(context); + cancellable = g_cancellable_new(); + wakeSource = g_unix_fd_source_new(wakeFd, G_IO_IN); + g_source_set_callback(wakeSource, G_SOURCE_FUNC(commands), this, nullptr); + g_source_attach(wakeSource, context); + portal = xdp_portal_new(); + if (!portal) { + fail("cannot create portal proxy"); + } else { + pending = true; + // Keyboard only: no screencast, pointer or clipboard access. + // Ask the portal to persist its permission and restore it on the + // next Fcitx restart when the compositor supports restore tokens. +#ifdef SKEY_LIBPORTAL_PERSIST + loadToken(); + xdp_portal_create_remote_desktop_session_full(portal, XDP_DEVICE_KEYBOARD, + static_cast(0), XDP_REMOTE_DESKTOP_FLAG_NONE, + XDP_CURSOR_MODE_HIDDEN, XDP_PERSIST_MODE_PERSISTENT, + restoreToken.empty() ? nullptr : restoreToken.c_str(), + cancellable, created, this); +#else + // libportal 0.7.1 supports EIS but not RemoteDesktop persistence. + status("requesting keyboard permission (libportal < 0.8 cannot restore it)"); + xdp_portal_create_remote_desktop_session(portal, XDP_DEVICE_KEYBOARD, + static_cast(0), XDP_REMOTE_DESKTOP_FLAG_NONE, + XDP_CURSOR_MODE_HIDDEN, cancellable, created, this); +#endif + while (!stop || pending) g_main_context_iteration(context, TRUE); + } + ready = false; + if (inputSource) { g_source_destroy(inputSource); g_source_unref(inputSource); } + if (wakeSource) { g_source_destroy(wakeSource); g_source_unref(wakeSource); } + if (keyboard) keyboard = ei_device_unref(keyboard); + if (connection) connection = ei_unref(connection); + if (session) { xdp_session_close(session); g_object_unref(session); } + if (portal) g_object_unref(portal); + g_object_unref(cancellable); + g_main_context_pop_thread_default(context); + g_main_context_unref(context); + } +#endif +}; +NativeInjector::NativeInjector() : impl_(std::make_unique()) {} +NativeInjector::~NativeInjector() { + impl_->stop = true; +#ifdef SKEY_HAVE_LIBEI + impl_->wake(); +#endif + if (impl_->worker.joinable()) impl_->worker.join(); + if (impl_->x11Display) { + XCloseDisplay(impl_->x11Display); + impl_->x11Display = nullptr; + } +#ifdef SKEY_HAVE_LIBEI + if (impl_->wakeFd >= 0) close(impl_->wakeFd); +#endif +} +void NativeInjector::start() { + if (impl_->x11Display) return; + const char *session = std::getenv("XDG_SESSION_TYPE"); + const char *display = std::getenv("DISPLAY"); + const char *waylandDisplay = std::getenv("WAYLAND_DISPLAY"); + const bool x11 = (session && std::string(session) == "x11") || + ((!waylandDisplay || !*waylandDisplay) && display && *display); + if (x11) { + XInitThreads(); + impl_->x11Display = XOpenDisplay(nullptr); + if (!impl_->x11Display) { + impl_->status("cannot open X11 display"); + return; + } + int eventBase = 0, errorBase = 0, major = 0, minor = 0; + if (!XTestQueryExtension(impl_->x11Display, &eventBase, &errorBase, + &major, &minor)) { + XCloseDisplay(impl_->x11Display); + impl_->x11Display = nullptr; + impl_->status("XTest extension unavailable"); + return; + } + impl_->ready = true; + impl_->status("ready (XTest)"); + return; + } +#ifdef SKEY_HAVE_LIBEI + if (impl_->worker.joinable()) return; + impl_->wakeFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + if (impl_->wakeFd < 0) { impl_->status("eventfd failed"); return; } + impl_->status("connecting to portal"); + impl_->worker = std::thread([this] { impl_->run(); }); +#else + impl_->status("built without libei/libportal development libraries"); +#endif +} +bool NativeInjector::ready() const { return impl_->ready; } +std::string NativeInjector::status() const { + std::lock_guard lock(impl_->mutex); return impl_->message; +} +void NativeInjector::cancel() { ++impl_->generation; } +bool NativeInjector::backspaces(int count, bool escape, unsigned pace) { + if (impl_->x11Display) { + std::lock_guard lock(impl_->x11Mutex); + KeyCode backspace = XKeysymToKeycode(impl_->x11Display, XK_BackSpace); + KeyCode escapeKey = XKeysymToKeycode(impl_->x11Display, XK_Escape); + if (!backspace || (escape && !escapeKey) || count < 0 || count > 64) + return false; + if (escape) { + XTestFakeKeyEvent(impl_->x11Display, escapeKey, True, 0); + XTestFakeKeyEvent(impl_->x11Display, escapeKey, False, 0); + XFlush(impl_->x11Display); + usleep(std::min(pace, 100000u)); + } + for (int i = 0; i < count; ++i) { + XTestFakeKeyEvent(impl_->x11Display, backspace, True, 0); + XTestFakeKeyEvent(impl_->x11Display, backspace, False, 0); + XFlush(impl_->x11Display); + if (i + 1 < count) usleep(std::min(pace, 100000u)); + } + return true; + } +#ifdef SKEY_HAVE_LIBEI + if (!ready() || count < 0 || count > 64) return false; + { + std::lock_guard lock(impl_->mutex); + if (impl_->batch.count || impl_->batch.escape) return false; + impl_->batch = {count, escape, std::min(pace, 100000u), + impl_->generation.load(), g_get_monotonic_time() + 100000}; + } + impl_->wake(); return true; +#else + (void)count; (void)escape; (void)pace; return false; +#endif +} +} diff --git a/src/native_injector.h b/src/native_injector.h new file mode 100644 index 0000000..ab31621 --- /dev/null +++ b/src/native_injector.h @@ -0,0 +1,22 @@ +#ifndef SKEY_NATIVE_INJECTOR_H +#define SKEY_NATIVE_INJECTOR_H +#include +#include +namespace skey { +// XTest on X11; a keyboard-only Libei portal session on Wayland. +// Portal/EI work runs on a worker. Neither transport falls back to Uinput. +class NativeInjector { +public: + NativeInjector(); + ~NativeInjector(); + void start(); + bool ready() const; + std::string status() const; + bool backspaces(int count, bool escape, unsigned paceUsec); + void cancel(); +private: + struct Impl; + std::unique_ptr impl_; +}; +} +#endif diff --git a/src/settings/app_modes_tab.cpp b/src/settings/app_modes_tab.cpp index 57ca1fd..3df00af 100644 --- a/src/settings/app_modes_tab.cpp +++ b/src/settings/app_modes_tab.cpp @@ -2,6 +2,7 @@ #include "app_delay_dialog.h" #include "config_io.h" #include "tr.h" +#include "mode_choices.h" #include "../app_delay_key.h" @@ -28,7 +29,7 @@ #include // ── Mode values as displayed in the per-app config ───────────────────── -static const char *kAppModeValues[] = {"Auto", "Uinput", "Surrounding Text", "Preedit", "Excluded", nullptr}; +static const char *kAppModeValues[] = {"Auto", "Uinput", "Surrounding Text", "Preedit", "Native", "Excluded", nullptr}; AppModesTab::AppModesTab(QWidget *parent) : QWidget(parent) { setupUI(); @@ -94,6 +95,7 @@ void AppModesTab::setupUI() { addrBarModeCombo_->addItem("Uinput", "Uinput"); addrBarModeCombo_->addItem("Surrounding Text", "Surrounding Text"); addrBarModeCombo_->addItem("Preedit", "Preedit"); + addrBarModeCombo_->addItem("Native", "Native"); addrBarModeCombo_->addItem(T("Không gõ tiếng Việt"), "No Vietnamese"); addrBarRow->addWidget(addrBarModeCombo_); @@ -326,7 +328,10 @@ void AppModesTab::addRow(const std::string &name, const std::string &mode, for (int i = 0; kAppModeValues[i]; ++i) { combo->addItem(kAppModeValues[i], kAppModeValues[i]); } - int idx = combo->findData(QString::fromStdString(mode)); + syncTransportChoices(combo, replaceUinput_); + const std::string canonical = mode == "Libei" ? "Native" : + (replaceUinput_ && mode == "Uinput" ? "Native" : mode); + int idx = combo->findData(QString::fromStdString(canonical)); if (idx >= 0) combo->setCurrentIndex(idx); table_->setCellWidget(row, 1, combo); @@ -520,6 +525,7 @@ void AppModesTab::onAddApp() { for (int i = 0; kAppModeValues[i]; ++i) { modeCombo->addItem(kAppModeValues[i], kAppModeValues[i]); } + syncTransportChoices(modeCombo, replaceUinput_); modeCombo->setCurrentIndex(0); // default Uinput layout->addRow(T("Chế độ xuất:"), modeCombo); @@ -547,7 +553,9 @@ std::string AppModesTab::chromiumAddressBarMode() const { } void AppModesTab::setChromiumAddressBarMode(const std::string &mode) { - int idx = addrBarModeCombo_->findData(QString::fromStdString(mode)); + const std::string canonical = mode == "Libei" ? "Native" : + (replaceUinput_ && mode == "Uinput" ? "Native" : mode); + int idx = addrBarModeCombo_->findData(QString::fromStdString(canonical)); if (idx >= 0) addrBarModeCombo_->setCurrentIndex(idx); } @@ -564,3 +572,11 @@ void AppModesTab::onDeleteApp() { } } } + +void AppModesTab::setNativeReplacement(bool enabled) { + replaceUinput_ = enabled; + syncTransportChoices(addrBarModeCombo_, enabled); + for (int row = 0; row < table_->rowCount(); ++row) + if (auto *combo = qobject_cast(table_->cellWidget(row, 1))) + syncTransportChoices(combo, enabled); +} diff --git a/src/settings/app_modes_tab.h b/src/settings/app_modes_tab.h index 718b505..06b795a 100644 --- a/src/settings/app_modes_tab.h +++ b/src/settings/app_modes_tab.h @@ -31,6 +31,8 @@ class AppModesTab : public QWidget { std::string chromiumAddressBarMode() const; void setChromiumAddressBarMode(const std::string &mode); + void setNativeReplacement(bool enabled); + private slots: void onAddApp(); void onDeleteApp(); @@ -38,6 +40,7 @@ private slots: void onFilterChanged(const QString &text); private: + bool replaceUinput_ = false; void setupUI(); void addRow(const std::string &name, const std::string &mode, const std::string &delayX11 = {}, diff --git a/src/settings/config_io.cpp b/src/settings/config_io.cpp index 941b1d9..69ce447 100644 --- a/src/settings/config_io.cpp +++ b/src/settings/config_io.cpp @@ -89,7 +89,8 @@ SKeyConfig readSkeyConfig() { stripQuotes(val); if (key == "InputMethod") cfg.inputMethod = val; - else if (key == "OutputMode") cfg.outputMode = val; + else if (key == "OutputMode") cfg.outputMode = val == "Libei" ? "Native" : val; + else if (key == "PreferLibeiAuto") cfg.replaceUinputWithNative = parseBool(val); else if (key == "Charset") cfg.charset = val; else if (key == "ShortW") cfg.shortW = parseBool(val); else if (key == "BracketUO") cfg.bracketUO = parseBool(val); @@ -98,7 +99,7 @@ SKeyConfig readSkeyConfig() { else if (key == "Dict") cfg.dict = parseBool(val); else if (key == "ShowPreedit") cfg.showPreedit = parseBool(val); else if (key == "AutoEnableA11y") cfg.autoEnableA11y = parseBool(val); - else if (key == "ChromiumAddressBarMode") cfg.chromiumAddressBarMode = val; + else if (key == "ChromiumAddressBarMode") cfg.chromiumAddressBarMode = val == "Libei" ? "Native" : val; else if (key == "Debug") cfg.debug = parseBool(val); else if (key == "AutoDelay") cfg.autoDelay = parseBool(val); else if (key == "EnableMacro") cfg.enableMacro = parseBool(val); @@ -134,6 +135,7 @@ bool writeSkeyConfig(const SKeyConfig &cfg) { out << "InputMethod=" << maybeQuote(cfg.inputMethod) << "\n"; out << "# Output Mode" << "\n"; out << "OutputMode=" << maybeQuote(cfg.outputMode) << "\n"; + out << "PreferLibeiAuto=" << boolStr(cfg.replaceUinputWithNative) << "\n"; out << "# Character set (Unicode / TCVN3 (ABC) / VNI Windows / Windows CP1258 / VIQR)" << "\n"; out << "Charset=" << maybeQuote(cfg.charset) << "\n"; out << "# Telex: type w as ư" << "\n"; diff --git a/src/settings/config_io.h b/src/settings/config_io.h index 18af99b..230e530 100644 --- a/src/settings/config_io.h +++ b/src/settings/config_io.h @@ -26,6 +26,7 @@ enum class UpdateChannel { Stable = 0, Dev = 1 }; struct SKeyConfig { std::string inputMethod = "Telex"; // "Telex", "VNI" std::string outputMode = "Auto"; // "Auto", "Uinput", "Surrounding Text", "Preedit" + bool replaceUinputWithNative = false; // Replace Uinput in Auto, explicit modes and menus std::string charset = "Unicode"; // "Unicode", "TCVN3 (ABC)", "VNI Windows" bool shortW = false; // Telex: bare 'w' → 'ư' bool bracketUO = false; // Telex: '[' → 'ơ', ']' → 'ư' @@ -34,7 +35,7 @@ struct SKeyConfig { bool dict = false; // auto-restore checks the word list, not rules bool showPreedit = true; bool autoEnableA11y = true; // auto-set toolkit-accessibility at start - std::string chromiumAddressBarMode = "Auto"; // "Auto", "Uinput", "Surrounding Text", "Preedit", "No Vietnamese" + std::string chromiumAddressBarMode = "Auto"; // "Auto", "Uinput", "Surrounding Text", "Preedit", "Native", "No Vietnamese" bool debug = false; bool autoDelay = false; // learn per-app uinput round trip, seed commit delay bool enableMacro = true; diff --git a/src/settings/general_tab.cpp b/src/settings/general_tab.cpp index 4b6469f..9b64d0f 100644 --- a/src/settings/general_tab.cpp +++ b/src/settings/general_tab.cpp @@ -2,6 +2,7 @@ #include "config_io.h" #include "hotkey_edit.h" #include "tr.h" +#include "mode_choices.h" #include #include @@ -39,6 +40,7 @@ void GeneralTab::setupUI() { outputModeCombo_->addItem("Auto", "Auto"); outputModeCombo_->addItem("Uinput", "Uinput"); outputModeCombo_->addItem("Surrounding Text", "Surrounding Text"); + outputModeCombo_->addItem(T("Native (XTest/Libei)"), "Native"); outputModeCombo_->addItem("Preedit", "Preedit"); enumLayout->addRow(T("Chế độ xuất:"), outputModeCombo_); @@ -119,7 +121,6 @@ void GeneralTab::setupUI() { checkLayout->addWidget(showPreeditCheck_, 2, 0); debugCheck_ = new QCheckBox(T("Ghi log debug"), checkFrame); - checkLayout->addWidget(debugCheck_, 2, 1); autoDelayCheck_ = new QCheckBox(T("Tự động tối ưu delay theo app"), checkFrame); @@ -129,7 +130,16 @@ void GeneralTab::setupUI() { "từ lần chuyển cửa sổ kế tiếp.\n" "Dữ liệu lưu ở ~/.config/fcitx5/conf/skey-app-delays.conf — " "xoá tệp đó để quên dữ liệu đã học.")); - checkLayout->addWidget(autoDelayCheck_, 3, 1); + checkLayout->addWidget(autoDelayCheck_, 2, 1); + + replaceUinputWithNativeCheck_ = + new QCheckBox(T("Thay thế Uinput bằng Native"), checkFrame); + replaceUinputWithNativeCheck_->setToolTip(T( + "Khi bật, Native thay thế Uinput trong Auto và trong menu chọn mode.")); + checkLayout->addWidget(replaceUinputWithNativeCheck_, 3, 1); + checkLayout->addWidget(debugCheck_, 4, 1); + connect(replaceUinputWithNativeCheck_, &QCheckBox::toggled, this, + [this](bool) { syncNativeReplacementChoice(); }); // A11y checkbox + info icon: SKey needs the browsers' a11y trees; the // checkbox toggles whether SKey auto-enables the system-wide @@ -189,7 +199,9 @@ void GeneralTab::loadFromConfig(const SKeyConfig &cfg) { }; setCombo(inputMethodCombo_, cfg.inputMethod); - setCombo(outputModeCombo_, cfg.outputMode); + replaceUinputWithNativeCheck_->setChecked(cfg.replaceUinputWithNative); + syncNativeReplacementChoice(); + setCombo(outputModeCombo_, cfg.replaceUinputWithNative && cfg.outputMode == "Uinput" ? "Native" : cfg.outputMode); setCombo(charsetCombo_, cfg.charset); setCombo(languageCombo_, cfg.uiLanguage); @@ -205,6 +217,11 @@ void GeneralTab::loadFromConfig(const SKeyConfig &cfg) { a11yCheck_->setChecked(cfg.autoEnableA11y); } +void GeneralTab::syncNativeReplacementChoice() { + syncTransportChoices(outputModeCombo_, replaceUinputWithNativeCheck_->isChecked()); + emit nativeReplacementChanged(replaceUinputWithNativeCheck_->isChecked()); +} + SKeyConfig GeneralTab::collectConfig() const { SKeyConfig cfg; cfg.inputMethod = inputMethodCombo_->currentData().toString().toStdString(); @@ -220,6 +237,7 @@ SKeyConfig GeneralTab::collectConfig() const { cfg.showPreedit = showPreeditCheck_->isChecked(); cfg.debug = debugCheck_->isChecked(); cfg.autoDelay = autoDelayCheck_->isChecked(); + cfg.replaceUinputWithNative = replaceUinputWithNativeCheck_->isChecked(); cfg.autoEnableA11y = a11yCheck_->isChecked(); cfg.modeMenuKey = modeMenuKeyEdit_->fcitx5Value(); return cfg; @@ -246,4 +264,3 @@ std::string GeneralTab::modeMenuKey() const { void GeneralTab::setModeMenuKey(const std::string &fcitx5Key) { modeMenuKeyEdit_->setFcitx5Value(fcitx5Key); } - diff --git a/src/settings/general_tab.h b/src/settings/general_tab.h index c9f2d8e..83c1b77 100644 --- a/src/settings/general_tab.h +++ b/src/settings/general_tab.h @@ -25,8 +25,12 @@ class GeneralTab : public QWidget { std::string modeMenuKey() const; void setModeMenuKey(const std::string &fcitx5Key); +signals: + void nativeReplacementChanged(bool enabled); + private: void setupUI(); + void syncNativeReplacementChoice(); QComboBox *inputMethodCombo_; QComboBox *outputModeCombo_; @@ -42,6 +46,7 @@ class GeneralTab : public QWidget { QCheckBox *showPreeditCheck_; QCheckBox *debugCheck_; QCheckBox *autoDelayCheck_; + QCheckBox *replaceUinputWithNativeCheck_; QCheckBox *a11yCheck_; }; diff --git a/src/settings/info_tab.cpp b/src/settings/info_tab.cpp index 34ab3f1..edbb060 100644 --- a/src/settings/info_tab.cpp +++ b/src/settings/info_tab.cpp @@ -459,12 +459,12 @@ void InfoTab::onInstallFinished(bool success, const QString &message) { progressBar_->hide(); if (success) { - if (updater_->distro() == Distro::NixOS) { - // No package postinst on NixOS: restart fcitx5 + the user's uinput - // server (polkit rule in the NixOS module permits it without a - // password) so the newly-built skey.so takes effect. - restartFcitx5(); - } + // A package postinst may not be able to find the user's graphical + // session (especially when installation was launched through pkexec), + // so it cannot reliably restart the user's Fcitx5. Restart explicitly + // from this GUI after installation. This also starts the new Libei + // portal session and lets its permission prompt appear immediately. + restartFcitx5(); statusLabel_->setText(T("✓ %1").arg(message)); statusLabel_->setStyleSheet("font-size: 12px; color: green;"); versionLabel_->setText( diff --git a/src/settings/mode_choices.h b/src/settings/mode_choices.h new file mode 100644 index 0000000..67e975d --- /dev/null +++ b/src/settings/mode_choices.h @@ -0,0 +1,22 @@ +#ifndef SKEY_MODE_CHOICES_H +#define SKEY_MODE_CHOICES_H +#include +#include +#include "tr.h" + +// Keep serialized values stable while swapping the transport shown at slot 2. +inline void syncTransportChoices(QComboBox *combo, bool replace) { + QString selected = combo->currentData().toString(); + const QSignalBlocker blocker(combo); + for (const auto &mode : {QStringLiteral("Uinput"), QStringLiteral("Native")}) { + int index = combo->findData(mode); + if (index >= 0) combo->removeItem(index); + } + combo->insertItem(1, replace ? T("Native") : T("Uinput"), + replace ? "Native" : "Uinput"); + if (!replace) combo->insertItem(4, T("Native (XTest/Libei)"), "Native"); + if (replace && selected == QLatin1String("Uinput")) selected = "Native"; + const int index = combo->findData(selected); + if (index >= 0) combo->setCurrentIndex(index); +} +#endif diff --git a/src/settings/settings_window.cpp b/src/settings/settings_window.cpp index 2de79b7..330330a 100644 --- a/src/settings/settings_window.cpp +++ b/src/settings/settings_window.cpp @@ -48,6 +48,8 @@ void SkeySettingsWindow::setupUI() { tabWidget_->tabBar()->setElideMode(Qt::ElideRight); generalTab_ = new GeneralTab(this); appModesTab_ = new AppModesTab(this); + connect(generalTab_, &GeneralTab::nativeReplacementChanged, + appModesTab_, &AppModesTab::setNativeReplacement); macroTab_ = new MacroTab(this); dictTab_ = new DictTab(this); appearanceTab_ = new AppearanceTab(this); diff --git a/src/test_engine_performance.cpp b/src/test_engine_performance.cpp index 5f69f09..e6f7d88 100644 --- a/src/test_engine_performance.cpp +++ b/src/test_engine_performance.cpp @@ -84,6 +84,133 @@ class TestInput : public fcitx::InputContext { namespace fcitx { struct EnginePerformanceTest { + static void nativeAddressBarChurn(Instance &instance, SKeyOutputMode mode, + bool settling) { + SKeyEngine engine(&instance, false); + FocusGroup group("x11:test", instance.inputContextManager()); + TestInput input(instance.inputContextManager(), "google-chrome-stable"); + input.setFocusGroup(&group); + input.setCapabilityFlags(CapabilityFlag::Preedit); + input.setCursorRect(Rect(239, 54, 240, 72)); + auto &state = *input.propertyFor(&engine.factory_); + state.cachedProgram_ = "google-chrome-stable"; + state.cachedIsChromium_ = 1; + state.cachedIsFirefoxOrSnap_ = state.cachedIsTerminalApp_ = 0; + state.cachedMode_ = mode; + state.modeCacheValid_ = true; + state.viet_.setRawInput("chaf"); + state.committedLen_ = 3; + state.armAddrBarCycle(); + state.uinputDeleting_ = true; + state.uinputSettling_ = settling; + state.pendingUinputCommit_ = "chà"; + state.expectedUinputBackspaces_ = settling ? 0 : 4; + state.seenUinputBackspaces_ = settling ? 0 : 2; + state.uinputBsOutstanding_ = settling ? 0 : 3; + state.deactivate(); + check(state.pendingUinputCommit_ == "chà" && state.uinputDeleting_, + "omnibox focus churn retains pending replacement for both transports"); + check(state.uinputSettling_ == settling && + state.expectedUinputBackspaces_ == (settling ? 0 : 4) && + state.seenUinputBackspaces_ == (settling ? 0 : 2), + "omnibox churn retains deletion counters and settle state"); + check(state.viet_.getRawInput() == "chaf", + "omnibox churn preserves composition until replacement commits"); + state.addrBarCycleTimer_.reset(); + state.resetForCellChange(); + } + static void replacementMenus(Instance &instance) { + SKeyEngine engine(&instance, false); + FocusGroup group("wayland:test", instance.inputContextManager()); + TestInput input(instance.inputContextManager(), "google-chrome"); + input.setFocusGroup(&group); + auto &state = *input.propertyFor(&engine.factory_); + state.cachedProgram_ = "google-chrome"; + state.cachedIsChromium_ = 1; + for (bool replace : {false, true}) { + engine.config_.replaceUinputWithNative.setValue(replace); + for (bool address : {false, true}) { + input.setCapabilityFlags(address ? CapabilityFlag::Url : CapabilityFlags{}); + engine.config_.outputMode.setValue(SKeyOutputMode::Uinput); + engine.config_.chromiumAddressBarMode.setValue(SKeyChromiumAddressBarMode::Uinput); + state.hasAppModeOverride_ = false; + state.modeCacheValid_ = false; + check(state.effectiveMode() == (replace ? SKeyOutputMode::Native : SKeyOutputMode::Uinput), + "saved Uinput config resolves to selected transport"); + state.showModeMenu(); + auto list = input.inputPanel().candidateList(); + check(list && list->size() == (replace ? 5 : 6), "replacement menu removes duplicate transport slot"); + check(list->candidate(1).text().toString() == (replace ? "2. Native" : "2. Uinput"), + "Native occupies Uinput menu position"); + check(list->cursorIndex() == 1, "saved transport keeps correct highlighted row"); + if (replace) for (int i = 0; i < list->size(); ++i) + check(list->candidate(i).text().toString().find("Uinput") == std::string::npos, + "replacement menu has no Uinput label"); + KeyEvent select(&input, Key(FcitxKey_2)); + state.keyEvent(select); + check(select.accepted() && !state.modeMenuActive_, "numeric key selects visible candidate"); + state.modeCacheValid_ = false; + check(state.effectiveMode() == (replace ? SKeyOutputMode::Native : SKeyOutputMode::Uinput), + "numeric menu selection uses displayed transport"); + } + } + } + + // Model keys arriving at Fcitx BEFORE the application. Consuming a real + // deletion here reproduces chaào/caác regardless of the settle delay. + static void injectedBackspaceContract(Instance &instance, SKeyOutputMode mode) { + SKeyEngine engine(&instance, false); + FocusGroup group("wayland:test", instance.inputContextManager()); + OfficeTextInput input(instance.inputContextManager(), true); + input.setFocusGroup(&group); + input.setCapabilityFlags(CapabilityFlag::SurroundingText); + auto &state = *input.propertyFor(&engine.factory_); + state.cachedProgram_ = "google-chrome"; + state.cachedIsChromium_ = 1; + state.cachedIsFirefoxOrSnap_ = state.cachedIsTerminalApp_ = 0; + state.modeCacheValid_ = true; + state.cachedMode_ = mode; + check(state.useUinputMode(), "both transports share Uinput replacement policy"); + struct Replacement { const char *before; const char *after; int deletes; const char *suffix; const char *tail; }; + for (const auto &r : {Replacement{"cha", "chà", 1, "à", "o "}, + Replacement{"ca", "cá", 1, "á", "c "}, + Replacement{"ban", "bạn", 2, "ạn", ""}}) { + input.text += r.before; + state.viet_.setRawInput(r.after); + state.committedLen_ = utf8::length(std::string_view(r.before)); + state.pendingUinputCommit_ = r.suffix; + state.uinputPendingFinalLen_ = utf8::length(std::string_view(r.after)); + state.expectedUinputBackspaces_ = r.deletes; + state.seenUinputBackspaces_ = 0; + state.uinputBsOutstanding_ = r.deletes + 1; + state.uinputDeleting_ = true; + state.bsSentAt_ = now(CLOCK_MONOTONIC); + for (int i = 0; i < r.deletes; ++i) { + KeyEvent deletion(&input, Key(FcitxKey_BackSpace)); + check(state.handlePendingUinputBackspace(deletion), "injected BS handled by transaction"); + check(!deletion.accepted(), "real injected Backspace must reach app, not be swallowed"); + input.key(deletion.rawKey(), false); + check(!state.uinputSettling_, "wait for anchor before scheduling commit"); + check(state.viet_.getComposed() == r.after, "injected BS must not pop engine composition"); + } + const auto afterDeletion = input.text; + KeyEvent anchor(&input, Key(FcitxKey_BackSpace)); + check(state.handlePendingUinputBackspace(anchor) && anchor.accepted(), + "only the sync anchor is consumed"); + check(state.uinputSettling_ && state.uinputBsOutstanding_ == 0, + "anchor starts shared settle and clears all outstanding keys"); + state.uinputCommitTimer_.reset(); + state.finishUinputSettle(0, 0); + input.flushCommits(); + check(input.text == afterDeletion + r.suffix, "replace suffix exactly once after actual deletion"); + KeyEvent userBs(&input, Key(FcitxKey_BackSpace)); + check(!state.handlePendingUinputBackspace(userBs) && !userBs.accepted(), + "next user Backspace is not swallowed as an outstanding injected key"); + input.text += r.tail; + } + check(input.text == "chào các bạn", "both transports preserve visible chào các bạn"); + } + static void chromiumFirstShortW(Instance &instance, bool wayland, bool uinput, bool shortW, char keyChar) { SKeyEngine engine(&instance, false); @@ -1801,6 +1928,24 @@ int main(int argc, char **argv) { // active-window origin or DPI. Live X11 geometry is tested separately. unsetenv("DISPLAY"); unsetenv("WAYLAND_DISPLAY"); + { + fcitx::Instance instance(argc, argv); + fcitx::EnginePerformanceTest::replacementMenus(instance); + } + for (auto mode : {fcitx::SKeyOutputMode::Uinput, fcitx::SKeyOutputMode::Native}) { + fcitx::Instance instance(argc, argv); + fcitx::EnginePerformanceTest::injectedBackspaceContract(instance, mode); + } + for (auto mode : {fcitx::SKeyOutputMode::Uinput, fcitx::SKeyOutputMode::Native}) { + for (bool settling : {false, true}) { + fcitx::Instance instance(argc, argv); + fcitx::EnginePerformanceTest::nativeAddressBarChurn(instance, mode, settling); + } + } + if (std::getenv("SKEY_TEST_BACKSPACE_ONLY")) { + std::cout << "Injected Backspace contract: " << checks << " checks passed\n"; + return 0; + } policies(); for (bool wayland : {false, true}) for (bool uinput : {false, true})