Skip to content

feat(linux): launch_at_login + input device access permission check#172

Merged
AprilNEA merged 11 commits into
AprilNEA:masterfrom
cserby:story/linux-packaging/udev-install
Jun 12, 2026
Merged

feat(linux): launch_at_login + input device access permission check#172
AprilNEA merged 11 commits into
AprilNEA:masterfrom
cserby:story/linux-packaging/udev-install

Conversation

@cserby

@cserby cserby commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • launch_at_login on Linux — writes/removes a systemd user unit at $XDG_CONFIG_HOME/systemd/user/openlogi-agent.service. Mirrors the macOS LaunchAgent behaviour exactly: Restart=on-failure (crash respawns; clean exit(0) stays stopped), WantedBy=graphical-session.target (takes effect at next login). Unit content is compared before writing (idempotent). systemctl --user enable/disable is best-effort — failures are logged, not propagated.
  • Input device access permission check on LinuxSettings → Permissions now shows a Linux-specific row ("Input device access") backed by probes of /dev/uinput (write) and /dev/hidraw* (read/write, Logitech vendor verified numerically from sysfs HID_ID). Returns Granted / Denied / Unknown (unknown = uinput accessible but no Logitech device connected yet). Description is shown only when access is not yet granted.
  • macOS-only UI hidden on Linux — accessibility footer, permission "Open" button, and "log in to macOS" copy are all gated to macOS.

Test plan

  • cargo test -p openlogi-agent -p openlogi-gui passes on Linux (7 new unit tests in launch_agent.rs, 4 in permissions.rs)
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --check clean
  • Toggle "Launch at login" → ~/.config/systemd/user/openlogi-agent.service appears; systemctl --user is-enabled openlogi-agent reports enabled; toggle off → file removed, disabled
  • Settings → Permissions shows "Granted" with udev rules installed; "Not granted" without
  • No macOS-specific text or buttons visible in Linux GUI build

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR brings Linux parity for two agent features: launch_at_login via a systemd user unit, and an input-device permission check in the Settings window backed by probes of /dev/uinput and /dev/hidraw*. macOS-only UI surfaces (accessibility footer, "Open" button, login copy) are now gated behind #[cfg(target_os = "macos")].

  • launch_agent.rs — writes/removes $XDG_CONFIG_HOME/systemd/user/openlogi-agent.service; systemctl enable/disable calls are best-effort with logging. Proper % and $ escaping for ExecStart is implemented and well-tested (7 new unit tests).
  • permissions.rs — new input_device_access() probes uinput writability and Logitech hidraw read/write access via sysfs VID lookup; classify() is factored out and covered by 4 unit tests.
  • Locale files — "Input device access" key added to all 22 locales (mostly untranslated placeholders for non-Italian/Japanese/Russian/Chinese variants).

Confidence Score: 5/5

Safe to merge — all new Linux code paths are additive and gated behind #[cfg(target_os = "linux")]; existing macOS and Windows behaviour is unchanged.

The Linux additions are well-structured, idempotent, and covered by 11 new unit tests. The only notable concern is that input_device_access() performs live filesystem I/O inside a GPUI render closure rather than being cached — a quality-of-life issue for future work, not a correctness defect. No logic errors, data-loss paths, or security issues were found.

crates/openlogi-gui/src/windows/settings.rs and crates/openlogi-gui/src/platform/permissions.rs — the render-time device probing is worth revisiting when adding a background refresh model.

Important Files Changed

Filename Overview
crates/openlogi-agent/src/launch_agent.rs Adds Linux systemd user-unit reconcile: writes/removes the unit file, calls systemctl enable/disable best-effort, includes proper % and $ escaping; 7 new unit tests cover all escape paths.
crates/openlogi-gui/src/platform/permissions.rs Adds Linux device-file probes (/dev/uinput, /dev/hidraw* via sysfs VID check); logic is correct but input_device_access() is called directly in the render closure, performing filesystem I/O on every repaint.
crates/openlogi-core/src/paths.rs Adds xdg_config_home() (raw base without the app subdir) and refactors config_dir() to delegate to it; no logic change, existing tests unaffected.
crates/openlogi-gui/src/windows/settings.rs Gates macOS-only UI behind #[cfg(target_os = "macos")]; adds Linux SettingField for input device access with correct Denied/Unknown/Granted messages. The input_device_access() call in the render closure performs live I/O on every repaint.
crates/openlogi-gui/src/app.rs Gates accessibility_status to macOS only and silences the unused granted parameter on other platforms; straightforward conditional compilation change.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["reconcile(enabled)"] --> B{platform}
    B -->|macOS| C[reconcile_macos]
    B -->|Windows| D[reconcile_windows]
    B -->|Linux| E[reconcile_linux]
    B -->|other| F["log: no backend"]
    E --> G["unit_path via xdg_config_home"]
    G --> H["read current unit file"]
    H --> I{desired vs current}
    I -->|same content| J["no-op: already current"]
    I -->|"enable - file differs"| K["write unit file"]
    I -->|"disable - file exists"| L["systemctl disable"]
    K --> M["systemctl daemon-reload"]
    M --> N["systemctl enable"]
    L --> O["remove_file"]
    O --> P["systemctl daemon-reload"]
    I -->|"disable - no file"| Q["no-op: already absent"]
    subgraph "Linux permissions probe"
        R["input_device_access()"] --> S["probe_uinput"]
        R --> T["probe_logitech_hidraw"]
        T --> U["is_logitech_hidraw via sysfs HID_ID"]
        S --> W["classify(uinput_ok, hidraw_ok)"]
        U --> W
        W -->|"true + Some(true)"| X[Granted]
        W -->|"false or Some(false)"| Y[Denied]
        W -->|"true + None"| Z[Unknown]
    end
Loading

Reviews (11): Last reviewed commit: "Merge remote-tracking branch 'origin/mas..." | Re-trigger Greptile

Comment thread crates/openlogi-gui/src/platform/permissions.rs Outdated
Comment thread crates/openlogi-gui/src/windows/settings.rs Outdated
Comment thread crates/openlogi-gui/src/windows/settings.rs Outdated
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 8, 2026
- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 8, 2026
- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby mentioned this pull request Jun 8, 2026
4 tasks
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 10, 2026
- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 10, 2026
- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/udev-install branch 3 times, most recently from c9bc3df to 811a693 Compare June 11, 2026 05:31
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cserby added 9 commits June 11, 2026 09:24
Reconcile the agent's autostart state on Linux by writing/removing a
systemd user unit at $XDG_CONFIG_HOME/systemd/user/openlogi-agent.service.
Mirrors the macOS LaunchAgent semantics exactly:

- Restart=on-failure (crash respawns; clean exit(0) stays stopped)
- WantedBy=graphical-session.target (takes effect at next login)
- ExecStart escaped for systemd (% doubled, spaces quoted)
- Idempotent write/remove — only touches disk when content changes
- systemctl --user daemon-reload + enable/disable best-effort
Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]
- Accessibility footer in the main window hidden on Linux
- "Open" button in permission rows gated to macOS only
- Launch-at-login description no longer says "log in to macOS"
- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide
- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()
Added missing translations for the Linux permission row label introduced
in this PR. Follows the same pattern as 'Input Monitoring'.
… scope

- Add 'Unifying receiver' and 'Input device access' keys to en.yml
  (all locale files must match en.yml for the i18n test)
- Add 'Ricevitore Unifying' to it.yml for key parity
- Move InteractiveElement import outside #[cfg(target_os = "macos")]
  so on_action() calls for CloseWindow/Minimize/Zoom work on Linux
- Fix rustfmt line-wrap in launch_agent::unit_path
- Add StatefulInteractiveElement as _ to unconditional gpui import so
  on_click() resolves on macOS (fixes E0599)
- Gate status_badge with any(macos, linux) since both platforms call it
  (fixes Windows dead_code warning)
- Remove unreachable input_device_access stub for non-mac/non-linux
  targets; nothing calls it there (fixes Windows dead_code warning)
Upstream added Option<bool> accessibility_granted with a 3-state match.
Merge that with our Linux cfg structure:
- Take upstream's Some(true)/Some(false)/None match for accessibility
- Restore #[cfg(target_os = "macos")] on permission_field, permission_item,
  Permission import, and StatefulInteractiveElement import — all are
  macOS-only; gating them unconditionally caused dead_code / unused-import
  errors on Linux
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/udev-install branch from bee3c3f to aa95e08 Compare June 11, 2026 07:30
cserby and others added 2 commits June 11, 2026 10:57
On Windows, PermissionStatus, classify, rgb, and the permissions module
are all dead — they only exist for macOS/Linux permission dialogs.
Gate each with the appropriate cfg so Windows clippy -D warnings passes.
# Conflicts:
#	crates/openlogi-gui/src/windows/settings.rs
@AprilNEA AprilNEA force-pushed the story/linux-packaging/udev-install branch from 6e1ca41 to c36eccc Compare June 12, 2026 06:24
@AprilNEA AprilNEA merged commit 59cc79b into AprilNEA:master Jun 12, 2026
8 checks passed
AprilNEA added a commit that referenced this pull request Jun 12, 2026
* feat(linux): launch_at_login via systemd user unit

Reconcile the agent's autostart state on Linux by writing/removing a
systemd user unit at $XDG_CONFIG_HOME/systemd/user/openlogi-agent.service.
Mirrors the macOS LaunchAgent semantics exactly:

- Restart=on-failure (crash respawns; clean exit(0) stays stopped)
- WantedBy=graphical-session.target (takes effect at next login)
- ExecStart escaped for systemd (% doubled, spaces quoted)
- Idempotent write/remove — only touches disk when content changes
- systemctl --user daemon-reload + enable/disable best-effort

* feat(linux): input device access permission check

Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

* fix(gui): hide macOS-only UI on Linux

- Accessibility footer in the main window hidden on Linux
- "Open" button in permission rows gated to macOS only
- Launch-at-login description no longer says "log in to macOS"

* fix(linux): address PR #172 review comments

- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

* fix(linux): address PR #172 round-2 review comments

- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

* i18n(gui): add translations for 'Input device access'

Added missing translations for the Linux permission row label introduced
in this PR. Follows the same pattern as 'Input Monitoring'.

* fix(gui,i18n): add missing locale keys; fix InteractiveElement import scope

- Add 'Unifying receiver' and 'Input device access' keys to en.yml
  (all locale files must match en.yml for the i18n test)
- Add 'Ricevitore Unifying' to it.yml for key parity
- Move InteractiveElement import outside #[cfg(target_os = "macos")]
  so on_action() calls for CloseWindow/Minimize/Zoom work on Linux
- Fix rustfmt line-wrap in launch_agent::unit_path

* fix(gui): gate dead code; add StatefulInteractiveElement import

- Add StatefulInteractiveElement as _ to unconditional gpui import so
  on_click() resolves on macOS (fixes E0599)
- Gate status_badge with any(macos, linux) since both platforms call it
  (fixes Windows dead_code warning)
- Remove unreachable input_device_access stub for non-mac/non-linux
  targets; nothing calls it there (fixes Windows dead_code warning)

* fix(gui): rebase onto upstream; correct cfg gates for macOS-only imports

Upstream added Option<bool> accessibility_granted with a 3-state match.
Merge that with our Linux cfg structure:
- Take upstream's Some(true)/Some(false)/None match for accessibility
- Restore #[cfg(target_os = "macos")] on permission_field, permission_item,
  Permission import, and StatefulInteractiveElement import — all are
  macOS-only; gating them unconditionally caused dead_code / unused-import
  errors on Linux

* fix(gui): gate platform-specific imports to fix Windows clippy

On Windows, PermissionStatus, classify, rgb, and the permissions module
are all dead — they only exist for macOS/Linux permission dialogs.
Gate each with the appropriate cfg so Windows clippy -D warnings passes.

* feat(linux): input device access permission check

Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

* feat(linux): add install artifacts

- udev/70-openlogi.rules: TAG+="uaccess" for hidraw (Logitech VID 046d)
  and uinput; includes non-systemd GROUP="input" fallback instructions
- systemd/openlogi-agent.service: packaged user-unit template for
  /usr/lib/systemd/user/ (complements the per-user unit written by
  launch_at_login)
- desktop/openlogi.desktop: XDG application launcher
- install.sh / uninstall.sh: POSIX sh, set -eu; copies binaries + all
  artifacts, reloads udevadm, best-effort systemd and icon cache updates

* docs(linux): add Linux install guide and README section

docs/INSTALL-linux.md covers: prerequisites, build from source, udev
rules (uaccess + non-systemd fallback), install.sh usage, autostart via
systemctl, verification steps, and known limitations table.

README.md gets a Linux subsection under ## Install with the minimal
quick-start (build + udev) and a link to the full guide.

* fix(linux): address PR #173 review comments

- install.sh: rewrite ExecStart in the systemd unit via sed so the
  installed service always points to $BINDIR/openlogi-agent, not the
  hardcoded /usr/bin path (which mismatches the default /usr/local prefix)
- uninstall.sh: add udevadm trigger after reload so hidraw and uinput
  nodes lose the uaccess tag immediately, not only after re-plug/reboot

* fix(linux): address PR #173 round-2 review comments

- install.sh: escape sed replacement string for BINDIR metacharacters
  (& \ |) so paths like /opt/my&pkg don't corrupt the unit file
- install.sh: best-effort daemon-reload via sudo -u $SUDO_USER after
  writing the unit so reinstalls pick up the new ExecStart immediately
- uninstall.sh: use SUDO_USER to target the real user's systemd session
  when the script is run under sudo, preventing the disable from silently
  targeting root's session while the user's agent keeps running

* fix(linux): also grant uaccess for Bluetooth-connected Logitech devices

Bluetooth HID devices go through the uhid virtual bus, which has no
idVendor sysfs attribute — ATTRS{idVendor}=="046d" doesn't match them.
Add a second rule matching on the HID kernel name format
"bus:VID:PID.iface", whose VID field (046D) covers both BT and USB.

* fix(linux): set XDG_RUNTIME_DIR for systemctl --user calls under sudo

sudo -u strips the environment, so systemctl --user cannot locate the
user's D-Bus socket without XDG_RUNTIME_DIR. Without it, disable --now
in uninstall.sh silently fails (exit code 1, swallowed by || true),
leaving the agent enabled even after the binary is removed.
daemon-reload in install.sh has the same problem — it would silently
skip the reload, requiring a manual reload before the updated unit takes
effect.

Fix both by computing REAL_UID / INSTALL_USER and passing
XDG_RUNTIME_DIR=/run/user/<uid> explicitly to the sudo -u invocation.

* refactor(linux): use @bindir@ placeholder in service template

Both install.sh (PREFIX/bin) and nfpm postinstall (/usr/bin) now
expand the placeholder explicitly, removing the implicit assumption
that the template's hardcoded path matches any particular installer.

* docs(linux): note replug requirement when device connected at install time

---------

Co-authored-by: AprilNEA <github@sku.moe>
AprilNEA added a commit that referenced this pull request Jun 12, 2026
* feat(linux): launch_at_login via systemd user unit

Reconcile the agent's autostart state on Linux by writing/removing a
systemd user unit at $XDG_CONFIG_HOME/systemd/user/openlogi-agent.service.
Mirrors the macOS LaunchAgent semantics exactly:

- Restart=on-failure (crash respawns; clean exit(0) stays stopped)
- WantedBy=graphical-session.target (takes effect at next login)
- ExecStart escaped for systemd (% doubled, spaces quoted)
- Idempotent write/remove — only touches disk when content changes
- systemctl --user daemon-reload + enable/disable best-effort

* feat(linux): input device access permission check

Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

* fix(gui): hide macOS-only UI on Linux

- Accessibility footer in the main window hidden on Linux
- "Open" button in permission rows gated to macOS only
- Launch-at-login description no longer says "log in to macOS"

* fix(linux): address PR #172 review comments

- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

* fix(linux): address PR #172 round-2 review comments

- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

* i18n(gui): add translations for 'Input device access'

Added missing translations for the Linux permission row label introduced
in this PR. Follows the same pattern as 'Input Monitoring'.

* fix(gui,i18n): add missing locale keys; fix InteractiveElement import scope

- Add 'Unifying receiver' and 'Input device access' keys to en.yml
  (all locale files must match en.yml for the i18n test)
- Add 'Ricevitore Unifying' to it.yml for key parity
- Move InteractiveElement import outside #[cfg(target_os = "macos")]
  so on_action() calls for CloseWindow/Minimize/Zoom work on Linux
- Fix rustfmt line-wrap in launch_agent::unit_path

* fix(gui): gate dead code; add StatefulInteractiveElement import

- Add StatefulInteractiveElement as _ to unconditional gpui import so
  on_click() resolves on macOS (fixes E0599)
- Gate status_badge with any(macos, linux) since both platforms call it
  (fixes Windows dead_code warning)
- Remove unreachable input_device_access stub for non-mac/non-linux
  targets; nothing calls it there (fixes Windows dead_code warning)

* fix(gui): rebase onto upstream; correct cfg gates for macOS-only imports

Upstream added Option<bool> accessibility_granted with a 3-state match.
Merge that with our Linux cfg structure:
- Take upstream's Some(true)/Some(false)/None match for accessibility
- Restore #[cfg(target_os = "macos")] on permission_field, permission_item,
  Permission import, and StatefulInteractiveElement import — all are
  macOS-only; gating them unconditionally caused dead_code / unused-import
  errors on Linux

* fix(gui): gate platform-specific imports to fix Windows clippy

On Windows, PermissionStatus, classify, rgb, and the permissions module
are all dead — they only exist for macOS/Linux permission dialogs.
Gate each with the appropriate cfg so Windows clippy -D warnings passes.

* feat(linux): input device access permission check

Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

* feat(linux): add install artifacts

- udev/70-openlogi.rules: TAG+="uaccess" for hidraw (Logitech VID 046d)
  and uinput; includes non-systemd GROUP="input" fallback instructions
- systemd/openlogi-agent.service: packaged user-unit template for
  /usr/lib/systemd/user/ (complements the per-user unit written by
  launch_at_login)
- desktop/openlogi.desktop: XDG application launcher
- install.sh / uninstall.sh: POSIX sh, set -eu; copies binaries + all
  artifacts, reloads udevadm, best-effort systemd and icon cache updates

* docs(linux): add Linux install guide and README section

docs/INSTALL-linux.md covers: prerequisites, build from source, udev
rules (uaccess + non-systemd fallback), install.sh usage, autostart via
systemctl, verification steps, and known limitations table.

README.md gets a Linux subsection under ## Install with the minimal
quick-start (build + udev) and a link to the full guide.

* fix(linux): address PR #173 review comments

- install.sh: rewrite ExecStart in the systemd unit via sed so the
  installed service always points to $BINDIR/openlogi-agent, not the
  hardcoded /usr/bin path (which mismatches the default /usr/local prefix)
- uninstall.sh: add udevadm trigger after reload so hidraw and uinput
  nodes lose the uaccess tag immediately, not only after re-plug/reboot

* fix(linux): address PR #173 round-2 review comments

- install.sh: escape sed replacement string for BINDIR metacharacters
  (& \ |) so paths like /opt/my&pkg don't corrupt the unit file
- install.sh: best-effort daemon-reload via sudo -u $SUDO_USER after
  writing the unit so reinstalls pick up the new ExecStart immediately
- uninstall.sh: use SUDO_USER to target the real user's systemd session
  when the script is run under sudo, preventing the disable from silently
  targeting root's session while the user's agent keeps running

* fix(linux): also grant uaccess for Bluetooth-connected Logitech devices

Bluetooth HID devices go through the uhid virtual bus, which has no
idVendor sysfs attribute — ATTRS{idVendor}=="046d" doesn't match them.
Add a second rule matching on the HID kernel name format
"bus:VID:PID.iface", whose VID field (046D) covers both BT and USB.

* fix(linux): set XDG_RUNTIME_DIR for systemctl --user calls under sudo

sudo -u strips the environment, so systemctl --user cannot locate the
user's D-Bus socket without XDG_RUNTIME_DIR. Without it, disable --now
in uninstall.sh silently fails (exit code 1, swallowed by || true),
leaving the agent enabled even after the binary is removed.
daemon-reload in install.sh has the same problem — it would silently
skip the reload, requiring a manual reload before the updated unit takes
effect.

Fix both by computing REAL_UID / INSTALL_USER and passing
XDG_RUNTIME_DIR=/run/user/<uid> explicitly to the sudo -u invocation.

* refactor(linux): use @bindir@ placeholder in service template

Both install.sh (PREFIX/bin) and nfpm postinstall (/usr/bin) now
expand the placeholder explicitly, removing the implicit assumption
that the template's hardcoded path matches any particular installer.

* docs(linux): note replug requirement when device connected at install time

* feat(linux): add nfpm package descriptor and install scripts

nfpm.yaml maps the three binaries + udev rules + systemd user unit +
desktop entry + icon into .deb/.rpm. VERSION env var is templated in
at build time by the xtask.

nfpm-scripts/postinstall.sh: reload udevadm, refresh icon and desktop
caches, print enable-agent instructions.
nfpm-scripts/preremove.sh: reload udevadm to revoke uaccess tags.

* feat(linux): xtask package-linux command and CI release job

xtask/src/linux.rs: PackageLinux command builds release binaries (unless
--no-build), then invokes nfpm for both deb and rpm. Reads the workspace
version from Cargo.toml and passes it as VERSION to nfpm.

.github/workflows/release.yml: new linux-packages job (ubuntu-latest)
installs nfpm via the goreleaser apt repo, runs `cargo run -p xtask --
package-linux`, and uploads .deb/.rpm as an artifact. The publish job now
depends on both dmg and linux-packages, downloads both artifact sets, and
attaches .deb/.rpm to the GitHub Release alongside the DMGs.

* refactor(linux): simplify package-linux xtask and CI

- Replace workspace_version() file-I/O + fragile TOML line-scanner with
  env!("CARGO_PKG_VERSION") — Cargo bakes the workspace version in at
  compile time, zero I/O and always correct
- Merge two separate apt-get update + install steps in the linux-packages
  CI job into one (add goreleaser repo first, then single update + install)

* fix(linux): address PR #179 review comments

- nfpm.yaml: drop v prefix from version field — dpkg/rpm expect bare
  semver (0.6.0 not v0.6.0); v prefix can cause upgrade ordering issues
- nfpm.yaml: fix maintainer to Name <email> format required by Debian
- release.yml: replace trusted=yes with a proper GPG key pin for the
  goreleaser apt repo to prevent supply-chain substitution attacks

* fix(ci): pin nfpm to a specific release with SHA256 verification

Replace the goreleaser apt repo (which has no GPG key and requires
trusted=yes) with a direct download of a pinned nfpm release from
GitHub. The SHA256 is checked before install, preventing a compromised
CDN or MITM from substituting a malicious nfpm binary in the release
pipeline.

* fix(linux): udevadm settle in package scripts; sign .deb/.rpm

postinstall.sh / preremove.sh: udevadm trigger is asynchronous — it
queues uevent changes but returns before udev finishes processing them.
Without udevadm settle, a user who immediately runs
`systemctl --user enable --now openlogi-agent` after package install
may find /dev/hidraw* and /dev/uinput still denying access because
the uaccess tags haven't propagated yet. Add `udevadm settle || true`
after the trigger calls in both scripts.

release.yml: extend the minisign signing loop from dist/*.dmg to also
cover dist/*.deb and dist/*.rpm, so all release artifacts have a .minisig
sidecar and SHA256SUMS is consistent across all platforms.

* fix(linux): expand @bindir@ in service unit during package postinstall

* style(xtask): rustfmt linux.rs args array

* fix(ci,pkg): artifact version mismatch, publish race, postremove ordering

- Upgrade all upload-artifact@v7 → @v8 to match pre-existing download-
  artifact@v8 in publish job (v7/v8 use incompatible artifact backends)
- Add linux-packages to publish job needs[] so download never races upload
- Add *.deb *.rpm to sha256sum so Linux packages are integrity-checked
- Rename preremove.sh → postremove.sh: dpkg/RPM invoke prerm before
  removing files, so udevadm trigger re-applied the rules instead of
  revoking them; postrm runs after deletion so the reload actually revokes

---------

Co-authored-by: AprilNEA <github@sku.moe>
@aprilnea aprilnea Bot mentioned this pull request Jun 12, 2026
AprilNEA added a commit that referenced this pull request Jun 12, 2026
* feat(linux): launch_at_login via systemd user unit

Reconcile the agent's autostart state on Linux by writing/removing a
systemd user unit at $XDG_CONFIG_HOME/systemd/user/openlogi-agent.service.
Mirrors the macOS LaunchAgent semantics exactly:

- Restart=on-failure (crash respawns; clean exit(0) stays stopped)
- WantedBy=graphical-session.target (takes effect at next login)
- ExecStart escaped for systemd (% doubled, spaces quoted)
- Idempotent write/remove — only touches disk when content changes
- systemctl --user daemon-reload + enable/disable best-effort

* feat(linux): input device access permission check

Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

* fix(gui): hide macOS-only UI on Linux

- Accessibility footer in the main window hidden on Linux
- "Open" button in permission rows gated to macOS only
- Launch-at-login description no longer says "log in to macOS"

* fix(linux): address PR #172 review comments

- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

* fix(linux): address PR #172 round-2 review comments

- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

* i18n(gui): add translations for 'Input device access'

Added missing translations for the Linux permission row label introduced
in this PR. Follows the same pattern as 'Input Monitoring'.

* fix(gui,i18n): add missing locale keys; fix InteractiveElement import scope

- Add 'Unifying receiver' and 'Input device access' keys to en.yml
  (all locale files must match en.yml for the i18n test)
- Add 'Ricevitore Unifying' to it.yml for key parity
- Move InteractiveElement import outside #[cfg(target_os = "macos")]
  so on_action() calls for CloseWindow/Minimize/Zoom work on Linux
- Fix rustfmt line-wrap in launch_agent::unit_path

* fix(gui): gate dead code; add StatefulInteractiveElement import

- Add StatefulInteractiveElement as _ to unconditional gpui import so
  on_click() resolves on macOS (fixes E0599)
- Gate status_badge with any(macos, linux) since both platforms call it
  (fixes Windows dead_code warning)
- Remove unreachable input_device_access stub for non-mac/non-linux
  targets; nothing calls it there (fixes Windows dead_code warning)

* fix(gui): rebase onto upstream; correct cfg gates for macOS-only imports

Upstream added Option<bool> accessibility_granted with a 3-state match.
Merge that with our Linux cfg structure:
- Take upstream's Some(true)/Some(false)/None match for accessibility
- Restore #[cfg(target_os = "macos")] on permission_field, permission_item,
  Permission import, and StatefulInteractiveElement import — all are
  macOS-only; gating them unconditionally caused dead_code / unused-import
  errors on Linux

* fix(gui): gate platform-specific imports to fix Windows clippy

On Windows, PermissionStatus, classify, rgb, and the permissions module
are all dead — they only exist for macOS/Linux permission dialogs.
Gate each with the appropriate cfg so Windows clippy -D warnings passes.

* feat(linux): input device access permission check

Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

* feat(linux): add install artifacts

- udev/70-openlogi.rules: TAG+="uaccess" for hidraw (Logitech VID 046d)
  and uinput; includes non-systemd GROUP="input" fallback instructions
- systemd/openlogi-agent.service: packaged user-unit template for
  /usr/lib/systemd/user/ (complements the per-user unit written by
  launch_at_login)
- desktop/openlogi.desktop: XDG application launcher
- install.sh / uninstall.sh: POSIX sh, set -eu; copies binaries + all
  artifacts, reloads udevadm, best-effort systemd and icon cache updates

* docs(linux): add Linux install guide and README section

docs/INSTALL-linux.md covers: prerequisites, build from source, udev
rules (uaccess + non-systemd fallback), install.sh usage, autostart via
systemctl, verification steps, and known limitations table.

README.md gets a Linux subsection under ## Install with the minimal
quick-start (build + udev) and a link to the full guide.

* fix(linux): address PR #173 review comments

- install.sh: rewrite ExecStart in the systemd unit via sed so the
  installed service always points to $BINDIR/openlogi-agent, not the
  hardcoded /usr/bin path (which mismatches the default /usr/local prefix)
- uninstall.sh: add udevadm trigger after reload so hidraw and uinput
  nodes lose the uaccess tag immediately, not only after re-plug/reboot

* fix(linux): address PR #173 round-2 review comments

- install.sh: escape sed replacement string for BINDIR metacharacters
  (& \ |) so paths like /opt/my&pkg don't corrupt the unit file
- install.sh: best-effort daemon-reload via sudo -u $SUDO_USER after
  writing the unit so reinstalls pick up the new ExecStart immediately
- uninstall.sh: use SUDO_USER to target the real user's systemd session
  when the script is run under sudo, preventing the disable from silently
  targeting root's session while the user's agent keeps running

* fix(linux): also grant uaccess for Bluetooth-connected Logitech devices

Bluetooth HID devices go through the uhid virtual bus, which has no
idVendor sysfs attribute — ATTRS{idVendor}=="046d" doesn't match them.
Add a second rule matching on the HID kernel name format
"bus:VID:PID.iface", whose VID field (046D) covers both BT and USB.

* fix(linux): set XDG_RUNTIME_DIR for systemctl --user calls under sudo

sudo -u strips the environment, so systemctl --user cannot locate the
user's D-Bus socket without XDG_RUNTIME_DIR. Without it, disable --now
in uninstall.sh silently fails (exit code 1, swallowed by || true),
leaving the agent enabled even after the binary is removed.
daemon-reload in install.sh has the same problem — it would silently
skip the reload, requiring a manual reload before the updated unit takes
effect.

Fix both by computing REAL_UID / INSTALL_USER and passing
XDG_RUNTIME_DIR=/run/user/<uid> explicitly to the sudo -u invocation.

* refactor(linux): use @bindir@ placeholder in service template

Both install.sh (PREFIX/bin) and nfpm postinstall (/usr/bin) now
expand the placeholder explicitly, removing the implicit assumption
that the template's hardcoded path matches any particular installer.

* docs(linux): note replug requirement when device connected at install time

* feat(linux): add nfpm package descriptor and install scripts

nfpm.yaml maps the three binaries + udev rules + systemd user unit +
desktop entry + icon into .deb/.rpm. VERSION env var is templated in
at build time by the xtask.

nfpm-scripts/postinstall.sh: reload udevadm, refresh icon and desktop
caches, print enable-agent instructions.
nfpm-scripts/preremove.sh: reload udevadm to revoke uaccess tags.

* feat(linux): xtask package-linux command and CI release job

xtask/src/linux.rs: PackageLinux command builds release binaries (unless
--no-build), then invokes nfpm for both deb and rpm. Reads the workspace
version from Cargo.toml and passes it as VERSION to nfpm.

.github/workflows/release.yml: new linux-packages job (ubuntu-latest)
installs nfpm via the goreleaser apt repo, runs `cargo run -p xtask --
package-linux`, and uploads .deb/.rpm as an artifact. The publish job now
depends on both dmg and linux-packages, downloads both artifact sets, and
attaches .deb/.rpm to the GitHub Release alongside the DMGs.

* refactor(linux): simplify package-linux xtask and CI

- Replace workspace_version() file-I/O + fragile TOML line-scanner with
  env!("CARGO_PKG_VERSION") — Cargo bakes the workspace version in at
  compile time, zero I/O and always correct
- Merge two separate apt-get update + install steps in the linux-packages
  CI job into one (add goreleaser repo first, then single update + install)

* fix(linux): address PR #179 review comments

- nfpm.yaml: drop v prefix from version field — dpkg/rpm expect bare
  semver (0.6.0 not v0.6.0); v prefix can cause upgrade ordering issues
- nfpm.yaml: fix maintainer to Name <email> format required by Debian
- release.yml: replace trusted=yes with a proper GPG key pin for the
  goreleaser apt repo to prevent supply-chain substitution attacks

* fix(ci): pin nfpm to a specific release with SHA256 verification

Replace the goreleaser apt repo (which has no GPG key and requires
trusted=yes) with a direct download of a pinned nfpm release from
GitHub. The SHA256 is checked before install, preventing a compromised
CDN or MITM from substituting a malicious nfpm binary in the release
pipeline.

* fix(linux): udevadm settle in package scripts; sign .deb/.rpm

postinstall.sh / preremove.sh: udevadm trigger is asynchronous — it
queues uevent changes but returns before udev finishes processing them.
Without udevadm settle, a user who immediately runs
`systemctl --user enable --now openlogi-agent` after package install
may find /dev/hidraw* and /dev/uinput still denying access because
the uaccess tags haven't propagated yet. Add `udevadm settle || true`
after the trigger calls in both scripts.

release.yml: extend the minisign signing loop from dist/*.dmg to also
cover dist/*.deb and dist/*.rpm, so all release artifacts have a .minisig
sidecar and SHA256SUMS is consistent across all platforms.

* fix(linux): expand @bindir@ in service unit during package postinstall

* style(xtask): rustfmt linux.rs args array

* fix(ci,pkg): artifact version mismatch, publish race, postremove ordering

- Upgrade all upload-artifact@v7 → @v8 to match pre-existing download-
  artifact@v8 in publish job (v7/v8 use incompatible artifact backends)
- Add linux-packages to publish job needs[] so download never races upload
- Add *.deb *.rpm to sha256sum so Linux packages are integrity-checked
- Rename preremove.sh → postremove.sh: dpkg/RPM invoke prerm before
  removing files, so udevadm trigger re-applied the rules instead of
  revoking them; postrm runs after deletion so the reload actually revokes

* feat(hid): Unifying receiver — protocol, discovery, enumeration, routing

OpenLogi previously only enumerated Logi Bolt receivers (PID 0xC548).
Users with a Unifying receiver (PID 0xC52B / 0xC532) saw no devices.

openlogi-hidpp/receiver/unifying.rs
  Add the missing HID++ 1.0 methods needed for device enumeration:
  count_pairings(), trigger_device_arrival(), get_device_pairing_information(),
  get_unique_id(). Add EventEmitter<Event> + message listener for 0x41
  device-connection notifications (same pattern as BoltReceiver). Add
  DeviceKind enum (shifted vs Bolt at values 5+: Remote=5, Trackball=6,
  Touchpad=7). Update receiver/mod.rs to call get_unique_id() directly.

openlogi-hid/transport.rs
  Add is_receiver_child_node() (Linux): the hid-logitech-dj kernel driver
  creates a virtual hidraw node per paired Unifying device; these matched
  our HID++ filter and caused 5-second probe timeouts every tick. Detect
  them by checking whether any known receiver PID appears as a *parent*
  directory component in the device's sysfs path, and filter them out
  before probing. Extract the path-matching logic to is_receiver_child_sysfs_path()
  for testability without filesystem access.
  Add BOLT_PIDS and UNIFYING_PIDS as the canonical PID lists; pairing.rs
  now derives from them so a new receiver PID needs editing in one place.

openlogi-hid/inventory.rs
  probe_one: replace Bolt-only match with explicit Bolt / Unifying / direct
  arms. Add probe_unifying_receiver (uses device-arrival events to surface
  online paired devices) and probe_unifying_slot (builds PairedDevice from
  the event; falls back gracefully on timeout). Add UNIFYING_SLOT_PROBE
  (3.5 s) per-slot budget so a slow wireless HID++ 2.0 round-trip on one
  device cannot consume the shared PROBE_BUDGET on behalf of others. Add
  drain_device_arrival_unifying and map_unifying_kind.

openlogi-hid/route.rs
  Add DeviceRoute::Unifying { receiver_uid, slot } — same structure as Bolt
  but distinct so the label and write path are correct. Add device_route_for()
  constructor that picks Unifying/Bolt/Direct from the receiver's product ID
  using the canonical PID lists. Update open_route_channel to handle the
  Unifying arm. Add BOLT_PIDS and UNIFYING_PIDS constants.

* fix(gui,agent,cli): wire Unifying receiver into the app stack

Consumers of the HID layer all constructed DeviceRoute::Bolt for any
receiver-backed device. This caused three problems for Unifying devices:
1. open_route_channel only tried Receiver::Bolt — writes silently failed.
2. The Device tab showed "Bolt receiver" for Unifying devices.
3. The Buttons tab showed the generic mouse hotspot layout for keyboards
   (K540 has ReprogControls so capabilities.buttons=true, but no asset).
4. Paired devices without HID++ 2.0 model info were skipped entirely by
   build_device_list, so the K540 never appeared in the carousel.

openlogi-agent-core
  device_order.rs: DeviceStableId::from_parts folds Unifying into the Bolt
  variant (same slot-based sort key regardless of receiver family, so the
  GUI carousel and agent agree on "first device").
  orchestrator.rs: call DeviceRoute::device_route_for() directly.

openlogi-cli/diag
  Use DeviceRoute::device_route_for() at both diag call sites.

openlogi-gui/app.rs
  route_label: add "Unifying receiver" arm for DeviceRoute::Unifying.
  tabs_for: gate the Buttons tab on `can_show_mouse_model` — a pointer-type
  device (Mouse/Trackball) or one with a resolved asset. A keyboard that
  exposes ReprogControls but has no asset would otherwise show the generic
  mouse hotspot layout (Middle Click, DPI Toggle, …) which is wrong.
  Update tabs_follow_capabilities_not_kind test to use kind=Mouse (the
  0x0005 kind-correction fix from #127 corrects mislabeled mice at probe
  time; the test scenario of kind=Keyboard + mouse-caps no longer arises).
  Add keyboard_without_asset_hides_buttons_tab test.

openlogi-gui/state/devices.rs
  build_device_list: remove the hard model_info guard. Devices without
  HID++ 2.0 model info (HID++ 1.0 devices, or probes that timed out)
  are now surfaced with a wpid-based config_key ("wpid{:04x}") so their
  settings persist across sessions; slot is the last-resort fallback.
  Call DeviceRoute::device_route_for() directly (one-liner wrapper removed).

* fix(hid): scope Unifying cache key to receiver; clarify Bolt fallback

inventory.rs — probe_unifying_slot now receives the receiver's unique_id
and incorporates its first 3 ASCII bytes into the CacheKey so two
Unifying receivers with a device on the same slot number use distinct
cache entries. The previous [0,0,0,slot] key was receiver-agnostic and
would hand receiver B's slot-1 query the cached ProbedFeatures built for
receiver A's slot-1 device (different model, different capabilities).

route.rs — device_route_for: make the Bolt-default design explicit in
the doc comment and add a tracing::debug for receivers whose PID is in
neither BOLT_PIDS nor UNIFYING_PIDS. Behaviour is unchanged — returning
None for unknown PIDs would silently drop writes for future Bolt variants
with new PIDs, which is worse than routing them as Bolt.

* fix(hid): safe Clone for UnifyingReceiver; proper Unifying cache key

unifying.rs — #[derive(Clone)] copied msg_listener_hdl (u32) verbatim,
so dropping any clone called remove_msg_listener with the shared handle
and silently deregistered the listener for all surviving clones. The
next drain_device_arrival_unifying would return empty, making Unifying
devices vanish from inventory.

Replace the bare u32 with Arc<ListenerDropGuard>: every Receiver clone
shares the Arc; remove_msg_listener is called exactly once, when the
Arc's refcount hits zero (last clone dropped). The manual Drop impl is
removed — ListenerDropGuard::drop owns the cleanup.

inventory.rs — CacheKey::Bolt { unit_id: [uid[0..3], slot] } used only
3 ASCII bytes of the receiver serial as a prefix, so two receivers whose
serials share the same first three characters (common in Logitech batches,
e.g. "DA2699E1" and "DA2604F2") still collided on the same slot.

Add CacheKey::UnifyingSlot { receiver_uid: String, slot: u8 } keyed on
the full serial string + slot. Two Unifying receivers with a device on
the same slot number now have provably distinct cache entries.

* i18n(gui): add translations for 'Unifying receiver'

Added missing translations for the route label introduced in this PR.
Placed alongside 'Bolt receiver' following the same pattern.

* fix(hidpp): safe Clone for BoltReceiver; share ListenerDropGuard

BoltReceiver had the same Clone+Drop bug fixed for UnifyingReceiver in
the previous commit: #[derive(Clone)] copied msg_listener_hdl (u32)
verbatim, so the first drop of any clone deregistered the HID++ listener
for all surviving copies — subsequent drain_device_arrival calls would
return empty and Bolt devices would vanish from inventory.

Move ListenerDropGuard to receiver/mod.rs (pub(super)) so it is shared
by both bolt.rs and unifying.rs without duplication. Apply the Arc-wrap
fix to BoltReceiver identically: store Arc<ListenerDropGuard> instead of
a bare u32, remove the manual impl Drop.

* fix(hid): preserve cached data on Unifying slot probe timeout

On timeout the fallback returned ProbedFeatures::default() even when a
prior successful probe was in the cache, causing battery, model name,
and capability flags to disappear from the carousel for that device.
Return the cached probe when available and emit Seen so the entry is not
counted toward eviction.

* fix(hid): gate is_receiver_child_sysfs_path for non-Linux builds

The function is only called from a #[cfg(target_os = "linux")] context,
making it dead code on macOS/Windows. Gate it with
#[cfg(any(target_os = "linux", test))] so the tests remain cross-platform
while silencing the dead_code lint in production builds.

* fix(hid): fall back to PID when Unifying receiver UID is unavailable

Empty string fallback causes CacheKey collisions when two receivers
share the same slot and both fail get_unique_id(). Product ID is a
weaker but non-empty discriminant; a warning is logged so the
degraded isolation is visible.

* fix(agent): re-enable systemd unit even when content is already current

A manually-disabled unit is not re-enabled on app restart because
reconcile exits early when the file content matches. Always call
systemctl enable in that arm — it is idempotent and no daemon-reload
is needed when the file is unchanged.

* fix(gui,xtask): gate platform permissions to macos/linux; drop dead xtask fn

PermissionStatus and classify are only meaningful on platforms where
permission-check functions exist (macOS and Linux). Gate them so the
Windows clippy job no longer reports dead-code warnings. Gate the test
module to Linux-only since the tests exercise Linux-specific classify
logic. Remove the unused workspace_version helper from xtask/linux.rs
(package_linux uses env! instead).

---------

Co-authored-by: AprilNEA <github@sku.moe>
AprilNEA added a commit that referenced this pull request Jun 12, 2026
* feat(linux): launch_at_login via systemd user unit

Reconcile the agent's autostart state on Linux by writing/removing a
systemd user unit at $XDG_CONFIG_HOME/systemd/user/openlogi-agent.service.
Mirrors the macOS LaunchAgent semantics exactly:

- Restart=on-failure (crash respawns; clean exit(0) stays stopped)
- WantedBy=graphical-session.target (takes effect at next login)
- ExecStart escaped for systemd (% doubled, spaces quoted)
- Idempotent write/remove — only touches disk when content changes
- systemctl --user daemon-reload + enable/disable best-effort

* feat(linux): input device access permission check

Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

* fix(gui): hide macOS-only UI on Linux

- Accessibility footer in the main window hidden on Linux
- "Open" button in permission rows gated to macOS only
- Launch-at-login description no longer says "log in to macOS"

* fix(linux): address PR #172 review comments

- Remove misplaced sysfs comment above the open() call in
  probe_logitech_hidraw (the sysfs check is in is_logitech_hidraw)
- Remove dead #[cfg(not(target_os = "macos"))] suppressor inside the
  already-macOS-gated permission_field function
- Split Denied/Unknown description text: Unknown means uinput is
  accessible but no Logitech device is connected, so point the user
  at the device rather than the udev install guide

* fix(linux): address PR #172 round-2 review comments

- escape_systemd_exec: double $ → $$ to prevent systemd variable
  substitution in ExecStart paths containing a literal dollar sign
- paths: add pub xdg_config_home() that returns the raw XDG config base
  without the openlogi sub-directory; refactor config_dir() to call it
- unit_path: use xdg_config_home() directly instead of relying on the
  fragile .parent() traversal from config_dir()

* i18n(gui): add translations for 'Input device access'

Added missing translations for the Linux permission row label introduced
in this PR. Follows the same pattern as 'Input Monitoring'.

* fix(gui,i18n): add missing locale keys; fix InteractiveElement import scope

- Add 'Unifying receiver' and 'Input device access' keys to en.yml
  (all locale files must match en.yml for the i18n test)
- Add 'Ricevitore Unifying' to it.yml for key parity
- Move InteractiveElement import outside #[cfg(target_os = "macos")]
  so on_action() calls for CloseWindow/Minimize/Zoom work on Linux
- Fix rustfmt line-wrap in launch_agent::unit_path

* fix(gui): gate dead code; add StatefulInteractiveElement import

- Add StatefulInteractiveElement as _ to unconditional gpui import so
  on_click() resolves on macOS (fixes E0599)
- Gate status_badge with any(macos, linux) since both platforms call it
  (fixes Windows dead_code warning)
- Remove unreachable input_device_access stub for non-mac/non-linux
  targets; nothing calls it there (fixes Windows dead_code warning)

* fix(gui): rebase onto upstream; correct cfg gates for macOS-only imports

Upstream added Option<bool> accessibility_granted with a 3-state match.
Merge that with our Linux cfg structure:
- Take upstream's Some(true)/Some(false)/None match for accessibility
- Restore #[cfg(target_os = "macos")] on permission_field, permission_item,
  Permission import, and StatefulInteractiveElement import — all are
  macOS-only; gating them unconditionally caused dead_code / unused-import
  errors on Linux

* fix(gui): gate platform-specific imports to fix Windows clippy

On Windows, PermissionStatus, classify, rgb, and the permissions module
are all dead — they only exist for macOS/Linux permission dialogs.
Gate each with the appropriate cfg so Windows clippy -D warnings passes.

* feat(linux): input device access permission check

Add a Linux-specific permission probe and settings page row:

- probe_uinput(): checks write access to /dev/uinput
- probe_logitech_hidraw(): iterates /dev/hidraw*, confirms Logitech
  vendor (HID_ID sysfs field parsed numerically — 0000046D matches 046d)
- classify(uinput_ok, hidraw_ok): pure function → Granted/Denied/Unknown
- Settings → Permissions shows one "Input device access" row on Linux
  with description only when access is not yet granted (no noise when
  everything works)
- macOS permission rows and helpers gated #[cfg(target_os = "macos")]

* feat(linux): add install artifacts

- udev/70-openlogi.rules: TAG+="uaccess" for hidraw (Logitech VID 046d)
  and uinput; includes non-systemd GROUP="input" fallback instructions
- systemd/openlogi-agent.service: packaged user-unit template for
  /usr/lib/systemd/user/ (complements the per-user unit written by
  launch_at_login)
- desktop/openlogi.desktop: XDG application launcher
- install.sh / uninstall.sh: POSIX sh, set -eu; copies binaries + all
  artifacts, reloads udevadm, best-effort systemd and icon cache updates

* docs(linux): add Linux install guide and README section

docs/INSTALL-linux.md covers: prerequisites, build from source, udev
rules (uaccess + non-systemd fallback), install.sh usage, autostart via
systemctl, verification steps, and known limitations table.

README.md gets a Linux subsection under ## Install with the minimal
quick-start (build + udev) and a link to the full guide.

* fix(linux): address PR #173 review comments

- install.sh: rewrite ExecStart in the systemd unit via sed so the
  installed service always points to $BINDIR/openlogi-agent, not the
  hardcoded /usr/bin path (which mismatches the default /usr/local prefix)
- uninstall.sh: add udevadm trigger after reload so hidraw and uinput
  nodes lose the uaccess tag immediately, not only after re-plug/reboot

* fix(linux): address PR #173 round-2 review comments

- install.sh: escape sed replacement string for BINDIR metacharacters
  (& \ |) so paths like /opt/my&pkg don't corrupt the unit file
- install.sh: best-effort daemon-reload via sudo -u $SUDO_USER after
  writing the unit so reinstalls pick up the new ExecStart immediately
- uninstall.sh: use SUDO_USER to target the real user's systemd session
  when the script is run under sudo, preventing the disable from silently
  targeting root's session while the user's agent keeps running

* fix(linux): also grant uaccess for Bluetooth-connected Logitech devices

Bluetooth HID devices go through the uhid virtual bus, which has no
idVendor sysfs attribute — ATTRS{idVendor}=="046d" doesn't match them.
Add a second rule matching on the HID kernel name format
"bus:VID:PID.iface", whose VID field (046D) covers both BT and USB.

* fix(linux): set XDG_RUNTIME_DIR for systemctl --user calls under sudo

sudo -u strips the environment, so systemctl --user cannot locate the
user's D-Bus socket without XDG_RUNTIME_DIR. Without it, disable --now
in uninstall.sh silently fails (exit code 1, swallowed by || true),
leaving the agent enabled even after the binary is removed.
daemon-reload in install.sh has the same problem — it would silently
skip the reload, requiring a manual reload before the updated unit takes
effect.

Fix both by computing REAL_UID / INSTALL_USER and passing
XDG_RUNTIME_DIR=/run/user/<uid> explicitly to the sudo -u invocation.

* refactor(linux): use @bindir@ placeholder in service template

Both install.sh (PREFIX/bin) and nfpm postinstall (/usr/bin) now
expand the placeholder explicitly, removing the implicit assumption
that the template's hardcoded path matches any particular installer.

* docs(linux): note replug requirement when device connected at install time

* feat(linux): add nfpm package descriptor and install scripts

nfpm.yaml maps the three binaries + udev rules + systemd user unit +
desktop entry + icon into .deb/.rpm. VERSION env var is templated in
at build time by the xtask.

nfpm-scripts/postinstall.sh: reload udevadm, refresh icon and desktop
caches, print enable-agent instructions.
nfpm-scripts/preremove.sh: reload udevadm to revoke uaccess tags.

* feat(linux): xtask package-linux command and CI release job

xtask/src/linux.rs: PackageLinux command builds release binaries (unless
--no-build), then invokes nfpm for both deb and rpm. Reads the workspace
version from Cargo.toml and passes it as VERSION to nfpm.

.github/workflows/release.yml: new linux-packages job (ubuntu-latest)
installs nfpm via the goreleaser apt repo, runs `cargo run -p xtask --
package-linux`, and uploads .deb/.rpm as an artifact. The publish job now
depends on both dmg and linux-packages, downloads both artifact sets, and
attaches .deb/.rpm to the GitHub Release alongside the DMGs.

* refactor(linux): simplify package-linux xtask and CI

- Replace workspace_version() file-I/O + fragile TOML line-scanner with
  env!("CARGO_PKG_VERSION") — Cargo bakes the workspace version in at
  compile time, zero I/O and always correct
- Merge two separate apt-get update + install steps in the linux-packages
  CI job into one (add goreleaser repo first, then single update + install)

* fix(linux): address PR #179 review comments

- nfpm.yaml: drop v prefix from version field — dpkg/rpm expect bare
  semver (0.6.0 not v0.6.0); v prefix can cause upgrade ordering issues
- nfpm.yaml: fix maintainer to Name <email> format required by Debian
- release.yml: replace trusted=yes with a proper GPG key pin for the
  goreleaser apt repo to prevent supply-chain substitution attacks

* fix(ci): pin nfpm to a specific release with SHA256 verification

Replace the goreleaser apt repo (which has no GPG key and requires
trusted=yes) with a direct download of a pinned nfpm release from
GitHub. The SHA256 is checked before install, preventing a compromised
CDN or MITM from substituting a malicious nfpm binary in the release
pipeline.

* fix(linux): udevadm settle in package scripts; sign .deb/.rpm

postinstall.sh / preremove.sh: udevadm trigger is asynchronous — it
queues uevent changes but returns before udev finishes processing them.
Without udevadm settle, a user who immediately runs
`systemctl --user enable --now openlogi-agent` after package install
may find /dev/hidraw* and /dev/uinput still denying access because
the uaccess tags haven't propagated yet. Add `udevadm settle || true`
after the trigger calls in both scripts.

release.yml: extend the minisign signing loop from dist/*.dmg to also
cover dist/*.deb and dist/*.rpm, so all release artifacts have a .minisig
sidecar and SHA256SUMS is consistent across all platforms.

* fix(linux): expand @bindir@ in service unit during package postinstall

* style(xtask): rustfmt linux.rs args array

* fix(ci,pkg): artifact version mismatch, publish race, postremove ordering

- Upgrade all upload-artifact@v7 → @v8 to match pre-existing download-
  artifact@v8 in publish job (v7/v8 use incompatible artifact backends)
- Add linux-packages to publish job needs[] so download never races upload
- Add *.deb *.rpm to sha256sum so Linux packages are integrity-checked
- Rename preremove.sh → postremove.sh: dpkg/RPM invoke prerm before
  removing files, so udevadm trigger re-applied the rules instead of
  revoking them; postrm runs after deletion so the reload actually revokes

* feat(hid): Unifying receiver — protocol, discovery, enumeration, routing

OpenLogi previously only enumerated Logi Bolt receivers (PID 0xC548).
Users with a Unifying receiver (PID 0xC52B / 0xC532) saw no devices.

openlogi-hidpp/receiver/unifying.rs
  Add the missing HID++ 1.0 methods needed for device enumeration:
  count_pairings(), trigger_device_arrival(), get_device_pairing_information(),
  get_unique_id(). Add EventEmitter<Event> + message listener for 0x41
  device-connection notifications (same pattern as BoltReceiver). Add
  DeviceKind enum (shifted vs Bolt at values 5+: Remote=5, Trackball=6,
  Touchpad=7). Update receiver/mod.rs to call get_unique_id() directly.

openlogi-hid/transport.rs
  Add is_receiver_child_node() (Linux): the hid-logitech-dj kernel driver
  creates a virtual hidraw node per paired Unifying device; these matched
  our HID++ filter and caused 5-second probe timeouts every tick. Detect
  them by checking whether any known receiver PID appears as a *parent*
  directory component in the device's sysfs path, and filter them out
  before probing. Extract the path-matching logic to is_receiver_child_sysfs_path()
  for testability without filesystem access.
  Add BOLT_PIDS and UNIFYING_PIDS as the canonical PID lists; pairing.rs
  now derives from them so a new receiver PID needs editing in one place.

openlogi-hid/inventory.rs
  probe_one: replace Bolt-only match with explicit Bolt / Unifying / direct
  arms. Add probe_unifying_receiver (uses device-arrival events to surface
  online paired devices) and probe_unifying_slot (builds PairedDevice from
  the event; falls back gracefully on timeout). Add UNIFYING_SLOT_PROBE
  (3.5 s) per-slot budget so a slow wireless HID++ 2.0 round-trip on one
  device cannot consume the shared PROBE_BUDGET on behalf of others. Add
  drain_device_arrival_unifying and map_unifying_kind.

openlogi-hid/route.rs
  Add DeviceRoute::Unifying { receiver_uid, slot } — same structure as Bolt
  but distinct so the label and write path are correct. Add device_route_for()
  constructor that picks Unifying/Bolt/Direct from the receiver's product ID
  using the canonical PID lists. Update open_route_channel to handle the
  Unifying arm. Add BOLT_PIDS and UNIFYING_PIDS constants.

* fix(gui,agent,cli): wire Unifying receiver into the app stack

Consumers of the HID layer all constructed DeviceRoute::Bolt for any
receiver-backed device. This caused three problems for Unifying devices:
1. open_route_channel only tried Receiver::Bolt — writes silently failed.
2. The Device tab showed "Bolt receiver" for Unifying devices.
3. The Buttons tab showed the generic mouse hotspot layout for keyboards
   (K540 has ReprogControls so capabilities.buttons=true, but no asset).
4. Paired devices without HID++ 2.0 model info were skipped entirely by
   build_device_list, so the K540 never appeared in the carousel.

openlogi-agent-core
  device_order.rs: DeviceStableId::from_parts folds Unifying into the Bolt
  variant (same slot-based sort key regardless of receiver family, so the
  GUI carousel and agent agree on "first device").
  orchestrator.rs: call DeviceRoute::device_route_for() directly.

openlogi-cli/diag
  Use DeviceRoute::device_route_for() at both diag call sites.

openlogi-gui/app.rs
  route_label: add "Unifying receiver" arm for DeviceRoute::Unifying.
  tabs_for: gate the Buttons tab on `can_show_mouse_model` — a pointer-type
  device (Mouse/Trackball) or one with a resolved asset. A keyboard that
  exposes ReprogControls but has no asset would otherwise show the generic
  mouse hotspot layout (Middle Click, DPI Toggle, …) which is wrong.
  Update tabs_follow_capabilities_not_kind test to use kind=Mouse (the
  0x0005 kind-correction fix from #127 corrects mislabeled mice at probe
  time; the test scenario of kind=Keyboard + mouse-caps no longer arises).
  Add keyboard_without_asset_hides_buttons_tab test.

openlogi-gui/state/devices.rs
  build_device_list: remove the hard model_info guard. Devices without
  HID++ 2.0 model info (HID++ 1.0 devices, or probes that timed out)
  are now surfaced with a wpid-based config_key ("wpid{:04x}") so their
  settings persist across sessions; slot is the last-resort fallback.
  Call DeviceRoute::device_route_for() directly (one-liner wrapper removed).

* fix(hid): scope Unifying cache key to receiver; clarify Bolt fallback

inventory.rs — probe_unifying_slot now receives the receiver's unique_id
and incorporates its first 3 ASCII bytes into the CacheKey so two
Unifying receivers with a device on the same slot number use distinct
cache entries. The previous [0,0,0,slot] key was receiver-agnostic and
would hand receiver B's slot-1 query the cached ProbedFeatures built for
receiver A's slot-1 device (different model, different capabilities).

route.rs — device_route_for: make the Bolt-default design explicit in
the doc comment and add a tracing::debug for receivers whose PID is in
neither BOLT_PIDS nor UNIFYING_PIDS. Behaviour is unchanged — returning
None for unknown PIDs would silently drop writes for future Bolt variants
with new PIDs, which is worse than routing them as Bolt.

* fix(hid): safe Clone for UnifyingReceiver; proper Unifying cache key

unifying.rs — #[derive(Clone)] copied msg_listener_hdl (u32) verbatim,
so dropping any clone called remove_msg_listener with the shared handle
and silently deregistered the listener for all surviving clones. The
next drain_device_arrival_unifying would return empty, making Unifying
devices vanish from inventory.

Replace the bare u32 with Arc<ListenerDropGuard>: every Receiver clone
shares the Arc; remove_msg_listener is called exactly once, when the
Arc's refcount hits zero (last clone dropped). The manual Drop impl is
removed — ListenerDropGuard::drop owns the cleanup.

inventory.rs — CacheKey::Bolt { unit_id: [uid[0..3], slot] } used only
3 ASCII bytes of the receiver serial as a prefix, so two receivers whose
serials share the same first three characters (common in Logitech batches,
e.g. "DA2699E1" and "DA2604F2") still collided on the same slot.

Add CacheKey::UnifyingSlot { receiver_uid: String, slot: u8 } keyed on
the full serial string + slot. Two Unifying receivers with a device on
the same slot number now have provably distinct cache entries.

* i18n(gui): add translations for 'Unifying receiver'

Added missing translations for the route label introduced in this PR.
Placed alongside 'Bolt receiver' following the same pattern.

* fix(hidpp): safe Clone for BoltReceiver; share ListenerDropGuard

BoltReceiver had the same Clone+Drop bug fixed for UnifyingReceiver in
the previous commit: #[derive(Clone)] copied msg_listener_hdl (u32)
verbatim, so the first drop of any clone deregistered the HID++ listener
for all surviving copies — subsequent drain_device_arrival calls would
return empty and Bolt devices would vanish from inventory.

Move ListenerDropGuard to receiver/mod.rs (pub(super)) so it is shared
by both bolt.rs and unifying.rs without duplication. Apply the Arc-wrap
fix to BoltReceiver identically: store Arc<ListenerDropGuard> instead of
a bare u32, remove the manual impl Drop.

* fix(hid): preserve cached data on Unifying slot probe timeout

On timeout the fallback returned ProbedFeatures::default() even when a
prior successful probe was in the cache, causing battery, model name,
and capability flags to disappear from the carousel for that device.
Return the cached probe when available and emit Seen so the entry is not
counted toward eviction.

* fix(hid): gate is_receiver_child_sysfs_path for non-Linux builds

The function is only called from a #[cfg(target_os = "linux")] context,
making it dead code on macOS/Windows. Gate it with
#[cfg(any(target_os = "linux", test))] so the tests remain cross-platform
while silencing the dead_code lint in production builds.

* fix(hid): fall back to PID when Unifying receiver UID is unavailable

Empty string fallback causes CacheKey collisions when two receivers
share the same slot and both fail get_unique_id(). Product ID is a
weaker but non-empty discriminant; a warning is logged so the
degraded isolation is visible.

* fix(agent): re-enable systemd unit even when content is already current

A manually-disabled unit is not re-enabled on app restart because
reconcile exits early when the file content matches. Always call
systemctl enable in that arm — it is idempotent and no daemon-reload
is needed when the file is unchanged.

* fix(gui,xtask): gate platform permissions to macos/linux; drop dead xtask fn

PermissionStatus and classify are only meaningful on platforms where
permission-check functions exist (macOS and Linux). Gate them so the
Windows clippy job no longer reports dead-code warnings. Gate the test
module to Linux-only since the tests exercise Linux-specific classify
logic. Remove the unused workspace_version helper from xtask/linux.rs
(package_linux uses env! instead).

* docs: update README for Linux support

- Remove "Linux... coming soon" — Linux is now fully supported
- Roadmap: mark Linux hook, Unifying receivers, button remapping,
  action catalog, launch-at-login, and localization as ✅ macOS + Linux
- DPI and SmartShift are platform-independent writes — drop the macOS qualifier
- Add Linux packaging row (udev, systemd, .deb/.rpm)
- Per-app profiles: ✅ macOS, 🟡 Linux X11 only (Wayland pending)
- Install section: add Linux install steps (.deb/.rpm + systemd enable)
  and link to docs/INSTALL-linux.md
- Update footnote: media actions use D-Bus MPRIS on Linux

* fix(docs): correct Unifying label; remove duplicate Linux section

- 'Bolt successor' → 'older protocol, replaced by Bolt' (Bolt is the
  successor to Unifying, not the other way around)
- Remove the stale build-from-source Linux section introduced by the
  install-scripts PR; the new .deb/.rpm section supersedes it
- Rebased onto install-script-and-docs so docs/INSTALL-linux.md exists
  in this branch's tree and the link resolves

* docs(linux): remove stale pre-package and no-Unifying claims

- Replace WARNING callout (claimed Unifying unsupported) with NOTE
  listing both Bolt and Unifying as supported
- Replace 'no pre-built packages yet' paragraph with a pointer to the
  releases page and README install section
- Remove Unifying row from known-limitations table (now supported)

* fix(gui): restore cfg-gated StatefulInteractiveElement import

Rebase dropped the macOS-gated StatefulInteractiveElement import that
permission_field's .on_click() requires on Stateful<Div>.

* fix(gui): remove duplicate StatefulInteractiveElement import

Rebase introduced a second #[cfg(macos)] StatefulInteractiveElement
import alongside the one already at the top of the file.

---------

Co-authored-by: AprilNEA <github@sku.moe>
@aprilnea aprilnea Bot mentioned this pull request Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants