#!/usr/bin/env python3 """Generate a user launch daemon plist for macOS to run the dev server.""" import argparse from collections import OrderedDict from dataclasses import dataclass import json import os from pathlib import Path import plistlib import shutil import subprocess macos_root = Path(__file__).resolve().parent repo_root = macos_root.parent.parent run_sh = macos_root / "run.sh" main_scpt = macos_root / "main.applescript" @dataclass class Env: name: str destination: str devport: int appname: str appid: str svclabel: str constants = { "DevPort": 64224, "VedPort": 42246, } env_dev_obverse = Env( name="dev-obverse", destination="public/dev-obverse", devport=64224, appname="me.micahrl.com.dev-obverse.app", appid="com.micahrl.me.dev-obverse", svclabel="com.micahrl.me.dev-obverse.background", ) env_dev_reverse = Env( name="dev-reverse", destination="public/dev-reverse", devport=42246, appname="me.micahrl.com.dev-reverse.app", appid="com.micahrl.me.dev-reverse", svclabel="com.micahrl.me.dev-reverse.background", ) def get_tailscale_hostname(): """Get the Tailscale hostname for the current machine.""" try: result = subprocess.run( ["/Applications/Tailscale.app/Contents/MacOS/Tailscale", "status", "--json"], capture_output=True, text=True, check=True, ) status = json.loads(result.stdout) return status["Self"]["DNSName"].rstrip(".") except (subprocess.CalledProcessError, KeyError, FileNotFoundError): return None def build_app_bundle(bundlepath: str, hugo: str, devhost: str, env: Env, reporoot: str, force: bool = False): """Build the app bundle for the dev server.""" if os.path.exists(bundlepath): if force: print(f"Removing existing app bundle at {bundlepath}") shutil.rmtree(bundlepath) else: raise FileExistsError(f"App bundle already exists at {bundlepath}. Use --force to overwrite.") Path(bundlepath).parent.mkdir(parents=True, exist_ok=True) # Build the app bundle subprocess.run(["osacompile", "-o", bundlepath, main_scpt.as_posix()], check=True) plist_path = Path(bundlepath) / "Contents" / "Info.plist" with open(plist_path, "rb") as f: plist_data = plistlib.load(f, dict_type=OrderedDict) # Set the app to an "agent" which does not show in the dock or menu bar plist_data["LSUIElement"] = True # Set bundle identifiers and names plist_data["CFBundleName"] = env.appname # This shows in System Settings > Security & Privacy > Privacy > Full Disk Access, IF the bundle ID is set below # Otherwise the app will just be shown as `applet` plist_data["CFBundleDisplayName"] = env.appname # Set bundle ID plist_data["CFBundleIdentifier"] = env.appid # Write the envirohnment configuration to the app bundle too, so that the run.sh script can read it directly plist_data["MicahrlHugo"] = hugo plist_data["MicahrlEnvironment"] = env.name plist_data["MicahrlDestination"] = env.destination plist_data["MicahrlServiceHost"] = devhost plist_data["MicahrlServicePort"] = env.devport plist_data["MicahrlDevPort"] = constants["DevPort"] plist_data["MicahrlVedPort"] = constants["VedPort"] # Write the plist data back to the Info.plist with open(plist_path, "wb") as f: plistlib.dump(plist_data, f, sort_keys=False) # Embed the resources into the app bundle # The run script bundle_run_sh = Path(bundlepath) / "Contents" / "Resources" / "run.sh" shutil.copy(run_sh, bundle_run_sh) bundle_run_sh.chmod(0o755) # Copy and customize the launchd plist bundle_launchd_plist = Path(bundlepath) / "Contents" / "Resources" / "launchd.plist" with open(macos_root / "launchd.plist", "rb") as f: launchd_data = plistlib.load(f, dict_type=OrderedDict) # Set the actual values in the launchd plist launchd_data["Label"] = env.svclabel launchd_data["Program"] = str(Path(bundlepath).resolve() / "Contents" / "MacOS" / "applet") # Set the environment variables log_path = os.path.expanduser(f"~/Library/Logs/{env.svclabel}.log") launchd_data["EnvironmentVariables"]["MICAHRL_REPOROOT"] = reporoot launchd_data["EnvironmentVariables"]["MICAHRL_LOG_PATH"] = log_path # Write the customized launchd plist with open(bundle_launchd_plist, "wb") as f: plistlib.dump(launchd_data, f, sort_keys=False) # Code sign the whole thing subprocess.run(["codesign", "--deep", "--force", "--sign", "-", bundlepath], check=True) def main(): parser = argparse.ArgumentParser(description="Generate a launch daemon plist for macOS to run the dev server.") parser.add_argument("environment", choices=["dev-obverse", "dev-reverse"], help="The environment to run the dev server for") parser.add_argument("appbundle", type=Path) parser.add_argument("reporoot", type=Path, help="Path to the repository root") parser.add_argument("--force", action="store_true", help="Force overwrite the app bundle if it exists") devhost_group = parser.add_mutually_exclusive_group(required=False) devhost_group.add_argument("--devhost", default="localhost", help="The dev server host (default: %(default)s)") devhost_group.add_argument( "--devhost-tailscale", action="store_true", help="Use Tailscale to determine the dev server host" ) parser.add_argument("--hugo", help="Path to the Hugo binary (default: found in PATH)") parsed = parser.parse_args() if parsed.devhost_tailscale: devhost = get_tailscale_hostname() if not devhost: parser.error("Error: Could not determine Tailscale hostname.") else: devhost = parsed.devhost hugo = parsed.hugo or shutil.which("hugo") if not hugo: parser.error("Error: Hugo not found. Please specify --hugo or install Hugo to your $PATH.") if parsed.environment == "dev-obverse": env = env_dev_obverse elif parsed.environment == "dev-reverse": env = env_dev_reverse else: parser.error(f"Unknown environment: {parsed.environment}") build_app_bundle(parsed.appbundle, hugo, devhost, env, str(parsed.reporoot.resolve()), force=parsed.force) if __name__ == "__main__": main()