diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..0e7e80c --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -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 diff --git a/Justfile b/Justfile index 9bbf3d5..cc92e12 100644 --- a/Justfile +++ b/Justfile @@ -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 @@ -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/" diff --git a/daemon/deckd/macos_app.py b/daemon/deckd/macos_app.py index d9d8add..564c25f 100644 --- a/daemon/deckd/macos_app.py +++ b/daemon/deckd/macos_app.py @@ -14,6 +14,7 @@ import argparse import asyncio import logging +import os import shutil import sys import threading @@ -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. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 0f74438..6f3d8e3 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -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-.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). diff --git a/docs/PLATFORM-PARITY.md b/docs/PLATFORM-PARITY.md index 6a697ee..a14e7af 100644 --- a/docs/PLATFORM-PARITY.md +++ b/docs/PLATFORM-PARITY.md @@ -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 diff --git a/packaging/macos/deckd.spec b/packaging/macos/deckd.spec index 8a9f7bc..05dda4f 100644 --- a/packaging/macos/deckd.spec +++ b/packaging/macos/deckd.spec @@ -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. @@ -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), ) diff --git a/tests/test_app.py b/tests/test_app.py index 1bdc6ea..697e370 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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"