# BigCedilla

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.

---

## Why this exists

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.

---

## How it 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.

### Layout awareness

The `ç` fix needs different keycodes per layout, so the layout is resolved with
xkbcommon rather than hard-coded:

- **ABNT2** — acute on `KEY_LEFTBRACE`, `ç` on `KEY_SEMICOLON` (base plane).
- **us-intl** — acute on `KEY_APOSTROPHE`, `ç` on `AltGr` + 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:

1. `/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 by
   `localectl set-x11-keymap`.
2. `/etc/vconsole.conf` — its `XKBLAYOUT`, or failing that its console `KEYMAP`
   translated through systemd's own `kbd-model-map`. A machine configured only
   with `localectl set-keymap` has just the latter.
3. `XKB_DEFAULT_LAYOUT` / `_MODEL` / `_VARIANT` / `_OPTIONS`.
4. A `br` default.

A per-session KDE layout, when configured, overrides all of it through the state
file.

---

## Install

### Arch / BigLinux / Manjaro

```bash
cd pkgbuild
makepkg -si
```

For an explicit local-checkout evidence build, keep the PKGBUILD as the owner
and opt in to the validated source root:

```bash
cd pkgbuild
BIGCEDILLA_LOCAL_ROOT="$PWD/.." makepkg --cleanbuild
```

The package enables the root daemon (system preset) and the user-side path unit
(global preset). Verify:

```bash
systemctl status bigcedilla.service              # root daemon
systemctl --user status bigcedilla-agent.path    # layout watch
```

Two 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`).

### Manual

```bash
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.path
```

Enable 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.

---

## Configuration

| 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.

---

## Known limitations

- **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.

## Requirements

- A Linux seat with `/dev/uinput` and readable `/dev/input/event*` (the service
  runs as root with a bounding set containing only `CAP_DAC_OVERRIDE`, required
  to traverse the active user's private runtime directory).
- `libxkbcommon` and systemd.

Works on any Wayland compositor or Xorg — it is compositor-agnostic.

---

## Verification evidence

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. `memmap2` is 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 `´`+`c` and **U+00E1** for
  `´`+`a` in Brave 150.1.92.139 — the cedilla is repaired and normal
  acute-vowel composition is intact.
- Idle cost, measured from `/proc/<pid>/stat` over 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`, and `vconsole.conf` with
  only a console `KEYMAP`, 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 `kxkbrc` triggered 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.service` reports exposure **1.7 OK**.
- Packaging: `makepkg` from the local checkout produced
  `bigcedilla-26.07.25-0048-x86_64.pkg.tar.zst` carrying exactly the seven
  expected files. Installed with pacman on the VM, `pacman -Qkk` reports 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.

---

## License

GPL-3.0-or-later.
