Files
site/WESP_REL/app/services/kiosk_boot_service.py
2026-07-17 12:57:18 +03:00

805 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Автозапуск Chromium в kiosk (labwc-pi / XDG autostart)."""
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote, urlparse
from app.services.hardware_settings_service import default_app_landing_path
try:
import pwd
except ImportError: # Windows / non-Unix
pwd = None # type: ignore[assignment,misc]
_DESKTOP_ENTRY_NAME = "chromium-kiosk.desktop"
_STATE_DIR_NAME = "wesp-kiosk"
_STATE_FILE = "state.json"
_LABWC_MARKER = "Managed by WESP kiosk boot"
_DEFAULT_SYSTEM_LABWC = "/etc/xdg/labwc/autostart"
_DEFAULT_CHROMIUM_POLICY = Path("/etc/chromium/policies/managed/wesp-kiosk.json")
_CHROMIUM_PROFILE_DIRNAME = "wesp-kiosk-chromium"
# MAP → 127.0.0.1: сеть перевода не достигается (дополнение к policy и meta notranslate).
_DEFAULT_CHROMIUM_HOST_RULES = (
"MAP translate.googleapis.com 127.0.0.1,"
" MAP translate-pa.googleapis.com 127.0.0.1,"
" MAP translation.googleapis.com 127.0.0.1,"
" MAP translate.google.com 127.0.0.1"
)
def _is_root() -> bool:
geteuid = getattr(os, "geteuid", None)
if geteuid is None:
return False
try:
return geteuid() == 0
except OSError:
return False
def _env_bool(value: Any, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() not in ("0", "false", "no", "off", "")
def _chromium_user_data_dir(app_config: Dict[str, Any], home: Path) -> Path:
custom = (app_config.get("WESP_KIOSK_CHROMIUM_USER_DATA_DIR") or "").strip()
if custom:
return Path(custom)
return home / ".config" / _CHROMIUM_PROFILE_DIRNAME
def _chromium_policy_path(app_config: Dict[str, Any]) -> Path:
custom = (app_config.get("WESP_KIOSK_CHROMIUM_POLICY_PATH") or "").strip()
if custom:
return Path(custom)
return _DEFAULT_CHROMIUM_POLICY
def _managed_policy_content() -> str:
template = Path(__file__).resolve().parents[2] / "install" / "kiosk" / "chromium-managed-policy.json"
if template.is_file():
return template.read_text(encoding="utf-8")
return (
'{"TranslateEnabled":false,"OfferTranslateEnabled":false,'
'"DefaultNotificationsSetting":2}\n'
)
def _chromium_preferences_content() -> str:
return json.dumps(
{
"translate": {"enabled": False},
"translate_site_blocklist": ["localhost", "127.0.0.1"],
"intl": {"accept_languages": "ru-RU,ru"},
"profile": {
"default_content_setting_values": {"notifications": 2},
},
},
ensure_ascii=False,
)
def _ensure_chromium_translate_block(
home: Path,
user: str,
app_config: Dict[str, Any],
) -> Tuple[bool, str]:
"""Профиль Chromium + enterprise policy (перевод и уведомления)."""
profile_root = _chromium_user_data_dir(app_config, home)
prefs_path = profile_root / "Default" / "Preferences"
ok, msg = _write_file_as_user(
prefs_path,
_chromium_preferences_content(),
user,
)
if not ok:
return False, f"Preferences: {msg}"
# Каталоги могли создаться под root — зафиксируем владельца и доступы.
if _is_root():
_chown_tree(profile_root, user)
try:
profile_root.chmod(0o700)
except OSError:
pass
policy_path = _chromium_policy_path(app_config)
policy_path.parent.mkdir(parents=True, exist_ok=True)
ok, msg = _write_system_file(policy_path, _managed_policy_content(), executable=False)
if not ok:
return False, f"policy: {msg}"
return True, "Chromium profile + policy"
def _chromium_host_rules(app_config: Dict[str, Any]) -> str:
custom = (app_config.get("WESP_KIOSK_CHROMIUM_HOST_RULES") or "").strip()
return custom or _DEFAULT_CHROMIUM_HOST_RULES
def _chromium_flag_list(
app_config: Dict[str, Any],
*,
home: Path,
) -> Tuple[str, ...]:
"""Флаги kiosk-Chromium (Debian 13 / Chromium 147+)."""
extra = (app_config.get("WESP_KIOSK_CHROMIUM_EXTRA_FLAGS") or "").strip()
gl = (app_config.get("WESP_KIOSK_CHROMIUM_GL") or "").strip().lower()
user_data = _chromium_user_data_dir(app_config, home)
host_rules = _chromium_host_rules(app_config)
flags: List[str] = [
# Wayland (labwc): без Ozone часть сборок Chromium рисует чёрный экран.
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
"--kiosk",
"--no-first-run",
"--no-default-browser-check",
"--disable-session-crashed-bubble",
"--disable-restore-session-state",
f"--user-data-dir={user_data}",
"--password-store=basic",
"--disable-infobars",
"--disable-notifications",
"--disable-translate",
"--disable-translate-new-ux",
"--lang=ru",
"--accept-lang=ru-RU,ru",
(
"--disable-features="
"Translate,TranslateUI,TranslateOffer,TranslateBubble,"
"TranslateNewUX,OptimizationHints"
),
f"--host-rules={host_rules}",
]
if gl in ("swiftshader", "software"):
flags.insert(0, "--use-gl=swiftshader")
elif gl in ("egl",):
flags.insert(0, "--use-gl=egl")
if extra:
flags.extend(shlex.split(extra))
return tuple(flags)
def _gui_user(app_config: Dict[str, Any]) -> str:
return (app_config.get("WESP_KIOSK_GUI_USER") or "komton").strip() or "komton"
def _kiosk_target_url(app_config: Dict[str, Any]) -> str:
"""Конечная страница после /starting (весы и т.д.)."""
explicit = (app_config.get("WESP_KIOSK_TARGET_URL") or "").strip()
if explicit:
return explicit
legacy = (app_config.get("WESP_KIOSK_URL") or "").strip()
if legacy and "/starting" not in legacy:
return legacy
landing = default_app_landing_path()
return f"http://127.0.0.1{landing}"
def _kiosk_launch_url(app_config: Dict[str, Any]) -> str:
"""URL в Chromium: /starting с тёмной темой или сразу target."""
forced = (app_config.get("WESP_KIOSK_URL") or "").strip()
if forced:
return forced
target = _kiosk_target_url(app_config)
if not _env_bool(app_config.get("WESP_KIOSK_STARTUP_SCREEN"), True):
return target
parsed = urlparse(target)
if parsed.scheme and parsed.netloc:
base = f"{parsed.scheme}://{parsed.netloc}"
else:
base = "http://127.0.0.1"
next_path = parsed.path or default_app_landing_path()
if parsed.query:
next_path = f"{next_path}?{parsed.query}"
theme = (app_config.get("WESP_KIOSK_STARTUP_THEME") or "dark").strip() or "dark"
q = f"next={quote(next_path, safe='/?:=&')}&theme={quote(theme, safe='')}"
return f"{base}/starting?{q}"
def _chromium_bin(app_config: Dict[str, Any]) -> str:
return (app_config.get("WESP_KIOSK_CHROMIUM") or "/usr/bin/chromium").strip()
def _system_labwc_paths(app_config: Dict[str, Any]) -> Tuple[Path, Path]:
base = (
app_config.get("WESP_KIOSK_SYSTEM_LABWC_AUTOSTART") or _DEFAULT_SYSTEM_LABWC
).strip()
path = Path(base)
return path, Path(f"{path}.wesp-backup")
def _home_dir(user: str) -> Path:
if pwd is not None:
try:
return Path(pwd.getpwnam(user).pw_dir)
except KeyError:
pass
return Path(f"/home/{user}")
def _state_path(home: Path) -> Path:
return home / ".config" / _STATE_DIR_NAME / _STATE_FILE
def _autostart_dir(home: Path) -> Path:
return home / ".config" / "autostart"
def _user_labwc_autostart_path(home: Path) -> Path:
return home / ".config" / "labwc" / "autostart"
def _chromium_exec_line(
chromium: str,
url: str,
app_config: Dict[str, Any],
*,
home: Path,
) -> str:
# --app=URL: один документ, без «залипания» на / или NTP; & в query не ломает shell.
return " ".join(
[
shlex.quote(chromium),
*_chromium_flag_list(app_config, home=home),
f"--app={shlex.quote(url)}",
]
)
def _chown_path(path: Path, user: str) -> None:
if pwd is None:
return
try:
pw = pwd.getpwnam(user)
os.chown(path, pw.pw_uid, pw.pw_gid)
except (KeyError, OSError, AttributeError):
pass
def _chown_tree(path: Path, user: str) -> None:
if pwd is None:
return
try:
pw = pwd.getpwnam(user)
uid, gid = pw.pw_uid, pw.pw_gid
except KeyError:
return
if path.is_dir():
for root, _dirs, files in os.walk(path):
for name in files:
try:
os.chown(os.path.join(root, name), uid, gid)
except OSError:
pass
try:
os.chown(path, uid, gid)
except OSError:
pass
def build_chromium_desktop_content(
*, chromium: str, url: str, app_config: Dict[str, Any], home: Path
) -> str:
return (
"[Desktop Entry]\n"
"Type=Application\n"
"Name=Chromium Kiosk\n"
f"Exec={_chromium_exec_line(chromium, url, app_config, home=home)}\n"
"Hidden=false\n"
"X-GNOME-Autostart-enabled=true\n"
)
def _kiosk_chromium_boot_delay_sec(app_config: Dict[str, Any]) -> int:
"""Доп. пауза после появления HTTP (миграции / SD). 0 = только wait-for-http."""
raw = app_config.get("WESP_KIOSK_CHROMIUM_BOOT_DELAY_SEC", 2)
try:
return max(0, min(30, int(raw)))
except (TypeError, ValueError):
return 2
def _kiosk_wait_http_max_sec(app_config: Dict[str, Any]) -> int:
raw = app_config.get("WESP_KIOSK_WAIT_HTTP_MAX_SEC", 90)
try:
return max(5, min(180, int(raw)))
except (TypeError, ValueError):
return 90
def build_labwc_autostart_content(
*, chromium: str, url: str, app_config: Dict[str, Any], home: Path
) -> str:
"""Минимальный autostart для labwc-pi: kanshi + Chromium, без pcmanfm/панели."""
exec_line = _chromium_exec_line(chromium, url, app_config, home=home)
log_path = home / ".cache" / "wesp-kiosk-chromium.log"
wait_max = _kiosk_wait_http_max_sec(app_config)
boot_delay = _kiosk_chromium_boot_delay_sec(app_config)
inner = (
f"for i in $(seq 1 {wait_max}); do "
'curl -sS -m 2 -o /dev/null "http://127.0.0.1/starting" 2>/dev/null && break; '
"sleep 1; done; "
f"[ {boot_delay} -gt 0 ] && sleep {boot_delay}; "
f"{exec_line} --enable-logging=stderr --v=1 >>\"{log_path}\" 2>&1"
)
wrapped = f"bash -lc {shlex.quote(inner)}"
return (
"#!/bin/sh\n"
f"# {_LABWC_MARKER}\n"
"/usr/bin/kanshi &\n"
f"{wrapped} &\n"
)
def _is_wesp_labwc_autostart(path: Path) -> bool:
if not path.is_file():
return False
try:
return _LABWC_MARKER in path.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
def _read_url_from_text(text: str) -> Optional[str]:
m = re.search(r"https?://\S+", text)
return m.group(0).rstrip("'\"") if m else None
def _read_desktop_url(path: Path) -> Optional[str]:
if not path.is_file():
return None
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
for line in text.splitlines():
if line.strip().lower().startswith("exec="):
return _read_url_from_text(line)
return None
def _load_state(state_path: Path) -> Dict[str, Any]:
if not state_path.is_file():
return {}
try:
return json.loads(state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _save_state(state_path: Path, data: Dict[str, Any], user: str) -> None:
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
_chown_tree(state_path.parent, user)
def _write_system_file(path: Path, content: str, *, executable: bool = True) -> Tuple[bool, str]:
path.parent.mkdir(parents=True, exist_ok=True)
try:
path.write_text(content, encoding="utf-8")
if executable:
path.chmod(0o755)
return True, "Записано"
except OSError as exc:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".sh") as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
proc = subprocess.run(
["sudo", "cp", tmp_path, str(path)],
capture_output=True,
text=True,
timeout=60,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
return False, f"{exc}; sudo: {err}"
if executable:
subprocess.run(["sudo", "chmod", "755", str(path)], check=False, timeout=30)
return True, "Записано (sudo)"
finally:
try:
Path(tmp_path).unlink(missing_ok=True)
except OSError:
pass
def _ensure_system_backup(system: Path, backup: Path) -> Tuple[bool, str]:
if backup.is_file():
return True, "Резервная копия уже есть"
if not system.is_file():
return False, f"Не найден {system}"
try:
shutil.copy2(system, backup)
return True, "Создана резервная копия autostart"
except OSError as exc:
return False, str(exc)
def _restore_system_autostart(system: Path, backup: Path) -> Tuple[bool, str]:
if not backup.is_file():
return True, "Восстановление не требуется"
try:
shutil.copy2(backup, system)
system.chmod(0o755)
return True, "Восстановлен исходный /etc/xdg/labwc/autostart"
except OSError as exc:
proc = subprocess.run(
["sudo", "cp", str(backup), str(system)],
capture_output=True,
text=True,
timeout=60,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
return False, f"{exc}; sudo: {err}"
subprocess.run(["sudo", "chmod", "755", str(system)], check=False, timeout=30)
return True, "Восстановлено (sudo)"
def _patch_system_labwc(
app_config: Dict[str, Any],
*,
chromium: str,
url: str,
) -> Tuple[bool, str]:
system, backup = _system_labwc_paths(app_config)
ok, msg = _ensure_system_backup(system, backup)
if not ok:
return False, msg
home = _home_dir(_gui_user(app_config))
content = build_labwc_autostart_content(
chromium=chromium, url=url, app_config=app_config, home=home
)
ok, msg = _write_system_file(system, content, executable=True)
if not ok:
return False, msg
return True, f"Патч {system}"
def _unpatch_system_labwc(app_config: Dict[str, Any]) -> Tuple[bool, str]:
system, backup = _system_labwc_paths(app_config)
if _is_wesp_labwc_autostart(system):
return _restore_system_autostart(system, backup)
return True, "Системный autostart не изменён WESP"
def _write_file_as_user(
path: Path,
content: str,
user: str,
*,
write_cmd: str = "",
executable: bool = False,
) -> Tuple[bool, str]:
path.parent.mkdir(parents=True, exist_ok=True)
try:
if _is_root():
path.write_text(content, encoding="utf-8")
if executable:
path.chmod(0o755)
_chown_path(path, user)
_chown_path(path.parent, user)
# Профиль Chromium часто в ~/.config/... (user-data-dir).
# Если каталоги созданы от root, Chromium не сможет создать SingletonLock.
try:
_chown_path(path.parent.parent, user)
except Exception:
pass
return True, "Записано"
path.write_text(content, encoding="utf-8")
if executable:
path.chmod(0o755)
return True, "Записано"
except OSError:
pass
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".tmp") as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
if write_cmd:
args = shlex.split(write_cmd) + [str(path)]
proc = subprocess.run(
args,
input=content,
capture_output=True,
text=True,
timeout=60,
)
else:
proc = subprocess.run(
["sudo", "cp", tmp_path, str(path)],
capture_output=True,
text=True,
timeout=60,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
return False, (err or "write failed") + " Нужен root или WESP_KIOSK_WRITE_CMD."
if executable:
subprocess.run(["sudo", "chmod", "755", str(path)], check=False, timeout=30)
if _is_root():
_chown_path(path, user)
return True, "Записано (sudo)"
finally:
try:
Path(tmp_path).unlink(missing_ok=True)
except OSError:
pass
def _remove_path(path: Path, *, write_cmd: str = "") -> Tuple[bool, str]:
if not path.exists():
return True, "Уже отсутствует"
try:
if _is_root() or (path.is_file() and os.access(path, os.W_OK)):
path.unlink(missing_ok=True)
return True, "Удалено"
except OSError:
pass
if write_cmd:
args = shlex.split(write_cmd) + ["rm", "-f", str(path)]
proc = subprocess.run(args, capture_output=True, text=True, timeout=30)
else:
proc = subprocess.run(
["sudo", "rm", "-f", str(path)],
capture_output=True,
text=True,
timeout=30,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
return False, err or "rm failed"
return True, "Удалено (sudo)"
def get_kiosk_boot_status(app_config: Dict[str, Any]) -> Dict[str, Any]:
user = _gui_user(app_config)
home = _home_dir(user)
autostart = _autostart_dir(home)
desktop_path = autostart / _DESKTOP_ENTRY_NAME
user_labwc = _user_labwc_autostart_path(home)
system_labwc, system_backup = _system_labwc_paths(app_config)
chromium = _chromium_bin(app_config)
launch_url = _kiosk_launch_url(app_config)
target_url = _kiosk_target_url(app_config)
state = _load_state(_state_path(home))
hide_desktop = bool(state.get("hide_desktop"))
system_active = _is_wesp_labwc_autostart(system_labwc)
user_labwc_active = _is_wesp_labwc_autostart(user_labwc)
xdg_enabled = desktop_path.is_file()
enabled = system_active or user_labwc_active or xdg_enabled
detected_url = None
if system_active:
try:
detected_url = _read_url_from_text(
system_labwc.read_text(encoding="utf-8", errors="replace"),
)
except OSError:
pass
elif user_labwc_active:
try:
detected_url = _read_url_from_text(
user_labwc.read_text(encoding="utf-8", errors="replace"),
)
except OSError:
pass
elif xdg_enabled:
detected_url = _read_desktop_url(desktop_path)
if system_active:
mode = "system_labwc"
msg = f"Киоск: патч {system_labwc} (без рабочего стола)"
elif user_labwc_active:
mode = "user_labwc_legacy"
msg = (
f"Устаревший {user_labwc} — включите киоск снова "
f"(нужен патч {system_labwc})"
)
elif xdg_enabled:
mode = "xdg"
msg = "Киоск XDG (рабочий стол возможен до браузера)"
else:
mode = "off"
msg = "Киоск выключен"
return {
"gui_user": user,
"home_dir": str(home),
"kiosk_enabled": enabled,
"kiosk_mode": mode,
"hide_desktop": hide_desktop or system_active,
"system_labwc_autostart_path": str(system_labwc),
"system_labwc_patched": system_active,
"system_labwc_backup_path": str(system_backup),
"system_labwc_backup_exists": system_backup.is_file(),
"user_labwc_autostart_path": str(user_labwc),
"user_labwc_autostart_active": user_labwc_active,
"autostart_path": str(desktop_path),
"configured_url": launch_url,
"target_url": target_url,
"active_url": detected_url or (launch_url if enabled else None),
"startup_screen": _env_bool(app_config.get("WESP_KIOSK_STARTUP_SCREEN"), True),
"chromium_path": chromium,
"chromium_found": Path(chromium).is_file(),
"home_exists": home.is_dir(),
"running_as_root": _is_root(),
"message": msg,
}
def set_kiosk_boot(
enabled: bool,
app_config: Dict[str, Any],
*,
hide_desktop: Optional[bool] = None,
schedule_reboot: bool = True,
) -> Dict[str, Any]:
user = _gui_user(app_config)
home = _home_dir(user)
if not home.is_dir():
return {
"ok": False,
"code": "no_home",
"message": f"Домашний каталог не найден: {home}",
}
autostart = _autostart_dir(home)
desktop_path = autostart / _DESKTOP_ENTRY_NAME
user_labwc = _user_labwc_autostart_path(home)
state_path = _state_path(home)
write_cmd = (app_config.get("WESP_KIOSK_WRITE_CMD") or "").strip()
chromium = _chromium_bin(app_config)
launch_url = _kiosk_launch_url(app_config)
target_url = _kiosk_target_url(app_config)
system_labwc, _backup = _system_labwc_paths(app_config)
if not Path(chromium).is_file() and enabled:
return {
"ok": False,
"code": "chromium_missing",
"message": f"Chromium не найден: {chromium}",
}
state = _load_state(state_path)
if hide_desktop is not None:
state["hide_desktop"] = bool(hide_desktop)
apply_hide = bool(state.get("hide_desktop"))
if not enabled:
ok, msg = _unpatch_system_labwc(app_config)
if not ok:
return {"ok": False, "code": "write_error", "message": msg}
for path in (desktop_path, user_labwc):
ok, rm_msg = _remove_path(path, write_cmd=write_cmd)
if not ok:
return {"ok": False, "code": "write_error", "message": rm_msg}
state = {"hide_desktop": False, "system_patched": False}
_save_state(state_path, state, user)
return _maybe_attach_auto_reboot(
app_config,
{
"ok": True,
"code": "disabled",
"message": f"{msg} Киоск отключён.",
"kiosk_enabled": False,
"hide_desktop": False,
},
schedule_reboot=schedule_reboot,
)
# Всегда убираем устаревший ~/.config/labwc/autostart (labwc-pi его не использует).
_remove_path(user_labwc, write_cmd=write_cmd)
ok, chrome_msg = _ensure_chromium_translate_block(home, user, app_config)
if not ok:
return {"ok": False, "code": "chromium_profile_error", "message": chrome_msg}
if apply_hide:
ok, patch_msg = _patch_system_labwc(
app_config, chromium=chromium, url=launch_url
)
if not ok:
return {"ok": False, "code": "write_error", "message": patch_msg}
ok, msg = _remove_path(desktop_path, write_cmd=write_cmd)
if not ok:
return {"ok": False, "code": "write_error", "message": msg}
hide_note = (
f" Патч {system_labwc}: без pcmanfm/панели; Chromium сразу."
)
kiosk_mode = "system_labwc"
state = {"hide_desktop": True, "system_patched": True}
else:
ok, unpatch_msg = _unpatch_system_labwc(app_config)
if not ok:
return {"ok": False, "code": "write_error", "message": unpatch_msg}
content = build_chromium_desktop_content(
chromium=chromium,
url=launch_url,
app_config=app_config,
home=home,
)
ok, msg = _write_file_as_user(
desktop_path,
content,
user,
write_cmd=write_cmd,
)
if not ok:
return {"ok": False, "code": "write_error", "message": msg}
hide_note = (
" XDG autostart; рабочий стол может мелькать — включите «Без рабочего стола»."
)
kiosk_mode = "xdg"
state = {"hide_desktop": False, "system_patched": False}
_save_state(state_path, state, user)
return _maybe_attach_auto_reboot(
app_config,
{
"ok": True,
"code": "enabled",
"message": (
f"Киоск включён → {launch_url} (далее {target_url}).{hide_note}"
),
"kiosk_enabled": True,
"hide_desktop": apply_hide,
"kiosk_mode": kiosk_mode,
"system_labwc_autostart_path": str(system_labwc),
"user_labwc_autostart_path": str(user_labwc),
"autostart_path": str(desktop_path),
"home_dir": str(home),
"target_url": target_url,
},
schedule_reboot=schedule_reboot,
)
def _maybe_attach_auto_reboot(
app_config: Dict[str, Any],
result: Dict[str, Any],
*,
schedule_reboot: bool,
) -> Dict[str, Any]:
if not schedule_reboot:
result["reboot_scheduled"] = False
return result
return _attach_auto_reboot(app_config, result)
def _attach_auto_reboot(app_config: Dict[str, Any], result: Dict[str, Any]) -> Dict[str, Any]:
from app.services.system_restart_service import maybe_auto_reboot_after_kiosk_change
reboot = maybe_auto_reboot_after_kiosk_change(app_config)
result["reboot_scheduled"] = bool(reboot.get("scheduled"))
if reboot.get("scheduled") and reboot.get("message"):
base = str(result.get("message") or "").rstrip(".")
result["message"] = f"{base}. {reboot['message']}"
return result