controller: VNC toggle, gated on the Desktop running

Add a VNC toggle to the Controller (both UIs) that starts/stops whichever VNC
backend is installed: wayvnc for Wayland (Raspberry Pi OS), RealVNC for X11.

Runtime-only, like the Desktop toggle: the unit is never enabled, so VNC does
not return after a reboot. Users who want it permanently on can enable it with
raspi-config.

VNC serves the desktop session, so starting it without a desktop gives clients
a blank screen. The gate lives in the backend, not just the UI, so curl callers
get it too (409, distinct from a systemctl failure). Stopping is never gated so
a running VNC server can't be stranded on with no way to turn it off.

Backend selection deliberately avoids raspi-config's live-compositor probe
(pgrep labwc), which reports X11 — and would pick the wrong backend — whenever
the desktop is stopped. wayvnc also requires its config to exist; that is
checked directly rather than via the unit's ConditionResult, which reports 'no'
for any unit that has never been started.

Extracts _unit_properties()/_systemctl_unit() so Desktop and VNC share one
systemd probe and start/stop path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-29 16:53:45 -06:00 • committed by lrnselfreliance
parent a0fa03e6c5
commit 2328524ab2
14 changed files with 816 additions and 43 deletions

View file

@ -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() {

View file

@ -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() {
</div>;
}
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 <div style={{margin: '0.5em'}}>
<Toggle
label='VNC'
disabled={disabled}
checked={on === true}
onChange={checked => setVnc(checked)}
/>
{info && <InfoPopup content={info}/>}
</div>;
}
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);

View file

@ -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(<VncToggle/>);
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(<VncToggle/>);
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(<VncToggle/>);
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(<VncToggle/>);
expect(screen.getByTestId('toggle')).toBeChecked();
});
it('does nothing when VNC is unsupported', async () => {
mockVncState = {on: null, desktopRunning: true};
render(<VncToggle/>);
await userEvent.click(screen.getByTestId('toggle'));
expect(mockSetVnc).not.toHaveBeenCalled();
});
});

View file

@ -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() {
<HotspotToggle/>
<BluetoothToggle/>
<DesktopToggle/>
<VncToggle/>
<ThrottleToggle/>
<HotspotSettingsForm/>

View file

@ -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: () => <div data-testid="bluetooth-toggle">BluetoothToggle</div>,
DesktopToggle: () => <div data-testid="desktop-toggle">DesktopToggle</div>,
VncToggle: () => <div data-testid="vnc-toggle">VncToggle</div>,
HotspotToggle: () => <div data-testid="hotspot-toggle">HotspotToggle</div>,
ThrottleToggle: () => <div data-testid="throttle-toggle">ThrottleToggle</div>,
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();
});

View file

@ -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);

View file

@ -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)

View file

@ -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
# ============================================================================

View file

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

View file

@ -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 <start|stop> <unit>` 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 <start|stop> 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,

View file

@ -975,6 +975,7 @@
<button id="hotspot-btn" class="btn" onclick="toggleHotspot()" disabled>Loading...</button>
<button id="bluetooth-btn" class="btn" onclick="toggleBluetooth()" disabled>Loading...</button>
<button id="desktop-btn" class="btn" onclick="toggleDesktop()" disabled>Loading...</button>
<button id="vnc-btn" class="btn" onclick="toggleVnc()" disabled>Loading...</button>
</div>
<details style="margin-bottom: 1rem;">
<summary style="cursor: pointer;">Hotspot Settings</summary>
@ -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 = '<span class="loading"></span>';
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);
}
</script>

View file

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

View file

@ -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"]

View file

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