Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/release-linux.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Release Linux AppImage

# Phase 2 of #168: build the self-contained deckd AppImage on a Linux runner
# and attach it to the GitHub release.
#
# The build itself is `just build-linux-appimage` — the same recipe a
# contributor runs locally (docs/GUIDE.md § "Linux AppImage") — so CI and the
# documented path can't drift. This job only supplies the runner, the
# toolchain, and the publish step.
#
# Two architectures, one matrix: x86_64 on ubuntu-24.04 (arm64 hosted runners
# are free for public repos). aarch64 has no evdev-binary wheel, so the recipe
# source-builds python-evdev before freezing.
#
# The AppImage is unsigned; users run it directly (`chmod +x`, then execute).
# The root-only uinput step rides along as an install helper bundled inside the
# AppImage at usr/share/deckd/integration/install-system-integration.sh.

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Release tag to attach the AppImage to (e.g. v0.1.0)"
required: true
type: string

permissions:
contents: write

# Serialise publishes per tag: a second run must not `--clobber` an AppImage
# the first is still uploading. In-flight runs are not cancelled.
concurrency:
group: release-linux-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false

jobs:
appimage:
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
arch: x86_64
- runner: ubuntu-24.04-arm
arch: aarch64
runs-on: ${{ matrix.runner }}
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: client/package-lock.json

- uses: extractions/setup-just@v4

# librsvg2-bin gives the recipe rsvg-convert for the icon; build-essential
# provides the compiler evdev's source build needs on aarch64.
- name: Install build tools
run: sudo apt-get update && sudo apt-get install -y librsvg2-bin build-essential

# evdev (uinput) + dbus-fast ([dbus]) + PyInstaller. Installing these
# first also stops the recipe's `uv pip install` fallback from firing.
- name: Install Python deps
run: pip install -e ".[uinput,dbus,packaging]"

# `npm run build` already runs `tsc --noEmit`, so a type error fails
# the release before a broken bundle is frozen in.
- name: Build client
run: npm ci && npm run build
working-directory: client

# The tag (minus a leading `v`) is the release version, so the AppImage
# name and the daemon's version agree with the tag.
- name: Resolve version from the tag
run: echo "DECKD_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"

- name: Build AppImage
run: just build-linux-appimage

# Boot the frozen payload: --help parses args and exits before the
# server starts, which proves the bundle's interpreter and imports work.
- name: Smoke-test the AppImage
run: APPIMAGE_EXTRACT_AND_RUN=1 dist/deckd-*.AppImage --help >/dev/null

- uses: actions/upload-artifact@v4
with:
name: deckd-linux-appimage-${{ matrix.arch }}
path: |
dist/deckd-*.AppImage
dist/deckd-install-system-integration.sh
if-no-files-found: error

- name: Attach the AppImage to the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view "$RELEASE_TAG" >/dev/null 2>&1 \
|| gh release create "$RELEASE_TAG" --title "$RELEASE_TAG" --generate-notes
gh release upload "$RELEASE_TAG" \
dist/deckd-*.AppImage dist/deckd-install-system-integration.sh --clobber
82 changes: 82 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,88 @@ build-macos-dmg: build-macos-app
-ov -format UDZO "dist/deckd-${version}.dmg"
echo "Built dist/deckd-${version}.dmg"

# Build the self-contained Linux AppImage (dist/deckd-<version>-<arch>.AppImage,
# issue #168). Linux only: appimagetool wraps a PyInstaller onedir tree in a
# squashfs. Installs the [uinput,dbus,packaging] extras on demand and builds
# the client first if it's missing. The udev rule and focus-watcher sources ride
# along under usr/share/deckd/integration for the install helper.
build-linux-appimage:
#!/usr/bin/env bash
set -euo pipefail
if [ "$(uname)" != "Linux" ]; then
echo "build-linux-appimage needs Linux; an AppImage can't be built on $(uname)." >&2
exit 1
fi
arch="$(uname -m)"
if ! command -v pyinstaller >/dev/null 2>&1; then
echo "installing packaging deps..."
uv pip install -e ".[uinput,dbus,packaging]"
fi
if [ "$arch" != "x86_64" ]; then
echo "note: $arch has no evdev-binary wheel; ensuring a source build." >&2
PYTHON="$(command -v python)" bash scripts/install_evdev_source.sh \
|| echo "warn: evdev source build failed; key injection will no-op." >&2
fi
if [ ! -f client/dist/index.html ]; then
echo "client/dist missing; building client..."
just build-client
fi
pyinstaller --noconfirm --clean packaging/linux/deckd.spec

version="${DECKD_VERSION:-$(just version)}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
appdir="$work/deckd.AppDir"
mkdir -p "$appdir/usr/bin" "$appdir/usr/share/deckd/integration/gnome-shell" \
"$appdir/usr/share/deckd/integration/kwin-script"
cp -R dist/deckd/. "$appdir/usr/bin/"
cp packaging/udev/70-deckd-uinput.rules "$appdir/usr/share/deckd/integration/"
cp -R packaging/gnome-shell/deckd-focus@local "$appdir/usr/share/deckd/integration/gnome-shell/"
cp -R packaging/kwin-script/deckd-focus "$appdir/usr/share/deckd/integration/kwin-script/"
cp packaging/linux/install-system-integration.sh "$appdir/usr/share/deckd/integration/"
cp packaging/linux/appimage/AppRun "$appdir/AppRun"
chmod +x "$appdir/AppRun"
cp packaging/linux/appimage/deckd.desktop "$appdir/"
# appimagetool wants deckd.png (or deckd.svg) at the AppDir root.
if command -v rsvg-convert >/dev/null 2>&1; then
rsvg-convert -w 512 -h 512 -o "$appdir/deckd.png" client/public/icon.svg
elif command -v magick >/dev/null 2>&1; then
magick -background none client/public/icon.svg -resize 512x512 "$appdir/deckd.png"
elif command -v convert >/dev/null 2>&1; then
convert -background none client/public/icon.svg -resize 512x512 "$appdir/deckd.png"
else
cp client/public/icon.svg "$appdir/deckd.svg"
fi

tooling="${XDG_CACHE_HOME:-$HOME/.cache}/deckd/appimagetool-${arch}.AppImage"
if [ ! -x "$tooling" ]; then
echo "fetching appimagetool (${arch})..."
mkdir -p "$(dirname "$tooling")"
curl -fsSL -o "$tooling" \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
chmod +x "$tooling"
fi
export ARCH="$arch"
APPIMAGE_EXTRACT_AND_RUN=1 "$tooling" "$appdir" "dist/deckd-${version}-${arch}.AppImage"
cp packaging/linux/install-system-integration.sh dist/deckd-install-system-integration.sh
echo "Built dist/deckd-${version}-${arch}.AppImage (+ dist/deckd-install-system-integration.sh)"

# Stage the integration assets from a checkout and run the privileged helper
# for the current user (issue #168): udev rule + input group (root), plus the
# focus watcher and an XDG autostart entry (user). Prompts for sudo. Useful for
# a source install and for testing the helper without building an AppImage.
# Extra args pass through (e.g. `--desktop gnome`, `--uninstall`).
install-system-integration *args:
#!/usr/bin/env bash
set -euo pipefail
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/gnome-shell" "$stage/kwin-script"
cp packaging/udev/70-deckd-uinput.rules "$stage/"
cp -R packaging/gnome-shell/deckd-focus@local "$stage/gnome-shell/"
cp -R packaging/kwin-script/deckd-focus "$stage/kwin-script/"
sudo packaging/linux/install-system-integration.sh --assets "$stage" {{args}}

# Run the Nix flake checks: builds packages.deckd and the focus-watcher
# bundles, evaluates the NixOS + home-manager modules, unit-tests the
# activation scripts in a sandbox, and boots the packaged daemon on
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Pre-alpha, but usable day-to-day. Here's what deckd can do today and what's stil
- [ ] **Multi-daemon chooser** — pair and pick between several desktops.
- [ ] **Reliable web-app detection** — a browser extension reporting the active tab's real URL, so sites match by domain/path instead of the current window-title heuristic ([#90](https://github.com/jonocodes/deckd/issues/90)).
- [ ] **Windows support**
- [ ] **Packing and deployment** ([#165](https://github.com/jonocodes/deckd/issues/165))
- [ ] **Packing and deployment** — self-contained macOS DMG ([#165](https://github.com/jonocodes/deckd/issues/165)) and Linux AppImage ([#168](https://github.com/jonocodes/deckd/issues/168))

## Inspiration and Comparison

Expand Down
116 changes: 116 additions & 0 deletions daemon/deckd/app_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Platform-independent helpers for the packaged desktop artifacts.

Both the macOS app bundle (#165) and the Linux AppImage (#168) need the same
mechanical pieces: locate the frozen payload, find the bundled client and
layouts, seed layouts into a writable directory on first run, read the version
seam, and build the daemon argv. Those live here so they can be unit-tested on
any host, independent of the platform that ships them.

``deckd.macos_app`` and ``deckd.linux_app`` re-export what their wrappers need.
"""
from __future__ import annotations

import os
import shutil
import sys
from pathlib import Path

DEFAULT_PORT = 8765


def resource_root() -> Path:
"""Directory holding the bundled payload.

PyInstaller sets ``sys._MEIPASS`` to the onedir payload; in a source
checkout we fall back to the repo root so the packaging entry points can be
exercised without freezing.
"""
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
return Path(meipass)
return Path(__file__).resolve().parents[2]


def client_dist(root: Path) -> Path:
"""Bundled client build (``client/dist`` copied to ``web``)."""
return root / "web"


def layouts_src(root: Path) -> Path:
"""Bundled layouts directory."""
return root / "layouts"


def overlay_src(root: Path, suffix: str) -> Path:
"""Bundled per-platform overlay layouts (``layouts.<suffix>``)."""
return root / f"layouts.{suffix}"


def bundle_version(pyproject: Path | None = None) -> str:
"""The version stamped into the artifact.

``DECKD_VERSION`` wins when set — release CI passes the git tag (minus a
leading ``v``), so the tag is the single source for a release and the
artifact name matches. Local builds fall back to ``version`` in
``pyproject.toml``.
"""
override = os.environ.get("DECKD_VERSION", "").strip()
if override:
return override
path = pyproject or Path(__file__).resolve().parents[2] / "pyproject.toml"
for line in path.read_text().splitlines():
if line.startswith("version = "):
return line.split("=", 1)[1].strip().strip('"')
raise ValueError(f"no version found in {path}")


def seed_layouts(
src: Path, dest: Path, *, overlay: Path | None = None, overlay_suffix: str = "macos"
) -> bool:
"""Copy bundled layouts into the writable data dir on first run.

Returns ``True`` when it seeded, ``False`` when ``dest`` already existed.
An existing directory is never overwritten, so a user's hand-edited
layouts survive an upgrade (mirrors the Nix module's seed-once behaviour).
The per-platform overlay is copied to the sibling ``<dest>.<suffix>``
directory the daemon auto-discovers.
"""
if dest.exists():
return False
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dest)
if overlay is not None and overlay.is_dir():
overlay_dest = dest.parent / f"{dest.name}.{overlay_suffix}"
if not overlay_dest.exists():
shutil.copytree(overlay, overlay_dest)
return True


def app_argv(
*,
layouts_dir: Path,
client_dist: Path,
port: int = DEFAULT_PORT,
bind: list[str] | None = None,
password_file: Path | None = None,
log_file: Path | None = None,
verbose: bool = False,
) -> list[str]:
"""Build the daemon argv the packaged entry points pass to ``parse_args``.

Localhost-only unless ``bind`` is given, so the default stays safe.
"""
argv = [
"--layouts-dir", str(layouts_dir),
"--client-dist", str(client_dist),
"--port", str(port),
]
for addr in bind or []:
argv += ["--bind", addr]
if password_file is not None:
argv += ["--password-file", str(password_file)]
if log_file is not None:
argv += ["--log-file", str(log_file)]
if verbose:
argv.append("--verbose")
return argv
Loading
Loading