Skip to content
Merged
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
124 changes: 124 additions & 0 deletions .github/workflows/release-linux.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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

# build-essential provides the compiler evdev's source build needs on
# aarch64. Icons come from the committed PNGs (`just icons`), so no
# SVG rasterizer is required.
- name: Install build tools
run: sudo apt-get update && sudo apt-get install -y 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

# The install helper extracts its assets with `--appimage-extract`, and
# falls back to this launcher flag when a binfmt wrapper intercepts the
# AppImage first. Exercise the fallback so a release can't ship without
# the assets it needs.
- name: Smoke-test integration extraction
run: |
tmp="$(mktemp -d)"
APPIMAGE_EXTRACT_AND_RUN=1 dist/deckd-*.AppImage --extract-integration "$tmp"
test -f "$tmp/70-deckd-uinput.rules"
test -f "$tmp/deckd.png"
test -d "$tmp/gnome-shell/deckd-focus@local"
test -f "$tmp/install-system-integration.sh"

- 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
87 changes: 87 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,93 @@ 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/"
# Icons: the committed PNGs are the single source of truth (regenerate with
# `just icons`). appimagetool turns the root deckd.png into .DirIcon, and the
# hicolor copies let desktop integration resolve the .desktop's Icon=deckd.
[ -f client/public/icon-512.png ] \
|| { echo "missing client/public/icon-512.png; run: just icons" >&2; exit 1; }
cp client/public/icon-512.png "$appdir/deckd.png"
for size in 192 512; do
mkdir -p "$appdir/usr/share/icons/hicolor/${size}x${size}/apps"
cp "client/public/icon-${size}.png" \
"$appdir/usr/share/icons/hicolor/${size}x${size}/apps/deckd.png"
done
# The install helper copies the icon into the user's theme, so it rides
# along in the integration tree it extracts.
cp client/public/icon-512.png "$appdir/usr/share/deckd/integration/deckd.png"

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/"
cp client/public/icon-512.png "$stage/deckd.png"
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