mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
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:
parent
a0fa03e6c5
commit
2328524ab2
14 changed files with 816 additions and 43 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
57
app/src/components/VncToggle.test.js
Normal file
57
app/src/components/VncToggle.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue