项目文件夹

文件
Ali Khokhar d98a6b0ca0 Add a native FCC desktop launcher for Windows and macOS (#1225)
## Problem

Free Claude Code currently has to remain attached to a terminal, so
closing that window stops the proxy and users have no native way to
reopen Admin or control the background server. The contributed Windows
wrapper also would have introduced a second bundled server lifecycle
instead of reusing FCC's cleanup and restart ownership. Fixes #1147.

## Changes

| Before | After |
| --- | --- |
| Users keep `fcc-server` running in a terminal. | Windows and macOS
users can launch a console-free FCC Desktop host from a
desktop/application shortcut and control it from the tray or menu bar. |
| A wrapper would need to spawn and terminate a child server. | The
terminal and desktop paths share one in-process supervisor, one graceful
runtime shutdown path, and an OS-held singleton lock. |
| Installers manage only command entry points. | Windows installs
desktop and Start-menu shortcuts; macOS installs a per-user app bundle
and owned desktop link; uninstallers remove only those FCC artifacts. |
| Desktop behavior had no contract coverage. | Lifecycle, duplicate
launch, restart/quit, GUI packaging, Windows shortcuts, macOS bundle
creation, quoting, and ownership boundaries are covered alongside the
full CI suite. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR adds a native FCC desktop launcher for Windows and macOS. The
main changes are:

- A shared server supervisor for terminal and desktop launches.
- A singleton desktop host with tray or menu-bar controls.
- Windows shortcuts and a per-user macOS app bundle.
- Ownership checks for launcher installation and removal.
- Tests for lifecycle, packaging, shortcuts, and uninstall behavior.
</details>

<h3>Confidence Score: 5/5</h3>

This looks safe to merge.

Startup restart requests are reserved before the worker starts. macOS
bundle operations verify ownership before modifying or deleting files.
Windows shortcut operations verify their targets before replacement or
removal.

No blocking issues were found in the updated code.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Compared the pre-change contract test results against the parent
commit f81af55630aa1adb518b748b9e6985c73c4c4775 and observed 3 failures
and 4 passes, indicating the missing scheduled-startup lifecycle
contract.
- Executed the after-state contract validation with uv run pytest -n 0
tests/cli/test\_desktop.py -q and confirmed the run finished with 7
passes and an exit code of 0.
- Verified that no real proxy or native GUI dependency was started
during the after-state run.
- Inspected the two log artifacts that accompany the proof to
corroborate the test outcomes.

<a
href="https://app.greptile.com/trex/runs/15235588/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/cli/commands.py | Adds the shared server
supervisor and scheduled-run state for startup restart requests. |
| src/free_claude_code/cli/desktop.py | Adds singleton locking and
coordinates the tray loop with the server worker. |
| scripts/install.sh | Creates the macOS app bundle only when an
existing bundle is FCC-owned. |
| scripts/uninstall.sh | Removes the macOS launcher only on macOS and
only with the expected ownership marker. |
| scripts/install.ps1 | Creates Windows shortcuts while preserving
shortcuts with unrelated targets. |
| scripts/uninstall.ps1 | Removes Windows shortcuts only when their
targets match an FCC desktop entry point. |

</details>

<sub>Reviews (3): Last reviewed commit: ["fix: coalesce desktop startup
restarts"](https://github.com/alishahryar1/free-claude-code/commit/b9554729770e08a58818d677de39757c751e760e)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=45925660)</sub>

<!-- /greptile_comment -->
2026-07-21 05:48:17 -07:00

272 行
9.1 KiB
Python

"""Desktop shell lifecycle and singleton contracts."""
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
from free_claude_code.cli.commands import ServerStatus, ServerSupervisor
from free_claude_code.cli.desktop import DesktopController, DesktopInstanceLock
from free_claude_code.config.settings import Settings
def _settings() -> Settings:
return Settings.model_construct(host="0.0.0.0", port=8082)
def test_desktop_instance_lock_is_exclusive_and_reusable(tmp_path: Path) -> None:
lock_path = tmp_path / "desktop.lock"
first = DesktopInstanceLock(lock_path)
second = DesktopInstanceLock(lock_path)
assert first.acquire() is True
assert first.acquire() is True
assert second.acquire() is False
first.release()
first.release()
assert second.acquire() is True
second.release()
def test_supervisor_accepts_restart_during_scheduled_startup() -> None:
supervisor = ServerSupervisor(console_logging=False)
settings = _settings()
with (
patch(
"free_claude_code.cli.commands.load_server_settings",
return_value=settings,
),
patch.object(supervisor, "_run_once", return_value=False) as run_once,
patch("free_claude_code.cli.commands.kill_all_best_effort"),
):
assert supervisor.schedule_run() is True
assert supervisor.status is ServerStatus.STARTING
assert supervisor.request_restart() is True
supervisor.run(open_admin_browser=False)
run_once.assert_called_once_with(
settings,
open_admin_browser=False,
restart_generation=1,
)
assert supervisor.status is ServerStatus.STOPPED
def test_desktop_controller_owns_server_thread_and_graceful_quit() -> None:
opened = threading.Event()
class FakeSupervisor:
def __init__(self) -> None:
self.status = ServerStatus.STARTING
self.started = threading.Event()
self.stopped = threading.Event()
self.run_arguments: list[bool | None] = []
self.schedule_count = 0
self.restart_count = 0
self.stop_count = 0
def schedule_run(self) -> bool:
self.schedule_count += 1
return True
def run(self, *, open_admin_browser: bool | None = None) -> None:
self.run_arguments.append(open_admin_browser)
self.status = ServerStatus.RUNNING
self.started.set()
assert self.stopped.wait(2)
self.status = ServerStatus.STOPPED
def request_restart(self) -> bool:
self.restart_count += 1
return True
def request_stop(self) -> None:
self.stop_count += 1
self.status = ServerStatus.STOPPING
self.stopped.set()
class FakeTray:
def __init__(self, controller: DesktopController) -> None:
self.controller = controller
self.run_thread_id: int | None = None
self.stop_count = 0
def run(self) -> None:
self.run_thread_id = threading.get_ident()
assert supervisor.started.wait(2)
self.controller.open_admin()
self.controller.restart_server()
self.controller.quit()
def stop(self) -> None:
self.stop_count += 1
supervisor = FakeSupervisor()
tray: FakeTray | None = None
def make_tray(controller: DesktopController) -> FakeTray:
nonlocal tray
tray = FakeTray(controller)
return tray
main_thread_id = threading.get_ident()
controller = DesktopController(supervisor, make_tray, opened.set)
controller.run()
assert tray is not None
assert tray.run_thread_id == main_thread_id
assert supervisor.run_arguments == [False]
assert supervisor.schedule_count == 1
assert supervisor.restart_count == 1
assert supervisor.stop_count >= 1
assert tray.stop_count >= 1
assert opened.is_set()
def test_restart_during_server_startup_is_accepted_without_waiting() -> None:
class StartupSupervisor:
def __init__(self) -> None:
self.status = ServerStatus.STARTING
self.run_called = threading.Event()
self.allow_run = threading.Event()
self.worker_started = threading.Event()
self.release_worker = threading.Event()
self.run_scheduled = False
self.restart_count = 0
self.accepted_restart_count = 0
def schedule_run(self) -> bool:
self.run_scheduled = True
return True
def run(self, *, open_admin_browser: bool | None = None) -> None:
assert open_admin_browser is False
self.run_called.set()
assert self.allow_run.wait(2)
self.run_scheduled = False
self.worker_started.set()
assert self.release_worker.wait(2)
self.status = ServerStatus.STOPPED
def request_restart(self) -> bool:
self.restart_count += 1
if self.run_scheduled:
self.accepted_restart_count += 1
return True
return False
def request_stop(self) -> None:
self.release_worker.set()
class WaitingTray:
def __init__(self, _controller: DesktopController) -> None:
self.started = threading.Event()
self.stopped = threading.Event()
def run(self) -> None:
self.started.set()
assert self.stopped.wait(2)
def stop(self) -> None:
self.stopped.set()
supervisor = StartupSupervisor()
tray: WaitingTray | None = None
def make_tray(controller: DesktopController) -> WaitingTray:
nonlocal tray
tray = WaitingTray(controller)
return tray
controller = DesktopController(supervisor, make_tray, MagicMock())
controller_thread = threading.Thread(target=controller.run)
controller_thread.start()
assert tray is not None
assert tray.started.wait(2)
assert supervisor.run_called.wait(2)
restart_thread = threading.Thread(target=controller.restart_server)
restart_thread.start()
restart_thread.join(0.5)
restart_blocked = restart_thread.is_alive()
supervisor.allow_run.set()
assert supervisor.worker_started.wait(2)
controller.quit()
supervisor.release_worker.set()
restart_thread.join(2)
controller_thread.join(2)
assert restart_blocked is False
assert supervisor.restart_count == 1
assert supervisor.accepted_restart_count == 1
assert not restart_thread.is_alive()
assert not controller_thread.is_alive()
def test_second_desktop_launch_opens_existing_admin_without_new_server() -> None:
from free_claude_code.cli import desktop
settings = _settings()
instance_lock = MagicMock()
instance_lock.acquire.return_value = False
with (
patch.object(desktop, "load_server_settings", return_value=settings),
patch.object(desktop, "DesktopInstanceLock", return_value=instance_lock),
patch.object(desktop, "open_admin_when_ready", return_value=True) as open_admin,
patch.object(desktop, "ServerSupervisor") as supervisor,
):
desktop.launch_desktop(MagicMock())
open_admin.assert_called_once_with(settings)
supervisor.assert_not_called()
instance_lock.release.assert_not_called()
def test_desktop_attaches_to_terminal_server_instead_of_binding_twice() -> None:
from free_claude_code.cli import desktop
settings = _settings()
instance_lock = MagicMock()
instance_lock.acquire.return_value = True
with (
patch.object(desktop, "load_server_settings", return_value=settings),
patch.object(desktop, "DesktopInstanceLock", return_value=instance_lock),
patch.object(desktop, "preflight_proxy", return_value=None),
patch.object(desktop, "open_admin_when_ready", return_value=True) as open_admin,
patch.object(desktop, "ServerSupervisor") as supervisor,
):
desktop.launch_desktop(MagicMock())
open_admin.assert_called_once_with(settings)
supervisor.assert_not_called()
instance_lock.release.assert_called_once_with()
def test_fresh_desktop_launch_disables_console_and_automatic_browser() -> None:
from free_claude_code.cli import desktop
settings = _settings()
instance_lock = MagicMock()
instance_lock.acquire.return_value = True
supervisor = MagicMock()
controller = MagicMock()
with (
patch.object(desktop, "load_server_settings", return_value=settings),
patch.object(desktop, "DesktopInstanceLock", return_value=instance_lock),
patch.object(desktop, "preflight_proxy", return_value="connection refused"),
patch.object(desktop, "ServerSupervisor", return_value=supervisor) as owner,
patch.object(desktop, "DesktopController", return_value=controller) as shell,
):
tray_factory = MagicMock()
desktop.launch_desktop(tray_factory)
owner.assert_called_once_with(console_logging=False)
assert shell.call_args.args[:2] == (supervisor, tray_factory)
controller.run.assert_called_once_with()
instance_lock.release.assert_called_once_with()