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
35 changes: 35 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,41 @@ metrics:
set -euo pipefail
deckctl metrics

# 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
# client first if it's missing. Output is ad-hoc signed, not notarized.
build-macos-app:
#!/usr/bin/env bash
set -euo pipefail
if [ "$(uname)" != "Darwin" ]; then
echo "build-macos-app needs macOS; a .app can't be built on $(uname)." >&2
exit 1
fi
if ! command -v pyinstaller >/dev/null 2>&1; then
echo "installing PyInstaller ([packaging] extra)..."
uv pip install -e ".[packaging]"
fi
if [ ! -f client/dist/index.html ]; then
echo "client/dist missing; building client..."
just build-client
fi
pyinstaller --noconfirm --clean packaging/macos/deckd.spec
echo "Built dist/deckd.app (ad-hoc signed, not notarized)."

# Wrap dist/deckd.app in a distributable DMG for a GitHub release (#165).
build-macos-dmg: build-macos-app
#!/usr/bin/env bash
set -euo pipefail
version="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1)"
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
cp -R dist/deckd.app "$stage/"
ln -s /Applications "$stage/Applications"
hdiutil create -volname "deckd ${version}" -srcfolder "$stage" \
-ov -format UDZO "dist/deckd-${version}.dmg"
echo "Built dist/deckd-${version}.dmg"

# 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**
- [ ] **Packing and deployment** ([#165](https://github.com/jonocodes/deckd/issues/165))

## Inspiration and Comparison

Expand Down
63 changes: 51 additions & 12 deletions daemon/deckd/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,20 @@
from .server import PortInUseError, Server


async def _run(server: Server) -> None:
async def serve(
server: Server,
*,
install_signal_handlers: bool = True,
on_started: "Callable[[asyncio.Task[None]], None] | None" = None,
) -> None:
"""Run ``server`` until cancelled, then stop it.

``install_signal_handlers`` is the CLI path (Ctrl-C / SIGTERM cancel the
server). The packaged macOS app owns the main thread for AppKit and runs
this on a background thread, where ``loop.add_signal_handler`` is illegal
— it passes ``False`` and cancels via ``on_started``'s task handle
(issue #165).
"""
loop = asyncio.get_running_loop()
server_task = asyncio.create_task(server.start())
server.start_focus_watcher()
Expand All @@ -42,8 +55,12 @@ async def _run(server: Server) -> None:
# watcher above.
server.start_session_state_watcher()

for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, server_task.cancel)
if on_started is not None:
on_started(server_task)

if install_signal_handlers:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, server_task.cancel)

try:
await server_task
Expand Down Expand Up @@ -95,9 +112,7 @@ def _build_sinks() -> tuple[object | None, ScrollSink, KeySink]:
return None, LoggingScrollSink(), LoggingKeySink()


def main() -> None:
from .bind import DEFAULT_BIND

def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="deckd")
parser.add_argument(
"--bind",
Expand Down Expand Up @@ -187,10 +202,27 @@ def main() -> None:
help="Append logs to this path in addition to stderr (issue #70).",
)
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
return parser


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = _build_parser()
args = parser.parse_args(argv)

if not 0 <= args.scroll_momentum_friction < 1:
parser.error("--scroll-momentum-friction must be >= 0 and < 1")
return args


def build_server(args: argparse.Namespace) -> Server:
"""Assemble a ``Server`` from parsed CLI args.

Split out of ``main`` so the packaged macOS app can construct a server
with its own args and run it on a background thread (issue #165). Raises
``PasswordError`` when the password file is untrustworthy; ``main`` turns
that into a CLI refusal.
"""
from .bind import DEFAULT_BIND

setup_logging(
level=logging.DEBUG if args.verbose else logging.INFO,
Expand Down Expand Up @@ -233,10 +265,7 @@ def main() -> None:
)
else:
password_path = args.password_file or default_password_path()
try:
password = load_or_create_password(password_path)
except PasswordError as exc:
parser.error(str(exc))
password = load_or_create_password(password_path)

# ``dbus-fast`` is an optional extra (issue #27). It backs the ``dbus:``
# action primitive and MPRIS now-playing — both Linux-only in practice
Expand Down Expand Up @@ -322,8 +351,18 @@ async def spa(_req):
)
server.app.router.add_static("/", args.client_dist, show_index=False, append_version=False)

return server


def main() -> None:
args = parse_args()
try:
server = build_server(args)
except PasswordError as exc:
logging.getLogger("deckd").error("%s", exc)
raise SystemExit(2) from None
try:
asyncio.run(_run(server))
asyncio.run(serve(server))
except PortInUseError as exc:
# Fail fast with the actionable message instead of a raw asyncio
# traceback ending in OSError: [Errno 98].
Expand Down
197 changes: 197 additions & 0 deletions daemon/deckd/macos_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Helpers for the packaged macOS app bundle (issue #165).

Platform-independent pieces of the menu-bar wrapper: resource discovery,
first-run layout seeding, argv construction for the embedded server, and a
background-thread server runner. These live in the daemon package (rather
than ``packaging/macos/menubar.py``) so they can be unit-tested on Linux —
AppKit itself cannot be.

The wrapper that uses them is ``packaging/macos/menubar.py``, frozen by
``packaging/macos/deckd.spec`` into ``deckd.app``.
"""
from __future__ import annotations

import argparse
import asyncio
import logging
import shutil
import sys
import threading
from pathlib import Path

log = logging.getLogger("deckd.macos_app")

BUNDLE_ID = "com.deckd.daemon"
DEFAULT_PORT = 8765

# The daemon already defaults its password to ``~/.config/deckd/password``;
# keep that so the app and a CLI run share one secret. Layouts, however,
# must be writable (the editor saves back to disk) and the bundle is
# read-only, so they are seeded into Application Support on first run.
APP_SUPPORT_DIRNAME = "deckd"


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

PyInstaller sets ``sys._MEIPASS`` to the onedir payload (inside
``deckd.app/Contents/Frameworks``); in a source checkout we fall back to
the repo root so ``menubar.py`` 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) -> Path:
"""Bundled macOS overlay layouts (``layouts.macos``)."""
return root / "layouts.macos"


def app_support_dir() -> Path:
"""``~/Library/Application Support/deckd`` — the writable data dir."""
return Path.home() / "Library" / "Application Support" / APP_SUPPORT_DIRNAME


def default_log_file() -> Path:
"""``~/Library/Logs/deckd.log`` — where the app tees its logs."""
return Path.home() / "Library" / "Logs" / "deckd.log"


def seed_layouts(src: Path, dest: Path, *, overlay: Path | None = None) -> 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 app upgrade (mirrors the Nix module's seed-once
behaviour). The per-platform overlay is copied to the sibling
``<dest>.macos`` 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}.macos"
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 app passes to ``parse_args``.

Localhost-only unless ``bind`` is given (the menu's LAN toggle supplies
``["0.0.0.0"]``), 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


class ServerRunner:
"""Run the deckd asyncio server on a background thread.

The macOS app owns the main thread for AppKit, so the server gets its own
event loop on a daemon thread. ``start()`` is idempotent while running;
``stop()`` cancels the server task (so ``serve`` runs its ``server.stop()``
cleanup) and joins the thread.
"""

def __init__(self, args: argparse.Namespace) -> None:
self._args = args
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._task: asyncio.Task[None] | None = None
self._server: object | None = None
self._error: BaseException | None = None
self._ready = threading.Event()

@property
def error(self) -> BaseException | None:
"""The exception that ended the server thread, if any."""
return self._error

def wait_ready(self, timeout: float | None = None) -> bool:
"""Block until the server task is created (or the thread exits)."""
return self._ready.wait(timeout)

def start(self) -> None:
if self._thread is not None and self._thread.is_alive():
return
self._error = None
self._ready.clear()
self._thread = threading.Thread(
target=self._run, name="deckd-server", daemon=True
)
self._thread.start()

def _run(self) -> None:
from .__main__ import build_server, serve

loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
try:
self._server = build_server(self._args)
loop.run_until_complete(
serve(
self._server, # type: ignore[arg-type]
install_signal_handlers=False,
on_started=self._on_started,
)
)
except BaseException as exc: # noqa: BLE001 — surfaced via ``error``
self._error = exc
log.exception("deckd server thread exited")
finally:
self._loop = None
self._task = None
self._server = None
loop.close()
self._ready.set()

def _on_started(self, task: asyncio.Task[None]) -> None:
self._task = task
self._ready.set()

def stop(self, timeout: float = 5.0) -> None:
loop = self._loop
task = self._task
if loop is not None and not loop.is_closed() and task is not None:
loop.call_soon_threadsafe(task.cancel)
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=timeout)
self._thread = None
17 changes: 17 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,23 @@ tail -f deckd.log # follow logs (written in the checkout)

**Prefer not to use `just`?** The recipes are thin wrappers you can run by hand — `install-service` is a path-substituting `sed` into `~/.config/systemd/user/` (or `~/Library/LaunchAgents/`) followed by the `systemctl --user enable --now` / `launchctl load` above; `install-focus-extension` is `gnome-extensions pack/install/enable` on `packaging/gnome-shell/deckd-focus@local`. See the `Justfile` for the exact commands.

### macOS app bundle (experimental, [#165](https://github.com/jonocodes/deckd/issues/165))

Beyond the source-checkout + LaunchAgent path above, deckd can be built as a self-contained `deckd.app`: a private Python runtime, the built client, and the layouts in one bundle, driven by a menu-bar UI. The target Mac needs no Python, Node, or Homebrew. Build it **on a Mac** (a `.app` needs Apple tooling):

```sh
just build-macos-app # -> dist/deckd.app
just build-macos-dmg # -> dist/deckd-<version>.dmg
```

The bundle is **ad-hoc signed, not notarized** (no paid Apple Developer Program). A DMG downloaded through a browser is quarantined by Gatekeeper, so the first launch needs either right-click → Open, or:

```sh
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.

**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).

## Nix flake, NixOS, and home-manager
Expand Down
Loading
Loading