diff --git a/dashboard/src/api/modules/update.ts b/dashboard/src/api/modules/update.ts index b2dcff9b..74d98d19 100644 --- a/dashboard/src/api/modules/update.ts +++ b/dashboard/src/api/modules/update.ts @@ -7,6 +7,8 @@ export interface UpdateStatus { is_editable: boolean; /** Non-null when the process was launched via `octop service start` (systemd or launchd). */ service_mode: "systemd" | "launchd" | null; + /** True when Octop is spawned by the Wails desktop shell (or ``OCTOP_DESKTOP=1``). */ + desktop?: boolean; error: string | null; last_check_time: string | null; /** Markdown changelog for latest_version, null if not available. */ diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx index 9bab4e7b..f15cf167 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx @@ -150,9 +150,10 @@ export default function UpdateConfig() { const [checking, setChecking] = useState(false); const [upgrading, setUpgrading] = useState(false); const [progress, setProgress] = useState(null); - const { restartPhase, isRestarting, requestRestart } = + const { restartPhase, isRestarting, requestRestart, executeRestart } = useServiceRestartContext(); const pollTimerRef = useRef | null>(null); + const autoRestartedRef = useRef(false); useEffect(() => { updateApi @@ -167,6 +168,13 @@ export default function UpdateConfig() { }; }, []); + useEffect(() => { + if (progress?.status !== "complete" || !status?.desktop) return; + if (restartPhase !== "idle" || autoRestartedRef.current) return; + autoRestartedRef.current = true; + void executeRestart(); + }, [progress?.status, status?.desktop, restartPhase, executeRestart]); + const handleCheck = useCallback(async () => { setChecking(true); try { @@ -207,6 +215,7 @@ export default function UpdateConfig() { const handleUpgrade = useCallback(async () => { setUpgrading(true); setProgress(null); + autoRestartedRef.current = false; try { const started = await updateApi.triggerUpgrade(); setProgress({ @@ -394,6 +403,7 @@ export default function UpdateConfig() { {restartPhase === "idle" && + !status?.desktop && (isServiceMode ? (
diff --git a/desktop/package-dev.sh b/desktop/package-dev.sh new file mode 100644 index 00000000..e9f1f135 --- /dev/null +++ b/desktop/package-dev.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Desktop shell against a source Octop (hot reload). Not ~/.octop/portable. +# Starts Octop in this shell and stops it when wails3 / this script exits. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${OCTOP_PORT:-8088}" +URL="${OCTOP_DESKTOP_URL:-http://127.0.0.1:${PORT}}" +HEALTH="${URL%/}/api/health" +WAILS3_PKG="github.com/wailsapp/wails/v3/cmd/wails3@latest" + +missing=0 +if ! command -v go >/dev/null 2>&1; then + missing=1 + echo "go is not installed (need Go 1.25+)." >&2 + echo " macOS: brew install go" >&2 + echo " Linux: https://go.dev/dl/" >&2 + echo " then: export PATH=\"\$(go env GOPATH)/bin:\$PATH\"" >&2 +fi +if ! command -v wails3 >/dev/null 2>&1; then + missing=1 + echo "wails3 is not installed." >&2 + echo " go install ${WAILS3_PKG}" >&2 + echo " export PATH=\"\$(go env GOPATH)/bin:\$PATH\"" >&2 +fi +if [[ "$missing" -eq 1 ]]; then + exit 1 +fi + +octop_pid="" +cleaned=0 + +cleanup() { + [[ "$cleaned" -eq 1 ]] && return + cleaned=1 + trap - EXIT INT TERM + if [[ -n "$octop_pid" ]] && kill -0 "$octop_pid" 2>/dev/null; then + kill -TERM "$octop_pid" 2>/dev/null || true + wait "$octop_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +if curl -sf -o /dev/null --max-time 1 "$HEALTH"; then + echo "Octop already listening at ${URL}; stop it first so this script can own the process." >&2 + exit 1 +fi + +echo "starting octop --reload on port ${PORT}" +( + cd "$REPO" + exec env OCTOP_DESKTOP=1 uv run octop run --reload --host 127.0.0.1 --port "$PORT" +) & +octop_pid=$! + +for _ in $(seq 1 60); do + if curl -sf -o /dev/null --max-time 1 "$HEALTH"; then + break + fi + if ! kill -0 "$octop_pid" 2>/dev/null; then + echo "octop exited before becoming healthy" >&2 + exit 1 + fi + sleep 0.5 +done + +if ! curl -sf -o /dev/null --max-time 1 "$HEALTH"; then + echo "octop did not become healthy at ${URL}" >&2 + exit 1 +fi + +cd "$REPO/desktop/src" +OCTOP_DESKTOP_URL="$URL" wails3 dev diff --git a/src/octop/api/routers/update.py b/src/octop/api/routers/update.py index 3f49f4a2..3daf573b 100644 --- a/src/octop/api/routers/update.py +++ b/src/octop/api/routers/update.py @@ -4,6 +4,8 @@ import asyncio import logging +import os +import sys import time from typing import Any @@ -25,6 +27,7 @@ fetch_pypi_info, get_editable_path, get_local_version, + green_packages_dir, is_newer, parse_changelog_for_version, run_upgrade, @@ -58,6 +61,7 @@ def _build_status( "has_update": has_update, "is_editable": get_editable_path() is not None, "service_mode": detect_service_mode(), + "desktop": _is_desktop_process(), "error": error, "last_check_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "release_notes": release_notes if has_update else None, @@ -146,6 +150,19 @@ async def upgrade_progress( } +def _is_desktop_process() -> bool: + if green_packages_dir() is not None: + return True + raw = (os.environ.get("OCTOP_DESKTOP") or "").strip().lower() + return raw in {"1", "true", "yes", "on"} + + +def _restart_desktop_process() -> None: + time.sleep(0.4) + argv = list(getattr(sys, "orig_argv", None) or [sys.executable, *sys.argv]) + os.execv(argv[0], argv) + + def _restart_service_task(runtime: ServiceRuntime) -> None: try: restart_service(runtime) @@ -158,6 +175,9 @@ async def restart_service_endpoint( background_tasks: BackgroundTasks, _: Any = Depends(require_permission("update")), ) -> dict[str, Any]: + if _is_desktop_process(): + background_tasks.add_task(_restart_desktop_process) + return {"status": "restarting", "service_mode": "desktop"} mode = detect_service_mode() if mode is None: raise OctopError( diff --git a/tests/integration/test_update_api.py b/tests/integration/test_update_api.py index 163299f6..3fb9c72c 100644 --- a/tests/integration/test_update_api.py +++ b/tests/integration/test_update_api.py @@ -16,6 +16,7 @@ async def test_update_status_shape(env_admin_client: Any) -> None: "has_update", "is_editable", "service_mode", + "desktop", "error", "last_check_time", "release_notes", diff --git a/tests/unit/api/test_update_router.py b/tests/unit/api/test_update_router.py index bcb6f54a..f62a3077 100644 --- a/tests/unit/api/test_update_router.py +++ b/tests/unit/api/test_update_router.py @@ -68,6 +68,7 @@ async def test_restart_endpoint_schedules_background_restart( )() monkeypatch.setattr(update_router, "detect_service_mode", lambda: "systemd") + monkeypatch.setattr(update_router, "_is_desktop_process", lambda: False) monkeypatch.setattr(update_router, "build_runtime", lambda mode: fake_runtime) monkeypatch.setattr(update_router, "is_service_installed", lambda *_, **__: True) monkeypatch.setattr( @@ -102,6 +103,7 @@ async def test_restart_endpoint_rejects_when_service_not_installed( )() monkeypatch.setattr(update_router, "detect_service_mode", lambda: "systemd") + monkeypatch.setattr(update_router, "_is_desktop_process", lambda: False) monkeypatch.setattr(update_router, "build_runtime", lambda mode: fake_runtime) monkeypatch.setattr(update_router, "is_service_installed", lambda *_, **__: False) monkeypatch.setattr( @@ -121,6 +123,30 @@ async def test_restart_endpoint_rejects_when_service_not_installed( assert bg.tasks == [] +@pytest.mark.asyncio +async def test_restart_endpoint_desktop_schedules_process_exec( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: list[bool] = [] + monkeypatch.setattr(update_router, "_is_desktop_process", lambda: True) + monkeypatch.setattr( + update_router, + "_restart_desktop_process", + lambda: called.append(True), + ) + + from fastapi import BackgroundTasks + + bg = BackgroundTasks() + result = await update_router.restart_service_endpoint(bg, _=None) + + assert result == {"status": "restarting", "service_mode": "desktop"} + assert called == [] + for task in bg.tasks: + await task() + assert called == [True] + + @pytest.mark.asyncio async def test_upgrade_worker_records_mirror_errors(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(