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
95 changes: 95 additions & 0 deletions .github/workflows/release-macos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Release macOS app

# Phase 2 of #165: build the self-contained deckd.app on a macOS runner,
# wrap it in a DMG, and attach it to the GitHub release.
#
# The build itself is `just build-macos-dmg` — the same recipe a contributor
# runs locally (docs/GUIDE.md § "macOS app bundle") — so CI and the documented
# path can't drift. This job only supplies the runner, the toolchain, and the
# publish step.
#
# arm64 only: macos-14 runners are Apple Silicon, so the DMG targets Apple
# Silicon Macs. An Intel / universal2 build is a documented follow-up
# (issue #165, "Open questions").
#
# The bundle is ad-hoc signed and NOT notarized (no paid Apple Developer
# Program), so a browser-downloaded DMG is quarantined — first launch needs
# right-click → Open or `xattr -dr com.apple.quarantine /Applications/deckd.app`.

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

permissions:
contents: write

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

jobs:
dmg:
runs-on: macos-14
env:
# A pushed tag, or the tag supplied to a manual run.
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

# PyObjC (the menu-bar wrapper) + PyInstaller. Installing these first
# also stops the recipe's `uv pip install` fallback from firing.
- name: Install Python deps
run: pip install -e ".[macos,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 DMG name,
# its volume name, and the app's CFBundleShortVersionString all match
# the tag. Rough CalVer (v2026.09.23) or any other `v*` string works.
- name: Resolve version from the tag
run: echo "DECKD_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"

- name: Build .app + DMG
run: just build-macos-dmg

- name: Verify the DMG
run: hdiutil verify dist/deckd-*.dmg

- uses: actions/upload-artifact@v4
with:
name: deckd-macos-dmg
path: dist/deckd-*.dmg
if-no-files-found: error

- name: Attach the DMG 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-*.dmg --clobber
9 changes: 8 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,11 @@ metrics:
set -euo pipefail
deckctl --port {{DECKD_PORT}} metrics

# Print the version from pyproject.toml. The single source for artifact
# names (the DMG) and the release tag guard, so they can't drift.
version:
@sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1

# Build the self-contained macOS app bundle (dist/deckd.app, issue #165).
# macOS only: a .app needs Apple tooling, so this refuses to run elsewhere.
# Installs the [packaging] extra (PyInstaller) on demand and builds the
Expand All @@ -463,10 +468,12 @@ build-macos-app:
echo "Built dist/deckd.app (ad-hoc signed, not notarized)."

# Wrap dist/deckd.app in a distributable DMG for a GitHub release (#165).
# ``DECKD_VERSION`` names the artifact (the release workflow sets it from the
# git tag); otherwise it falls back to pyproject's version.
build-macos-dmg: build-macos-app
#!/usr/bin/env bash
set -euo pipefail
version="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1)"
version="${DECKD_VERSION:-$(just version)}"
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
cp -R dist/deckd.app "$stage/"
Expand Down
47 changes: 47 additions & 0 deletions daemon/deckd/macos_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import argparse
import asyncio
import logging
import os
import shutil
import sys
import threading
Expand Down Expand Up @@ -69,6 +70,52 @@ def default_log_file() -> Path:
return Path.home() / "Library" / "Logs" / "deckd.log"


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

``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 DMG
name, ``CFBundleShortVersionString``, and volume name all agree. 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 bundle_info_plist(version: str) -> dict[str, object]:
"""Info.plist entries for ``deckd.app`` (issue #165).

Kept here (not inline in ``deckd.spec``) so the TCC-relevant keys are
unit-testable on the Linux dev/CI hosts.

``NSAppleEventsUsageDescription`` is load-bearing: the daemon drives
keystrokes and focus by shelling out to ``osascript`` → System Events,
and macOS attributes those Apple Events to the *responsible process* —
the bundle, not the ``osascript`` child. Without this string the
Automation prompt can't be shown, so macOS refuses the event
(``errAEEventNotPermitted``, -1743) — the silent failure #165 fixes.
"""
return {
"LSUIElement": True,
"CFBundleName": "deckd",
"CFBundleDisplayName": "deckd",
"CFBundleShortVersionString": version,
"CFBundleVersion": version,
"LSMinimumSystemVersion": "12.0",
"NSHighResolutionCapable": True,
"NSAppleEventsUsageDescription": (
"deckd sends keystrokes and focuses windows through System "
"Events when you press buttons on your deck."
),
}


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

Expand Down
14 changes: 13 additions & 1 deletion docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,19 @@ The bundle is **ad-hoc signed, not notarized** (no paid Apple Developer Program)
xattr -dr com.apple.quarantine /Applications/deckd.app
```

Then grant the TCC permissions as for the source build (see [macOS](#macos) above): Accessibility, System Events, and — for window titles — Screen Recording. The app seeds layouts into `~/Library/Application Support/deckd/layouts` on first run and logs to `~/Library/Logs/deckd.log`. The menu offers Open surface / Open layouts folder / Restart server / Allow LAN access / Quit; it stays localhost-only until you enable LAN access. This path is not yet verified on hardware.
Then grant the TCC permissions as for the source build (see [macOS](#macos) above): Accessibility, System Events, and — for window titles — Screen Recording. The app seeds layouts into `~/Library/Application Support/deckd/layouts` on first run and logs to `~/Library/Logs/deckd.log`. The menu offers Open surface / Open layouts folder / Restart server / Allow LAN access / Quit; it stays localhost-only until you enable LAN access.

Because the bundle is ad-hoc signed, its code identity is its content hash: **every rebuild changes it, so macOS may ask you to re-grant Accessibility / System Events after an upgrade.** Remove the stale `deckd` entry from System Settings → Privacy & Security and re-add the app if a grant stops working.

Verified so far (macOS 26.6.2, Apple Silicon): the bundle builds, launches as a menu-bar app, seeds layouts, and serves the surface on `127.0.0.1:8765` with logs in `~/Library/Logs/deckd.log`. The injected-input features still depend on the three TCC grants, which need a human on the target Mac to confirm — the same caveat as the source build.

#### Releasing a DMG

Pushing a `v*` tag runs [`.github/workflows/release-macos.yml`](../.github/workflows/release-macos.yml) on a `macos-14` runner: it builds the client, freezes the app, wraps it in a DMG (the same `just build-macos-dmg` recipe above), and attaches the DMG to the GitHub release for that tag. A manual **Run workflow** can attach to an existing tag.

The tag (minus a leading `v`) is the version for that release: it names the DMG (`deckd-<version>.dmg`), its volume, and the app's `CFBundleShortVersionString`. So a rough CalVer tag like `v2026.09.23` yields `deckd-2026.09.23.dmg`. No scheme is enforced — any `v*` string works. For a local build without a tag, `just build-macos-dmg` falls back to `version` in `pyproject.toml`.

The DMG is **arm64 only** (Apple Silicon): `macos-14` runners are Apple Silicon. An Intel / `universal2` build is a follow-up ([#165](https://github.com/jonocodes/deckd/issues/165), "Open questions").

**NixOS** users can skip all of the above — the flake's home-manager module owns the same user service, and the NixOS module owns the udev rule and `input` group. See [Nix flake, NixOS, and home-manager](#nix-flake-nixos-and-home-manager).

Expand Down
2 changes: 2 additions & 0 deletions docs/PLATFORM-PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ one of three evidence levels:
| MPRIS media browser | human-observed **not working**; now says so | no session bus on macOS ([#56](https://github.com/jonocodes/deckd/issues/56) tracks a native replacement). Verified live: the daemon's connect frame is `{"supported": false, …}` |
| `media` widget (VLC HTTP) | **unverified** | nobody has pointed it at a VLC on a Mac |

**Packaged app ([#165](https://github.com/jonocodes/deckd/issues/165)), checked 2026-09-23 (macOS 26.6.2, Apple Silicon):** the self-contained `deckd.app` builds, launches as a menu-bar app, and serves the surface on loopback — see `docs/GUIDE.md` § "macOS app bundle" for the run and its caveats. The rows above apply to it unchanged: the same TCC grants gate the same features, and because the bundle is ad-hoc signed the grant keys on its content hash, so a rebuild may need re-granting.

The Linux columns are code-and-CI truth. No dated hardware run backs them, and
the GNOME rows in particular have a history of passing tests while broken on a
live session — treat them as *unverified* until someone repeats the exercise
Expand Down
30 changes: 15 additions & 15 deletions packaging/macos/deckd.spec
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,23 @@ and layouts are copied in as data under ``Contents/Frameworks`` (where
``sys._MEIPASS`` points at runtime).
"""
import os
import sys
from pathlib import Path

ROOT = Path(SPECPATH).resolve().parents[1] # SPECPATH is packaging/macos

version = "0.0.1"
for line in (ROOT / "pyproject.toml").read_text().splitlines():
if line.startswith("version = "):
version = line.split("=", 1)[1].strip().strip('"')
break
# Importable even when the package isn't pip-installed (e.g. a bare
# ``pyinstaller packaging/macos/deckd.spec`` from a checkout).
sys.path.insert(0, str(ROOT / "daemon"))
from deckd.macos_app import ( # noqa: E402
BUNDLE_ID,
bundle_info_plist,
bundle_version,
)

# ``DECKD_VERSION`` (set by the release workflow from the git tag) wins;
# otherwise pyproject's ``version``.
version = bundle_version(ROOT / "pyproject.toml")

# Optional custom icon: generate an .icns (e.g. from client/public/icon.svg)
# and point DECKD_ICON at it, otherwise the default PyInstaller icon is used.
Expand Down Expand Up @@ -88,14 +96,6 @@ app = BUNDLE(
coll,
name="deckd.app",
icon=icon,
bundle_identifier="com.deckd.daemon",
info_plist={
"LSUIElement": True,
"CFBundleName": "deckd",
"CFBundleDisplayName": "deckd",
"CFBundleShortVersionString": version,
"CFBundleVersion": version,
"LSMinimumSystemVersion": "12.0",
"NSHighResolutionCapable": True,
},
bundle_identifier=BUNDLE_ID,
info_plist=bundle_info_plist(version),
)
33 changes: 33 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,39 @@ def test_seed_layouts_first_run_then_noop(tmp_path: Path) -> None:
assert "edited" in (dest / "default.yaml").read_text()


def test_bundle_version_prefers_env_override(monkeypatch, tmp_path: Path) -> None:
# The release workflow passes the git tag (minus its `v`) this way, so a
# rough CalVer tag is fine — no PEP 440 normalisation applies here.
monkeypatch.setenv("DECKD_VERSION", "2026.09.23")
assert macos_app.bundle_version(tmp_path / "pyproject.toml") == "2026.09.23"


def test_bundle_version_falls_back_to_pyproject(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("DECKD_VERSION", " ")
pyproject = tmp_path / "pyproject.toml"
_write(pyproject, '[project]\nname = "deckd"\nversion = "1.2.3"\n')
assert macos_app.bundle_version(pyproject) == "1.2.3"


def test_bundle_info_plist_declares_tcc_usage() -> None:
plist = macos_app.bundle_info_plist("1.2.3")
assert plist["CFBundleShortVersionString"] == "1.2.3"
assert plist["CFBundleVersion"] == "1.2.3"
# Menu-bar only: no Dock icon, no main window.
assert plist["LSUIElement"] is True
# The daemon drives System Events via osascript; without this string
# macOS denies the Apple Event with no prompt (issue #165).
assert plist["NSAppleEventsUsageDescription"]


def test_bundle_info_plist_has_no_empty_strings() -> None:
# An empty usage description suppresses the TCC prompt, so none of the
# string values may be blank.
for key, value in macos_app.bundle_info_plist("0.0.1").items():
if isinstance(value, str):
assert value.strip(), key


def test_app_argv_defaults_to_localhost(tmp_path: Path) -> None:
argv = macos_app.app_argv(
layouts_dir=tmp_path / "layouts", client_dist=tmp_path / "web"
Expand Down
Loading