diff --git a/myagents/commands/sync_workspace.py b/myagents/commands/sync_workspace.py index 5c85a3e..9a9ca64 100644 --- a/myagents/commands/sync_workspace.py +++ b/myagents/commands/sync_workspace.py @@ -197,24 +197,79 @@ def _print_report(workspace: Path, report: dict) -> None: console.print(f" [dim]- {rel}[/dim]") +_APP_INFO_PLIST = """ + + + + CFBundleNameXiaoheAgent + CFBundleDisplayNameXiaoheAgent + CFBundleIdentifiercom.xiaohe.agent + CFBundleVersion1 + CFBundlePackageTypeAPPL + CFBundleExecutableXiaoheAgent + CFBundleIconFileXiaoheAgent + LSMinimumSystemVersion11.0 + NSHighResolutionCapable + + +""" + +_APP_EXECUTABLE = """#!/usr/bin/env bash +# XiaoheAgent launcher — opens Terminal running xiaohe. +export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" +if command -v xiaohe >/dev/null 2>&1; then + osascript -e 'tell application "Terminal" to activate' \\ + -e 'tell application "Terminal" to do script "xiaohe"' +else + osascript -e 'display dialog "xiaohe command not found — run install.sh first" buttons {"OK"}' +fi +""" + + +def _icon_assets() -> tuple[Path | None, Path | None]: + """(icns, png) shipped in the runtime tree, if present.""" + runtime = get_runtime_root() + if runtime is None: + return None, None + icons = runtime / "assets" / "icon" + icns = icons / "XiaoheAgent.icns" + png = icons / "xiaohe-icon-512.png" + return (icns if icns.is_file() else None), (png if png.is_file() else None) + + def _create_launcher() -> None: """Double-click launcher that opens a terminal running ``xiaohe``.""" home = Path.home() + icns, png = _icon_assets() if os.uname().sysname == "Darwin": # noqa: PLR2004 — platform check desktop = home / "Desktop" - if desktop.is_dir(): - launcher = desktop / "XiaoheAgent.command" - launcher.write_text( - "#!/usr/bin/env bash\n" - "# XiaoheAgent launcher — double-click opens Terminal running xiaohe.\n" - "exec xiaohe\n", - encoding="utf-8", - ) - launcher.chmod(0o755) - console.print(f" [green]launcher:[/green] {launcher}") + if not desktop.is_dir(): + return + # Legacy .command launcher is superseded by the .app bundle. + legacy = desktop / "XiaoheAgent.command" + if legacy.exists(): + legacy.unlink() + app = desktop / "XiaoheAgent.app" + macos = app / "Contents" / "MacOS" + resources = app / "Contents" / "Resources" + macos.mkdir(parents=True, exist_ok=True) + resources.mkdir(parents=True, exist_ok=True) + (app / "Contents" / "Info.plist").write_text(_APP_INFO_PLIST, encoding="utf-8") + executable = macos / "XiaoheAgent" + executable.write_text(_APP_EXECUTABLE, encoding="utf-8") + executable.chmod(0o755) + if icns is not None: + shutil.copy2(icns, resources / "XiaoheAgent.icns") + console.print(f" [green]launcher:[/green] {app}") else: apps = home / ".local" / "share" / "applications" apps.mkdir(parents=True, exist_ok=True) + icon_line = "Icon=utilities-terminal\n" + if png is not None: + icon_dir = home / ".local" / "share" / "icons" / "hicolor" / "512x512" / "apps" + icon_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(png, icon_dir / "xiaohe-agent.png") + icon_line = "Icon=xiaohe-agent\n" (apps / "XiaoheAgent.desktop").write_text( "[Desktop Entry]\n" "Type=Application\n" @@ -222,7 +277,7 @@ def _create_launcher() -> None: "Comment=Xiaohe Agent terminal\n" "Exec=xiaohe\n" "Terminal=true\n" - "Icon=utilities-terminal\n" + f"{icon_line}" "Categories=Utility;\n", encoding="utf-8", ) diff --git a/tests/test_sync_workspace.py b/tests/test_sync_workspace.py index 7c789aa..ca9fe7d 100644 --- a/tests/test_sync_workspace.py +++ b/tests/test_sync_workspace.py @@ -85,3 +85,55 @@ class TestSyncWorkspace: sync_mod.sync_workspace(workspace) report = sync_mod.sync_workspace(workspace, force=True) assert report["added"] + + +class TestCreateLauncher: + @pytest.fixture() + def fake_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "home" + (home / "Desktop").mkdir(parents=True) + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + return home + + def test_macos_app_bundle_with_icon( + self, runtime: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (runtime / "assets" / "icon").mkdir(parents=True) + (runtime / "assets" / "icon" / "XiaoheAgent.icns").write_bytes(b"icns") + legacy = fake_home / "Desktop" / "XiaoheAgent.command" + legacy.write_text("#!/bin/sh\n") + monkeypatch.setattr( + sync_mod.os, "uname", lambda: type("U", (), {"sysname": "Darwin"}) + ) + sync_mod._create_launcher() + app = fake_home / "Desktop" / "XiaoheAgent.app" + executable = app / "Contents" / "MacOS" / "XiaoheAgent" + assert (app / "Contents" / "Info.plist").is_file() + assert (app / "Contents" / "Resources" / "XiaoheAgent.icns").is_file() + assert executable.stat().st_mode & 0o111 + assert "xiaohe" in executable.read_text() + assert not legacy.exists() # superseded .command removed + + def test_linux_desktop_entry_with_icon( + self, runtime: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (runtime / "assets" / "icon").mkdir(parents=True) + (runtime / "assets" / "icon" / "xiaohe-icon-512.png").write_bytes(b"png") + monkeypatch.setattr( + sync_mod.os, "uname", lambda: type("U", (), {"sysname": "Linux"}) + ) + sync_mod._create_launcher() + entry = ( + fake_home / ".local" / "share" / "applications" / "XiaoheAgent.desktop" + ) + assert "Icon=xiaohe-agent" in entry.read_text() + assert ( + fake_home + / ".local" + / "share" + / "icons" + / "hicolor" + / "512x512" + / "apps" + / "xiaohe-agent.png" + ).is_file()