diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 5763da54a..a46ba2317 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -114,6 +114,13 @@ Not part of `ci.yml`, not in the default run: - **devenv CI** (path-filtered): format `devenv.nix` and `devenv --no-tui shell -- true`. - **Build**: unsigned installers on every PR. Run the matching `cargo xtask` package command only when the diff touches packaging. +- **Build / Linux Flatpak**: gated behind the `needs: build` label like the rest + of that matrix, so a PR touching `packaging/linux/flatpak/**` gets no feedback + until someone labels it. Reproduce locally with + `flatpak-builder --user --disable-rofiles-fuse --force-clean --repo=repo + --default-branch=stable build packaging/linux/flatpak/org.openlogi.OpenLogi.yml`, + and keep the build tree off `/tmp` if it is a tmpfs: the workspace needs well + over 10 GB to compile inside the sandbox. ## When you add a CI job diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 26f00382f..5f7c86f18 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,9 @@ on: linux_result: description: Result of the Linux packages build job. value: ${{ jobs.results.outputs.linux }} + linux_flatpak_result: + description: Result of the Linux Flatpak bundle build job. + value: ${{ jobs.results.outputs.linux_flatpak }} # PR builds are opt-in: add the `needs: build` label to produce unsigned # installers for testing. `closed` / unlabel cancel an in-flight run via # concurrency (jobs below no-op on those actions). @@ -875,6 +878,126 @@ jobs: name: OpenLogi-linux-packages-${{ matrix.arch }} path: dist/* + # The same tree the .deb/.rpm are cut from, packaged as a single-file Flatpak + # bundle for the distributions where neither is native. Separate from + # linux-packages rather than folded into it: this build compiles the whole + # workspace again inside the Flatpak SDK, where the runner's rust-cache cannot + # reach, so pairing them would tie a thirty-minute job to an hour-long one and + # lose the .deb when the Flatpak leg failed. + linux-flatpak: + name: Linux Flatpak (${{ matrix.arch }}) + needs: should-build + if: ${{ needs.should-build.outputs.run == 'true' }} + runs-on: ${{ matrix.runner }} + # Measured at ~10 minutes cold on ubuntu-latest, so this is a bound on a + # hang rather than an estimate — sized like linux-packages' 30 above, with + # room for the arm runner being slower. + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + # Native builds, like the packages above: the bundle is labelled with this + # arch, so a mismatched runner would ship a wrongly-named file. + - name: Verify runner architecture + env: + EXPECTED_ARCH: ${{ matrix.arch }} + run: | + case "$(uname -m)" in + x86_64) got=amd64 ;; + aarch64) got=arm64 ;; + *) echo "unexpected machine $(uname -m)" >&2; exit 1 ;; + esac + test "$got" = "$EXPECTED_ARCH" + + - name: Install flatpak and the runtime + env: + RUNTIME_VERSION: "25.08" + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder + flatpak remote-add --if-not-exists --user \ + flathub https://dl.flathub.org/repo/flathub.flatpakrepo + # llvm20 because openlogi-camera pulls v4l2-sys-mit, whose build + # script runs bindgen and dlopens libclang; the base SDK ships none. + flatpak install --user -y --noninteractive flathub \ + "org.freedesktop.Platform//${RUNTIME_VERSION}" \ + "org.freedesktop.Sdk//${RUNTIME_VERSION}" \ + "org.freedesktop.Sdk.Extension.llvm20//${RUNTIME_VERSION}" + + # AppStream is what software centres read, and a stale would + # advertise a version the bundle is not. Both values come from the tree + # being built rather than from an input, so the label cannot drift from + # the binaries the way a version passed by hand can. + - name: Stamp the AppStream release + run: | + set -euo pipefail + version="$(cargo metadata --no-deps --format-version 1 \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])')" + # The commit's own date, not today's: a rebuild of an old tag must + # not claim to have been released the day it was rebuilt. + released="$(git log -1 --format=%cs)" + echo "stamping ${version} (${released})" + python3 - "$version" "$released" <<'PY' + import re, sys + version, released = sys.argv[1], sys.argv[2] + path = "packaging/linux/flatpak/org.openlogi.OpenLogi.metainfo.xml" + text = open(path, encoding="utf-8").read() + text, n = re.subn( + r'', + f'', + text, + count=1, + ) + if n != 1: + raise SystemExit("no element to stamp in " + path) + open(path, "w", encoding="utf-8").write(text) + PY + grep ' SHA256SUMS + sha256sum -- *.dmg *.zip *.msi *.deb *.rpm *.pkg.tar.zst *.flatpak > SHA256SUMS # softprops' `files` can't list a glob that matches nothing without # tripping fail_on_unmatched_files. The Windows zip/msi (per arch leg) @@ -187,7 +196,7 @@ jobs: - name: Detect best-effort artifacts id: artifacts run: | - for ext in zip msi deb rpm; do + for ext in zip msi deb rpm flatpak; do if compgen -G "dist/*.$ext" > /dev/null; then echo "$ext=true" >> "$GITHUB_OUTPUT" else @@ -256,12 +265,15 @@ jobs: # The Windows binaries and Linux packages get the same minisign # treatment as the DMGs: manual verification today, and the future # auto-updaters need the detached signatures to exist for every - # shipped version. - # nullglob: the zip/msi/deb/rpm sets are best-effort per arch leg, so - # the globs may match nothing; the DMGs are guaranteed by the publish - # gate. + # shipped version. The Flatpak bundle is in that set for the same + # reason — it is attached to the release and copied to R2 like the + # rest, so leaving it unsigned would make it the one installer a user + # cannot verify. + # nullglob: the zip/msi/deb/rpm/flatpak sets are best-effort per arch + # leg, so the globs may match nothing; the DMGs are guaranteed by the + # publish gate. shopt -s nullglob - for artifact in dist/*.dmg dist/*.zip dist/*.msi dist/*.deb dist/*.rpm dist/*.pkg.tar.zst; do + for artifact in dist/*.dmg dist/*.zip dist/*.msi dist/*.deb dist/*.rpm dist/*.pkg.tar.zst dist/*.flatpak; do minisign -S -m "$artifact" -s "$key_file" -x "$artifact.minisig" -W minisign -V -m "$artifact" -P "$OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY" -x "$artifact.minisig" done @@ -330,6 +342,7 @@ jobs: ${{ steps.artifacts.outputs.deb == 'true' && 'dist/*.deb' || '' }} ${{ steps.artifacts.outputs.rpm == 'true' && 'dist/*.rpm' || '' }} ${{ steps.artifacts.outputs.pacman == 'true' && 'dist/*.pkg.tar.zst' || '' }} + ${{ steps.artifacts.outputs.flatpak == 'true' && 'dist/*.flatpak' || '' }} fail_on_unmatched_files: true homebrew-tap: diff --git a/.gitignore b/.gitignore index ed9145664..c0181ff2b 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,8 @@ node_modules/ # Packaging output. *.dmg + +# flatpak-builder output and scratch state. +/.flatpak-builder/ +/build/ +/repo/ diff --git a/README.md b/README.md index 51868696c..d0e891a23 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,27 @@ sudo pacman -U openlogi-*.pkg.tar.zst Packages are published for both `x86_64`/`amd64` and `arm64`/`aarch64`. Pre-built packages require GLIBC 2.35 or newer (Ubuntu 22.04 baseline). +Each release also carries a Flatpak bundle, for `amd64` and `arm64`: + +```sh +flatpak install --user ./openlogi-*.flatpak +``` + +It is a one-time install: there is no remote behind it, so `flatpak update` has +nothing to check and a new version means downloading the next bundle. + +A Flatpak cannot write to `/etc`, so its udev rules go on the host separately. +They ship inside the application, so this needs no extra download: + +```sh +flatpak run --command=cat org.openlogi.OpenLogi /app/share/openlogi/udev/70-openlogi.rules | sudo tee /etc/udev/rules.d/70-openlogi.rules >/dev/null && sudo udevadm control --reload-rules && sudo udevadm trigger +``` + +Until that runs, no devices are detected at all. Nothing extra is needed for +`/dev/uinput`: the rules create its node at boot with `static_node=uinput`, and +opening it loads the module. The agent is launched by the application itself, so +unlike the packages below there is no user service to enable. + NixOS users can instead import the repository's module, which installs the package and udev rules and starts the agent with the graphical session: diff --git a/docs/INSTALL-linux.md b/docs/INSTALL-linux.md index 52c1bc8e6..23515ad2f 100644 --- a/docs/INSTALL-linux.md +++ b/docs/INSTALL-linux.md @@ -57,6 +57,52 @@ For a build without installing the module: nix build github:AprilNEA/OpenLogi#openlogi ``` +## Flatpak + +Each release carries a `.flatpak` bundle for `amd64` and `arm64`, on the +[releases page](https://github.com/AprilNEA/OpenLogi/releases/latest): + +```sh +flatpak install --user ./openlogi-*.flatpak +``` + +It is a one-time install. There is no remote behind a bundle, so `flatpak +update` has nothing to check and a new version means downloading the next one. + +> [!IMPORTANT] +> A Flatpak cannot write to `/etc`, so the udev rules have to be installed on +> the host separately. **Until you do, no devices are detected at all**: the +> sandbox is allowed to reach `/dev/hidraw*`, `/dev/uinput` and +> `/dev/input/event*`, but the kernel still refuses your user access to them, +> so the app opens to an empty device list. + +The same rules the packages install ship inside the app, so this needs no +checkout and no extra download: + +```sh +flatpak run --command=cat org.openlogi.OpenLogi \ + /app/share/openlogi/udev/70-openlogi.rules | + sudo tee /etc/udev/rules.d/70-openlogi.rules >/dev/null + +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +Nothing extra is needed for `/dev/uinput`: the rules create its node at boot +with `static_node=uinput`, and opening it loads the module. See +[Device access: udev rules](#device-access-udev-rules) for what the rules grant +and how to verify them. + +The agent installs its input hook once at startup, so restart the app after +granting access: + +```sh +flatpak kill org.openlogi.OpenLogi +``` + +Then launch it again. There is no user service to enable — unlike the packages, +the application launches its own agent. + ## Build from source Pre-built `.deb` and `.rpm` packages are available on the diff --git a/packaging/linux/flatpak/org.openlogi.OpenLogi.desktop b/packaging/linux/flatpak/org.openlogi.OpenLogi.desktop new file mode 100644 index 000000000..6c366ac9d --- /dev/null +++ b/packaging/linux/flatpak/org.openlogi.OpenLogi.desktop @@ -0,0 +1,15 @@ +[Desktop Entry] +Type=Application +Name=OpenLogi +Comment=Logitech HID++ device control: remap buttons, DPI, SmartShift +Exec=openlogi-desktop +Icon=org.openlogi.OpenLogi +Terminal=false +# Ties the running window (Wayland xdg-toplevel app_id / X11 WM_CLASS) back to +# this launcher. The GUI advertises `openlogi_core::brand::APP_ID`, which is +# lowercase and therefore differs from the flatpak application ID; without +# this key the shell shows a generic icon for the window. +StartupWMClass=org.openlogi.openlogi +Categories=Settings;HardwareSettings; +Keywords=logitech;mouse;hid;remap;dpi; +StartupNotify=true diff --git a/packaging/linux/flatpak/org.openlogi.OpenLogi.metainfo.xml b/packaging/linux/flatpak/org.openlogi.OpenLogi.metainfo.xml new file mode 100644 index 000000000..206574bce --- /dev/null +++ b/packaging/linux/flatpak/org.openlogi.OpenLogi.metainfo.xml @@ -0,0 +1,71 @@ + + + org.openlogi.OpenLogi + CC0-1.0 + Apache-2.0 OR MIT + + OpenLogi + Control Logitech mice and keyboards + + + AprilNEA + + + +

+ OpenLogi is a native, local-first alternative to Logitech Options+. It remaps + buttons, sets DPI, tunes SmartShift, and applies per-application profiles to + Logitech HID++ devices: over a Bolt or Unifying receiver, over Bluetooth, or + wired. +

+

+ There is no account and no telemetry. Configuration is a plain TOML file you + can read, edit, and put in version control. +

+

Features:

+
    +
  • Remap buttons, including gesture and Actions Ring bindings
  • +
  • Set DPI and cycle between DPI presets
  • +
  • Configure SmartShift and wheel behaviour
  • +
  • Per-application profiles that switch on the focused window
  • +
  • Battery level for connected devices
  • +
+

+ One setup step is required after installing. Device nodes are owned by root, + so OpenLogi's udev rules must be installed on the host, because a Flatpak + cannot write to /etc. The rules ship inside the application at + /app/share/openlogi/udev/70-openlogi.rules; copy that file to + /etc/udev/rules.d/ and reload udev. Until then no devices are detected. +

+
+ + org.openlogi.OpenLogi.desktop + + https://github.com/AprilNEA/OpenLogi + https://github.com/AprilNEA/OpenLogi/issues + https://github.com/AprilNEA/OpenLogi + + + Settings + HardwareSettings + + + + pointing + keyboard + + + + 768 + + + + + + + + +
diff --git a/packaging/linux/flatpak/org.openlogi.OpenLogi.yml b/packaging/linux/flatpak/org.openlogi.OpenLogi.yml new file mode 100644 index 000000000..dcce7c5d8 --- /dev/null +++ b/packaging/linux/flatpak/org.openlogi.OpenLogi.yml @@ -0,0 +1,144 @@ +# Flatpak manifest for OpenLogi. +# +# Built into a single-file `.flatpak` bundle and attached to each release, next +# to the .deb/.rpm/.pkg.tar.zst. It is the install route for the distributions +# where none of those three is native — the immutable ones especially, where +# Flatpak is simply how software is installed. +# +# A bundle is a one-time install, not a subscription: `flatpak update` has no +# remote to check. That is a deliberate limit of this change rather than an +# oversight, and closing it needs a repository to serve, which is a larger +# decision than adding a build artifact. +# +# Nothing about the application is patched here. Only the build environment +# lives in this manifest, because that part is genuinely packaging's problem: +# +# - rustup rather than org.freedesktop.Sdk.Extension.rust-stable. The +# workspace sets `rust-version` to whatever stable is current, and the SDK +# extension trails it by weeks (1.97.1 against a workspace asking for 1.98), +# which cargo refuses outright. rustup installs what rust-toolchain.toml +# names, so the build tracks this repository rather than the SDK's cadence. +# - llvm20, because openlogi-camera pulls v4l2-sys-mit, whose build script +# runs bindgen and dlopens libclang. The base SDK ships none. +# +# Local build, from the repository root: +# flatpak-builder --user --install --force-clean \ +# build packaging/linux/flatpak/org.openlogi.OpenLogi.yml +id: org.openlogi.OpenLogi +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.llvm20 +command: openlogi-desktop + +finish-args: + # ── display ──────────────────────────────────────────────────────────────── + - --socket=wayland + - --socket=fallback-x11 + - --share=ipc + # GPUI renders through wgpu/Vulkan. + - --device=dri + + # ── device access ────────────────────────────────────────────────────────── + # /dev/hidraw* for HID++ I/O, /dev/input/event* for the evdev read side of the + # hook, /dev/uinput for the synthetic-event write side. `--device=all` is the + # only permission covering hidraw and uinput; `--device=input` leaves both out. + # + # This grants the sandbox a path to those nodes, not DAC permission: the host + # still needs 70-openlogi.rules installed, exactly as a distro package would. + # The rules ship inside the app at /app/share/openlogi/udev/ for that purpose. + - --device=all + + # ── network ──────────────────────────────────────────────────────────────── + # Device render images are fetched from the asset mirrors and cached in the + # app's data dir. No account, no telemetry. + - --share=network + + # ── D-Bus ────────────────────────────────────────────────────────────────── + # Media-key actions drive whatever player is on the bus, over MPRIS. + - --talk-name=org.mpris.MediaPlayer2.* + +modules: + - name: openlogi + buildsystem: simple + build-options: + append-path: /run/build/openlogi/cargo/bin:/usr/lib/sdk/llvm20/bin + prepend-ld-library-path: /usr/lib/sdk/llvm20/lib + build-args: + # Cargo resolves crates.io and the workspace's git dependencies during + # the build, and rustup fetches the toolchain. Fine here; a Flathub + # submission would have to vendor all of it, since Flathub's builders + # have no network at all. + - --share=network + env: + CARGO_HOME: /run/build/openlogi/cargo + RUSTUP_HOME: /run/build/openlogi/rustup + # Where bindgen looks for libclang (v4l2-sys-mit's build script). + LIBCLANG_PATH: /usr/lib/sdk/llvm20/lib + # The GPUI crates are the long pole; debug info is dead weight here. + CARGO_PROFILE_RELEASE_DEBUG: 'false' + build-commands: + # No version is pinned here on purpose: rust-toolchain.toml is the source + # of truth, and rustup installs exactly what it names. + - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs + | sh -s -- -y --profile minimal --no-modify-path + + - cargo build --release --locked + -p openlogi -p openlogi-desktop -p openlogi-overlay -p openlogi-agent + + # ── binaries ───────────────────────────────────────────────────────── + # All four. The GUI owns no device I/O (it is a pure IPC client) and it + # launches the agent from beside its own executable, so a bundle missing + # the agent starts, renders, and lists nothing. + - install -Dm755 target/release/openlogi /app/bin/openlogi + - install -Dm755 target/release/openlogi-desktop /app/bin/openlogi-desktop + - install -Dm755 target/release/openlogi-overlay /app/bin/openlogi-overlay + - install -Dm755 target/release/openlogi-agent /app/bin/openlogi-agent + + # ── XDG integration ────────────────────────────────────────────────── + # Flatpak requires both files to be named for the application ID, which + # the packaged .desktop entry is not, and there is no AppStream metadata + # in the tree at all — software centres need it to list the app. + - install -Dm644 packaging/linux/flatpak/org.openlogi.OpenLogi.desktop + /app/share/applications/org.openlogi.OpenLogi.desktop + - install -Dm644 packaging/linux/flatpak/org.openlogi.OpenLogi.metainfo.xml + /app/share/metainfo/org.openlogi.OpenLogi.metainfo.xml + + # design/icon/openlogi.png is 1024x1024, which `flatpak build-export` + # rejects outright ("Image too large. Max. size 512x512"), so the source + # icon is only ever installed rescaled. The SDK has no ImageMagick; + # ffmpeg is present and scales PNG fine. + - for px in 512 256 128 64; do + install -d "/app/share/icons/hicolor/${px}x${px}/apps"; + ffmpeg -loglevel error -i design/icon/openlogi.png -vf "scale=${px}:${px}" + "/app/share/icons/hicolor/${px}x${px}/apps/org.openlogi.OpenLogi.png"; + done + + # ── host-side udev rules ───────────────────────────────────────────── + # A Flatpak cannot write /etc/udev/rules.d. Ship the same rules the .deb + # and .rpm install so the user can copy them out; docs/INSTALL-linux.md + # carries the one-time command. + - install -Dm644 packaging/linux/udev/70-openlogi.rules + /app/share/openlogi/udev/70-openlogi.rules + + - install -Dm644 LICENSE-APACHE /app/share/licenses/openlogi/LICENSE-APACHE + - install -Dm644 LICENSE-MIT /app/share/licenses/openlogi/LICENSE-MIT + sources: + # The checkout itself, so a release build is exactly the tagged tree and a + # local build is exactly the working tree. `target` is skipped because a + # host build tree is both useless in the sandbox and large enough to slow + # the copy noticeably. + - type: dir + path: ../../.. + skip: + - target + - .git + - .direnv + - .devenv + # This build's own scratch state. Absent on a fresh checkout, so CI + # never sees it, but a second local build would otherwise copy the + # first one's tree into itself — gigabytes, and growing each time. + - .flatpak-builder + - build + - repo