A tiny evdev/uinput daemon that restores the Brazilian ç when the
dead-acute key (' / ´) is followed by c — rewriting the key below the
compositor, so the fix reaches Chromium and Electron too.
Since GTK 4.20 (GNOME/gtk MR 8556, "wayland: Stop deriving from GtkIMContextSimple", merged 2025-05-13) the GTK4 Wayland backend no longer does client-side compose — it delegates dead keys and compose sequences to the compositor. Chromium and Electron never used the Wayland input method for this in the first place. The result on Brazilian and US-International layouts:
' + c → ć (U+0107, c-with-acute) ← wrong
expected:
' + c → ç (U+00E7)
' + C → Ç
GTK_IM_MODULE=simple papers over it for GTK apps, but not for Chromium
or Electron. A compositor input method (fcitx5, or the earlier
input-method-v2 build of bigcedilla) can't help either: Chromium ignores the
Wayland IM, and KWin only routes keys to the IM when a client activates
text-input, which Chromium does not.
Nor can a ~/.XCompose rule: Chromium reads the compose table for the process
locale and ignores that file entirely (measured — see Verification evidence).
The only layer that reaches every client — Chromium, Electron, GTK, Qt, X11 — is below the toolkit, at the kernel input level. That is where bigcedilla works.
kxkbrc change one-shot state file
────────────────► systemd path ─────────► publisher ───► /run/user/<uid>/bigcedilla/keymap
(exits) │ inotify
▼
physical keyboard ─grab(EVIOCGRAB)─► daemon (root) ─uinput─► compositor ─► every app
│
└─ dead_acute + c → ç keycode
everything else → forwarded verbatim
One resident process. The root daemon grabs every physical keyboard with
EVIOCGRAB and creates one uinput virtual keyboard. It runs keys through a
small state machine: dead_acute then c emits the active layout's ç
keycode (not a Unicode string) — the compositor turns that keycode into ç
for whatever app is focused, Chromium included. Held Shift rides along and
yields Ç. Everything else is forwarded untouched, so normal compose (á,
é, ´ ), the crase Shift+' → `, shortcuts, and other layouts keep
working.
The daemon never polls. It sleeps in poll() on inotify and wakes only when a
keyboard is hotplugged, the login session changes, the system keyboard
configuration is edited, or a new layout is published — so an idle machine pays
essentially nothing for it.
No resident user process. KDE stores the keyboard choice in kxkbrc, inside
$HOME, which the root daemon has no business reading. A systemd path unit
watches that file and starts a one-shot publisher, which writes a closed profile
(br, us-intl, or off) to a private state file and exits. The daemon
accepts files only from the active local seat and maps those profiles to
built-in, xkb-tested keycodes; user-controlled raw keycodes never cross into
root. The publisher has no device access, so no app gains raw input.
The publisher writes with a unique, private temporary file, fsync, atomic
rename, and a directory fsync. Both sides reject relative or symlinked runtime
paths, non-private ownership/modes, non-regular state files, extra hard links,
oversized content, and every profile outside br, us-intl, or off.
Because the daemon sits at evdev, it coexists with maliit and fcitx5 — those live at the compositor input-method layer and bigcedilla never touches that slot.
The ç fix needs different keycodes per layout, so the layout is resolved with
xkbcommon rather than hard-coded:
- ABNT2 — acute on
KEY_LEFTBRACE,çonKEY_SEMICOLON(base plane). - us-intl — acute on
KEY_APOSTROPHE,çonAltGr+ comma (injected).
Layouts with no dead_acute (French AZERTY, plain US, us alt-intl) resolve to
nothing and the daemon passes those keys through untouched — it never grabs the
wrong key.
Sources are tried in order, and a change to any of them is applied live:
/etc/X11/xorg.conf.d/00-keyboard.conf. Despite the path this is not X11-only configuration: it is where systemd-localed stores the system keyboard layout, and Wayland compositors read it too. Written bylocalectl set-x11-keymap./etc/vconsole.conf— itsXKBLAYOUT, or failing that its consoleKEYMAPtranslated through systemd's ownkbd-model-map. A machine configured only withlocalectl set-keymaphas just the latter.XKB_DEFAULT_LAYOUT/_MODEL/_VARIANT/_OPTIONS.- A
brdefault.
A per-session KDE layout, when configured, overrides all of it through the state file.
cd pkgbuild
makepkg -siFor an explicit local-checkout evidence build, keep the PKGBUILD as the owner and opt in to the validated source root:
cd pkgbuild
BIGCEDILLA_LOCAL_ROOT="$PWD/.." makepkg --cleanbuildThe package enables the root daemon (system preset) and the user-side path unit (global preset). Verify:
systemctl status bigcedilla.service # root daemon
systemctl --user status bigcedilla-agent.path # layout watchTwo version numbers, on purpose: following the BigLinux convention the package
is versioned by build date and time (pacman -Q bigcedilla → 26.07.25-0048),
while the program keeps a semantic version (bigcedilla --version → 1.0.0).
cargo build --release
sudo install -Dm755 target/release/bigcedilla /usr/bin/bigcedilla
sudo install -Dm644 pkgbuild/bigcedilla.service \
/usr/lib/systemd/system/bigcedilla.service
sudo install -Dm644 pkgbuild/bigcedilla-agent.path \
/usr/lib/systemd/user/bigcedilla-agent.path
sudo install -Dm644 pkgbuild/bigcedilla-agent.service \
/usr/lib/systemd/user/bigcedilla-agent.service
sudo systemctl enable --now bigcedilla.service
systemctl --user enable --now bigcedilla-agent.pathEnable the path unit, not the service: the service is a one-shot, so enabling it alone would publish the layout once at login and never notice a later change.
| Environment variable | Effect |
|---|---|
XKB_DEFAULT_LAYOUT / _MODEL / _VARIANT / _OPTIONS |
Override the detected layout when the /etc files are absent. |
RUST_LOG |
Log level, e.g. RUST_LOG=bigcedilla=info. There is deliberately no verbose level — see below. |
The command line is intentionally closed: no arguments starts the device
daemon, agent runs the one-shot publisher, and resolve [layout [variant]]
prints only the built-in profile resolution. --help and --version are
non-privileged inspection paths; unknown commands and excess arguments fail
instead of falling through to the privileged daemon.
The daemon logs the resolved keycodes at startup, e.g.
resolved layout keycodes: trigger=26 ccedilla=39 altgr=false.
It never logs keystrokes. A process that grabs every keyboard must not have
a verbose mode that could be switched on in the field, so log::debug! and
log::trace! are rejected at compile time (clippy.toml), and the security
gate additionally forbids any output statement inside the scopes that see
individual key events.
- A KDE tray layout toggle is not followed. The path unit sees changes to
the configured layouts in
kxkbrc; which of several configured layouts is active right now lives only in the running session and never touches that file. Changing the configured layout — in System Settings,localectl, or/etc— is applied live. - The per-session override is KDE-specific (
kxkbrc). On other desktops the daemon uses the system-wide layout, which it does follow live.
- A Linux seat with
/dev/uinputand readable/dev/input/event*(the service runs as root with a bounding set containing onlyCAP_DAC_OVERRIDE, required to traverse the active user's private runtime directory). libxkbcommonand systemd.
Works on any Wayland compositor or Xorg — it is compositor-agnostic.
Exercised on 2026-07-24/25 in a Manjaro KDE Plasma Wayland VM (kernel 6.18.38, 1280×720), with keystrokes injected as PS/2 scancodes through the hypervisor — below evdev, the same path as a physical keyboard — and results read back as Unicode code points rather than by eye.
cargo fmt, Clippy with warnings denied, 36 tests,cargo-deny(advisories, licenses, bans, sources),cargo-audit,cargo-machete, Gitleaks and ShellCheck pass.memmap2is locked to 0.9.11, the first version patched for RUSTSEC-2026-0186.- The daemon resolved the layout from the system configuration, grabbed the
emulated AT keyboard, and produced U+00E7 for
´+cand U+00E1 for´+ain Brave 150.1.92.139 — the cedilla is repaired and normal acute-vowel composition is intact. - Idle cost, measured from
/proc/<pid>/statover 60 s with the machine at rest: 0.017 % of one core, 3.3 MB RSS, one resident process. The 0.x polling design measured 4.8 % on the same VM. - Hotplug: a uinput keyboard created inside the guest was grabbed sub-second, its removal reaped in ~300 ms, and it was re-grabbed on replug.
- Live reconfiguration:
localectl set-x11-keymap, andvconsole.confwith only a consoleKEYMAP, both re-point the daemon without a restart —br-abnt2→ 26/39,us-acentos→ 40/51 with AltGr,fr→ inactive. - The user side leaves no resident process: changing
kxkbrctriggered the path unit, the one-shot published and exited (Result=success), and the daemon picked the new profile up through inotify. systemd-analyze security bigcedilla.servicereports exposure 1.7 OK.- Packaging:
makepkgfrom the local checkout producedbigcedilla-26.07.25-0048-x86_64.pkg.tar.zstcarrying exactly the seven expected files. Installed with pacman on the VM,pacman -Qkkreports 19 files and 0 altered, the daemon came up enabled and active, the path unit was globally enabled and armed in the running session by the install hook, and the packaged binary producedç áin Brave. A subsequent package upgrade restarted the daemon, kept the path unit enabled, and left no resident user-side process.
One honest caveat: on that VM the original defect did not reproduce. Brave
150 composes ´+c to ç on its own under a pt_BR.UTF-8 locale, whose compose
table carries the rule. The tests above therefore demonstrate correctness and
the absence of regressions, not the fix's necessity; the necessity is
established by field reports on affected systems. A ~/.XCompose probe
confirmed Chromium ignores that file, which rules out the file-only
alternative.
GPL-3.0-or-later.