Skip to content

feat(linux): nfpm .deb/.rpm packaging and CI release job#179

Merged
AprilNEA merged 29 commits into
AprilNEA:masterfrom
cserby:story/linux-packaging/nfpm
Jun 12, 2026
Merged

feat(linux): nfpm .deb/.rpm packaging and CI release job#179
AprilNEA merged 29 commits into
AprilNEA:masterfrom
cserby:story/linux-packaging/nfpm

Conversation

@cserby

@cserby cserby commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Dependency

Requires #173 to be merged first. This branch is built on top of story/linux-packaging/install-script-and-docs and references the packaging artifacts it introduces (packaging/linux/udev/, packaging/linux/systemd/, packaging/linux/desktop/). Please merge #173 before this PR and rebase onto master at that point.

Summary

  • packaging/linux/nfpm.yaml — nfpm package descriptor mapping the three binaries (openlogi, openlogi-gui, openlogi-agent), udev rules, systemd user unit, desktop entry, and icon into .deb/.rpm. The package version is templated via ${VERSION} env var set by the xtask at build time.
  • packaging/linux/nfpm-scripts/postinstall.sh — runs after package install: reloads udevadm, refreshes the icon and desktop caches, and prints instructions to enable the background agent.
  • packaging/linux/nfpm-scripts/preremove.sh — runs before package removal: reloads udevadm to revoke uaccess tags immediately.
  • xtask/src/linux.rs — new package-linux xtask command: builds release binaries, reads the workspace version from Cargo.toml, and invokes nfpm for both deb and rpm. Accepts --no-build to skip the cargo step.
  • .github/workflows/release.yml — new linux-packages job (runs on ubuntu-latest, parallel to dmg): installs nfpm via the goreleaser apt repo, runs cargo run -p xtask -- package-linux, and uploads .deb/.rpm as a release artifact. The publish job now depends on both dmg and linux-packages, downloads both artifact sets, includes .deb/.rpm in the checksums and in the GitHub Release.

Test plan

  • cargo run -p xtask -- package-linux produces target/release/openlogi_*.deb and target/release/openlogi_*.rpm after a release build
  • dpkg -c target/release/openlogi_*.deb shows all expected paths (/usr/bin/, /etc/udev/rules.d/, /usr/lib/systemd/user/, /usr/share/)
  • rpm -qlp target/release/openlogi_*.rpm shows the same paths
  • Install the .deb on a fresh Ubuntu VM — udev rules active, agent unit present, desktop entry visible
  • CI linux-packages job passes on a tag push

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds nfpm-based .deb/.rpm packaging for Linux and a new linux-packages CI job that builds, packages, and uploads the artifacts as part of the release pipeline. The xtask package-linux command, packaging scripts, udev rules, systemd user unit, desktop entry, and supporting shell scripts are all new.

  • Several previously-flagged issues are addressed: nfpm is now installed via a pinned version + verified SHA256 (replacing the old trusted=yes apt repo), the removal hook is correctly a postremove (runs after file deletion so udev reload actually revokes access), linux-packages is added to the publish job's needs list, and .deb/.rpm are included in SHA256SUMS.
  • The publish job's if condition still does not check needs.linux-packages.result, and the artifact download still uses the hard-fail name: form rather than the best-effort pattern: form used for Windows — so a Linux build failure will block the macOS release publish.
  • The reconcile_linux function skips systemctl enable when the unit file is already current, which can leave a manually-disabled service disabled even after the user re-enables "Launch at Login" in the GUI.

Confidence Score: 3/5

Safe to merge for feature development, but a Linux build failure will block the macOS DMG release until the hard-fail download is switched to best-effort.

The publish job uses a hard-fail name:-based artifact download for the Linux packages, and the if condition does not gate on needs.linux-packages.result. A Linux packaging failure will cause the download-artifact step to error out, preventing the macOS DMG from being published. The reconcile_linux also silently skips systemctl enable when the unit file content is unchanged, which can leave the autostart broken after a manual systemctl --user disable.

.github/workflows/release.yml — the publish job artifact download and if-condition; crates/openlogi-agent/src/launch_agent.rs — the idempotency guard in reconcile_linux

Important Files Changed

Filename Overview
.github/workflows/release.yml Adds linux-packages CI job with pinned nfpm SHA256 verification; adds linux-packages to publish needs; but download-artifact for linux packages uses hard-fail name: instead of best-effort pattern:, meaning a Linux build failure still blocks the macOS release publish
xtask/src/linux.rs New xtask subcommand for building .deb/.rpm; uses env!("CARGO_PKG_VERSION") which correctly inherits the workspace version via version.workspace=true; clean implementation
packaging/linux/nfpm.yaml nfpm package descriptor for .deb/.rpm; version uses ${VERSION} set by xtask (no v-prefix issue); maintainer format is now correct; postremove (not preremove) correctly triggers udev after file removal
crates/openlogi-agent/src/launch_agent.rs Adds Linux systemd user-unit reconciliation; render_unit/escape_systemd_exec are correct; but reconcile skips systemctl enable when unit content is unchanged, meaning a manually-disabled service won't be re-enabled by the GUI toggle
crates/openlogi-gui/src/platform/permissions.rs Adds Linux input-device access probing via /dev/uinput and sysfs HID_ID; classify logic is testable and correct; probe_logitech_hidraw correctly checks vendor via sysfs uevent
packaging/linux/nfpm-scripts/postinstall.sh Runs as root during dpkg/rpm install; correctly expands @bindir@ placeholder, reloads udev rules, and runs udevadm settle to ensure uaccess tags are applied synchronously
packaging/linux/nfpm-scripts/postremove.sh Correctly placed as postremove (runs after package files are deleted) so udev reload actually revokes the uaccess tags; addresses the previous preremove timing issue
packaging/linux/install.sh New shell install script with proper @bindir@ substitution, best-effort daemon-reload via SUDO_USER, and udevadm reload; uses sed metachar escaping for path safety

Sequence Diagram

sequenceDiagram
    participant Tag as Git Tag Push
    participant LPkg as linux-packages job
    participant MacOS as macos job
    participant Pub as publish job

    Tag->>LPkg: trigger
    Tag->>MacOS: trigger (parallel)

    LPkg->>LPkg: cargo run -p xtask -- package-linux
    LPkg->>LPkg: nfpm deb + rpm
    LPkg->>LPkg: upload-artifact OpenLogi-linux-packages

    MacOS->>MacOS: build DMG
    MacOS->>MacOS: "upload-artifact OpenLogi-macos-dmg-*"

    LPkg-->>Pub: (needs gate)
    MacOS-->>Pub: (needs gate)

    Pub->>Pub: download-artifact pattern OpenLogi-macos-dmg best-effort
    Pub->>Pub: download-artifact pattern OpenLogi-windows best-effort
    Pub->>Pub: download-artifact name OpenLogi-linux-packages hard-fail

    Note over Pub: if linux-packages failed, download fails, publish blocked
    Pub->>Pub: "sha256sum .dmg .exe .msi .deb .rpm > SHA256SUMS"
    Pub->>Pub: softprops/action-gh-release publish release
Loading

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

Comment thread .github/workflows/release.yml Outdated
Comment thread packaging/linux/nfpm.yaml Outdated
Comment thread packaging/linux/nfpm.yaml Outdated
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 8, 2026
- 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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 20c8db1 to 742e2d3 Compare June 8, 2026 09:52
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 8, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 742e2d3 to 4c91eeb Compare June 8, 2026 11:55
@cserby cserby mentioned this pull request Jun 8, 2026
4 tasks
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 9, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 4c91eeb to f715d5e Compare June 9, 2026 07:20
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 10, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from f715d5e to 5ab635e Compare June 10, 2026 06:56
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 10, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 5ab635e to 7dbbbb9 Compare June 10, 2026 07:10
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 10, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 7dbbbb9 to f18f39f Compare June 10, 2026 07:17
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from f18f39f to 731bb70 Compare June 11, 2026 05:22
Comment thread packaging/linux/nfpm-scripts/postremove.sh
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 731bb70 to 1141817 Compare June 11, 2026 05:35
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 1141817 to 1d4ff6c Compare June 11, 2026 06:19
cserby added 5 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()
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 1725e8e to 0f49105 Compare June 11, 2026 08:01
cserby added 19 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.
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")]
- 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/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.
- 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
- 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
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.
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.
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.
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.
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.
- 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)
- 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
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.
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.
…ring

- 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
cserby added a commit to cserby/OpenLogi that referenced this pull request Jun 11, 2026
- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cserby cserby force-pushed the story/linux-packaging/nfpm branch from 0f49105 to dd9644e Compare June 11, 2026 08:58
# Conflicts:
#	crates/openlogi-gui/src/windows/settings.rs
@AprilNEA AprilNEA force-pushed the story/linux-packaging/nfpm branch from dd9644e to b81d298 Compare June 12, 2026 06:24
@AprilNEA AprilNEA merged commit 329c789 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

* 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 added a commit that referenced this pull request Jun 12, 2026
PR #179 bumped all upload-artifact steps from v7 to v8 to 'match the
pre-existing download-artifact@v8', claiming the v7/v8 artifact backends
are incompatible. Neither holds: actions/upload-artifact has no v8 tag
(latest is v7.0.1; the two actions' major versions have never been in
lockstep), and the v0.6.7 release shipped green on the v7+v8 pair hours
earlier. Every job on the v0.6.8 tag run failed at job setup with
'Unable to resolve action actions/upload-artifact@v8', so no GitHub
release was published for v0.6.8.
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