diff --git a/app/src/api/controller.js b/app/src/api/controller.js index 5dfcb32a..9e54a01c 100644 --- a/app/src/api/controller.js +++ b/app/src/api/controller.js @@ -136,6 +136,18 @@ export async function stopDesktop() { return controllerFetch('/desktop/stop', {method: 'POST'}); } +export async function getVncStatus() { + return controllerFetch('/vnc/status'); +} + +export async function startVnc() { + return controllerFetch('/vnc/start', {method: 'POST'}); +} + +export async function stopVnc() { + return controllerFetch('/vnc/stop', {method: 'POST'}); +} + // --- Samba Endpoints --- export async function getSambaStatus() { diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 51df2055..6e2ed99b 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -21,7 +21,7 @@ import { } from 'semantic-ui-react'; import {Link, NavLink, useNavigate} from "react-router"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; -import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useWROLMode} from "../hooks/customHooks"; +import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useVnc, useWROLMode} from "../hooks/customHooks"; import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts"; import { Accordion, @@ -1062,6 +1062,34 @@ export function DesktopToggle() { ; } +export function VncToggle() { + let {on, desktopRunning, setVnc} = useVnc(); + const unsupported = on === null; + // VNC serves the desktop session; it cannot be started without one. Stopping + // stays available so a running VNC server is never stranded on. + const blockedByDesktop = !desktopRunning && on !== true; + const disabled = unsupported || blockedByDesktop; + + let info = null; + if (unsupported) { + info = 'VNC is not supported on this server'; + } else if (blockedByDesktop) { + info = 'Start the Desktop before starting VNC'; + } else if (on === true && !desktopRunning) { + info = 'The Desktop is stopped, so VNC clients will see nothing'; + } + + return
+ setVnc(checked)} + /> + {info && } +
; +} + export function Toggle({label, checked, disabled, onChange, icon, popupContent = null, info = null}) { // Custom toggle because Semantic UI does not handle inverted labels correctly. const {t, inverted} = useContext(ThemeContext); diff --git a/app/src/components/VncToggle.test.js b/app/src/components/VncToggle.test.js new file mode 100644 index 00000000..9c2c37ce --- /dev/null +++ b/app/src/components/VncToggle.test.js @@ -0,0 +1,57 @@ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {VncToggle} from './Common'; + +const mockSetVnc = jest.fn(); +let mockVncState = {on: false, desktopRunning: true}; + +jest.mock('../hooks/customHooks', () => ({ + ...jest.requireActual('../hooks/customHooks'), + useVnc: () => ({...mockVncState, setVnc: mockSetVnc}), +})); + +describe('VncToggle', () => { + beforeEach(() => { + mockSetVnc.mockClear(); + mockVncState = {on: false, desktopRunning: true}; + }); + + it('starts VNC when the Desktop is running', async () => { + render(); + const toggle = screen.getByTestId('toggle'); + expect(toggle).not.toBeDisabled(); + await userEvent.click(toggle); + expect(mockSetVnc).toHaveBeenCalledWith(true); + }); + + it('is disabled while the Desktop is stopped', async () => { + mockVncState = {on: false, desktopRunning: false}; + render(); + await userEvent.click(screen.getByTestId('toggle')); + expect(mockSetVnc).not.toHaveBeenCalled(); + }); + + it('can always be stopped, even with the Desktop stopped', async () => { + // Otherwise a running VNC server would be stranded on with no way to stop it. + mockVncState = {on: true, desktopRunning: false}; + render(); + const toggle = screen.getByTestId('toggle'); + expect(toggle).toBeChecked(); + await userEvent.click(toggle); + expect(mockSetVnc).toHaveBeenCalledWith(false); + }); + + it('renders checked when VNC is running', () => { + mockVncState = {on: true, desktopRunning: true}; + render(); + expect(screen.getByTestId('toggle')).toBeChecked(); + }); + + it('does nothing when VNC is unsupported', async () => { + mockVncState = {on: null, desktopRunning: true}; + render(); + await userEvent.click(screen.getByTestId('toggle')); + expect(mockSetVnc).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/admin/ControllerPage.js b/app/src/components/admin/ControllerPage.js index f53cb5d0..de3aa23c 100644 --- a/app/src/components/admin/ControllerPage.js +++ b/app/src/components/admin/ControllerPage.js @@ -1,7 +1,7 @@ import React from 'react'; import {Checkbox, Container, Dropdown, Form, Icon, Input} from "semantic-ui-react"; import {Button, Confirm, Header, Loader, Modal, Segment, Table} from "../Theme"; -import {APIButton, BluetoothToggle, DesktopToggle, DirectorySearch, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle,} from "../Common"; +import {APIButton, BluetoothToggle, DesktopToggle, DirectorySearch, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle, VncToggle,} from "../Common"; import {useDockerized, useMediaDirectory} from "../../hooks/customHooks"; import {Media} from "../../contexts/contexts"; import { @@ -1590,6 +1590,7 @@ function AdminControlsSection() { + diff --git a/app/src/components/admin/ControllerPage.test.js b/app/src/components/admin/ControllerPage.test.js index 50a26ee5..9a1f789a 100644 --- a/app/src/components/admin/ControllerPage.test.js +++ b/app/src/components/admin/ControllerPage.test.js @@ -80,6 +80,7 @@ jest.mock('../../hooks/customHooks', () => ({ useMediaDirectory: jest.fn().mockReturnValue('/media/wrolpi'), useBluetooth: jest.fn().mockReturnValue({on: false, setBluetooth: jest.fn()}), useDesktop: jest.fn().mockReturnValue({on: true, setDesktop: jest.fn()}), + useVnc: jest.fn().mockReturnValue({on: false, desktopRunning: true, setVnc: jest.fn()}), useHotspot: jest.fn().mockReturnValue({on: true, setHotspot: jest.fn()}), useThrottle: jest.fn().mockReturnValue({on: false, setThrottle: jest.fn()}), })); @@ -91,6 +92,7 @@ jest.mock('../Common', () => { ...actual, BluetoothToggle: () =>
BluetoothToggle
, DesktopToggle: () =>
DesktopToggle
, + VncToggle: () =>
VncToggle
, HotspotToggle: () =>
HotspotToggle
, ThrottleToggle: () =>
ThrottleToggle
, Toggle: ({label, checked, onChange}) => ( @@ -179,6 +181,7 @@ describe('ControllerPage', () => { expect(screen.getByTestId('hotspot-toggle')).toBeInTheDocument(); expect(screen.getByTestId('bluetooth-toggle')).toBeInTheDocument(); expect(screen.getByTestId('desktop-toggle')).toBeInTheDocument(); + expect(screen.getByTestId('vnc-toggle')).toBeInTheDocument(); expect(screen.getByTestId('throttle-toggle')).toBeInTheDocument(); }); diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index cff3e751..d3913e19 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -49,6 +49,8 @@ import { stopHotspot, startDesktop, stopDesktop, + startVnc, + stopVnc, enableThrottle, disableThrottle, getControllerStats, @@ -1252,6 +1254,48 @@ export const useDesktop = () => { return {on, setDesktop: localSetDesktop}; } +export const useVnc = () => { + const [on, setOn] = useState(null); + const {status} = useContext(StatusContext); + // VNC serves the desktop session, so it cannot be started without one. + const desktopRunning = status?.desktop_status === 'on'; + + useEffect(() => { + const vncStatus = status?.vnc_status; + if (vncStatus === 'on') { + setOn(true); + } else if (vncStatus === 'off') { + setOn(false); + } else { + setOn(null); + } + }, [status?.vnc_status]); + + const localSetVnc = async (running) => { + const previous = on; + setOn(null); + try { + if (running) { + await startVnc(); + } else { + await stopVnc(); + } + } catch (e) { + console.error('VNC error:', e); + toast({ + type: 'error', + title: 'VNC Error', + description: e.message || 'Could not start/stop VNC. See server logs.', + time: 5000, + }); + // The status poll will not re-sync `on` because VNC did not change. + setOn(previous); + } + } + + return {on, desktopRunning, setVnc: localSetVnc}; +} + export const useSearchDirectories = (value) => { const [directoryName, setDirectoryName] = useState(value ?? ''); const [directories, setDirectories] = useState(null); diff --git a/controller/api/admin.py b/controller/api/admin.py index c8a3e36a..6d54516f 100644 --- a/controller/api/admin.py +++ b/controller/api/admin.py @@ -27,6 +27,8 @@ from controller.api.schemas import ( TimezoneSetRequest, TimezoneSetResponse, TimezoneStatusResponse, + VncActionResponse, + VncStatusResponse, ) from controller.lib.admin import ( block_bluetooth, @@ -34,6 +36,7 @@ from controller.lib.admin import ( enable_throttle, get_bluetooth_status_dict, get_desktop_status_dict, + get_vnc_status_dict, get_device_hotspot_protocols, get_hotspot_device, get_hotspot_password, @@ -49,8 +52,10 @@ from controller.lib.admin import ( shutdown_system, start_desktop, start_hotspot, + start_vnc, stop_desktop, stop_hotspot, + stop_vnc, unblock_bluetooth, update_hotspot_settings, ) @@ -180,6 +185,34 @@ async def desktop_stop() -> DesktopActionResponse: return DesktopActionResponse(**result) +# --- VNC --- + +@router.get("/api/vnc/status", response_model=VncStatusResponse) +async def vnc_status() -> VncStatusResponse: + """Get VNC server status.""" + return VncStatusResponse(**await get_vnc_status_dict()) + + +@router.post("/api/vnc/start", response_model=VncActionResponse) +async def vnc_start() -> VncActionResponse: + """Start the VNC server; requires a running desktop.""" + result = await start_vnc() + if not result.get("success"): + # 409 distinguishes "the desktop is not running" from a systemctl failure. + status_code = 409 if result.get("precondition_failed") else 500 + raise HTTPException(status_code=status_code, detail=result.get("error", "Failed")) + return VncActionResponse(success=True) + + +@router.post("/api/vnc/stop", response_model=VncActionResponse) +async def vnc_stop() -> VncActionResponse: + """Stop the VNC server; allowed even when the desktop is stopped.""" + result = await stop_vnc() + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + return VncActionResponse(success=True) + + # --- Samba --- @router.get("/api/samba/status", response_model=SambaStatusResponse) diff --git a/controller/api/schemas.py b/controller/api/schemas.py index 80270b61..c476b2c5 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -238,6 +238,29 @@ class DesktopActionResponse(BaseModel): error: Optional[str] = Field(default=None, description="Error message if failed") +# ============================================================================ +# Admin - VNC +# ============================================================================ + +class VncStatusResponse(BaseModel): + """Response model for /api/vnc/status endpoint.""" + + running: bool = Field(description="Whether the VNC server is currently running") + available: bool = Field(description="Whether a usable VNC server is installed") + desktop_running: bool = Field(description="Whether the desktop VNC would serve is running") + can_start: bool = Field(description="Whether VNC can be started right now") + reason: Optional[str] = Field(default=None, description="Why VNC is unavailable or cannot start") + backend: Optional[str] = Field(default=None, description="VNC backend in use (wayvnc or realvnc)") + port: int = Field(description="Port the VNC server listens on") + + +class VncActionResponse(BaseModel): + """Response model for VNC start/stop actions.""" + + success: bool = Field(description="Whether the action succeeded") + error: Optional[str] = Field(default=None, description="Error message if failed") + + # ============================================================================ # Admin - Samba # ============================================================================ diff --git a/controller/api/status.py b/controller/api/status.py index 294aeff9..e55aebce 100644 --- a/controller/api/status.py +++ b/controller/api/status.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from controller.lib.admin import get_bluetooth_status, get_desktop_status, get_hotspot_ssid, get_hotspot_status, \ - get_throttle_status, BluetoothStatus, HotspotStatus + get_throttle_status, get_vnc_status, BluetoothStatus, HotspotStatus from controller.lib.config import is_docker_mode, is_rpi, is_rpi4, is_rpi5 from controller.lib.status import ( get_cpu_status, @@ -40,6 +40,7 @@ async def get_system_info() -> dict: hotspot_status = get_hotspot_status() bluetooth_status = await get_bluetooth_status() desktop_status = await get_desktop_status() + vnc_status = await get_vnc_status() throttle_status = get_throttle_status() # Get hotspot SSID from config if hotspot is connected @@ -52,6 +53,7 @@ async def get_system_info() -> dict: "is_rpi5": is_rpi5(), "bluetooth_status": bluetooth_status.name, "desktop_status": desktop_status.name, + "vnc_status": vnc_status.name, "hotspot_status": hotspot_status.name, "hotspot_ssid": hotspot_ssid, "throttle_status": throttle_status.name, diff --git a/controller/lib/admin.py b/controller/lib/admin.py index eeeec083..11ef62bd 100644 --- a/controller/lib/admin.py +++ b/controller/lib/admin.py @@ -47,6 +47,14 @@ class DesktopStatus(enum.Enum): unknown = enum.auto() # Docker mode or cannot determine. +class VncStatus(enum.Enum): + """VNC server status.""" + on = enum.auto() # VNC server is running. + off = enum.auto() # A VNC server is installed, but stopped. + unavailable = enum.auto() # No usable VNC server installed. + unknown = enum.auto() # Docker mode or cannot determine. + + class GovernorStatus(enum.Enum): """CPU governor status enum matching wrolpi/admin.py""" ondemand = enum.auto() @@ -708,6 +716,68 @@ async def block_bluetooth() -> dict: return {"success": False, "error": str(e)} +# --- systemd units --- + +async def _unit_properties(unit: str, *properties: str) -> dict | None: + """ + Read systemd properties for a unit, e.g. LoadState and ActiveState. + + `systemctl show` exits 0 even for a unit that does not exist (reporting + LoadState=not-found), so callers must judge existence from LoadState. + Note ConditionResult is useless here: it reports "no" for any unit that has + never been started, configured or not. + + Returns None when systemctl itself failed. Timeouts and OSError propagate so + callers can tell "no systemd here" from "systemd would not answer". + """ + proc = await asyncio.create_subprocess_exec( + "systemctl", "show", unit, f"--property={','.join(properties)}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5) + + if proc.returncode != 0: + return None + + return dict( + line.split("=", 1) for line in stdout.decode().strip().splitlines() if "=" in line + ) + + +async def _systemctl_unit(action: str, unit: str, label: str) -> dict: + """Run `systemctl ` and report the result.""" + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + logger.info("%s %s requested (%s)", label, action, unit) + try: + proc = await asyncio.create_subprocess_exec( + "systemctl", action, unit, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) + + if proc.returncode == 0: + logger.info("%s %s succeeded", label, action) + return {"success": True} + else: + error_msg = stderr.decode().strip() + logger.warning("Failed to %s %s: %s", action, label.lower(), error_msg) + return {"success": False, "error": error_msg or "Unknown error"} + + except asyncio.TimeoutError: + logger.warning("Timeout during %s %s", label.lower(), action) + return {"success": False, "error": f"Timeout during {label.lower()} {action}"} + except FileNotFoundError: + logger.warning("systemctl not found, cannot %s %s", action, label.lower()) + return {"success": False, "error": "systemctl not found"} + except OSError as e: + logger.warning("Failed to %s %s: %s", action, label.lower(), e) + return {"success": False, "error": str(e)} + + # --- Desktop --- # Every display manager (lightdm on RPi OS, gdm3/sddm on Debian) provides this @@ -726,19 +796,10 @@ async def get_desktop_status() -> DesktopStatus: return DesktopStatus.unknown try: - proc = await asyncio.create_subprocess_exec( - "systemctl", "show", DISPLAY_MANAGER_UNIT, "--property=LoadState,ActiveState", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5) - - if proc.returncode != 0: + properties = await _unit_properties(DISPLAY_MANAGER_UNIT, "LoadState", "ActiveState") + if properties is None: return DesktopStatus.unknown - properties = dict( - line.split("=", 1) for line in stdout.decode().strip().splitlines() if "=" in line - ) if properties.get("LoadState") != "loaded": # "not-found" when no display manager is installed (e.g. RPi OS Lite). return DesktopStatus.unavailable @@ -782,35 +843,7 @@ async def get_desktop_status_dict() -> dict: async def _systemctl_desktop(action: str) -> dict: """Run `systemctl display-manager.service` and report the result.""" - if is_docker_mode(): - return {"success": False, "error": "Not available in Docker mode"} - - logger.info("Desktop %s requested (%s)", action, DISPLAY_MANAGER_UNIT) - try: - proc = await asyncio.create_subprocess_exec( - "systemctl", action, DISPLAY_MANAGER_UNIT, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) - - if proc.returncode == 0: - logger.info("Desktop %s succeeded", action) - return {"success": True} - else: - error_msg = stderr.decode().strip() - logger.warning("Failed to %s desktop: %s", action, error_msg) - return {"success": False, "error": error_msg or "Unknown error"} - - except asyncio.TimeoutError: - logger.warning("Timeout during desktop %s", action) - return {"success": False, "error": f"Timeout during desktop {action}"} - except FileNotFoundError: - logger.warning("systemctl not found, cannot %s desktop", action) - return {"success": False, "error": "systemctl not found"} - except OSError as e: - logger.warning("Failed to %s desktop: %s", action, e) - return {"success": False, "error": str(e)} + return await _systemctl_unit(action, DISPLAY_MANAGER_UNIT, "Desktop") # Stopping the display manager leaves the monitor black with only a blinking @@ -934,6 +967,176 @@ async def stop_desktop() -> dict: return result +# --- VNC --- + +# Raspberry Pi OS ships wayvnc for Wayland sessions and RealVNC for X11; a Pi may +# have both installed. wayvnc only works when its config exists, which is also +# the unit's own ConditionPathExists. +WAYVNC_UNIT = "wayvnc.service" +WAYVNC_CONFIG = Path("/etc/wayvnc/config") +REALVNC_UNIT = "vncserver-x11-serviced.service" +VNC_PORT = 5900 + + +async def _unit_is_loaded(unit: str) -> bool: + """Whether systemd knows about a unit.""" + properties = await _unit_properties(unit, "LoadState") + return bool(properties) and properties.get("LoadState") == "loaded" + + +async def get_vnc_backend() -> tuple[str | None, str | None, str | None]: + """ + Pick the VNC backend to control. + + Prefers wayvnc (Raspberry Pi OS uses Wayland), falling back to RealVNC. + + Deliberately does not copy `raspi-config`'s live-compositor probe + (`pgrep labwc || pgrep wayfire`), which reports X11 — and would therefore pick + RealVNC — whenever the desktop happens to be stopped. + + Never raises: a host without systemd simply has no VNC backend. + + Returns: + (unit, name, reason) where reason explains an absent backend + """ + try: + wayvnc_loaded = await _unit_is_loaded(WAYVNC_UNIT) + if wayvnc_loaded and WAYVNC_CONFIG.is_file(): + return WAYVNC_UNIT, "wayvnc", None + + if await _unit_is_loaded(REALVNC_UNIT): + return REALVNC_UNIT, "realvnc", None + except (asyncio.TimeoutError, OSError): + return None, None, "VNC not supported or systemctl unavailable" + + if wayvnc_loaded: + # Installed but unconfigured; starting it would fail its own condition. + return None, None, f"wayvnc is installed but not configured ({WAYVNC_CONFIG} is missing)" + + return None, None, "No VNC server installed" + + +async def get_vnc_status() -> VncStatus: + """ + Get the VNC server status from whichever backend is installed. + + Returns: + VncStatus enum value + """ + if is_docker_mode(): + return VncStatus.unknown + + try: + unit, _, _ = await get_vnc_backend() + if unit is None: + return VncStatus.unavailable + + properties = await _unit_properties(unit, "ActiveState") + if properties is None: + return VncStatus.unknown + + return VncStatus.on if properties.get("ActiveState") == "active" else VncStatus.off + + except asyncio.TimeoutError: + return VncStatus.unknown + except FileNotFoundError: + return VncStatus.unavailable + except OSError: + return VncStatus.unknown + + +async def get_vnc_status_dict() -> dict: + """ + Get VNC status as a dict for API responses. + + Returns dict matching VncStatusResponse schema: + running: bool - Whether the VNC server is currently running + available: bool - Whether a usable VNC server is installed + desktop_running: bool - Whether the desktop VNC would serve is running + can_start: bool - Whether VNC can be started right now + reason: Optional[str] - Why VNC is unavailable or cannot be started + backend: Optional[str] - "wayvnc" or "realvnc" + port: int - Port the VNC server listens on + """ + status = await get_vnc_status() + _, backend, unavailable_reason = await get_vnc_backend() + desktop_running = await get_desktop_status() == DesktopStatus.on + + running = status == VncStatus.on + available = status in (VncStatus.on, VncStatus.off) + # VNC serves the desktop session, so there is nothing to show without it. + can_start = available and desktop_running + + reason = None + if status == VncStatus.unknown: + reason = "VNC not supported or systemctl unavailable" + elif status == VncStatus.unavailable: + reason = unavailable_reason or "No VNC server installed" + elif not desktop_running: + reason = "The Desktop must be running to use VNC" + + return { + "running": running, + "available": available, + "desktop_running": desktop_running, + "can_start": can_start, + "reason": reason, + "backend": backend, + "port": VNC_PORT, + } + + +async def start_vnc() -> dict: + """ + Start the VNC server until stopped or reboot. + + Requires a running desktop: VNC serves that session, so starting it without + one gives clients a blank screen. + + Fail open in the same sense as the desktop: the unit is never enabled, so VNC + does not come back after a reboot. Users who want it permanently on can + enable it with raspi-config. + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + unit, _, reason = await get_vnc_backend() + if unit is None: + return {"success": False, "error": reason or "No VNC server installed"} + + if await get_desktop_status() != DesktopStatus.on: + return { + "success": False, + "error": "The Desktop must be running to start VNC", + "precondition_failed": True, + } + + return await _systemctl_unit("start", unit, "VNC") + + +async def stop_vnc() -> dict: + """ + Stop the VNC server. + + Always allowed, even with the desktop stopped, so a running VNC server can + never be left on with no way to turn it off. + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + unit, _, reason = await get_vnc_backend() + if unit is None: + return {"success": False, "error": reason or "No VNC server installed"} + + return await _systemctl_unit("stop", unit, "VNC") + + GOVERNOR_MAP = { 'ondemand': GovernorStatus.ondemand, 'powersave': GovernorStatus.powersave, diff --git a/controller/templates/index.html b/controller/templates/index.html index b6c466e6..49a33273 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -975,6 +975,7 @@ +
Hotspot Settings @@ -1593,6 +1594,60 @@ alert(`Desktop error: ${e.message}`); } await updateDesktopButton(); + await updateVncButton(); + } + + // --- VNC --- + let vncRunning = null; + + async function updateVncButton() { + const btn = document.getElementById('vnc-btn'); + if (!btn) return; + try { + const data = await apiCall('api/vnc/status'); + vncRunning = data.running; + if (!data.available) { + btn.textContent = 'VNC Unavailable'; + btn.disabled = true; + btn.className = 'btn'; + btn.title = data.reason || ''; + } else if (data.running) { + btn.textContent = 'Stop VNC'; + btn.disabled = false; + btn.className = 'btn btn-warning'; + // VNC serves the desktop session, so say when there is nothing to serve. + btn.title = data.desktop_running ? '' : 'The Desktop is stopped, so VNC clients see nothing'; + } else if (!data.can_start) { + btn.textContent = 'Start VNC'; + btn.disabled = true; + btn.className = 'btn'; + btn.title = data.reason || ''; + } else { + btn.textContent = 'Start VNC'; + btn.disabled = false; + btn.className = 'btn btn-primary'; + btn.title = ''; + } + } catch (e) { + btn.textContent = 'VNC Unavailable'; + btn.disabled = true; + } + } + + async function toggleVnc() { + const btn = document.getElementById('vnc-btn'); + btn.disabled = true; + btn.innerHTML = ''; + try { + if (vncRunning) { + await apiCall('api/vnc/stop', 'POST'); + } else { + await apiCall('api/vnc/start', 'POST'); + } + } catch (e) { + alert(`VNC error: ${e.message}`); + } + await updateVncButton(); } async function waitForController(maxWaitMs = 60000) { @@ -2978,6 +3033,7 @@ updateHotspotButton(); updateBluetoothButton(); updateDesktopButton(); + updateVncButton(); loadHotspotSettings(); // Initial update after a short delay (to not overlap with initial load) @@ -2997,6 +3053,7 @@ updateHotspotButton(); updateBluetoothButton(); updateDesktopButton(); + updateVncButton(); }, updateInterval); } diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py index f783f890..1b3af89f 100644 --- a/controller/test/test_admin.py +++ b/controller/test/test_admin.py @@ -37,8 +37,14 @@ from controller.lib.admin import ( get_desktop_status_dict, start_desktop, stop_desktop, + get_vnc_backend, + get_vnc_status, + get_vnc_status_dict, + start_vnc, + stop_vnc, BluetoothStatus, DesktopStatus, + VncStatus, HotspotStatus, GovernorStatus, ) @@ -871,6 +877,8 @@ class TestDockerModeErrors: (stop_hotspot, False, ()), (start_desktop, True, ()), (stop_desktop, True, ()), + (start_vnc, True, ()), + (stop_vnc, True, ()), (enable_throttle, False, ()), (disable_throttle, False, ()), (shutdown_system, False, ()), @@ -1304,3 +1312,226 @@ class TestApplyTimezoneFromConfig: with mock.patch("controller.lib.admin.set_timezone") as mock_set: apply_timezone_from_config() mock_set.assert_not_called() + + +def _show_proc(output: str, returncode: int = 0) -> mock.AsyncMock: + """A mocked `systemctl show` process printing the given properties.""" + proc = mock.AsyncMock() + proc.communicate.return_value = (output.encode(), b"") + proc.returncode = returncode + return proc + + +def _ok_proc() -> mock.AsyncMock: + proc = mock.AsyncMock() + proc.communicate.return_value = (b"", b"") + proc.returncode = 0 + return proc + + +class TestGetVncBackend: + """wayvnc is preferred over RealVNC, and must actually be configured.""" + + @pytest.mark.asyncio + async def test_prefers_wayvnc_when_configured(self): + """A loaded, configured wayvnc wins even when RealVNC is also installed.""" + with mock.patch("asyncio.create_subprocess_exec", return_value=_show_proc("LoadState=loaded\n")): + with mock.patch("controller.lib.admin.WAYVNC_CONFIG") as config: + config.is_file.return_value = True + unit, name, reason = await get_vnc_backend() + + assert unit == "wayvnc.service" + assert name == "wayvnc" + assert reason is None + + @pytest.mark.asyncio + async def test_falls_back_to_realvnc_when_wayvnc_unconfigured(self): + """An unconfigured wayvnc must not be chosen; RealVNC is used instead.""" + with mock.patch("asyncio.create_subprocess_exec", return_value=_show_proc("LoadState=loaded\n")): + with mock.patch("controller.lib.admin.WAYVNC_CONFIG") as config: + config.is_file.return_value = False + unit, name, reason = await get_vnc_backend() + + assert unit == "vncserver-x11-serviced.service" + assert name == "realvnc" + assert reason is None + + @pytest.mark.asyncio + async def test_reports_unconfigured_wayvnc_when_alone(self): + """wayvnc installed but unconfigured, with no RealVNC, explains itself.""" + procs = [_show_proc("LoadState=loaded\n"), _show_proc("LoadState=not-found\n")] + with mock.patch("asyncio.create_subprocess_exec", side_effect=procs): + with mock.patch("controller.lib.admin.WAYVNC_CONFIG") as config: + config.is_file.return_value = False + unit, name, reason = await get_vnc_backend() + + assert unit is None + assert name is None + assert "not configured" in reason + + @pytest.mark.asyncio + async def test_reports_nothing_installed(self): + """Neither backend installed is reported plainly.""" + with mock.patch("asyncio.create_subprocess_exec", return_value=_show_proc("LoadState=not-found\n")): + unit, name, reason = await get_vnc_backend() + + assert unit is None + assert reason == "No VNC server installed" + + @pytest.mark.asyncio + async def test_does_not_probe_the_compositor(self): + """Backend choice must not depend on a running compositor. + + raspi-config uses `pgrep labwc` to decide Wayland-vs-X11, which reports X11 + whenever the desktop is stopped and would pick the wrong backend. + """ + with mock.patch("asyncio.create_subprocess_exec", return_value=_show_proc("LoadState=loaded\n")) as mock_exec: + with mock.patch("controller.lib.admin.WAYVNC_CONFIG") as config: + config.is_file.return_value = True + await get_vnc_backend() + + for call in mock_exec.call_args_list: + assert "pgrep" not in call.args[0] + + +class TestGetVncStatus: + """Tests for get_vnc_status.""" + + @pytest.mark.asyncio + async def test_returns_unknown_in_docker_mode(self): + """Should return unknown when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + assert await get_vnc_status() == VncStatus.unknown + + @pytest.mark.asyncio + @pytest.mark.parametrize("active_state,expected", [ + ("active", VncStatus.on), + ("inactive", VncStatus.off), + ("failed", VncStatus.off), + ]) + async def test_maps_active_state(self, active_state, expected): + """ActiveState of the chosen unit decides on/off.""" + procs = [_show_proc("LoadState=loaded\n"), _show_proc(f"ActiveState={active_state}\n")] + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", side_effect=procs): + with mock.patch("controller.lib.admin.WAYVNC_CONFIG") as config: + config.is_file.return_value = True + assert await get_vnc_status() == expected + + @pytest.mark.asyncio + async def test_unavailable_without_a_backend(self): + """No installed backend reports unavailable.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", return_value=_show_proc("LoadState=not-found\n")): + assert await get_vnc_status() == VncStatus.unavailable + + +class TestGetVncStatusDict: + """The status dict gates starting on the desktop being up.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("vnc,desktop_up,running,available,can_start", [ + (VncStatus.on, True, True, True, True), + (VncStatus.off, True, False, True, True), + (VncStatus.off, False, False, True, False), # desktop stopped: cannot start + (VncStatus.on, False, True, True, False), # running, but could not be started now + (VncStatus.unavailable, True, False, False, False), + (VncStatus.unknown, True, False, False, False), + ]) + async def test_flags(self, vnc, desktop_up, running, available, can_start): + """running/available/can_start reflect VNC and desktop state.""" + desktop = DesktopStatus.on if desktop_up else DesktopStatus.off + with mock.patch("controller.lib.admin.get_vnc_status", return_value=vnc): + with mock.patch("controller.lib.admin.get_vnc_backend", return_value=("u", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=desktop): + result = await get_vnc_status_dict() + + assert result["running"] is running + assert result["available"] is available + assert result["can_start"] is can_start + assert result["desktop_running"] is desktop_up + assert result["port"] == 5900 + + @pytest.mark.asyncio + async def test_explains_a_stopped_desktop(self): + """The reason should name the desktop when that is what blocks VNC.""" + with mock.patch("controller.lib.admin.get_vnc_status", return_value=VncStatus.off): + with mock.patch("controller.lib.admin.get_vnc_backend", return_value=("u", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.off): + result = await get_vnc_status_dict() + + assert "Desktop must be running" in result["reason"] + + +class TestVncStartStop: + """Starting requires a desktop; stopping never does.""" + + @pytest.mark.asyncio + async def test_start_runs_systemctl_start(self): + """With a desktop up, start should start the chosen unit and nothing else.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=("wayvnc.service", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.on): + with mock.patch("asyncio.create_subprocess_exec", return_value=_ok_proc()) as mock_exec: + result = await start_vnc() + + assert result["success"] is True + mock_exec.assert_called_once_with( + "systemctl", "start", "wayvnc.service", stdout=mock.ANY, stderr=mock.ANY) + + @pytest.mark.asyncio + async def test_start_refused_when_desktop_stopped(self): + """The gate lives here, not only in the UI, so curl callers get it too.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=("wayvnc.service", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.off): + with mock.patch("asyncio.create_subprocess_exec") as mock_exec: + result = await start_vnc() + + assert result["success"] is False + assert result["precondition_failed"] is True + assert "Desktop must be running" in result["error"] + mock_exec.assert_not_called() + + @pytest.mark.asyncio + async def test_stop_allowed_with_desktop_stopped(self): + """A running VNC must always be stoppable, or it could be stranded on.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=("wayvnc.service", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.off): + with mock.patch("asyncio.create_subprocess_exec", return_value=_ok_proc()) as mock_exec: + result = await stop_vnc() + + assert result["success"] is True + mock_exec.assert_called_once_with( + "systemctl", "stop", "wayvnc.service", stdout=mock.ANY, stderr=mock.ANY) + + @pytest.mark.asyncio + @pytest.mark.parametrize("func", [start_vnc, stop_vnc]) + async def test_never_enables_or_disables(self, func): + """Runtime-only: VNC must not be persisted across reboots.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=("wayvnc.service", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.on): + with mock.patch("asyncio.create_subprocess_exec", return_value=_ok_proc()) as mock_exec: + await func() + + forbidden = {"enable", "disable", "mask", "unmask", "set-default", "isolate"} + for call in mock_exec.call_args_list: + assert not forbidden.intersection(call.args), call.args + + @pytest.mark.asyncio + @pytest.mark.parametrize("func", [start_vnc, stop_vnc]) + async def test_reports_missing_backend(self, func): + """Without a backend both actions explain themselves instead of failing obscurely.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=(None, None, "No VNC server installed")): + result = await func() + + assert result["success"] is False + assert result["error"] == "No VNC server installed" diff --git a/controller/test/test_admin_api.py b/controller/test/test_admin_api.py index 840c30ca..5d429f21 100644 --- a/controller/test/test_admin_api.py +++ b/controller/test/test_admin_api.py @@ -12,6 +12,7 @@ class TestStatusEndpoints: ("/api/hotspot/status", ["enabled", "available"]), ("/api/bluetooth/status", ["enabled", "available"]), ("/api/desktop/status", ["running", "available"]), + ("/api/vnc/status", ["running", "available", "desktop_running", "can_start", "port"]), ("/api/throttle/status", ["enabled", "available"]), ("/api/timezone/status", ["available", "timezone"]), ]) @@ -27,6 +28,7 @@ class TestStatusEndpoints: "/api/hotspot/status", "/api/bluetooth/status", "/api/desktop/status", + "/api/vnc/status", "/api/throttle/status", "/api/timezone/status", ]) @@ -131,6 +133,8 @@ class TestDockerModeRejectsAdminActions: ("post", "/api/bluetooth/block", 500, None), ("post", "/api/desktop/start", 500, None), ("post", "/api/desktop/stop", 500, None), + ("post", "/api/vnc/start", 500, None), + ("post", "/api/vnc/stop", 500, None), ("post", "/api/throttle/enable", 500, None), ("post", "/api/throttle/disable", 500, None), ("post", "/api/timezone/set", 500, {"timezone": "America/Denver"}), @@ -189,3 +193,77 @@ class TestDesktopEndpoints: response = test_client.post(endpoint) assert response.status_code == 500 assert "Failed to connect to bus" in response.json()["detail"] + + +class TestVncEndpoints: + """VNC endpoints gate starting on a running desktop.""" + + def test_start_returns_409_when_desktop_stopped(self, test_client): + """A stopped desktop is a precondition failure, not a server error.""" + from unittest import mock + from controller.lib.admin import DesktopStatus + + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=("wayvnc.service", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.off): + response = test_client.post("/api/vnc/start") + + assert response.status_code == 409 + assert "Desktop must be running" in response.json()["detail"] + + def test_start_runs_systemctl_when_desktop_running(self, test_client): + """With the desktop up, start should start the backend unit.""" + from unittest import mock + from controller.lib.admin import DesktopStatus + + mock_proc = mock.AsyncMock() + mock_proc.communicate.return_value = (b"", b"") + mock_proc.returncode = 0 + + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=("wayvnc.service", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.on): + with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec: + response = test_client.post("/api/vnc/start") + + assert response.status_code == 200 + assert response.json()["success"] is True + mock_exec.assert_called_once_with( + "systemctl", "start", "wayvnc.service", stdout=mock.ANY, stderr=mock.ANY) + + def test_stop_works_with_desktop_stopped(self, test_client): + """Stopping is never gated, so VNC cannot be stranded on.""" + from unittest import mock + from controller.lib.admin import DesktopStatus + + mock_proc = mock.AsyncMock() + mock_proc.communicate.return_value = (b"", b"") + mock_proc.returncode = 0 + + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=("wayvnc.service", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.off): + with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec: + response = test_client.post("/api/vnc/stop") + + assert response.status_code == 200 + mock_exec.assert_called_once_with( + "systemctl", "stop", "wayvnc.service", stdout=mock.ANY, stderr=mock.ANY) + + def test_start_failure_returns_500(self, test_client): + """A systemctl failure is still a 500, distinct from the 409 gate.""" + from unittest import mock + from controller.lib.admin import DesktopStatus + + mock_proc = mock.AsyncMock() + mock_proc.communicate.return_value = (b"", b"Job for wayvnc.service failed") + mock_proc.returncode = 1 + + with mock.patch("controller.lib.admin.get_vnc_backend", + return_value=("wayvnc.service", "wayvnc", None)): + with mock.patch("controller.lib.admin.get_desktop_status", return_value=DesktopStatus.on): + with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc): + response = test_client.post("/api/vnc/start") + + assert response.status_code == 500 + assert "failed" in response.json()["detail"] diff --git a/controller/test/test_api.py b/controller/test/test_api.py index e34a0d51..50501694 100644 --- a/controller/test/test_api.py +++ b/controller/test/test_api.py @@ -109,6 +109,7 @@ class TestOpenAPI: "/api/hotspot/status", "/api/hotspot/start", "/api/hotspot/stop", "/api/bluetooth/status", "/api/bluetooth/unblock", "/api/bluetooth/block", "/api/desktop/status", "/api/desktop/start", "/api/desktop/stop", + "/api/vnc/status", "/api/vnc/start", "/api/vnc/stop", "/api/throttle/status", "/api/throttle/enable", "/api/throttle/disable", "/api/timezone/status", "/api/timezone/set", "/api/shutdown", "/api/reboot", "/api/restart",