Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dashboard/src/api/modules/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
12 changes: 11 additions & 1 deletion dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,10 @@ export default function UpdateConfig() {
const [checking, setChecking] = useState(false);
const [upgrading, setUpgrading] = useState(false);
const [progress, setProgress] = useState<UpgradeProgress | null>(null);
const { restartPhase, isRestarting, requestRestart } =
const { restartPhase, isRestarting, requestRestart, executeRestart } =
useServiceRestartContext();
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoRestartedRef = useRef(false);

useEffect(() => {
updateApi
Expand All @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -394,6 +403,7 @@ export default function UpdateConfig() {
</div>

{restartPhase === "idle" &&
!status?.desktop &&
(isServiceMode ? (
<div className={`${styles.alert} ${styles.alertInfo}`}>
<AlertTriangle size={15} />
Expand Down
73 changes: 73 additions & 0 deletions desktop/package-dev.sh
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions src/octop/api/routers/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import asyncio
import logging
import os
import sys
import time
from typing import Any

Expand All @@ -25,6 +27,7 @@
fetch_pypi_info,
get_editable_path,
get_local_version,
green_packages_dir,
is_newer,
parse_changelog_for_version,
run_upgrade,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_update_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/api/test_update_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
Loading