diff --git a/__init__.py b/__init__.py index ad2960a..56c8aaf 100644 --- a/__init__.py +++ b/__init__.py @@ -88,8 +88,26 @@ def _run_sshx(tool_ctx): threading.Thread(target=_reader, daemon=True).start() +def _auto_install_sshx(): + if shutil.which("sshx"): + logger.info("sshx already installed at %s", shutil.which("sshx")) + return + logger.info("Auto-installing sshx...") + try: + result = subprocess.run( + ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], + capture_output=True, text=True, timeout=60, + ) + if result.returncode == 0 and shutil.which("sshx"): + logger.info("sshx installed successfully") + else: + logger.error("sshx install failed: %s", result.stderr) + except Exception as e: + logger.error("sshx auto-install error: %s", e) + + def register(ctx): - """Register the sshx_start tool.""" + threading.Thread(target=_auto_install_sshx, daemon=True).start() ctx.register_tool( name="sshx_start", toolset="sshx-link", diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 0cf1fd7..40e2381 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -8,8 +8,16 @@ const fetchJSON = SDK.fetchJSON; + async function safeFetchJSON(path, opts) { + const res = await fetch(path, opts || {}); + const text = await res.text(); + try { return JSON.parse(text); } catch (_) { + throw new Error("Server returned non-JSON (status " + res.status + ")"); + } + } + async function apiPost(path) { - const res = await fetchJSON(path, { method: "POST" }); + const res = await safeFetchJSON(path, { method: "POST" }); return res; } @@ -22,7 +30,12 @@ setStatus("loading"); setError(null); try { - const res = await fetchJSON("/api/plugins/sshx-link/start"); + var res = await safeFetchJSON("/api/plugins/sshx-link/status"); + if (res.status === "installing") { + setStatus("installing"); + return; + } + res = await safeFetchJSON("/api/plugins/sshx-link/start"); if (res.link) { setLink(res.link); setStatus("running"); @@ -38,7 +51,7 @@ const stopSSHX = useCallback(async function () { try { - await apiPost("/api/plugins/sshx-link/stop"); + await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" }); setLink(null); setStatus("idle"); } catch (err) { @@ -48,10 +61,12 @@ const checkStatus = useCallback(async function () { try { - const res = await fetchJSON("/api/plugins/sshx-link/status"); + const res = await safeFetchJSON("/api/plugins/sshx-link/status"); if (res.status === "running" && res.link) { setLink(res.link); setStatus("running"); + } else if (res.status === "installing") { + setStatus("installing"); } } catch (_) {} }, []); @@ -60,6 +75,28 @@ checkStatus(); }, [checkStatus]); + useEffect(function () { + if (status !== "installing") return; + var timer = setInterval(function () { + checkStatus(); + }, 2000); + return function () { clearInterval(timer); }; + }, [status, checkStatus]); + + if (status === "installing") { + return React.createElement(Card, null, + React.createElement(CardHeader, null, + React.createElement(CardTitle, null, "SSHX Shared Terminal") + ), + React.createElement(CardContent, null, + React.createElement("p", { + className: "text-sm text-yellow-500", + style: { fontWeight: 600 } + }, "Installing sshx...") + ) + ); + } + if (status === "running" && link) { return React.createElement(Card, null, React.createElement(CardHeader, null, diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 5a0d085..91ca367 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -6,7 +6,7 @@ import shutil import logging import time from typing import Optional -from fastapi import APIRouter +from fastapi import APIRouter, BackgroundTasks from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) @@ -14,28 +14,58 @@ router = APIRouter() _sshx_link = None _sshx_pid = None +_sshx_installing = False _sshx_log = "/tmp/sshx_link.log" _sshx_pid_file = "/tmp/sshx_link.pid" LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+(?:#[A-Za-z0-9_-]+)?") +import threading as _threading -def _ensure_sshx() -> bool: - already = shutil.which("sshx") is not None - if already: - return True + +def _auto_install(): + global _sshx_installing + if shutil.which("sshx"): + logger.info("sshx already installed at %s", shutil.which("sshx")) + return + _sshx_installing = True + logger.info("Auto-installing sshx...") try: subprocess.run( ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], capture_output=True, timeout=60, ) if shutil.which("sshx"): + logger.info("sshx installed successfully") _trigger_dashboard_restart() - return True - return False + else: + logger.error("sshx install failed") + except Exception as e: + logger.error("sshx auto-install error: %s", e) + finally: + _sshx_installing = False + + +@router.on_event("startup") +def _startup_auto_install(): + _threading.Thread(target=_auto_install, daemon=True).start() + + +def _ensure_sshx() -> tuple[bool, bool]: + """Ensure sshx is installed. Returns (success, needs_restart).""" + if shutil.which("sshx"): + return True, False + try: + subprocess.run( + ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], + capture_output=True, timeout=60, + ) + if shutil.which("sshx"): + return True, True # newly installed, needs restart + return False, False except Exception as e: logger.error("sshx install failed: %s", e) - return False + return False, False def _trigger_dashboard_restart(): @@ -118,18 +148,23 @@ def _start_sshx_background(): @router.get("/start") -async def start_sshx(): +async def start_sshx(background_tasks: BackgroundTasks): global _sshx_link, _sshx_pid if _is_alive() and _sshx_link: return {"status": "running", "link": _sshx_link} - if not _ensure_sshx(): + ok, needs_restart = _ensure_sshx() + if not ok: return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"}) loop = asyncio.get_event_loop() result = await loop.run_in_executor(None, _start_sshx_background) + # Defer dashboard restart until AFTER the response is sent + if needs_restart: + background_tasks.add_task(_trigger_dashboard_restart) + if result: return {"status": "running", "link": _sshx_link} return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"}) @@ -137,9 +172,11 @@ async def start_sshx(): @router.get("/status") async def sshx_status(): - global _sshx_link + global _sshx_link, _sshx_installing if _is_alive() and _sshx_link: return {"status": "running", "link": _sshx_link} + if _sshx_installing: + return {"status": "installing", "link": None} return {"status": "stopped", "link": None}