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",