From b32fe963d7448cb98c8623128e2bfcdfef0491d7 Mon Sep 17 00:00:00 2001 From: Jono Date: Wed, 23 Sep 2026 09:08:23 -0700 Subject: [PATCH] feat(macos): packaged app bundle scaffolding (#165) Phase 0 of the self-contained deckd.app: the daemon seam and the packaging artifacts, all buildable/testable on Linux except the AppKit wrapper. - Split __main__.main() into parse_args()/build_server()/serve() so the app can construct a Server and run it on a background thread without installing signal handlers. Pure refactor; _build_sinks and _overlay_dir_for are unchanged. - Add deckd.macos_app: resource discovery, seed-once layout install, argv construction, and a thread-based ServerRunner. - Add packaging/macos/menubar.py (PyObjC status item) and deckd.spec (PyInstaller, LSUIElement, ad-hoc signed) plus just build-macos-app and build-macos-dmg. - Add the [packaging] extra (PyInstaller), tests, and docs. Refs #165 --- Justfile | 35 +++++++ README.md | 2 +- daemon/deckd/__main__.py | 63 +++++++++--- daemon/deckd/macos_app.py | 197 +++++++++++++++++++++++++++++++++++++ docs/GUIDE.md | 17 ++++ packaging/macos/deckd.spec | 101 +++++++++++++++++++ packaging/macos/menubar.py | 147 +++++++++++++++++++++++++++ pyproject.toml | 6 ++ tests/test_app.py | 112 +++++++++++++++++++++ 9 files changed, 667 insertions(+), 13 deletions(-) create mode 100644 daemon/deckd/macos_app.py create mode 100644 packaging/macos/deckd.spec create mode 100644 packaging/macos/menubar.py create mode 100644 tests/test_app.py diff --git a/Justfile b/Justfile index 34efd3b..e1c5da6 100644 --- a/Justfile +++ b/Justfile @@ -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 diff --git a/README.md b/README.md index d459210..aace39b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/daemon/deckd/__main__.py b/daemon/deckd/__main__.py index e646e61..ec70c92 100644 --- a/daemon/deckd/__main__.py +++ b/daemon/deckd/__main__.py @@ -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() @@ -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 @@ -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", @@ -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, @@ -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 @@ -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]. diff --git a/daemon/deckd/macos_app.py b/daemon/deckd/macos_app.py new file mode 100644 index 0000000..d9d8add --- /dev/null +++ b/daemon/deckd/macos_app.py @@ -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 + ``.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 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index a7c6da2..380907c 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -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-.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 diff --git a/packaging/macos/deckd.spec b/packaging/macos/deckd.spec new file mode 100644 index 0000000..8a9f7bc --- /dev/null +++ b/packaging/macos/deckd.spec @@ -0,0 +1,101 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller spec: freeze the daemon + client into ``deckd.app`` (issue #165). + +Build on macOS (a .app needs Apple tooling): + + just build-macos-app + # or: pyinstaller --noconfirm --clean packaging/macos/deckd.spec + +Output: ``dist/deckd.app`` (ad-hoc signed by PyInstaller; not notarized). +The menu-bar entry point is ``packaging/macos/menubar.py``; the client build +and layouts are copied in as data under ``Contents/Frameworks`` (where +``sys._MEIPASS`` points at runtime). +""" +import os +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 + +# 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. +icon = os.environ.get("DECKD_ICON") +if icon and not Path(icon).is_file(): + raise SystemExit(f"DECKD_ICON set but not found: {icon}") + +datas = [ + (str(ROOT / "client/dist"), "web"), + (str(ROOT / "layouts"), "layouts"), +] +if (ROOT / "layouts.macos").is_dir(): + datas.append((str(ROOT / "layouts.macos"), "layouts.macos")) + +a = Analysis( + [str(ROOT / "packaging/macos/menubar.py")], + pathex=[str(ROOT / "daemon")], + binaries=[], + datas=datas, + hiddenimports=[ + "deckd.macos_app", + "deckd.platform_macos", + "objc", + "AppKit", + "Foundation", + "PyObjCTools", + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["tkinter", "pytest"], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="deckd", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="deckd", +) + +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, + }, +) diff --git a/packaging/macos/menubar.py b/packaging/macos/menubar.py new file mode 100644 index 0000000..f5e2859 --- /dev/null +++ b/packaging/macos/menubar.py @@ -0,0 +1,147 @@ +"""Menu-bar wrapper for the packaged deckd macOS app (issue #165). + +PyInstaller entry point for ``deckd.app``. AppKit owns the main thread (a +status item with a small menu); the deckd server runs on a background thread +via ``deckd.macos_app.ServerRunner``. + +Only frozen into the app bundle — the daemon never imports this. Requires the +``[macos]`` extra (PyObjC Cocoa). The platform-independent helpers live in +``deckd.macos_app`` so they can be unit-tested on Linux. +""" +from __future__ import annotations + +import logging +import webbrowser +from pathlib import Path + +from deckd.macos_app import ( + DEFAULT_PORT, + ServerRunner, + app_argv, + app_support_dir, + client_dist, + default_log_file, + layouts_src, + overlay_src, + resource_root, + seed_layouts, +) + +log = logging.getLogger("deckd.menubar") + + +def _surface_url(port: int = DEFAULT_PORT) -> str: + return f"http://127.0.0.1:{port}/" + + +def _prepare() -> tuple[Path, Path, Path]: + """Seed writable data, returning (layouts_dir, client_dist, log_file).""" + root = resource_root() + layouts_dir = app_support_dir() / "layouts" + if seed_layouts(layouts_src(root), layouts_dir, overlay=overlay_src(root)): + log.info("seeded bundled layouts into %s", layouts_dir) + log_file = default_log_file() + log_file.parent.mkdir(parents=True, exist_ok=True) + return layouts_dir, client_dist(root), log_file + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + + import objc + from AppKit import ( + NSApp, + NSApplication, + NSMenu, + NSMenuItem, + NSStatusBar, + NSURL, + NSVariableStatusItemLength, + NSWorkspace, + ) + from Foundation import NSObject + from PyObjCTools import AppHelper + + from deckd.__main__ import parse_args + + layouts_dir, web, log_file = _prepare() + + class MenuTarget(NSObject): + def initWithLayouts_lan_(self, layouts, lan): + self = objc.super(MenuTarget, self).init() + if self is None: + return None + self._layouts_dir = layouts + self._lan = lan + self._runner = None + self._start_runner() + return self + + def _start_runner(self): + bind = ["0.0.0.0"] if self._lan else None + argv = app_argv( + layouts_dir=self._layouts_dir, + client_dist=web, + bind=bind, + log_file=log_file, + ) + self._runner = ServerRunner(parse_args(argv)) + self._runner.start() + + def openSurface_(self, _sender): + webbrowser.open(_surface_url()) + + def openLayouts_(self, _sender): + NSWorkspace.sharedWorkspace().openURL_( + NSURL.fileURLWithPath_(str(self._layouts_dir)) + ) + + def restartServer_(self, _sender): + if self._runner is not None: + self._runner.stop() + self._start_runner() + + def toggleLan_(self, sender): + self._lan = not self._lan + sender.setState_(1 if self._lan else 0) + self.restartServer_(sender) + + def quit_(self, _sender): + if self._runner is not None: + self._runner.stop() + NSApp.terminate_(None) + + def add_item(menu, target, title, action, key=""): + item = menu.addItemWithTitle_action_keyEquivalent_(title, action, key) + item.setTarget_(target) + return item + + app = NSApplication.sharedApplication() + # Accessory: menu-bar only, no Dock icon (also declared via LSUIElement + # in Info.plist so the Dock icon never appears even for a moment). + app.setActivationPolicy_(1) + + target = MenuTarget.alloc().initWithLayouts_lan_(layouts_dir, False) + + status_item = NSStatusBar.systemStatusBar().statusItemWithLength_( + NSVariableStatusItemLength + ) + status_item.button().setTitle_("deckd") + + menu = NSMenu.alloc().init() + add_item(menu, target, "Open deckd surface", "openSurface:") + add_item(menu, target, "Open layouts folder", "openLayouts:") + add_item(menu, target, "Restart server", "restartServer:") + add_item(menu, target, "Allow LAN access", "toggleLan:") + menu.addItem_(NSMenuItem.separatorItem()) + add_item(menu, target, "Quit deckd", "quit:", "q") + status_item.setMenu_(menu) + + AppHelper.runEventLoop() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index e02515e..2bc204d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,12 @@ macos = [ "pyobjc-framework-Cocoa>=10.0", "pyobjc-framework-ApplicationServices>=10.0", ] +packaging = [ + # PyInstaller freezes the daemon + client into the self-contained + # deckd.app bundle (issue #165). macOS-only in practice, but installing + # it elsewhere is harmless; the build recipes refuse to run off-mac. + "pyinstaller>=6.0", +] uinput = [ # x86_64 gets the prebuilt evdev-binary wheel. Other arches (notably # aarch64) have no evdev-binary wheel, so fall back to the sdist `evdev`, diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..1bdc6ea --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,112 @@ +"""Tests for the macOS app-bundle helpers (issue #165). + +The AppKit wrapper itself (``packaging/macos/menubar.py``) is macOS-only and +not exercised here; everything platform-independent lives in +``deckd.macos_app`` so it runs on the Linux dev/CI hosts. +""" +from __future__ import annotations + +import asyncio +import threading +from pathlib import Path + +from deckd import macos_app + + +def _write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def test_resource_root_dev_fallback(monkeypatch) -> None: + monkeypatch.delattr(macos_app.sys, "_MEIPASS", raising=False) + root = macos_app.resource_root() + assert (root / "daemon" / "deckd").is_dir() + assert (root / "pyproject.toml").is_file() + + +def test_resource_root_frozen(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(macos_app.sys, "_MEIPASS", str(tmp_path), raising=False) + assert macos_app.resource_root() == tmp_path + + +def test_seed_layouts_first_run_then_noop(tmp_path: Path) -> None: + src = tmp_path / "bundled" + _write(src / "default.yaml", "id: default\n") + overlay = tmp_path / "bundled.macos" + _write(overlay / "firefox.yaml", "id: firefox\n") + dest = tmp_path / "support" / "layouts" + + assert macos_app.seed_layouts(src, dest, overlay=overlay) is True + assert (dest / "default.yaml").read_text() == "id: default\n" + # The overlay lands beside the layouts dir, where the daemon looks. + assert (dest.parent / "layouts.macos" / "firefox.yaml").is_file() + + # A user's edits survive the next run: an existing dir is never re-seeded. + (dest / "default.yaml").write_text("id: default\nedited: true\n") + assert macos_app.seed_layouts(src, dest, overlay=overlay) is False + assert "edited" in (dest / "default.yaml").read_text() + + +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" + ) + assert "--bind" not in argv + assert argv[argv.index("--layouts-dir") + 1] == str(tmp_path / "layouts") + assert argv[argv.index("--client-dist") + 1] == str(tmp_path / "web") + assert argv[argv.index("--port") + 1] == str(macos_app.DEFAULT_PORT) + + +def test_app_argv_lan_and_extras(tmp_path: Path) -> None: + argv = macos_app.app_argv( + layouts_dir=tmp_path / "l", + client_dist=tmp_path / "w", + bind=["0.0.0.0"], + password_file=tmp_path / "pw", + log_file=tmp_path / "log", + verbose=True, + ) + assert ["--bind", "0.0.0.0"] == argv[argv.index("--bind") : argv.index("--bind") + 2] + assert str(tmp_path / "pw") in argv + assert str(tmp_path / "log") in argv + assert "--verbose" in argv + + +class _FakeServer: + """Minimal ``Server`` stand-in for exercising ``ServerRunner``.""" + + def __init__(self) -> None: + self.started = threading.Event() + self.stopped = threading.Event() + + async def start(self) -> None: + self.started.set() + try: + await asyncio.sleep(3600) + finally: + self.stopped.set() + + def start_focus_watcher(self) -> None: ... + def start_windows_watcher(self) -> None: ... + def start_session_state_watcher(self) -> None: ... + + async def stop(self) -> None: ... + + +def test_server_runner_starts_and_stops(monkeypatch) -> None: + import deckd.__main__ as main_mod + + fake = _FakeServer() + monkeypatch.setattr(main_mod, "build_server", lambda _args: fake) + + runner = macos_app.ServerRunner(object()) + runner.start() + try: + assert runner.wait_ready(timeout=5) + assert fake.started.wait(5) + assert runner.error is None + finally: + runner.stop() + assert fake.stopped.wait(5) + assert runner.error is None