mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-19 11:08:39 +00:00
Compare commits
20 Commits
a84b65fffb
...
fix/post-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae95a1015 | ||
|
|
246ea54635 | ||
|
|
a0f957be9e | ||
|
|
76cd010aab | ||
|
|
587daa780e | ||
|
|
c19df29a21 | ||
|
|
b361866679 | ||
|
|
ceb4c4105f | ||
|
|
e9af18cdf1 | ||
|
|
5e6c40ad55 | ||
|
|
d6bd1ee215 | ||
|
|
acaf8a248e | ||
|
|
db9585cea9 | ||
|
|
65e3e8319b | ||
|
|
4ef3f8cad5 | ||
|
|
338bdc44cb | ||
|
|
73c00140df | ||
|
|
68a38c39f7 | ||
|
|
941291561a | ||
|
|
39ccdcf00d |
@@ -1,4 +1,5 @@
|
||||
# LEDMatrix
|
||||
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
## Welcome to LEDMatrix!
|
||||
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 657 KiB |
@@ -1,17 +1,9 @@
|
||||
{
|
||||
"ledmatrix-weather": {
|
||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||
},
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"music": {
|
||||
"SPOTIFY_CLIENT_ID": "YOUR_SPOTIFY_CLIENT_ID_HERE",
|
||||
"SPOTIFY_CLIENT_SECRET": "YOUR_SPOTIFY_CLIENT_SECRET_HERE",
|
||||
"SPOTIFY_REDIRECT_URI": "http://127.0.0.1:8888/callback"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -599,9 +599,13 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
||||
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
||||
{
|
||||
"weather": {
|
||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||
}
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
# Check if service runs as root and set ownership accordingly
|
||||
@@ -1082,6 +1086,7 @@ SYSTEMCTL_PATH=$(which systemctl)
|
||||
REBOOT_PATH=$(which reboot)
|
||||
POWEROFF_PATH=$(which poweroff)
|
||||
BASH_PATH=$(which bash)
|
||||
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||
|
||||
# Create sudoers content
|
||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||
@@ -1097,10 +1102,23 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
||||
echo "Sudoers configuration already up to date"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
requests>=2.28.0
|
||||
Pillow>=9.1.0
|
||||
Pillow>=12.2.0
|
||||
pytz>=2022.1
|
||||
numpy>=1.24.0
|
||||
|
||||
@@ -35,24 +35,24 @@ class WebUIInfoPlugin(BasePlugin):
|
||||
"""Initialize the Web UI Info plugin."""
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# AP mode cache (must be initialized before _get_local_ip)
|
||||
self._ap_mode_cached = False
|
||||
self._ap_mode_cache_time = 0.0
|
||||
self._ap_mode_cache_ttl = 60.0
|
||||
|
||||
# Get device hostname
|
||||
try:
|
||||
self.device_id = socket.gethostname()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
||||
self.device_id = "localhost"
|
||||
|
||||
|
||||
# Get device IP address
|
||||
self.device_ip = self._get_local_ip()
|
||||
|
||||
|
||||
# IP refresh tracking
|
||||
self.last_ip_refresh = time.time()
|
||||
self.ip_refresh_interval = 300.0 # Refresh IP every 5 minutes
|
||||
|
||||
# AP mode cache
|
||||
self._ap_mode_cached = False
|
||||
self._ap_mode_cache_time = 0.0
|
||||
self._ap_mode_cache_ttl = 60.0 # Cache AP mode check for 60 seconds
|
||||
self.ip_refresh_interval = 300.0
|
||||
|
||||
# Rotation state
|
||||
self.current_display_mode = "hostname" # "hostname" or "ip"
|
||||
@@ -200,9 +200,7 @@ class WebUIInfoPlugin(BasePlugin):
|
||||
elif current_interface == "wlan0":
|
||||
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
||||
try:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
|
||||
@@ -89,9 +89,9 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||
|
||||
# Optional: journalctl (non-critical — skip if not found)
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
|
||||
@@ -10,6 +10,7 @@ import sys
|
||||
import time
|
||||
import logging
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path (parent of scripts/utils/)
|
||||
@@ -43,7 +44,11 @@ class WiFiMonitorDaemon:
|
||||
self.wifi_manager = WiFiManager()
|
||||
self.running = True
|
||||
self.last_state = None
|
||||
|
||||
# Counts consecutive checks where nmcli says "connected" but internet is unreachable.
|
||||
# After _nm_restart_threshold failures, NetworkManager is restarted as a recovery step.
|
||||
self._consecutive_internet_failures = 0
|
||||
self._nm_restart_threshold = 5 # ~2.5 min at 30s interval
|
||||
|
||||
# Register signal handlers for graceful shutdown
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
@@ -122,6 +127,43 @@ class WiFiMonitorDaemon:
|
||||
else:
|
||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||
|
||||
# Escalating recovery: if nmcli reports connected but actual internet
|
||||
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||
# transient packet loss on otherwise working WiFi.
|
||||
if updated_status.connected and not updated_status.ap_mode_active:
|
||||
if not self.wifi_manager.check_internet_connectivity():
|
||||
self._consecutive_internet_failures += 1
|
||||
logger.warning(
|
||||
f"Internet unreachable despite nmcli connection "
|
||||
f"({self._consecutive_internet_failures}/{self._nm_restart_threshold})"
|
||||
)
|
||||
if self._consecutive_internet_failures >= self._nm_restart_threshold:
|
||||
logger.warning("Restarting NetworkManager to recover internet connectivity")
|
||||
try:
|
||||
subprocess.run(
|
||||
["/usr/bin/systemctl", "restart", "NetworkManager"],
|
||||
capture_output=True, timeout=20, check=True
|
||||
)
|
||||
self._consecutive_internet_failures = 0
|
||||
# NM restart causes a brief WiFi drop; reset the AP-mode grace
|
||||
# counter so that transient disconnect doesn't count toward
|
||||
# triggering AP mode.
|
||||
self.wifi_manager._disconnected_checks = 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"NetworkManager restart failed (rc={e.returncode}); "
|
||||
"resetting failure counter to avoid tight retry loop")
|
||||
self._consecutive_internet_failures = 0
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.error(f"NetworkManager restart error: {e}; "
|
||||
"resetting failure counter to avoid tight retry loop")
|
||||
self._consecutive_internet_failures = 0
|
||||
else:
|
||||
self._consecutive_internet_failures = 0
|
||||
else:
|
||||
self._consecutive_internet_failures = 0
|
||||
|
||||
# Sleep until next check
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ used from scripts.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -189,7 +188,7 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
with state_file.open("r", encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
raw_plugins = state.get("plugins", {}) if isinstance(state, dict) else {}
|
||||
raw_plugins = state.get("states", {}) if isinstance(state, dict) else {}
|
||||
if isinstance(raw_plugins, dict):
|
||||
for plugin_id, info in raw_plugins.items():
|
||||
if not isinstance(info, dict):
|
||||
@@ -290,53 +289,54 @@ def create_backup(
|
||||
|
||||
contents: List[str] = []
|
||||
|
||||
# Build bundle in memory first, then atomically write to final path.
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Config files.
|
||||
if (project_root / _CONFIG_REL).exists():
|
||||
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
||||
contents.append("config")
|
||||
if (project_root / _SECRETS_REL).exists():
|
||||
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
|
||||
contents.append("secrets")
|
||||
if (project_root / _WIFI_REL).exists():
|
||||
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
||||
contents.append("wifi")
|
||||
|
||||
# User-uploaded fonts.
|
||||
user_fonts = iter_user_fonts(project_root)
|
||||
if user_fonts:
|
||||
for font in user_fonts:
|
||||
arcname = font.relative_to(project_root).as_posix()
|
||||
zf.write(font, arcname)
|
||||
contents.append("fonts")
|
||||
|
||||
# Plugin uploads.
|
||||
plugin_uploads = iter_plugin_uploads(project_root)
|
||||
if plugin_uploads:
|
||||
for upload in plugin_uploads:
|
||||
arcname = upload.relative_to(project_root).as_posix()
|
||||
zf.write(upload, arcname)
|
||||
contents.append("plugin_uploads")
|
||||
|
||||
# Installed plugins manifest.
|
||||
plugins = list_installed_plugins(project_root)
|
||||
if plugins:
|
||||
zf.writestr(
|
||||
PLUGINS_MANIFEST_NAME,
|
||||
json.dumps(plugins, indent=2),
|
||||
)
|
||||
contents.append("plugins")
|
||||
|
||||
# Manifest goes last so that `contents` reflects what we actually wrote.
|
||||
manifest = _build_manifest(contents, project_root)
|
||||
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||
|
||||
# Write atomically.
|
||||
# Stream directly to a temp file so we never hold the whole ZIP in memory.
|
||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||
tmp_path.write_bytes(buffer.getvalue())
|
||||
os.replace(tmp_path, zip_path)
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Config files.
|
||||
if (project_root / _CONFIG_REL).exists():
|
||||
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
||||
contents.append("config")
|
||||
if (project_root / _SECRETS_REL).exists():
|
||||
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
|
||||
contents.append("secrets")
|
||||
if (project_root / _WIFI_REL).exists():
|
||||
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
||||
contents.append("wifi")
|
||||
|
||||
# User-uploaded fonts.
|
||||
user_fonts = iter_user_fonts(project_root)
|
||||
if user_fonts:
|
||||
for font in user_fonts:
|
||||
arcname = font.relative_to(project_root).as_posix()
|
||||
zf.write(font, arcname)
|
||||
contents.append("fonts")
|
||||
|
||||
# Plugin uploads.
|
||||
plugin_uploads = iter_plugin_uploads(project_root)
|
||||
if plugin_uploads:
|
||||
for upload in plugin_uploads:
|
||||
arcname = upload.relative_to(project_root).as_posix()
|
||||
zf.write(upload, arcname)
|
||||
contents.append("plugin_uploads")
|
||||
|
||||
# Installed plugins manifest.
|
||||
plugins = list_installed_plugins(project_root)
|
||||
if plugins:
|
||||
zf.writestr(
|
||||
PLUGINS_MANIFEST_NAME,
|
||||
json.dumps(plugins, indent=2),
|
||||
)
|
||||
contents.append("plugins")
|
||||
|
||||
# Manifest goes last so that `contents` reflects what we actually wrote.
|
||||
manifest = _build_manifest(contents, project_root)
|
||||
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||
|
||||
os.replace(tmp_path, zip_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size)
|
||||
return zip_path
|
||||
|
||||
@@ -395,15 +395,17 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
return False, "Backup is missing manifest.json", {}
|
||||
|
||||
total = 0
|
||||
for info in zf.infolist():
|
||||
if info.file_size > _MAX_MEMBER_BYTES:
|
||||
return False, f"Member {info.filename} is too large", {}
|
||||
total += info.file_size
|
||||
if total > _MAX_TOTAL_BYTES:
|
||||
return False, "Backup exceeds maximum allowed size", {}
|
||||
# Safety: reject members with unsafe paths up front.
|
||||
if _safe_extract_path(Path("/tmp/_zip_check"), info.filename) is None:
|
||||
return False, f"Unsafe path in backup: {info.filename}", {}
|
||||
with tempfile.TemporaryDirectory() as _sandbox:
|
||||
sandbox = Path(_sandbox)
|
||||
for info in zf.infolist():
|
||||
if info.file_size > _MAX_MEMBER_BYTES:
|
||||
return False, f"Member {info.filename} is too large", {}
|
||||
total += info.file_size
|
||||
if total > _MAX_TOTAL_BYTES:
|
||||
return False, "Backup exceeds maximum allowed size", {}
|
||||
# Safety: reject members with unsafe paths up front.
|
||||
if _safe_extract_path(sandbox, info.filename) is None:
|
||||
return False, f"Unsafe path in backup: {info.filename}", {}
|
||||
|
||||
try:
|
||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||
@@ -429,7 +431,10 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
detected.append("wifi")
|
||||
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||
detected.append("fonts")
|
||||
if any(n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") for n in names):
|
||||
if any(
|
||||
n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") and "/uploads/" in n
|
||||
for n in names
|
||||
):
|
||||
detected.append("plugin_uploads")
|
||||
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
@@ -569,6 +574,9 @@ def restore_backup(
|
||||
for name in files:
|
||||
src = Path(root) / name
|
||||
rel = src.relative_to(tmp_dir)
|
||||
if "/uploads/" not in rel.as_posix():
|
||||
result.errors.append(f"Rejected unexpected plugin path: {rel}")
|
||||
continue
|
||||
try:
|
||||
_copy_file(src, project_root / rel)
|
||||
count += 1
|
||||
|
||||
@@ -677,6 +677,44 @@ class PluginManager:
|
||||
# Default: 60 seconds
|
||||
return 60.0
|
||||
|
||||
def _record_update_failure(
|
||||
self,
|
||||
plugin_id: str,
|
||||
exc: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""Apply the standard failure-recovery path for a plugin update.
|
||||
|
||||
Stamps plugin_last_update with the actual failure time so the full
|
||||
configured interval elapses before the next retry, then transitions
|
||||
the plugin back to ENABLED (not ERROR) with structured error context
|
||||
so automatic recovery happens on the next scheduled cycle.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
exc: The exception that caused the failure, if any. When None a
|
||||
synthetic ExecutionFailure exception is constructed from the
|
||||
timeout/executor-error path.
|
||||
"""
|
||||
failure_time = time.time()
|
||||
if exc is not None:
|
||||
err: Exception = exc
|
||||
error_type = type(exc).__name__
|
||||
else:
|
||||
err = Exception(f"Plugin {plugin_id} execution failed (timeout or executor error)")
|
||||
error_type = 'ExecutionFailure'
|
||||
|
||||
error_info = {
|
||||
'error': str(err),
|
||||
'error_type': error_type,
|
||||
'timestamp': failure_time,
|
||||
'recoverable': True,
|
||||
}
|
||||
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
|
||||
self.plugin_last_update[plugin_id] = failure_time
|
||||
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, err)
|
||||
|
||||
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
|
||||
"""
|
||||
Trigger plugin updates based on their defined update intervals.
|
||||
@@ -734,16 +772,10 @@ class PluginManager:
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_success(plugin_id)
|
||||
else:
|
||||
# Execution failed (timeout or error)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, Exception("Plugin execution failed"))
|
||||
self._record_update_failure(plugin_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
||||
# Record failure
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, exc)
|
||||
self._record_update_failure(plugin_id, exc=exc)
|
||||
|
||||
def update_all_plugins(self) -> None:
|
||||
"""
|
||||
@@ -769,14 +801,12 @@ class PluginManager:
|
||||
if success:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
self.state_manager.record_update(plugin_id)
|
||||
# Update state back to ENABLED
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
else:
|
||||
# Execution failed
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
self._record_update_failure(plugin_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
||||
self._record_update_failure(plugin_id, exc=exc)
|
||||
|
||||
def get_plugin_health_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@ Manages plugin state machine (loaded → enabled → running → error)
|
||||
with state transitions and queries.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -34,6 +35,7 @@ class PluginStateManager:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or get_logger(__name__)
|
||||
self._lock = threading.RLock()
|
||||
self._states: Dict[str, PluginState] = {}
|
||||
self._state_history: Dict[str, list] = {}
|
||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -48,44 +50,44 @@ class PluginStateManager:
|
||||
) -> None:
|
||||
"""
|
||||
Set plugin state and record transition.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
state: New state
|
||||
error: Optional error if transitioning to ERROR state
|
||||
"""
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
# Record state transition
|
||||
if plugin_id not in self._state_history:
|
||||
self._state_history[plugin_id] = []
|
||||
|
||||
transition = {
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None
|
||||
}
|
||||
self._state_history[plugin_id].append(transition)
|
||||
|
||||
# Store error info if transitioning to ERROR state
|
||||
if state == PluginState.ERROR and error:
|
||||
self._error_info[plugin_id] = {
|
||||
'error': str(error),
|
||||
'error_type': type(error).__name__,
|
||||
'timestamp': datetime.now()
|
||||
with self._lock:
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
if plugin_id not in self._state_history:
|
||||
self._state_history[plugin_id] = []
|
||||
|
||||
transition = {
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None
|
||||
}
|
||||
elif state != PluginState.ERROR:
|
||||
# Clear error info when leaving ERROR state
|
||||
self._error_info.pop(plugin_id, None)
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s",
|
||||
plugin_id,
|
||||
old_state.value,
|
||||
state.value
|
||||
)
|
||||
self._state_history[plugin_id].append(transition)
|
||||
|
||||
# Store error info if transitioning to ERROR state
|
||||
if state == PluginState.ERROR and error:
|
||||
self._error_info[plugin_id] = {
|
||||
'error': str(error),
|
||||
'error_type': type(error).__name__,
|
||||
'timestamp': datetime.now()
|
||||
}
|
||||
elif state != PluginState.ERROR:
|
||||
# Clear error info when leaving ERROR state
|
||||
self._error_info.pop(plugin_id, None)
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s",
|
||||
plugin_id,
|
||||
old_state.value,
|
||||
state.value
|
||||
)
|
||||
|
||||
def get_state(self, plugin_id: str) -> PluginState:
|
||||
"""
|
||||
@@ -136,17 +138,82 @@ class PluginStateManager:
|
||||
"""
|
||||
return self._state_history.get(plugin_id, [])
|
||||
|
||||
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Get error information for a plugin in ERROR state.
|
||||
|
||||
Persist structured error context without changing plugin state.
|
||||
|
||||
Used for recoverable failures (e.g. update timeout) where the plugin
|
||||
stays ENABLED but the error details should remain queryable.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Error information dict or None
|
||||
error_info: Arbitrary dict describing the error
|
||||
"""
|
||||
return self._error_info.get(plugin_id)
|
||||
with self._lock:
|
||||
self._error_info[plugin_id] = dict(error_info)
|
||||
|
||||
def set_state_with_error(
|
||||
self,
|
||||
plugin_id: str,
|
||||
state: PluginState,
|
||||
error_info: Dict[str, Any],
|
||||
error: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""Set plugin state and persist error context atomically.
|
||||
|
||||
Unlike calling set_state() then set_error_info() separately, this
|
||||
method holds ``_lock`` for both writes so no reader can observe the
|
||||
new state without the accompanying error context.
|
||||
|
||||
Intentionally does not clear ``_error_info`` the way set_state() does
|
||||
for non-ERROR transitions — this is the recoverable-failure path where
|
||||
the error dict is the entire point.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
state: New state
|
||||
error_info: Structured error dict to persist alongside the state
|
||||
error: Optional exception recorded in the transition history
|
||||
"""
|
||||
with self._lock:
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
if plugin_id not in self._state_history:
|
||||
self._state_history[plugin_id] = []
|
||||
self._state_history[plugin_id].append({
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None,
|
||||
})
|
||||
|
||||
self._error_info[plugin_id] = dict(error_info)
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s (recoverable error stored)",
|
||||
plugin_id,
|
||||
old_state.value,
|
||||
state.value,
|
||||
)
|
||||
|
||||
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get error information for a plugin.
|
||||
|
||||
Returns the stored error dict whether the plugin is in ERROR state or
|
||||
still ENABLED after a recoverable failure. Returns a shallow copy so
|
||||
callers cannot mutate the stored snapshot.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Copy of the error information dict, or None
|
||||
"""
|
||||
with self._lock:
|
||||
info = self._error_info.get(plugin_id)
|
||||
return dict(info) if info is not None else None
|
||||
|
||||
def record_update(self, plugin_id: str) -> None:
|
||||
"""Record that plugin update() was called."""
|
||||
|
||||
@@ -8,7 +8,7 @@ Detects and fixes inconsistencies between:
|
||||
- State manager state
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Dict, Any, List, Optional, Set
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
@@ -86,16 +86,38 @@ class StateReconciliation:
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.store_manager = store_manager
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Plugin IDs that failed auto-repair and should NOT be retried this
|
||||
# process lifetime. Prevents the infinite "attempt to reinstall missing
|
||||
# plugin" loop when a config entry references a plugin that isn't in
|
||||
# the registry (e.g. legacy 'github', 'youtube' entries). A process
|
||||
# restart — or an explicit user-initiated reconcile with force=True —
|
||||
# clears this so recovery is possible after the underlying issue is
|
||||
# fixed.
|
||||
self._unrecoverable_missing_on_disk: Set[str] = set()
|
||||
|
||||
def reconcile_state(self) -> ReconciliationResult:
|
||||
def reconcile_state(self, force: bool = False) -> ReconciliationResult:
|
||||
"""
|
||||
Perform state reconciliation.
|
||||
|
||||
|
||||
Compares state from all sources and fixes safe inconsistencies.
|
||||
|
||||
|
||||
Args:
|
||||
force: If True, clear the unrecoverable-plugin cache before
|
||||
reconciling so previously-failed auto-repairs are retried.
|
||||
Intended for user-initiated reconcile requests after the
|
||||
underlying issue (e.g. registry update) has been fixed.
|
||||
|
||||
Returns:
|
||||
ReconciliationResult with findings and fixes
|
||||
"""
|
||||
if force and self._unrecoverable_missing_on_disk:
|
||||
self.logger.info(
|
||||
"Force reconcile requested; clearing %d cached unrecoverable plugin(s)",
|
||||
len(self._unrecoverable_missing_on_disk),
|
||||
)
|
||||
self._unrecoverable_missing_on_disk.clear()
|
||||
|
||||
self.logger.info("Starting state reconciliation")
|
||||
|
||||
inconsistencies = []
|
||||
@@ -280,7 +302,26 @@ class StateReconciliation:
|
||||
|
||||
# Check: Plugin in config but not on disk
|
||||
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
|
||||
can_repair = self.store_manager is not None
|
||||
# Skip plugins that previously failed auto-repair in this process.
|
||||
# Re-attempting wastes CPU (network + git clone each request) and
|
||||
# spams the logs with the same "Plugin not found in registry"
|
||||
# error. The entry is still surfaced as MANUAL_FIX_REQUIRED so the
|
||||
# UI can show it, but no auto-repair will run.
|
||||
previously_unrecoverable = plugin_id in self._unrecoverable_missing_on_disk
|
||||
# Also refuse to re-install a plugin that the user just uninstalled
|
||||
# through the UI — prevents a race where the reconciler fires
|
||||
# between file removal and config cleanup and resurrects the
|
||||
# plugin the user just deleted.
|
||||
recently_uninstalled = (
|
||||
self.store_manager is not None
|
||||
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
||||
and self.store_manager.was_recently_uninstalled(plugin_id)
|
||||
)
|
||||
can_repair = (
|
||||
self.store_manager is not None
|
||||
and not previously_unrecoverable
|
||||
and not recently_uninstalled
|
||||
)
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
|
||||
@@ -342,7 +383,13 @@ class StateReconciliation:
|
||||
return False
|
||||
|
||||
def _auto_repair_missing_plugin(self, plugin_id: str) -> bool:
|
||||
"""Attempt to reinstall a missing plugin from the store."""
|
||||
"""Attempt to reinstall a missing plugin from the store.
|
||||
|
||||
On failure, records plugin_id in ``_unrecoverable_missing_on_disk`` so
|
||||
subsequent reconciliation passes within this process do not retry and
|
||||
spam the log / CPU. A process restart (or an explicit ``force=True``
|
||||
reconcile) is required to clear the cache.
|
||||
"""
|
||||
if not self.store_manager:
|
||||
return False
|
||||
|
||||
@@ -351,6 +398,43 @@ class StateReconciliation:
|
||||
if plugin_id.startswith('ledmatrix-'):
|
||||
candidates.append(plugin_id[len('ledmatrix-'):])
|
||||
|
||||
# Cheap pre-check: is any candidate actually present in the registry
|
||||
# at all? If not, we know up-front this is unrecoverable and can skip
|
||||
# the expensive install_plugin path (which does a forced GitHub fetch
|
||||
# before failing).
|
||||
#
|
||||
# IMPORTANT: we must pass raise_on_failure=True here. The default
|
||||
# fetch_registry() silently falls back to a stale cache or an empty
|
||||
# dict on network failure, which would make it impossible to tell
|
||||
# "plugin genuinely not in registry" from "I can't reach the
|
||||
# registry right now" — in the second case we'd end up poisoning
|
||||
# _unrecoverable_missing_on_disk with every config entry on a fresh
|
||||
# boot with no cache.
|
||||
registry_has_candidate = False
|
||||
try:
|
||||
registry = self.store_manager.fetch_registry(raise_on_failure=True)
|
||||
registry_ids = {
|
||||
p.get('id') for p in (registry.get('plugins', []) or []) if p.get('id')
|
||||
}
|
||||
registry_has_candidate = any(c in registry_ids for c in candidates)
|
||||
except Exception as e:
|
||||
# If we can't reach the registry, treat this as transient — don't
|
||||
# mark unrecoverable, let the next pass try again.
|
||||
self.logger.warning(
|
||||
"[AutoRepair] Could not read registry to check %s: %s", plugin_id, e
|
||||
)
|
||||
return False
|
||||
|
||||
if not registry_has_candidate:
|
||||
self.logger.warning(
|
||||
"[AutoRepair] %s not present in registry; marking unrecoverable "
|
||||
"(will not retry this session). Reinstall from the Plugin Store "
|
||||
"or remove the stale config entry to clear this warning.",
|
||||
plugin_id,
|
||||
)
|
||||
self._unrecoverable_missing_on_disk.add(plugin_id)
|
||||
return False
|
||||
|
||||
for candidate_id in candidates:
|
||||
try:
|
||||
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
|
||||
@@ -366,6 +450,11 @@ class StateReconciliation:
|
||||
except Exception as e:
|
||||
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
|
||||
|
||||
self.logger.warning("[AutoRepair] Could not reinstall %s from store", plugin_id)
|
||||
self.logger.warning(
|
||||
"[AutoRepair] Could not reinstall %s from store; marking unrecoverable "
|
||||
"(will not retry this session).",
|
||||
plugin_id,
|
||||
)
|
||||
self._unrecoverable_missing_on_disk.add(plugin_id)
|
||||
return False
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ import zipfile
|
||||
import tempfile
|
||||
import requests
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
import logging
|
||||
|
||||
from src.common.permission_utils import sudo_remove_directory
|
||||
@@ -52,19 +53,89 @@ class PluginStoreManager:
|
||||
self.registry_cache = None
|
||||
self.registry_cache_time = None # Timestamp of when registry was cached
|
||||
self.github_cache = {} # Cache for GitHub API responses
|
||||
self.cache_timeout = 3600 # 1 hour cache timeout
|
||||
self.registry_cache_timeout = 300 # 5 minutes for registry cache
|
||||
self.cache_timeout = 3600 # 1 hour cache timeout (repo info: stars, default_branch)
|
||||
# 15 minutes for registry cache. Long enough that the plugin list
|
||||
# endpoint on a warm cache never hits the network, short enough that
|
||||
# new plugins show up within a reasonable window. See also the
|
||||
# stale-cache fallback in fetch_registry for transient network
|
||||
# failures.
|
||||
self.registry_cache_timeout = 900
|
||||
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
||||
self.commit_cache_timeout = 300 # 5 minutes (same as registry)
|
||||
# 30 minutes for commit/manifest caches. Plugin Store users browse
|
||||
# the catalog via /plugins/store/list which fetches commit info and
|
||||
# manifest data per plugin. 5-min TTLs meant every fresh browse on
|
||||
# a Pi4 paid for ~3 HTTP requests x N plugins (30-60s serial). 30
|
||||
# minutes keeps the cache warm across a realistic session while
|
||||
# still picking up upstream updates within a reasonable window.
|
||||
self.commit_cache_timeout = 1800
|
||||
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
||||
self.manifest_cache_timeout = 300 # 5 minutes
|
||||
self.manifest_cache_timeout = 1800
|
||||
self.github_token = self._load_github_token()
|
||||
self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)}
|
||||
self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation
|
||||
|
||||
# Per-plugin tombstone timestamps for plugins that were uninstalled
|
||||
# recently via the UI. Used by the state reconciler to avoid
|
||||
# resurrecting a plugin the user just deleted when reconciliation
|
||||
# races against the uninstall operation. Cleared after ``_uninstall_tombstone_ttl``.
|
||||
self._uninstall_tombstones: Dict[str, float] = {}
|
||||
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
||||
|
||||
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
|
||||
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
||||
# head_contents) so a fast-forward update to the current branch
|
||||
# (which touches .git/refs/heads/<branch> but NOT .git/HEAD) still
|
||||
# invalidates the cache. Before this cache, every
|
||||
# /plugins/installed request fired 4 git subprocesses per plugin,
|
||||
# which pegged the CPU on a Pi4 with a dozen plugins. The cached
|
||||
# ``data`` dict is the same shape returned by ``_get_local_git_info``
|
||||
# itself (sha / short_sha / branch / optional remote_url, date_iso,
|
||||
# date) — all string-keyed strings.
|
||||
self._git_info_cache: Dict[str, Tuple[Tuple, Dict[str, str]]] = {}
|
||||
|
||||
# How long to wait before re-attempting a failed GitHub metadata
|
||||
# fetch after we've already served a stale cache hit. Without this,
|
||||
# a single expired-TTL + network-error would cause every subsequent
|
||||
# request to re-hit the network (and fail again) until the network
|
||||
# actually came back — amplifying the failure and blocking request
|
||||
# handlers. Bumping the cached-entry timestamp on failure serves
|
||||
# the stale payload cheaply until the backoff expires.
|
||||
self._failure_backoff_seconds = 60
|
||||
|
||||
# Ensure plugins directory exists
|
||||
self.plugins_dir.mkdir(exist_ok=True)
|
||||
|
||||
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
|
||||
cache_timeout: int, payload: Any) -> None:
|
||||
"""Bump a cache entry's timestamp so subsequent lookups hit the
|
||||
cache rather than re-failing over the network.
|
||||
|
||||
Used by the stale-on-error fallbacks in the GitHub metadata fetch
|
||||
paths. Without this, a cache entry whose TTL just expired would
|
||||
cause every subsequent request to re-hit the network and fail
|
||||
again until the network actually came back. We write a synthetic
|
||||
timestamp ``(now + backoff - cache_timeout)`` so the cache-valid
|
||||
check ``(now - ts) < cache_timeout`` succeeds for another
|
||||
``backoff`` seconds.
|
||||
"""
|
||||
synthetic_ts = time.time() + self._failure_backoff_seconds - cache_timeout
|
||||
cache_dict[cache_key] = (synthetic_ts, payload)
|
||||
|
||||
def mark_recently_uninstalled(self, plugin_id: str) -> None:
|
||||
"""Record that ``plugin_id`` was just uninstalled by the user."""
|
||||
self._uninstall_tombstones[plugin_id] = time.time()
|
||||
|
||||
def was_recently_uninstalled(self, plugin_id: str) -> bool:
|
||||
"""Return True if ``plugin_id`` has an active uninstall tombstone."""
|
||||
ts = self._uninstall_tombstones.get(plugin_id)
|
||||
if ts is None:
|
||||
return False
|
||||
if time.time() - ts > self._uninstall_tombstone_ttl:
|
||||
# Expired — clean up so the dict doesn't grow unbounded.
|
||||
self._uninstall_tombstones.pop(plugin_id, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _load_github_token(self) -> Optional[str]:
|
||||
"""
|
||||
Load GitHub API token from config_secrets.json if available.
|
||||
@@ -308,7 +379,25 @@ class PluginStoreManager:
|
||||
if self.github_token:
|
||||
headers['Authorization'] = f'token {self.github_token}'
|
||||
|
||||
response = requests.get(api_url, headers=headers, timeout=10)
|
||||
try:
|
||||
response = requests.get(api_url, headers=headers, timeout=10)
|
||||
except requests.RequestException as req_err:
|
||||
# Network error: prefer a stale cache hit over an
|
||||
# empty default so the UI keeps working on a flaky
|
||||
# Pi WiFi link. Bump the cached entry's timestamp
|
||||
# into a short backoff window so subsequent
|
||||
# requests serve the stale payload cheaply instead
|
||||
# of re-hitting the network on every request.
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
self.logger.warning(
|
||||
"GitHub repo info fetch failed for %s (%s); serving stale cache.",
|
||||
cache_key, req_err,
|
||||
)
|
||||
return stale
|
||||
raise
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
|
||||
@@ -328,7 +417,20 @@ class PluginStoreManager:
|
||||
self.github_cache[cache_key] = (time.time(), repo_info)
|
||||
return repo_info
|
||||
elif response.status_code == 403:
|
||||
# Rate limit or authentication issue
|
||||
# Rate limit or authentication issue. If we have a
|
||||
# previously-cached value, serve it rather than
|
||||
# returning empty defaults — a stale star count is
|
||||
# better than a reset to zero. Apply the same
|
||||
# failure-backoff bump as the network-error path
|
||||
# so we don't hammer the API with repeat requests
|
||||
# while rate-limited.
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
self.logger.warning(
|
||||
"GitHub API 403 for %s; serving stale cache.", cache_key,
|
||||
)
|
||||
return stale
|
||||
if not self.github_token:
|
||||
self.logger.warning(
|
||||
f"GitHub API rate limit likely exceeded (403). "
|
||||
@@ -342,6 +444,10 @@ class PluginStoreManager:
|
||||
)
|
||||
else:
|
||||
self.logger.warning(f"GitHub API request failed: {response.status_code} for {api_url}")
|
||||
if cache_key in self.github_cache:
|
||||
_, stale = self.github_cache[cache_key]
|
||||
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||
return stale
|
||||
|
||||
return {
|
||||
'stars': 0,
|
||||
@@ -442,23 +548,34 @@ class PluginStoreManager:
|
||||
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def fetch_registry(self, force_refresh: bool = False) -> Dict:
|
||||
def fetch_registry(self, force_refresh: bool = False, raise_on_failure: bool = False) -> Dict:
|
||||
"""
|
||||
Fetch the plugin registry from GitHub.
|
||||
|
||||
|
||||
Args:
|
||||
force_refresh: Force refresh even if cached
|
||||
|
||||
raise_on_failure: If True, re-raise network / JSON errors instead
|
||||
of silently falling back to stale cache / empty dict. UI
|
||||
callers prefer the stale-fallback default so the plugin
|
||||
list keeps working on flaky WiFi; the state reconciler
|
||||
needs the explicit failure signal so it can distinguish
|
||||
"plugin genuinely not in registry" from "I couldn't reach
|
||||
the registry at all" and not mark everything unrecoverable.
|
||||
|
||||
Returns:
|
||||
Registry data with list of available plugins
|
||||
|
||||
Raises:
|
||||
requests.RequestException / json.JSONDecodeError when
|
||||
``raise_on_failure`` is True and the fetch fails.
|
||||
"""
|
||||
# Check if cache is still valid (within timeout)
|
||||
current_time = time.time()
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
|
||||
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
@@ -469,9 +586,30 @@ class PluginStoreManager:
|
||||
return self.registry_cache
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Error fetching registry: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
# Prefer stale cache over an empty list so the plugin list UI
|
||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||
# registry_cache_time into a short backoff window so the next
|
||||
# request serves the stale payload cheaply instead of
|
||||
# re-hitting the network on every request (matches the
|
||||
# pattern used by github_cache / commit_info_cache).
|
||||
if self.registry_cache:
|
||||
self.logger.warning("Falling back to stale registry cache")
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
if self.registry_cache:
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
|
||||
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
||||
@@ -517,68 +655,95 @@ class PluginStoreManager:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
|
||||
|
||||
results = []
|
||||
# First pass: apply cheap filters (category/tags/query) so we only
|
||||
# fetch GitHub metadata for plugins that will actually be returned.
|
||||
filtered: List[Dict] = []
|
||||
for plugin in plugins:
|
||||
# Category filter
|
||||
if category and plugin.get('category') != category:
|
||||
continue
|
||||
|
||||
# Tags filter (match any tag)
|
||||
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
|
||||
continue
|
||||
|
||||
# Query search (case-insensitive)
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
searchable_text = ' '.join([
|
||||
plugin.get('name', ''),
|
||||
plugin.get('description', ''),
|
||||
plugin.get('id', ''),
|
||||
plugin.get('author', '')
|
||||
plugin.get('author', ''),
|
||||
]).lower()
|
||||
|
||||
if query_lower not in searchable_text:
|
||||
continue
|
||||
filtered.append(plugin)
|
||||
|
||||
# Enhance plugin data with GitHub metadata
|
||||
def _enrich(plugin: Dict) -> Dict:
|
||||
"""Enrich a single plugin with GitHub metadata.
|
||||
|
||||
Called concurrently from a ThreadPoolExecutor. Each underlying
|
||||
HTTP helper (``_get_github_repo_info`` / ``_get_latest_commit_info``
|
||||
/ ``_fetch_manifest_from_github``) is thread-safe — they use
|
||||
``requests`` and write their own cache keys on Python dicts,
|
||||
which is atomic under the GIL for single-key assignments.
|
||||
"""
|
||||
enhanced_plugin = plugin.copy()
|
||||
|
||||
# Get real GitHub stars
|
||||
repo_url = plugin.get('repo', '')
|
||||
if repo_url:
|
||||
github_info = self._get_github_repo_info(repo_url)
|
||||
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
|
||||
enhanced_plugin['default_branch'] = github_info.get('default_branch', plugin.get('branch', 'main'))
|
||||
enhanced_plugin['last_updated_iso'] = github_info.get('last_commit_iso')
|
||||
enhanced_plugin['last_updated'] = github_info.get('last_commit_date')
|
||||
if not repo_url:
|
||||
return enhanced_plugin
|
||||
|
||||
if fetch_commit_info:
|
||||
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
|
||||
github_info = self._get_github_repo_info(repo_url)
|
||||
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
|
||||
enhanced_plugin['default_branch'] = github_info.get('default_branch', plugin.get('branch', 'main'))
|
||||
enhanced_plugin['last_updated_iso'] = github_info.get('last_commit_iso')
|
||||
enhanced_plugin['last_updated'] = github_info.get('last_commit_date')
|
||||
|
||||
commit_info = self._get_latest_commit_info(repo_url, branch)
|
||||
if commit_info:
|
||||
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
|
||||
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
|
||||
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
|
||||
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
|
||||
enhanced_plugin['last_commit_message'] = commit_info.get('message')
|
||||
enhanced_plugin['last_commit_author'] = commit_info.get('author')
|
||||
enhanced_plugin['branch'] = commit_info.get('branch', branch)
|
||||
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
|
||||
if fetch_commit_info:
|
||||
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
|
||||
|
||||
# Fetch manifest from GitHub for additional metadata (description, etc.)
|
||||
plugin_subpath = plugin.get('plugin_path', '')
|
||||
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
|
||||
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel)
|
||||
if github_manifest:
|
||||
if 'last_updated' in github_manifest and not enhanced_plugin.get('last_updated'):
|
||||
enhanced_plugin['last_updated'] = github_manifest['last_updated']
|
||||
if 'description' in github_manifest:
|
||||
enhanced_plugin['description'] = github_manifest['description']
|
||||
commit_info = self._get_latest_commit_info(repo_url, branch)
|
||||
if commit_info:
|
||||
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
|
||||
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
|
||||
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
|
||||
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
|
||||
enhanced_plugin['last_commit_message'] = commit_info.get('message')
|
||||
enhanced_plugin['last_commit_author'] = commit_info.get('author')
|
||||
enhanced_plugin['branch'] = commit_info.get('branch', branch)
|
||||
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
|
||||
|
||||
results.append(enhanced_plugin)
|
||||
# Intentionally NO per-plugin manifest.json fetch here.
|
||||
# The registry's plugins.json already carries ``description``
|
||||
# (it is generated from each plugin's manifest by
|
||||
# ``update_registry.py``), and ``last_updated`` is filled in
|
||||
# from the commit info above. An earlier implementation
|
||||
# fetched manifest.json per plugin anyway, which meant one
|
||||
# extra HTTPS round trip per result; on a Pi4 with a flaky
|
||||
# WiFi link the tail retries of that one extra call
|
||||
# (_http_get_with_retries does 3 attempts with exponential
|
||||
# backoff) dominated wall time even after parallelization.
|
||||
|
||||
return results
|
||||
return enhanced_plugin
|
||||
|
||||
# Fan out the per-plugin GitHub enrichment. The previous
|
||||
# implementation did this serially, which on a Pi4 with ~15 plugins
|
||||
# and a fresh cache meant 30+ HTTP requests in strict sequence (the
|
||||
# "connecting to display" hang reported by users). With a thread
|
||||
# pool, latency is dominated by the slowest request rather than
|
||||
# their sum. Workers capped at 10 to stay well under the
|
||||
# unauthenticated GitHub rate limit burst and avoid overwhelming a
|
||||
# Pi's WiFi link. For a small number of plugins the pool is
|
||||
# essentially free.
|
||||
if not filtered:
|
||||
return []
|
||||
|
||||
# Not worth the pool overhead for tiny workloads. Parenthesized to
|
||||
# make Python's default ``and`` > ``or`` precedence explicit: a
|
||||
# single plugin, OR a small batch where we don't need commit info.
|
||||
if (len(filtered) == 1) or ((not fetch_commit_info) and (len(filtered) < 4)):
|
||||
return [_enrich(p) for p in filtered]
|
||||
|
||||
max_workers = min(10, len(filtered))
|
||||
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='plugin-search') as executor:
|
||||
# executor.map preserves input order, which the UI relies on.
|
||||
return list(executor.map(_enrich, filtered))
|
||||
|
||||
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
@@ -676,7 +841,28 @@ class PluginStoreManager:
|
||||
last_error = None
|
||||
for branch_name in branches_to_try:
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
|
||||
response = requests.get(api_url, headers=headers, timeout=10)
|
||||
try:
|
||||
response = requests.get(api_url, headers=headers, timeout=10)
|
||||
except requests.RequestException as req_err:
|
||||
# Network failure: fall back to a stale cache hit if
|
||||
# available so the plugin store UI keeps populating
|
||||
# commit info on a flaky WiFi link. Bump the cached
|
||||
# timestamp into the backoff window so we don't
|
||||
# re-retry on every request.
|
||||
if cache_key in self.commit_info_cache:
|
||||
_, stale = self.commit_info_cache[cache_key]
|
||||
if stale is not None:
|
||||
self._record_cache_backoff(
|
||||
self.commit_info_cache, cache_key,
|
||||
self.commit_cache_timeout, stale,
|
||||
)
|
||||
self.logger.warning(
|
||||
"GitHub commit fetch failed for %s (%s); serving stale cache.",
|
||||
cache_key, req_err,
|
||||
)
|
||||
return stale
|
||||
last_error = str(req_err)
|
||||
continue
|
||||
if response.status_code == 200:
|
||||
commit_data = response.json()
|
||||
commit_sha_full = commit_data.get('sha', '')
|
||||
@@ -706,7 +892,23 @@ class PluginStoreManager:
|
||||
if last_error:
|
||||
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
|
||||
|
||||
# Cache negative result to avoid repeated failing calls
|
||||
# All branches returned a non-200 response (e.g. 404 on every
|
||||
# candidate, or a transient 5xx). If we already had a good
|
||||
# cached value, prefer serving that — overwriting it with
|
||||
# None here would wipe out commit info the UI just showed
|
||||
# on the previous request. Bump the timestamp into the
|
||||
# backoff window so subsequent lookups hit the cache.
|
||||
if cache_key in self.commit_info_cache:
|
||||
_, prior = self.commit_info_cache[cache_key]
|
||||
if prior is not None:
|
||||
self._record_cache_backoff(
|
||||
self.commit_info_cache, cache_key,
|
||||
self.commit_cache_timeout, prior,
|
||||
)
|
||||
return prior
|
||||
|
||||
# No prior good value — cache the negative result so we don't
|
||||
# hammer a plugin that genuinely has no reachable commits.
|
||||
self.commit_info_cache[cache_key] = (time.time(), None)
|
||||
|
||||
except Exception as e:
|
||||
@@ -1560,12 +1762,93 @@ class PluginStoreManager:
|
||||
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _git_cache_signature(self, git_dir: Path) -> Optional[Tuple]:
|
||||
"""Build a cache signature that invalidates on the kind of updates
|
||||
a plugin user actually cares about.
|
||||
|
||||
Caching on ``.git/HEAD`` mtime alone is not enough: a ``git pull``
|
||||
that fast-forwards the current branch updates
|
||||
``.git/refs/heads/<branch>`` (or ``.git/packed-refs``) but leaves
|
||||
HEAD's contents and mtime untouched. And the cached ``result``
|
||||
dict includes ``remote_url`` — a value read from ``.git/config`` —
|
||||
so a config-only change (e.g. a monorepo-migration re-pointing
|
||||
``remote.origin.url``) must also invalidate the cache.
|
||||
|
||||
Signature components:
|
||||
- HEAD contents (catches detach / branch switch)
|
||||
- HEAD mtime
|
||||
- if HEAD points at a ref, that ref file's mtime (catches
|
||||
fast-forward / reset on the current branch)
|
||||
- packed-refs mtime as a coarse fallback for repos using packed refs
|
||||
- .git/config contents + mtime (catches remote URL changes and
|
||||
any other config-only edit that affects what the cached
|
||||
``remote_url`` field should contain)
|
||||
|
||||
Returns ``None`` if HEAD cannot be read at all (caller will skip
|
||||
the cache and take the slow path).
|
||||
"""
|
||||
head_file = git_dir / 'HEAD'
|
||||
try:
|
||||
head_mtime = head_file.stat().st_mtime
|
||||
head_contents = head_file.read_text(encoding='utf-8', errors='replace').strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
ref_mtime = None
|
||||
if head_contents.startswith('ref: '):
|
||||
ref_path = head_contents[len('ref: '):].strip()
|
||||
# ``ref_path`` looks like ``refs/heads/main``. It lives either
|
||||
# as a loose file under .git/ or inside .git/packed-refs.
|
||||
loose_ref = git_dir / ref_path
|
||||
try:
|
||||
ref_mtime = loose_ref.stat().st_mtime
|
||||
except OSError:
|
||||
ref_mtime = None
|
||||
|
||||
packed_refs_mtime = None
|
||||
if ref_mtime is None:
|
||||
try:
|
||||
packed_refs_mtime = (git_dir / 'packed-refs').stat().st_mtime
|
||||
except OSError:
|
||||
packed_refs_mtime = None
|
||||
|
||||
config_mtime = None
|
||||
config_contents = None
|
||||
config_file = git_dir / 'config'
|
||||
try:
|
||||
config_mtime = config_file.stat().st_mtime
|
||||
config_contents = config_file.read_text(encoding='utf-8', errors='replace').strip()
|
||||
except OSError:
|
||||
config_mtime = None
|
||||
config_contents = None
|
||||
|
||||
return (
|
||||
head_contents, head_mtime,
|
||||
ref_mtime, packed_refs_mtime,
|
||||
config_contents, config_mtime,
|
||||
)
|
||||
|
||||
def _get_local_git_info(self, plugin_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Return local git branch, commit hash, and commit date if the plugin is a git checkout."""
|
||||
"""Return local git branch, commit hash, and commit date if the plugin is a git checkout.
|
||||
|
||||
Results are cached keyed on a signature that includes HEAD
|
||||
contents plus the mtime of HEAD AND the resolved ref (or
|
||||
packed-refs). Repeated calls skip the four ``git`` subprocesses
|
||||
when nothing has changed, and a ``git pull`` that fast-forwards
|
||||
the branch correctly invalidates the cache.
|
||||
"""
|
||||
git_dir = plugin_path / '.git'
|
||||
if not git_dir.exists():
|
||||
return None
|
||||
|
||||
cache_key = str(plugin_path)
|
||||
signature = self._git_cache_signature(git_dir)
|
||||
|
||||
if signature is not None:
|
||||
cached = self._git_info_cache.get(cache_key)
|
||||
if cached is not None and cached[0] == signature:
|
||||
return cached[1]
|
||||
|
||||
try:
|
||||
sha_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'rev-parse', 'HEAD'],
|
||||
@@ -1623,6 +1906,8 @@ class PluginStoreManager:
|
||||
result['date_iso'] = commit_date_iso
|
||||
result['date'] = self._iso_to_date(commit_date_iso)
|
||||
|
||||
if signature is not None:
|
||||
self._git_info_cache[cache_key] = (signature, result)
|
||||
return result
|
||||
except subprocess.CalledProcessError as err:
|
||||
self.logger.debug(f"Failed to read git info for {plugin_path.name}: {err}")
|
||||
|
||||
@@ -60,12 +60,16 @@ def get_wifi_config_path():
|
||||
|
||||
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
|
||||
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
|
||||
# Drop-in config for NetworkManager's built-in dnsmasq (ipv4.method=shared).
|
||||
# Writing address=/#/<ap_ip> here causes NM to resolve every hostname to the AP,
|
||||
# triggering the OS captive-portal popup automatically on iOS/Android/Windows/macOS.
|
||||
NM_DNSMASQ_SHARED_DIR = Path("/etc/NetworkManager/dnsmasq-shared.d")
|
||||
NM_DNSMASQ_SHARED_CONF = NM_DNSMASQ_SHARED_DIR / "ledmatrix-captive.conf"
|
||||
HOSTAPD_SERVICE = "hostapd"
|
||||
DNSMASQ_SERVICE = "dnsmasq"
|
||||
|
||||
# Default AP settings
|
||||
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
||||
DEFAULT_AP_PASSWORD = "ledmatrix123"
|
||||
DEFAULT_AP_CHANNEL = 7
|
||||
|
||||
# LED status message file (for display_controller integration)
|
||||
@@ -138,6 +142,11 @@ class WiFiManager:
|
||||
self._disconnected_checks = 0
|
||||
self._disconnected_checks_required = 3 # Require 3 consecutive disconnected checks (90 seconds at 30s interval)
|
||||
|
||||
# Timestamp set when AP mode is enabled; used for the idle-timeout check
|
||||
self._ap_enabled_at: Optional[float] = None
|
||||
# Which redirect backend was used (iptables/nftables/None); set per-instance
|
||||
self._redirect_backend: Optional[str] = None
|
||||
|
||||
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||
@@ -201,6 +210,24 @@ class WiFiManager:
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
def _find_command_path(self, command: str) -> Optional[str]:
|
||||
"""
|
||||
Return the absolute path of a command, checking sbin locations that may not
|
||||
be on PATH in restricted service environments. Returns None if not found.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(["which", command], capture_output=True,
|
||||
text=True, timeout=2)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
for path in [f"/usr/sbin/{command}", f"/sbin/{command}",
|
||||
f"/usr/local/sbin/{command}"]:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
def _discover_wifi_interface(self) -> str:
|
||||
"""
|
||||
Discover the primary WiFi interface name dynamically.
|
||||
@@ -303,7 +330,6 @@ class WiFiManager:
|
||||
else:
|
||||
self.config = {
|
||||
"ap_ssid": DEFAULT_AP_SSID,
|
||||
"ap_password": DEFAULT_AP_PASSWORD,
|
||||
"ap_channel": DEFAULT_AP_CHANNEL,
|
||||
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
||||
"saved_networks": []
|
||||
@@ -658,7 +684,286 @@ class WiFiManager:
|
||||
return False
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved")
|
||||
|
||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
|
||||
if not ssid or len(ssid) > 32 or not re.match(r'^[\x20-\x7E]+$', ssid):
|
||||
logger.warning(f"AP SSID '{ssid}' is invalid, falling back to default")
|
||||
ssid = DEFAULT_AP_SSID
|
||||
try:
|
||||
channel = int(self.config.get("ap_channel", DEFAULT_AP_CHANNEL))
|
||||
if channel < 1 or channel > 14:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("AP channel out of range, falling back to default")
|
||||
channel = DEFAULT_AP_CHANNEL
|
||||
return ssid, channel
|
||||
|
||||
def _setup_iptables_redirect(self) -> bool:
|
||||
"""
|
||||
Add port 80 → 5000 redirect rules for the captive portal.
|
||||
|
||||
Tries iptables first, falls back to nftables (used by Debian Trixie).
|
||||
When neither tool is available, logs a warning and returns True — the AP
|
||||
still works and DNS spoofing still triggers the OS popup; users just land
|
||||
on port 5000 directly rather than being redirected from port 80.
|
||||
|
||||
Only returns False when a tool was found but the rule addition itself failed.
|
||||
"""
|
||||
try:
|
||||
iptables = self._find_command_path("iptables")
|
||||
nft = self._find_command_path("nft")
|
||||
|
||||
if not iptables and not nft:
|
||||
logger.warning(
|
||||
"Neither iptables nor nft found; captive portal port-80 redirect unavailable. "
|
||||
"DNS spoofing will still trigger the OS popup but HTTP on port 80 won't reach Flask."
|
||||
)
|
||||
self._redirect_backend = None
|
||||
return True # AP works; redirect is best-effort
|
||||
|
||||
if iptables:
|
||||
return self._setup_iptables_redirect_iptables(iptables)
|
||||
else:
|
||||
return self._setup_iptables_redirect_nftables(nft)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set up port redirect: {e}")
|
||||
try:
|
||||
self._teardown_iptables_redirect()
|
||||
except Exception as cleanup_e:
|
||||
logger.warning(f"Cleanup after redirect exception also failed: {cleanup_e}")
|
||||
return False
|
||||
|
||||
def _setup_iptables_redirect_iptables(self, iptables: str) -> bool:
|
||||
"""Set up port 80→5000 redirect using iptables."""
|
||||
# Save ip_forward state before enabling
|
||||
try:
|
||||
current_fwd = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip()
|
||||
except OSError:
|
||||
current_fwd = None
|
||||
if current_fwd is not None:
|
||||
try:
|
||||
self._IP_FORWARD_SAVE_PATH.write_text(current_fwd)
|
||||
except OSError:
|
||||
current_fwd = None
|
||||
logger.warning("Could not write ip_forward save file; state will not be restored")
|
||||
|
||||
if current_fwd != "1":
|
||||
sysctl = self._find_command_path("sysctl")
|
||||
sysctl_bin = sysctl if sysctl else "sysctl"
|
||||
r = subprocess.run(["sudo", sysctl_bin, "-w", "net.ipv4.ip_forward=1"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if r.returncode != 0:
|
||||
logger.error(f"Failed to enable ip_forward: {r.stderr.strip()}")
|
||||
self._teardown_iptables_redirect()
|
||||
return False
|
||||
|
||||
if subprocess.run(
|
||||
["sudo", iptables, "-t", "nat", "-C", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True, timeout=5
|
||||
).returncode != 0:
|
||||
r = subprocess.run(
|
||||
["sudo", iptables, "-t", "nat", "-A", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode != 0:
|
||||
logger.error(f"Failed to add PREROUTING rule: {r.stderr.strip()}")
|
||||
self._teardown_iptables_redirect()
|
||||
return False
|
||||
|
||||
if subprocess.run(
|
||||
["sudo", iptables, "-C", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
||||
capture_output=True, timeout=5
|
||||
).returncode != 0:
|
||||
r = subprocess.run(
|
||||
["sudo", iptables, "-A", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode != 0:
|
||||
logger.error(f"Failed to add INPUT rule: {r.stderr.strip()}")
|
||||
self._teardown_iptables_redirect()
|
||||
return False
|
||||
|
||||
self._redirect_backend = "iptables"
|
||||
logger.info("iptables: port 80→5000 redirect rules added")
|
||||
return True
|
||||
|
||||
def _setup_iptables_redirect_nftables(self, nft: str) -> bool:
|
||||
"""Set up port 80→5000 redirect using nftables (Debian Trixie / modern systems)."""
|
||||
# NM's ipv4.method=shared already enables ip_forward; no sysctl needed.
|
||||
cmds = [
|
||||
["sudo", nft, "add", "table", "ip", "ledmatrix"],
|
||||
["sudo", nft, "add", "chain", "ip", "ledmatrix", "prerouting",
|
||||
"{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}"],
|
||||
["sudo", nft, "add", "rule", "ip", "ledmatrix", "prerouting",
|
||||
"iif", self._wifi_interface, "tcp", "dport", "80", "redirect", "to", ":5000"],
|
||||
]
|
||||
for cmd in cmds:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
if r.returncode != 0:
|
||||
# Table/chain may already exist — only fail on rule add
|
||||
if "add rule" in " ".join(cmd):
|
||||
logger.error(f"Failed to add nftables redirect rule: {r.stderr.strip()}")
|
||||
self._teardown_iptables_redirect()
|
||||
return False
|
||||
logger.debug(f"nft cmd non-zero (may already exist): {r.stderr.strip()}")
|
||||
|
||||
self._redirect_backend = "nftables"
|
||||
logger.info("nftables: port 80→5000 redirect rule added")
|
||||
return True
|
||||
|
||||
def _teardown_iptables_redirect(self) -> None:
|
||||
"""Remove the port 80→5000 redirect rules and restore ip_forward if saved."""
|
||||
try:
|
||||
backend = self._redirect_backend
|
||||
self._redirect_backend = None
|
||||
|
||||
if backend == "iptables":
|
||||
iptables = self._find_command_path("iptables")
|
||||
if iptables:
|
||||
subprocess.run(
|
||||
["sudo", iptables, "-t", "nat", "-D", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["sudo", iptables, "-D", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
|
||||
"-j", "ACCEPT"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
# Restore ip_forward only when we saved it
|
||||
if self._IP_FORWARD_SAVE_PATH.exists():
|
||||
try:
|
||||
saved = self._IP_FORWARD_SAVE_PATH.read_text().strip()
|
||||
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
|
||||
sysctl = self._find_command_path("sysctl")
|
||||
sysctl_bin = sysctl if sysctl else "sysctl"
|
||||
subprocess.run(["sudo", sysctl_bin, "-w", f"net.ipv4.ip_forward={saved}"],
|
||||
capture_output=True, timeout=5)
|
||||
logger.info(f"ip_forward restored to {saved}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not restore ip_forward: {e}")
|
||||
else:
|
||||
logger.debug("ip_forward not modified by setup; leaving unchanged")
|
||||
|
||||
elif backend == "nftables":
|
||||
nft = self._find_command_path("nft")
|
||||
if nft:
|
||||
subprocess.run(
|
||||
["sudo", nft, "delete", "table", "ip", "ledmatrix"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
logger.info("nftables ledmatrix table removed")
|
||||
|
||||
else:
|
||||
# No redirect was set up (neither tool available); nothing to tear down
|
||||
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not tear down port redirect: {e}")
|
||||
|
||||
def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = "192.168.4.1") -> None:
|
||||
"""
|
||||
Write the NM dnsmasq-shared.d drop-in that makes NM's built-in dnsmasq
|
||||
resolve every hostname to the AP IP. This triggers the OS captive-portal
|
||||
popup automatically on iOS / Android / Windows / macOS as soon as the
|
||||
device connects — no manual navigation required.
|
||||
|
||||
NetworkManager reads /etc/NetworkManager/dnsmasq-shared.d/*.conf when it
|
||||
starts the dnsmasq instance for ipv4.method=shared connections.
|
||||
"""
|
||||
try:
|
||||
content = f"# LEDMatrix captive portal: resolve all hostnames to AP\naddress=/#/{ap_ip}\n"
|
||||
with open("/tmp/ledmatrix-nm-dnsmasq.conf", "w") as f:
|
||||
f.write(content)
|
||||
subprocess.run(
|
||||
["sudo", "mkdir", "-p", str(NM_DNSMASQ_SHARED_DIR)],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["sudo", "cp", "/tmp/ledmatrix-nm-dnsmasq.conf", str(NM_DNSMASQ_SHARED_CONF)],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
logger.info(f"Wrote NM dnsmasq captive-portal config: {NM_DNSMASQ_SHARED_CONF}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not write NM dnsmasq captive config: {e}")
|
||||
|
||||
def _remove_nm_dnsmasq_captive_conf(self) -> None:
|
||||
"""Remove the NM dnsmasq-shared.d drop-in written by _write_nm_dnsmasq_captive_conf."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "rm", "-f", str(NM_DNSMASQ_SHARED_CONF)],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
logger.info("Removed NM dnsmasq captive-portal config")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove NM dnsmasq captive config: {e}")
|
||||
|
||||
def _check_internet_connectivity(self, timeout: int = 5) -> bool:
|
||||
"""
|
||||
Test actual internet reachability — not just nmcli association state.
|
||||
|
||||
A device can be 'connected' in nmcli (associated with an AP) while the
|
||||
router has no WAN link. This check catches that case so the daemon can
|
||||
auto-enable AP mode even when nmcli reports a connection.
|
||||
|
||||
Returns True if at least one reachability method succeeds.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ping", "-c", "1", "-W", str(timeout), "8.8.8.8"],
|
||||
capture_output=True, timeout=timeout + 1
|
||||
)
|
||||
if r.returncode == 0:
|
||||
logger.debug("Internet connectivity confirmed via ping 8.8.8.8")
|
||||
return True
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
try:
|
||||
import urllib.request as _ureq
|
||||
_ureq.urlopen("http://connectivity-check.ubuntu.com/", timeout=timeout)
|
||||
logger.debug("Internet connectivity confirmed via HTTP check")
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
logger.debug("Internet connectivity check failed (both ping and HTTP)")
|
||||
return False
|
||||
|
||||
def check_internet_connectivity(self, timeout: int = 5) -> bool:
|
||||
"""Public wrapper around _check_internet_connectivity for use by the daemon."""
|
||||
return self._check_internet_connectivity(timeout=timeout)
|
||||
|
||||
def _has_ap_clients(self) -> bool:
|
||||
"""
|
||||
Return True if at least one client is associated with the AP.
|
||||
Uses 'iw dev <iface> station dump' which works for both hostapd and
|
||||
nmcli AP modes.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["iw", "dev", self._wifi_interface, "station", "dump"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
|
||||
"""
|
||||
Scan for available WiFi networks.
|
||||
@@ -1293,12 +1598,27 @@ class WiFiManager:
|
||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||
logger.error(f"Failed to connect to {ssid}: {error_msg}")
|
||||
self._show_led_message("Connection failed", duration=5)
|
||||
if self._is_wrong_password_error(error_msg):
|
||||
return False, f"wrong_password: {error_msg}"
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting with nmcli: {e}")
|
||||
self._show_led_message("Connection error", duration=5)
|
||||
return False, str(e)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _is_wrong_password_error(error_msg: str) -> bool:
|
||||
"""Return True when nmcli's error output indicates an authentication failure."""
|
||||
indicators = [
|
||||
"secrets were required",
|
||||
"no secret agent",
|
||||
"802-11-wireless-security.psk",
|
||||
"authentication rejected",
|
||||
"association rejected",
|
||||
]
|
||||
lower = error_msg.lower()
|
||||
return any(ind in lower for ind in indicators)
|
||||
|
||||
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||
"""Connect using wpa_supplicant (fallback)"""
|
||||
try:
|
||||
@@ -1570,14 +1890,18 @@ class WiFiManager:
|
||||
if self.has_hostapd and self.has_dnsmasq:
|
||||
result = self._enable_ap_mode_hostapd()
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
return result
|
||||
|
||||
|
||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||
if self.has_nmcli:
|
||||
logger.info("hostapd/dnsmasq failed or unavailable, trying nmcli hotspot fallback...")
|
||||
self._show_led_message("Setup Mode", duration=5)
|
||||
return self._enable_ap_mode_nmcli_hotspot()
|
||||
|
||||
result = self._enable_ap_mode_nmcli_hotspot()
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
return result
|
||||
|
||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||
except Exception as e:
|
||||
logger.error(f"Error in enable_ap_mode: {e}")
|
||||
@@ -1649,63 +1973,21 @@ class WiFiManager:
|
||||
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
|
||||
return False, f"Failed to start dnsmasq: {result.stderr}"
|
||||
|
||||
# Set up iptables port forwarding: redirect port 80 to 5000
|
||||
# This makes the captive portal work on standard HTTP port
|
||||
try:
|
||||
# Check if iptables is available
|
||||
iptables_check = subprocess.run(
|
||||
["which", "iptables"],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if iptables_check.returncode == 0:
|
||||
# Enable IP forwarding (needed for NAT)
|
||||
subprocess.run(
|
||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=1"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Add NAT rule to redirect port 80 to 5000 on WiFi interface
|
||||
# First check if rule already exists
|
||||
check_result = subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-C", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
# Set up iptables port forwarding (port 80 → 5000) and save ip_forward state
|
||||
if not self._setup_iptables_redirect():
|
||||
logger.error("Captive-portal redirect setup failed; stopping AP services")
|
||||
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE],
|
||||
capture_output=True, timeout=10)
|
||||
subprocess.run(["sudo", "systemctl", "stop", DNSMASQ_SERVICE],
|
||||
capture_output=True, timeout=10)
|
||||
return False, "AP started but captive-portal redirect setup failed"
|
||||
|
||||
if check_result.returncode != 0:
|
||||
# Rule doesn't exist, add it
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
logger.info("Added iptables rule to redirect port 80 to 5000")
|
||||
|
||||
# Also allow incoming connections on port 80
|
||||
check_input = subprocess.run(
|
||||
["sudo", "iptables", "-C", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if check_input.returncode != 0:
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-A", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
else:
|
||||
logger.debug("iptables not available, port forwarding not set up")
|
||||
logger.info("Note: Port 80 forwarding requires iptables. Users will need to access port 5000 directly.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set up iptables port forwarding: {e}")
|
||||
# Continue anyway - port 5000 will still work
|
||||
|
||||
logger.info("AP mode enabled successfully")
|
||||
self._show_led_message("Setup Mode Active", duration=5)
|
||||
# Use the validated SSID so the displayed name matches what hostapd broadcast
|
||||
ap_ssid, _ = self._validate_ap_config()
|
||||
self._show_led_message(
|
||||
f"WiFi Setup\n{ap_ssid}\nNo password\n192.168.4.1:5000", duration=10
|
||||
)
|
||||
return True, "AP mode enabled"
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting AP services: {e}")
|
||||
@@ -1716,245 +1998,120 @@ class WiFiManager:
|
||||
|
||||
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Enable AP mode using nmcli hotspot.
|
||||
Enable AP mode using nmcli as an open (passwordless) access point.
|
||||
|
||||
This method is optimized for both Bookworm and Trixie:
|
||||
- Trixie: Uses Netplan, connections stored in /run/NetworkManager/system-connections
|
||||
- Bookworm: Traditional NetworkManager, connections in /etc/NetworkManager/system-connections
|
||||
Uses 'nmcli connection add type wifi 802-11-wireless.mode ap' instead of
|
||||
'nmcli device wifi hotspot' because the hotspot subcommand always creates a
|
||||
WPA2-protected network on Bookworm/Trixie and silently ignores attempts to
|
||||
strip security after creation.
|
||||
|
||||
On Trixie, we also disable PMF (Protected Management Frames) which can cause
|
||||
connection issues with certain WiFi adapters and clients.
|
||||
Tested for both Bookworm and Trixie (Netplan-based NetworkManager).
|
||||
"""
|
||||
try:
|
||||
# Stop any existing connection
|
||||
self.disconnect_from_network()
|
||||
time.sleep(1)
|
||||
|
||||
# Delete any existing hotspot connections (more thorough cleanup)
|
||||
# First, list all connections to find any with the same SSID or hotspot-related ones
|
||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||
result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
conn_name = parts[0].strip()
|
||||
conn_type = parts[1].strip().lower() if len(parts) > 1 else ""
|
||||
conn_ssid = parts[2].strip() if len(parts) > 2 else ""
|
||||
ap_ssid, ap_channel = self._validate_ap_config()
|
||||
|
||||
# Delete if:
|
||||
# 1. It's a hotspot type
|
||||
# 2. It has the same SSID as our AP
|
||||
# 3. It matches our known connection names
|
||||
should_delete = (
|
||||
'hotspot' in conn_type or
|
||||
conn_ssid == ap_ssid or
|
||||
'hotspot' in conn_name.lower() or
|
||||
conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]
|
||||
)
|
||||
|
||||
if should_delete:
|
||||
logger.info(f"Deleting existing connection: {conn_name} (type: {conn_type}, SSID: {conn_ssid})")
|
||||
# First disconnect it if active
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
# Then delete it
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", conn_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# Also explicitly delete known connection names (in case they weren't caught above)
|
||||
# Delete only the specific application-managed AP profiles by name.
|
||||
# Never delete by SSID — that would destroy a user's saved home network.
|
||||
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", conn_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
subprocess.run(["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True, timeout=5)
|
||||
subprocess.run(["nmcli", "connection", "delete", conn_name],
|
||||
capture_output=True, timeout=10)
|
||||
|
||||
# Wait a moment for deletions to complete
|
||||
time.sleep(1)
|
||||
|
||||
# Get AP settings from config
|
||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
||||
|
||||
# Use nmcli hotspot command (simpler, works with Broadcom chips)
|
||||
# Open network (no password) for easy setup access
|
||||
logger.info(f"Creating open hotspot with nmcli: {ap_ssid} on {self._wifi_interface} (no password)")
|
||||
|
||||
# Note: Some NetworkManager versions add a default password to hotspots
|
||||
# We'll create it and then immediately remove all security settings
|
||||
# Create an open AP connection profile from scratch.
|
||||
# Using 'connection add' instead of 'device wifi hotspot' because the
|
||||
# hotspot subcommand always attaches a WPA2 PSK on Bookworm/Trixie and
|
||||
# ignores post-creation security modifications.
|
||||
logger.info(f"Creating open AP with nmcli connection add: {ap_ssid} on "
|
||||
f"{self._wifi_interface} (no password)")
|
||||
cmd = [
|
||||
"nmcli", "device", "wifi", "hotspot",
|
||||
"ifname", self._wifi_interface,
|
||||
"nmcli", "connection", "add",
|
||||
"type", "wifi",
|
||||
"con-name", "LEDMatrix-Setup-AP",
|
||||
"ifname", self._wifi_interface,
|
||||
"ssid", ap_ssid,
|
||||
"band", "bg", # 2.4GHz for maximum compatibility
|
||||
"channel", str(ap_channel),
|
||||
# Don't pass password parameter - we'll remove security after creation
|
||||
"802-11-wireless.mode", "ap",
|
||||
"802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
|
||||
"802-11-wireless.channel", str(ap_channel),
|
||||
"ipv4.method", "shared",
|
||||
"ipv4.addresses", "192.168.4.1/24",
|
||||
# No 802-11-wireless-security section → open network
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
# PMF (Protected Management Frames) is only meaningful for WPA2/WPA3.
|
||||
# An open AP has no security section, so adding 802-11-wireless-security.pmf
|
||||
# would cause NM to require key-mgmt too, breaking the connection add on
|
||||
# Trixie NM 1.52+. Leave PMF untouched — open APs have no frame protection.
|
||||
|
||||
if result.returncode == 0:
|
||||
# Always explicitly remove all security settings to ensure open network
|
||||
# NetworkManager sometimes adds default security even when not specified
|
||||
logger.info("Ensuring hotspot is open (no password)...")
|
||||
time.sleep(2) # Give it a moment to create
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
# Remove all possible security settings
|
||||
security_settings = [
|
||||
("802-11-wireless-security.key-mgmt", "none"),
|
||||
("802-11-wireless-security.psk", ""),
|
||||
("802-11-wireless-security.wep-key", ""),
|
||||
("802-11-wireless-security.wep-key-type", ""),
|
||||
("802-11-wireless-security.auth-alg", "open"),
|
||||
]
|
||||
|
||||
# On Trixie, also disable PMF (Protected Management Frames)
|
||||
# This can cause connection issues with certain WiFi adapters and clients
|
||||
if self._is_trixie:
|
||||
security_settings.append(("802-11-wireless-security.pmf", "disable"))
|
||||
logger.info("Trixie detected: disabling PMF for better client compatibility")
|
||||
|
||||
for setting, value in security_settings:
|
||||
result_modify = subprocess.run(
|
||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP", setting, str(value)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result_modify.returncode != 0:
|
||||
logger.debug(f"Could not set {setting} to {value}: {result_modify.stderr}")
|
||||
|
||||
# On Trixie, set static IP address for the hotspot (default is 10.42.0.1)
|
||||
# We want 192.168.4.1 for consistency
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
||||
"ipv4.addresses", "192.168.4.1/24",
|
||||
"ipv4.method", "shared"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Verify it's open
|
||||
verify_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "802-11-wireless-security.key-mgmt,802-11-wireless-security.psk", "connection", "show", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if verify_result.returncode == 0:
|
||||
output = verify_result.stdout.strip()
|
||||
key_mgmt = ""
|
||||
psk = ""
|
||||
for line in output.split('\n'):
|
||||
if 'key-mgmt:' in line:
|
||||
key_mgmt = line.split(':', 1)[1].strip() if ':' in line else ""
|
||||
elif 'psk:' in line:
|
||||
psk = line.split(':', 1)[1].strip() if ':' in line else ""
|
||||
|
||||
if key_mgmt != "none" or (psk and psk != ""):
|
||||
logger.warning(f"Hotspot still has security (key-mgmt={key_mgmt}, psk={'set' if psk else 'empty'}), deleting and recreating...")
|
||||
# Delete and recreate as last resort
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
time.sleep(1)
|
||||
# Recreate without any password parameters
|
||||
cmd_recreate = [
|
||||
"nmcli", "device", "wifi", "hotspot",
|
||||
"ifname", self._wifi_interface,
|
||||
"con-name", "LEDMatrix-Setup-AP",
|
||||
"ssid", ap_ssid,
|
||||
"band", "bg",
|
||||
"channel", str(ap_channel),
|
||||
]
|
||||
subprocess.run(cmd_recreate, capture_output=True, timeout=30)
|
||||
# Set IP address for consistency
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
||||
"ipv4.addresses", "192.168.4.1/24",
|
||||
"ipv4.method", "shared"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
# Disable PMF on Trixie
|
||||
if self._is_trixie:
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
||||
"802-11-wireless-security.pmf", "disable"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
logger.info("Recreated hotspot as open network")
|
||||
else:
|
||||
logger.info("Hotspot verified as open (no password)")
|
||||
|
||||
# Restart the connection to apply all changes
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
time.sleep(1)
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.info("Hotspot restarted with open network settings")
|
||||
logger.info(f"AP mode started via nmcli hotspot: {ap_ssid}")
|
||||
time.sleep(2)
|
||||
|
||||
# Verify hotspot is running
|
||||
status = self._get_ap_status_nmcli()
|
||||
if status.get('active'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
logger.info(f"AP mode confirmed active at {ip}")
|
||||
self._show_led_message(f"Setup: {ip}", duration=5)
|
||||
return True, f"AP mode enabled (hotspot mode) - Access at {ip}:5000"
|
||||
else:
|
||||
logger.error("AP mode started but not verified")
|
||||
return False, "AP mode started but verification failed"
|
||||
else:
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||
logger.error(f"Failed to start AP mode via nmcli: {error_msg}")
|
||||
logger.error(f"Failed to create AP connection profile: {error_msg}")
|
||||
self._show_led_message("AP mode failed", duration=5)
|
||||
return False, f"Failed to start AP mode: {error_msg}"
|
||||
|
||||
return False, f"Failed to create AP profile: {error_msg}"
|
||||
|
||||
# Write the NM dnsmasq-shared.d captive-portal config BEFORE bringing up
|
||||
# the connection so NM's dnsmasq picks it up at start time.
|
||||
# This causes every hostname DNS query from a connected device to resolve
|
||||
# to 192.168.4.1, automatically triggering the OS captive-portal popup.
|
||||
self._write_nm_dnsmasq_captive_conf()
|
||||
|
||||
logger.info("AP connection profile created, bringing it up...")
|
||||
up_result = subprocess.run(
|
||||
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, text=True, timeout=20
|
||||
)
|
||||
if up_result.returncode != 0:
|
||||
error_msg = up_result.stderr.strip() or up_result.stdout.strip()
|
||||
logger.error(f"Failed to bring up AP connection: {error_msg}")
|
||||
self._remove_nm_dnsmasq_captive_conf()
|
||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, timeout=10)
|
||||
self._show_led_message("AP mode failed", duration=5)
|
||||
return False, f"Failed to start AP: {error_msg}"
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# NM's ipv4.method=shared manages ip_forward automatically, so we only
|
||||
# need to add the iptables port-redirect rules for the captive portal.
|
||||
if not self._setup_iptables_redirect():
|
||||
logger.error("Captive-portal redirect setup failed; rolling back AP profile")
|
||||
self._remove_nm_dnsmasq_captive_conf()
|
||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, timeout=10)
|
||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, timeout=10)
|
||||
self._clear_led_message()
|
||||
return False, "AP started but captive-portal redirect setup failed"
|
||||
|
||||
# Verify the AP is actually running
|
||||
status = self._get_ap_status_nmcli()
|
||||
if status.get('active'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:5000", duration=10)
|
||||
return True, f"AP mode enabled (open network) - Access at {ip}:5000"
|
||||
else:
|
||||
logger.error("AP mode started but not verified by status check — rolling back")
|
||||
self._teardown_iptables_redirect()
|
||||
self._remove_nm_dnsmasq_captive_conf()
|
||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, timeout=10)
|
||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, timeout=10)
|
||||
self._clear_led_message()
|
||||
return False, "AP mode started but verification failed"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting AP mode with nmcli hotspot: {e}")
|
||||
logger.error(f"Error starting AP mode with nmcli: {e}")
|
||||
self._remove_nm_dnsmasq_captive_conf()
|
||||
self._show_led_message("Setup mode error", duration=5)
|
||||
return False, str(e)
|
||||
|
||||
@@ -1976,7 +2133,12 @@ class WiFiManager:
|
||||
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2 and 'hotspot' in parts[1].lower():
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
conn_name = parts[0].strip()
|
||||
conn_type = parts[1].strip().lower()
|
||||
# Match our known AP profile name OR the legacy nmcli hotspot type
|
||||
if conn_name == "LEDMatrix-Setup-AP" or 'hotspot' in conn_type:
|
||||
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
||||
ip = '192.168.4.1'
|
||||
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
||||
@@ -2072,45 +2234,9 @@ class WiFiManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
||||
|
||||
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
|
||||
# Remove iptables redirect rules and restore ip_forward state (hostapd mode only)
|
||||
if hostapd_active:
|
||||
try:
|
||||
# Check if iptables is available
|
||||
iptables_check = subprocess.run(
|
||||
["which", "iptables"],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if iptables_check.returncode == 0:
|
||||
# Remove NAT redirect rule
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Remove INPUT rule
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-D", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
logger.info("Removed iptables port forwarding rules")
|
||||
else:
|
||||
logger.debug("iptables not available, skipping rule removal")
|
||||
|
||||
# Disable IP forwarding (restore to default client mode)
|
||||
subprocess.run(
|
||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=0"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
logger.info("Disabled IP forwarding")
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(f"Could not remove iptables rules or disable forwarding: {e}")
|
||||
# Continue anyway
|
||||
self._teardown_iptables_redirect()
|
||||
|
||||
# Clean up WiFi interface IP configuration
|
||||
subprocess.run(
|
||||
@@ -2153,14 +2279,17 @@ class WiFiManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
|
||||
else:
|
||||
# nmcli hotspot mode - restart not needed, just ensure WiFi radio is enabled
|
||||
logger.info("Skipping NetworkManager restart (nmcli hotspot mode, restart not needed)")
|
||||
# Still ensure WiFi radio is enabled (may have been disabled by nmcli operations)
|
||||
# Use retries for safety
|
||||
# nmcli AP mode — NM's ipv4.method=shared manages ip_forward automatically,
|
||||
# so we only need to remove the iptables redirect rules we added.
|
||||
logger.info("Skipping NetworkManager restart (nmcli AP mode, restart not needed)")
|
||||
self._teardown_iptables_redirect()
|
||||
self._remove_nm_dnsmasq_captive_conf()
|
||||
# Ensure WiFi radio is enabled after nmcli operations
|
||||
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
|
||||
if not wifi_enabled:
|
||||
logger.warning("WiFi radio may be disabled after nmcli hotspot cleanup")
|
||||
|
||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||
|
||||
self._ap_enabled_at = None
|
||||
logger.info("AP mode disabled successfully")
|
||||
return True, "AP mode disabled"
|
||||
except Exception as e:
|
||||
@@ -2175,9 +2304,11 @@ class WiFiManager:
|
||||
try:
|
||||
config_dir = HOSTAPD_CONFIG_PATH.parent
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
||||
|
||||
# Use validated values — strips invalid chars and ensures channel is an int.
|
||||
# Also strip newlines from SSID to prevent config-file injection.
|
||||
ap_ssid, ap_channel = self._validate_ap_config()
|
||||
ap_ssid = ap_ssid.replace('\n', '').replace('\r', '')
|
||||
|
||||
# Open network configuration (no password) for easy setup access
|
||||
config_content = f"""interface={self._wifi_interface}
|
||||
@@ -2305,22 +2436,21 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
f"Ethernet={ethernet_connected}, AP_active={ap_active}, "
|
||||
f"auto_enable={auto_enable}, disconnected_checks={self._disconnected_checks}")
|
||||
|
||||
# Determine if we should have AP mode active
|
||||
# AP mode should only be auto-enabled if:
|
||||
# - auto_enable_ap_mode is True AND
|
||||
# - WiFi is NOT connected AND
|
||||
# - Ethernet is NOT connected AND
|
||||
# - We've had multiple consecutive disconnected checks (grace period)
|
||||
# Determine if we should have AP mode active.
|
||||
# AP-enable uses only the nmcli association state (fast, no network calls).
|
||||
# This keeps the same reliable behaviour as before: momentary packet loss
|
||||
# while on working WiFi does NOT trigger AP mode. The internet-reachability
|
||||
# check is performed separately in the daemon watchdog for NM recovery.
|
||||
is_disconnected = not status.connected and not ethernet_connected
|
||||
|
||||
|
||||
if is_disconnected:
|
||||
# Increment disconnected check counter
|
||||
self._disconnected_checks += 1
|
||||
logger.debug(f"Network disconnected (check {self._disconnected_checks}/{self._disconnected_checks_required})")
|
||||
else:
|
||||
# Reset counter if we're connected
|
||||
# Reset counter if we're associated
|
||||
if self._disconnected_checks > 0:
|
||||
logger.debug(f"Network connected, resetting disconnected check counter")
|
||||
logger.debug("Network connected, resetting disconnected check counter")
|
||||
self._disconnected_checks = 0
|
||||
|
||||
# Only enable AP if we've had enough consecutive disconnected checks
|
||||
@@ -2365,6 +2495,24 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
# AP is active but auto_enable is disabled - this means it was manually enabled
|
||||
# Don't disable it automatically, let it stay active
|
||||
logger.debug("AP mode is active (manually enabled), keeping active")
|
||||
|
||||
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||
# Only applies when AP is active and we haven't just decided to enable/disable it.
|
||||
if ap_active and self._ap_enabled_at is not None:
|
||||
try:
|
||||
idle_timeout_min = max(1, min(1440, int(self.config.get("ap_idle_timeout_minutes", 15))))
|
||||
except (TypeError, ValueError):
|
||||
idle_timeout_min = 15
|
||||
elapsed = time.time() - self._ap_enabled_at
|
||||
if elapsed > idle_timeout_min * 60 and not self._has_ap_clients():
|
||||
logger.info(
|
||||
f"AP idle timeout ({idle_timeout_min} min, no clients) — disabling AP"
|
||||
)
|
||||
success, message = self.disable_ap_mode()
|
||||
if success:
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to disable AP on idle timeout: {message}")
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
|
||||
@@ -7,7 +7,7 @@ Wants=network.target
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -12,6 +11,7 @@ import pytest
|
||||
from src import backup_manager
|
||||
from src.backup_manager import (
|
||||
BUNDLED_FONTS,
|
||||
SCHEMA_VERSION,
|
||||
RestoreOptions,
|
||||
create_backup,
|
||||
list_installed_plugins,
|
||||
@@ -66,10 +66,11 @@ def _make_project(root: Path) -> Path:
|
||||
(root / "data" / "plugin_state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"plugins": {
|
||||
"version": 1,
|
||||
"states": {
|
||||
"my-plugin": {"version": "1.2.3", "enabled": True},
|
||||
"other-plugin": {"version": "0.1.0", "enabled": False},
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
@@ -204,7 +205,7 @@ def test_validate_backup_bad_schema_version(tmp_path: Path) -> None:
|
||||
def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "malicious.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": 1, "contents": []}))
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []}))
|
||||
zf.writestr("../../etc/passwd", "x")
|
||||
ok, err, _ = validate_backup(zip_path)
|
||||
assert not ok
|
||||
@@ -214,7 +215,7 @@ def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None:
|
||||
def test_validate_backup_not_a_zip(tmp_path: Path) -> None:
|
||||
p = tmp_path / "nope.zip"
|
||||
p.write_text("hello", encoding="utf-8")
|
||||
ok, err, _ = validate_backup(p)
|
||||
ok, _err, _ = validate_backup(p)
|
||||
assert not ok
|
||||
|
||||
|
||||
@@ -275,7 +276,7 @@ def test_restore_honors_options(project: Path, empty_project: Path, tmp_path: Pa
|
||||
def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": 1, "contents": []}))
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []}))
|
||||
zf.writestr("../escape.txt", "x")
|
||||
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||
# validate_backup catches it before extraction.
|
||||
|
||||
747
test/test_store_manager_caches.py
Normal file
747
test/test_store_manager_caches.py
Normal file
@@ -0,0 +1,747 @@
|
||||
"""
|
||||
Tests for the caching and tombstone behaviors added to PluginStoreManager
|
||||
to fix the plugin-list slowness and the uninstall-resurrection bugs.
|
||||
|
||||
Coverage targets:
|
||||
- ``mark_recently_uninstalled`` / ``was_recently_uninstalled`` lifecycle and
|
||||
TTL expiry.
|
||||
- ``_get_local_git_info`` mtime-gated cache: ``git`` subprocesses only run
|
||||
when ``.git/HEAD`` mtime changes.
|
||||
- ``fetch_registry`` stale-cache fallback on network failure.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
|
||||
class TestUninstallTombstone(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||
|
||||
def test_unmarked_plugin_is_not_recent(self):
|
||||
self.assertFalse(self.sm.was_recently_uninstalled("foo"))
|
||||
|
||||
def test_marking_makes_it_recent(self):
|
||||
self.sm.mark_recently_uninstalled("foo")
|
||||
self.assertTrue(self.sm.was_recently_uninstalled("foo"))
|
||||
|
||||
def test_tombstone_expires_after_ttl(self):
|
||||
self.sm._uninstall_tombstone_ttl = 0.05
|
||||
self.sm.mark_recently_uninstalled("foo")
|
||||
self.assertTrue(self.sm.was_recently_uninstalled("foo"))
|
||||
time.sleep(0.1)
|
||||
self.assertFalse(self.sm.was_recently_uninstalled("foo"))
|
||||
# Expired entry should also be pruned from the dict.
|
||||
self.assertNotIn("foo", self.sm._uninstall_tombstones)
|
||||
|
||||
|
||||
class TestGitInfoCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.plugins_dir = Path(self._tmp.name)
|
||||
self.sm = PluginStoreManager(plugins_dir=str(self.plugins_dir))
|
||||
|
||||
# Minimal fake git checkout: .git/HEAD needs to exist so the cache
|
||||
# key (its mtime) is stable, but we mock subprocess so no actual git
|
||||
# is required.
|
||||
self.plugin_path = self.plugins_dir / "plg"
|
||||
(self.plugin_path / ".git").mkdir(parents=True)
|
||||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
|
||||
def _fake_subprocess_run(self, *args, **kwargs):
|
||||
# Return different dummy values depending on which git subcommand
|
||||
# was invoked so the code paths that parse output all succeed.
|
||||
cmd = args[0]
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
|
||||
result.stdout = "abcdef1234567890\n"
|
||||
elif "--abbrev-ref" in cmd:
|
||||
result.stdout = "main\n"
|
||||
elif "config" in cmd:
|
||||
result.stdout = "https://example.com/repo.git\n"
|
||||
elif "log" in cmd:
|
||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
return result
|
||||
|
||||
def test_cache_hits_avoid_subprocess_calls(self):
|
||||
with patch(
|
||||
"src.plugin_system.store_manager.subprocess.run",
|
||||
side_effect=self._fake_subprocess_run,
|
||||
) as mock_run:
|
||||
first = self.sm._get_local_git_info(self.plugin_path)
|
||||
self.assertIsNotNone(first)
|
||||
self.assertEqual(first["short_sha"], "abcdef1")
|
||||
calls_after_first = mock_run.call_count
|
||||
self.assertEqual(calls_after_first, 4)
|
||||
|
||||
# Second call with unchanged HEAD: zero new subprocess calls.
|
||||
second = self.sm._get_local_git_info(self.plugin_path)
|
||||
self.assertEqual(second, first)
|
||||
self.assertEqual(mock_run.call_count, calls_after_first)
|
||||
|
||||
def test_cache_invalidates_on_head_mtime_change(self):
|
||||
with patch(
|
||||
"src.plugin_system.store_manager.subprocess.run",
|
||||
side_effect=self._fake_subprocess_run,
|
||||
) as mock_run:
|
||||
self.sm._get_local_git_info(self.plugin_path)
|
||||
calls_after_first = mock_run.call_count
|
||||
|
||||
# Bump mtime on .git/HEAD to simulate a new commit being checked out.
|
||||
head = self.plugin_path / ".git" / "HEAD"
|
||||
new_time = head.stat().st_mtime + 10
|
||||
os.utime(head, (new_time, new_time))
|
||||
|
||||
self.sm._get_local_git_info(self.plugin_path)
|
||||
self.assertEqual(mock_run.call_count, calls_after_first + 4)
|
||||
|
||||
def test_no_git_directory_returns_none(self):
|
||||
non_git = self.plugins_dir / "no_git"
|
||||
non_git.mkdir()
|
||||
self.assertIsNone(self.sm._get_local_git_info(non_git))
|
||||
|
||||
def test_cache_invalidates_on_git_config_change(self):
|
||||
"""A config-only change (e.g. ``git remote set-url``) must invalidate
|
||||
the cache, because the cached ``result`` dict includes ``remote_url``
|
||||
which is read from ``.git/config``. Without config in the signature,
|
||||
a stale remote URL would be served indefinitely.
|
||||
"""
|
||||
head_file = self.plugin_path / ".git" / "HEAD"
|
||||
head_file.write_text("ref: refs/heads/main\n")
|
||||
refs_heads = self.plugin_path / ".git" / "refs" / "heads"
|
||||
refs_heads.mkdir(parents=True, exist_ok=True)
|
||||
(refs_heads / "main").write_text("a" * 40 + "\n")
|
||||
config_file = self.plugin_path / ".git" / "config"
|
||||
config_file.write_text(
|
||||
'[remote "origin"]\n\turl = https://old.example.com/repo.git\n'
|
||||
)
|
||||
|
||||
remote_url = {"current": "https://old.example.com/repo.git"}
|
||||
|
||||
def fake_subprocess_run(*args, **kwargs):
|
||||
cmd = args[0]
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
||||
result.stdout = "a" * 40 + "\n"
|
||||
elif "--abbrev-ref" in cmd:
|
||||
result.stdout = "main\n"
|
||||
elif "config" in cmd:
|
||||
result.stdout = remote_url["current"] + "\n"
|
||||
elif "log" in cmd:
|
||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
return result
|
||||
|
||||
with patch(
|
||||
"src.plugin_system.store_manager.subprocess.run",
|
||||
side_effect=fake_subprocess_run,
|
||||
):
|
||||
first = self.sm._get_local_git_info(self.plugin_path)
|
||||
self.assertEqual(first["remote_url"], "https://old.example.com/repo.git")
|
||||
|
||||
# Simulate ``git remote set-url origin https://new.example.com/repo.git``:
|
||||
# ``.git/config`` contents AND mtime change. HEAD is untouched.
|
||||
time.sleep(0.01) # ensure a detectable mtime delta
|
||||
config_file.write_text(
|
||||
'[remote "origin"]\n\turl = https://new.example.com/repo.git\n'
|
||||
)
|
||||
new_time = config_file.stat().st_mtime + 10
|
||||
os.utime(config_file, (new_time, new_time))
|
||||
remote_url["current"] = "https://new.example.com/repo.git"
|
||||
|
||||
second = self.sm._get_local_git_info(self.plugin_path)
|
||||
self.assertEqual(
|
||||
second["remote_url"], "https://new.example.com/repo.git",
|
||||
"config-only change did not invalidate the cache — "
|
||||
".git/config mtime/contents must be part of the signature",
|
||||
)
|
||||
|
||||
def test_cache_invalidates_on_fast_forward_of_current_branch(self):
|
||||
"""Regression: .git/HEAD mtime alone is not enough.
|
||||
|
||||
``git pull`` that fast-forwards the current branch touches
|
||||
``.git/refs/heads/<branch>`` (or packed-refs) but NOT HEAD. If
|
||||
we cache on HEAD mtime alone, we serve a stale SHA indefinitely.
|
||||
"""
|
||||
# Build a realistic loose-ref layout.
|
||||
refs_heads = self.plugin_path / ".git" / "refs" / "heads"
|
||||
refs_heads.mkdir(parents=True)
|
||||
branch_file = refs_heads / "main"
|
||||
branch_file.write_text("a" * 40 + "\n")
|
||||
# Overwrite HEAD to point at refs/heads/main.
|
||||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
|
||||
call_log = []
|
||||
|
||||
def fake_subprocess_run(*args, **kwargs):
|
||||
call_log.append(args[0])
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
cmd = args[0]
|
||||
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
||||
result.stdout = branch_file.read_text().strip() + "\n"
|
||||
elif "--abbrev-ref" in cmd:
|
||||
result.stdout = "main\n"
|
||||
elif "config" in cmd:
|
||||
result.stdout = "https://example.com/repo.git\n"
|
||||
elif "log" in cmd:
|
||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
return result
|
||||
|
||||
with patch(
|
||||
"src.plugin_system.store_manager.subprocess.run",
|
||||
side_effect=fake_subprocess_run,
|
||||
):
|
||||
first = self.sm._get_local_git_info(self.plugin_path)
|
||||
calls_after_first = len(call_log)
|
||||
self.assertIsNotNone(first)
|
||||
self.assertTrue(first["sha"].startswith("a"))
|
||||
|
||||
# Second call: unchanged. Cache hit → no new subprocess calls.
|
||||
self.sm._get_local_git_info(self.plugin_path)
|
||||
self.assertEqual(len(call_log), calls_after_first,
|
||||
"cache should hit on unchanged state")
|
||||
|
||||
# Simulate a fast-forward: the branch ref file gets a new SHA
|
||||
# and a new mtime, but .git/HEAD is untouched.
|
||||
branch_file.write_text("b" * 40 + "\n")
|
||||
new_time = branch_file.stat().st_mtime + 10
|
||||
os.utime(branch_file, (new_time, new_time))
|
||||
|
||||
second = self.sm._get_local_git_info(self.plugin_path)
|
||||
# Cache MUST have been invalidated — we should have re-run git.
|
||||
self.assertGreater(
|
||||
len(call_log), calls_after_first,
|
||||
"cache should have invalidated on branch ref update",
|
||||
)
|
||||
self.assertTrue(second["sha"].startswith("b"))
|
||||
|
||||
|
||||
class TestSearchPluginsParallel(unittest.TestCase):
|
||||
"""Plugin Store browse path — the per-plugin GitHub enrichment used to
|
||||
run serially, turning a browse of 15 plugins into 30–45 sequential HTTP
|
||||
requests on a cold cache. This batch of tests locks in the parallel
|
||||
fan-out and verifies output shape/ordering haven't regressed.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||
|
||||
# Fake registry with 5 plugins.
|
||||
self.registry = {
|
||||
"plugins": [
|
||||
{"id": f"plg{i}", "name": f"Plugin {i}",
|
||||
"repo": f"https://github.com/owner/plg{i}", "category": "util"}
|
||||
for i in range(5)
|
||||
]
|
||||
}
|
||||
self.sm.registry_cache = self.registry
|
||||
self.sm.registry_cache_time = time.time()
|
||||
|
||||
self._enrich_calls = []
|
||||
|
||||
def fake_repo(repo_url):
|
||||
self._enrich_calls.append(("repo", repo_url))
|
||||
return {"stars": 1, "default_branch": "main",
|
||||
"last_commit_iso": "2026-04-08T00:00:00Z",
|
||||
"last_commit_date": "2026-04-08"}
|
||||
|
||||
def fake_commit(repo_url, branch):
|
||||
self._enrich_calls.append(("commit", repo_url, branch))
|
||||
return {"short_sha": "abc1234", "sha": "abc1234" + "0" * 33,
|
||||
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||||
"message": "m", "author": "a", "branch": branch}
|
||||
|
||||
def fake_manifest(repo_url, branch, manifest_path):
|
||||
self._enrich_calls.append(("manifest", repo_url, branch))
|
||||
return {"description": "desc"}
|
||||
|
||||
self.sm._get_github_repo_info = fake_repo
|
||||
self.sm._get_latest_commit_info = fake_commit
|
||||
self.sm._fetch_manifest_from_github = fake_manifest
|
||||
|
||||
def test_results_preserve_registry_order(self):
|
||||
results = self.sm.search_plugins(include_saved_repos=False)
|
||||
self.assertEqual([p["id"] for p in results],
|
||||
[f"plg{i}" for i in range(5)])
|
||||
|
||||
def test_filters_applied_before_enrichment(self):
|
||||
# Filter down to a single plugin via category — ensures we don't
|
||||
# waste GitHub calls enriching plugins that won't be returned.
|
||||
self.registry["plugins"][2]["category"] = "special"
|
||||
self.sm.registry_cache = self.registry
|
||||
self._enrich_calls.clear()
|
||||
results = self.sm.search_plugins(category="special", include_saved_repos=False)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["id"], "plg2")
|
||||
# Only one plugin should have been enriched.
|
||||
repo_calls = [c for c in self._enrich_calls if c[0] == "repo"]
|
||||
self.assertEqual(len(repo_calls), 1)
|
||||
|
||||
def test_enrichment_runs_concurrently(self):
|
||||
"""Verify the thread pool actually runs fetches in parallel.
|
||||
|
||||
Deterministic check: each stub repo fetch holds a lock while it
|
||||
increments a "currently running" counter, then sleeps briefly,
|
||||
then decrements. If execution is serial, the peak counter can
|
||||
never exceed 1. If the thread pool is engaged, we see at least
|
||||
2 concurrent workers.
|
||||
|
||||
We deliberately do NOT assert on elapsed wall time — that check
|
||||
was flaky on low-power / CI boxes where scheduler noise dwarfed
|
||||
the 50ms-per-worker budget. ``peak["count"] >= 2`` is the signal
|
||||
we actually care about.
|
||||
"""
|
||||
import threading
|
||||
peak_lock = threading.Lock()
|
||||
peak = {"count": 0, "current": 0}
|
||||
|
||||
def slow_repo(repo_url):
|
||||
with peak_lock:
|
||||
peak["current"] += 1
|
||||
if peak["current"] > peak["count"]:
|
||||
peak["count"] = peak["current"]
|
||||
# Small sleep gives other workers a chance to enter the
|
||||
# critical section before we leave it. 50ms is large enough
|
||||
# to dominate any scheduling jitter without slowing the test
|
||||
# suite meaningfully.
|
||||
time.sleep(0.05)
|
||||
with peak_lock:
|
||||
peak["current"] -= 1
|
||||
return {"stars": 0, "default_branch": "main",
|
||||
"last_commit_iso": "", "last_commit_date": ""}
|
||||
|
||||
self.sm._get_github_repo_info = slow_repo
|
||||
self.sm._get_latest_commit_info = lambda *a, **k: None
|
||||
self.sm._fetch_manifest_from_github = lambda *a, **k: None
|
||||
|
||||
results = self.sm.search_plugins(fetch_commit_info=False, include_saved_repos=False)
|
||||
|
||||
self.assertEqual(len(results), 5)
|
||||
self.assertGreaterEqual(
|
||||
peak["count"], 2,
|
||||
"no concurrent fetches observed — thread pool not engaging",
|
||||
)
|
||||
|
||||
|
||||
class TestStaleOnErrorFallbacks(unittest.TestCase):
|
||||
"""When GitHub is unreachable, previously-cached values should still be
|
||||
returned rather than zero/None. Important on Pi's WiFi links.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||
|
||||
def test_repo_info_stale_on_network_error(self):
|
||||
cache_key = "owner/repo"
|
||||
good = {"stars": 42, "default_branch": "main",
|
||||
"last_commit_iso": "", "last_commit_date": "",
|
||||
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||||
"language": "", "license": ""}
|
||||
# Seed the cache with a known-good value, then force expiry.
|
||||
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||||
self.sm.cache_timeout = 1 # force re-fetch
|
||||
|
||||
import requests as real_requests
|
||||
with patch("src.plugin_system.store_manager.requests.get",
|
||||
side_effect=real_requests.ConnectionError("boom")):
|
||||
result = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||
self.assertEqual(result["stars"], 42)
|
||||
|
||||
def test_repo_info_stale_bumps_timestamp_into_backoff(self):
|
||||
"""Regression: after serving stale, next lookup must hit cache.
|
||||
|
||||
Without the failure-backoff timestamp bump, a repeat request
|
||||
would see the cache as still expired and re-hit the network,
|
||||
amplifying the original failure. The fix is to update the
|
||||
cached entry's timestamp so ``(now - ts) < cache_timeout`` holds
|
||||
for the backoff window.
|
||||
"""
|
||||
cache_key = "owner/repo"
|
||||
good = {"stars": 99, "default_branch": "main",
|
||||
"last_commit_iso": "", "last_commit_date": "",
|
||||
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||||
"language": "", "license": ""}
|
||||
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||||
self.sm.cache_timeout = 1
|
||||
self.sm._failure_backoff_seconds = 60
|
||||
|
||||
import requests as real_requests
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_get(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
raise real_requests.ConnectionError("boom")
|
||||
|
||||
with patch("src.plugin_system.store_manager.requests.get", side_effect=counting_get):
|
||||
first = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||
self.assertEqual(first["stars"], 99)
|
||||
self.assertEqual(call_count["n"], 1)
|
||||
|
||||
# Second call must hit the bumped cache and NOT make another request.
|
||||
second = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||
self.assertEqual(second["stars"], 99)
|
||||
self.assertEqual(
|
||||
call_count["n"], 1,
|
||||
"stale-cache fallback must bump the timestamp to avoid "
|
||||
"re-retrying on every request during the backoff window",
|
||||
)
|
||||
|
||||
def test_repo_info_stale_on_403_also_backs_off(self):
|
||||
"""Same backoff requirement for 403 rate-limit responses."""
|
||||
cache_key = "owner/repo"
|
||||
good = {"stars": 7, "default_branch": "main",
|
||||
"last_commit_iso": "", "last_commit_date": "",
|
||||
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||||
"language": "", "license": ""}
|
||||
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||||
self.sm.cache_timeout = 1
|
||||
|
||||
rate_limited = MagicMock()
|
||||
rate_limited.status_code = 403
|
||||
rate_limited.text = "rate limited"
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_get(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
return rate_limited
|
||||
|
||||
with patch("src.plugin_system.store_manager.requests.get", side_effect=counting_get):
|
||||
self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||
self.assertEqual(call_count["n"], 1)
|
||||
self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||
self.assertEqual(
|
||||
call_count["n"], 1,
|
||||
"403 stale fallback must also bump the timestamp",
|
||||
)
|
||||
|
||||
def test_commit_info_stale_on_network_error(self):
|
||||
cache_key = "owner/repo:main"
|
||||
good = {"branch": "main", "sha": "a" * 40, "short_sha": "aaaaaaa",
|
||||
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||||
"author": "x", "message": "y"}
|
||||
self.sm.commit_info_cache[cache_key] = (time.time() - 10_000, good)
|
||||
self.sm.commit_cache_timeout = 1 # force re-fetch
|
||||
|
||||
import requests as real_requests
|
||||
with patch("src.plugin_system.store_manager.requests.get",
|
||||
side_effect=real_requests.ConnectionError("boom")):
|
||||
result = self.sm._get_latest_commit_info(
|
||||
"https://github.com/owner/repo", branch="main"
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["short_sha"], "aaaaaaa")
|
||||
|
||||
def test_commit_info_preserves_good_cache_on_all_branches_404(self):
|
||||
"""Regression: all-branches-404 used to overwrite good cache with None.
|
||||
|
||||
The previous implementation unconditionally wrote
|
||||
``self.commit_info_cache[cache_key] = (time.time(), None)`` after
|
||||
the branch loop, which meant a single transient failure (e.g. an
|
||||
odd 5xx or an ls-refs hiccup) wiped out the commit info we had
|
||||
just served to the UI the previous minute.
|
||||
"""
|
||||
cache_key = "owner/repo:main"
|
||||
good = {"branch": "main", "sha": "a" * 40, "short_sha": "aaaaaaa",
|
||||
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||||
"author": "x", "message": "y"}
|
||||
self.sm.commit_info_cache[cache_key] = (time.time() - 10_000, good)
|
||||
self.sm.commit_cache_timeout = 1
|
||||
|
||||
# Each branches_to_try attempt returns a 404. No network error
|
||||
# exception — just a non-200 response. This is the code path
|
||||
# that used to overwrite the cache with None.
|
||||
not_found = MagicMock()
|
||||
not_found.status_code = 404
|
||||
not_found.text = "Not Found"
|
||||
with patch("src.plugin_system.store_manager.requests.get", return_value=not_found):
|
||||
result = self.sm._get_latest_commit_info(
|
||||
"https://github.com/owner/repo", branch="main"
|
||||
)
|
||||
|
||||
self.assertIsNotNone(result, "good cache was wiped out by transient 404s")
|
||||
self.assertEqual(result["short_sha"], "aaaaaaa")
|
||||
# The cache entry must still be the good value, not None.
|
||||
self.assertIsNotNone(self.sm.commit_info_cache[cache_key][1])
|
||||
|
||||
|
||||
class TestInstallUpdateUninstallInvariants(unittest.TestCase):
|
||||
"""Regression guard: the caching and tombstone work added in this PR
|
||||
must not break the install / update / uninstall code paths.
|
||||
|
||||
Specifically:
|
||||
- ``install_plugin`` bypasses commit/manifest caches via force_refresh,
|
||||
so the 5→30 min TTL bump cannot cause users to install a stale commit.
|
||||
- ``update_plugin`` does the same.
|
||||
- The uninstall tombstone is only honored by the state reconciler, not
|
||||
by explicit ``install_plugin`` calls — so a user can uninstall and
|
||||
immediately reinstall from the store UI without the tombstone getting
|
||||
in the way.
|
||||
- ``was_recently_uninstalled`` is not touched by ``install_plugin``.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||
|
||||
def test_get_plugin_info_with_force_refresh_forwards_to_commit_fetch(self):
|
||||
"""install_plugin's code path must reach the network bypass."""
|
||||
self.sm.registry_cache = {
|
||||
"plugins": [{"id": "foo", "repo": "https://github.com/o/r"}]
|
||||
}
|
||||
self.sm.registry_cache_time = time.time()
|
||||
|
||||
repo_calls = []
|
||||
commit_calls = []
|
||||
manifest_calls = []
|
||||
|
||||
def fake_repo(url):
|
||||
repo_calls.append(url)
|
||||
return {"default_branch": "main", "stars": 0,
|
||||
"last_commit_iso": "", "last_commit_date": ""}
|
||||
|
||||
def fake_commit(url, branch, force_refresh=False):
|
||||
commit_calls.append((url, branch, force_refresh))
|
||||
return {"short_sha": "deadbee", "sha": "d" * 40,
|
||||
"message": "m", "author": "a", "branch": branch,
|
||||
"date": "2026-04-08", "date_iso": "2026-04-08T00:00:00Z"}
|
||||
|
||||
def fake_manifest(url, branch, manifest_path, force_refresh=False):
|
||||
manifest_calls.append((url, branch, manifest_path, force_refresh))
|
||||
return None
|
||||
|
||||
self.sm._get_github_repo_info = fake_repo
|
||||
self.sm._get_latest_commit_info = fake_commit
|
||||
self.sm._fetch_manifest_from_github = fake_manifest
|
||||
|
||||
info = self.sm.get_plugin_info("foo", fetch_latest_from_github=True, force_refresh=True)
|
||||
|
||||
self.assertIsNotNone(info)
|
||||
self.assertEqual(info["last_commit_sha"], "d" * 40)
|
||||
# force_refresh must have propagated through to the fetch helpers.
|
||||
self.assertTrue(commit_calls, "commit fetch was not called")
|
||||
self.assertTrue(commit_calls[0][2], "force_refresh=True did not reach _get_latest_commit_info")
|
||||
self.assertTrue(manifest_calls, "manifest fetch was not called")
|
||||
self.assertTrue(manifest_calls[0][3], "force_refresh=True did not reach _fetch_manifest_from_github")
|
||||
|
||||
def test_install_plugin_is_not_blocked_by_tombstone(self):
|
||||
"""A tombstone must only gate the reconciler, not explicit installs.
|
||||
|
||||
Uses a complete, valid manifest stub and a no-op dependency
|
||||
installer so ``install_plugin`` runs all the way through to a
|
||||
True return. Anything less (e.g. swallowing exceptions) would
|
||||
hide real regressions in the install path.
|
||||
"""
|
||||
import json as _json
|
||||
self.sm.registry_cache = {
|
||||
"plugins": [{"id": "bar", "repo": "https://github.com/o/bar",
|
||||
"plugin_path": ""}]
|
||||
}
|
||||
self.sm.registry_cache_time = time.time()
|
||||
|
||||
# Mark it recently uninstalled (simulates a user who just clicked
|
||||
# uninstall and then immediately clicked install again).
|
||||
self.sm.mark_recently_uninstalled("bar")
|
||||
self.assertTrue(self.sm.was_recently_uninstalled("bar"))
|
||||
|
||||
# Stub the heavy bits so install_plugin can run without network.
|
||||
self.sm._get_github_repo_info = lambda url: {
|
||||
"default_branch": "main", "stars": 0,
|
||||
"last_commit_iso": "", "last_commit_date": ""
|
||||
}
|
||||
self.sm._get_latest_commit_info = lambda *a, **k: {
|
||||
"short_sha": "abc1234", "sha": "a" * 40, "branch": "main",
|
||||
"message": "m", "author": "a",
|
||||
"date": "2026-04-08", "date_iso": "2026-04-08T00:00:00Z",
|
||||
}
|
||||
self.sm._fetch_manifest_from_github = lambda *a, **k: None
|
||||
# Skip dependency install entirely (real install calls pip).
|
||||
self.sm._install_dependencies = lambda *a, **k: True
|
||||
|
||||
def fake_install_via_git(repo_url, plugin_path, branches):
|
||||
# Write a COMPLETE valid manifest so install_plugin's
|
||||
# post-download validation succeeds. Required fields come
|
||||
# from install_plugin itself: id, name, class_name, display_modes.
|
||||
plugin_path.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {
|
||||
"id": "bar",
|
||||
"name": "Bar Plugin",
|
||||
"version": "1.0.0",
|
||||
"class_name": "BarPlugin",
|
||||
"entry_point": "manager.py",
|
||||
"display_modes": ["bar_mode"],
|
||||
}
|
||||
(plugin_path / "manifest.json").write_text(_json.dumps(manifest))
|
||||
return branches[0]
|
||||
|
||||
self.sm._install_via_git = fake_install_via_git
|
||||
|
||||
# No exception-swallowing: if install_plugin fails for ANY reason
|
||||
# unrelated to the tombstone, the test fails loudly.
|
||||
result = self.sm.install_plugin("bar")
|
||||
|
||||
self.assertTrue(
|
||||
result,
|
||||
"install_plugin returned False — the tombstone should not gate "
|
||||
"explicit installs and all other stubs should allow success.",
|
||||
)
|
||||
# Tombstone survives install (harmless — nothing reads it for installed plugins).
|
||||
self.assertTrue(self.sm.was_recently_uninstalled("bar"))
|
||||
|
||||
|
||||
class TestRegistryStaleCacheFallback(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||
|
||||
def test_network_failure_returns_stale_cache(self):
|
||||
# Prime the cache with a known-good registry.
|
||||
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||||
self.sm.registry_cache_time = time.time() - 10_000 # very old
|
||||
self.sm.registry_cache_timeout = 1 # force re-fetch attempt
|
||||
|
||||
import requests as real_requests
|
||||
with patch.object(
|
||||
self.sm,
|
||||
"_http_get_with_retries",
|
||||
side_effect=real_requests.RequestException("boom"),
|
||||
):
|
||||
result = self.sm.fetch_registry()
|
||||
|
||||
self.assertEqual(result, {"plugins": [{"id": "cached"}]})
|
||||
|
||||
def test_network_failure_with_no_cache_returns_empty(self):
|
||||
self.sm.registry_cache = None
|
||||
import requests as real_requests
|
||||
with patch.object(
|
||||
self.sm,
|
||||
"_http_get_with_retries",
|
||||
side_effect=real_requests.RequestException("boom"),
|
||||
):
|
||||
result = self.sm.fetch_registry()
|
||||
self.assertEqual(result, {"plugins": []})
|
||||
|
||||
def test_stale_fallback_bumps_timestamp_into_backoff(self):
|
||||
"""Regression: after the stale-cache fallback fires, the next
|
||||
fetch_registry call must NOT re-hit the network. Without the
|
||||
timestamp bump, a flaky connection causes every request to pay
|
||||
the network timeout before falling back to stale.
|
||||
"""
|
||||
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||||
self.sm.registry_cache_time = time.time() - 10_000 # expired
|
||||
self.sm.registry_cache_timeout = 1
|
||||
self.sm._failure_backoff_seconds = 60
|
||||
|
||||
import requests as real_requests
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_get(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
raise real_requests.ConnectionError("boom")
|
||||
|
||||
with patch.object(self.sm, "_http_get_with_retries", side_effect=counting_get):
|
||||
first = self.sm.fetch_registry()
|
||||
self.assertEqual(first, {"plugins": [{"id": "cached"}]})
|
||||
self.assertEqual(call_count["n"], 1)
|
||||
|
||||
second = self.sm.fetch_registry()
|
||||
self.assertEqual(second, {"plugins": [{"id": "cached"}]})
|
||||
self.assertEqual(
|
||||
call_count["n"], 1,
|
||||
"stale registry fallback must bump registry_cache_time so "
|
||||
"subsequent requests hit the cache instead of re-retrying",
|
||||
)
|
||||
|
||||
|
||||
class TestFetchRegistryRaiseOnFailure(unittest.TestCase):
|
||||
"""``fetch_registry(raise_on_failure=True)`` must propagate errors
|
||||
instead of silently falling back to the stale cache / empty dict.
|
||||
|
||||
Regression guard: the state reconciler relies on this to distinguish
|
||||
"plugin genuinely not in registry" from "I can't reach the registry
|
||||
right now". Without it, a fresh boot with flaky WiFi would poison
|
||||
``_unrecoverable_missing_on_disk`` with every config entry.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||
|
||||
def test_request_exception_propagates_when_flag_set(self):
|
||||
import requests as real_requests
|
||||
self.sm.registry_cache = None # no stale cache
|
||||
with patch.object(
|
||||
self.sm,
|
||||
"_http_get_with_retries",
|
||||
side_effect=real_requests.RequestException("boom"),
|
||||
):
|
||||
with self.assertRaises(real_requests.RequestException):
|
||||
self.sm.fetch_registry(raise_on_failure=True)
|
||||
|
||||
def test_request_exception_propagates_even_with_stale_cache(self):
|
||||
"""Explicit caller opt-in beats the stale-cache convenience."""
|
||||
import requests as real_requests
|
||||
self.sm.registry_cache = {"plugins": [{"id": "stale"}]}
|
||||
self.sm.registry_cache_time = time.time() - 10_000
|
||||
self.sm.registry_cache_timeout = 1
|
||||
with patch.object(
|
||||
self.sm,
|
||||
"_http_get_with_retries",
|
||||
side_effect=real_requests.RequestException("boom"),
|
||||
):
|
||||
with self.assertRaises(real_requests.RequestException):
|
||||
self.sm.fetch_registry(raise_on_failure=True)
|
||||
|
||||
def test_json_decode_error_propagates_when_flag_set(self):
|
||||
import json as _json
|
||||
self.sm.registry_cache = None
|
||||
bad_response = MagicMock()
|
||||
bad_response.status_code = 200
|
||||
bad_response.raise_for_status = MagicMock()
|
||||
bad_response.json = MagicMock(
|
||||
side_effect=_json.JSONDecodeError("bad", "", 0)
|
||||
)
|
||||
with patch.object(self.sm, "_http_get_with_retries", return_value=bad_response):
|
||||
with self.assertRaises(_json.JSONDecodeError):
|
||||
self.sm.fetch_registry(raise_on_failure=True)
|
||||
|
||||
def test_default_behavior_unchanged_by_new_parameter(self):
|
||||
"""UI callers that don't pass the flag still get stale-cache fallback."""
|
||||
import requests as real_requests
|
||||
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||||
self.sm.registry_cache_time = time.time() - 10_000
|
||||
self.sm.registry_cache_timeout = 1
|
||||
with patch.object(
|
||||
self.sm,
|
||||
"_http_get_with_retries",
|
||||
side_effect=real_requests.RequestException("boom"),
|
||||
):
|
||||
result = self.sm.fetch_registry() # default raise_on_failure=False
|
||||
self.assertEqual(result, {"plugins": [{"id": "cached"}]})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
395
test/test_uninstall_and_reconcile_endpoint.py
Normal file
395
test/test_uninstall_and_reconcile_endpoint.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""Regression tests for the transactional uninstall helper and the
|
||||
``/plugins/state/reconcile`` endpoint's payload handling.
|
||||
|
||||
Bug 1: the original uninstall flow caught
|
||||
``cleanup_plugin_config`` exceptions and only logged a warning before
|
||||
proceeding to file deletion. A failure there would leave the plugin
|
||||
files on disk with no config entry (orphan). The fix is a
|
||||
``_do_transactional_uninstall`` helper that (a) aborts before touching
|
||||
the filesystem if cleanup fails, and (b) restores the config+secrets
|
||||
snapshot if file removal fails after cleanup succeeded.
|
||||
|
||||
Bug 2: the reconcile endpoint did ``payload.get('force', False)`` after
|
||||
``request.get_json(silent=True) or {}``, which raises AttributeError if
|
||||
the client sent a non-object JSON body (e.g. a bare string or array).
|
||||
Additionally, ``bool("false")`` is ``True``, so string-encoded booleans
|
||||
were mis-handled. The fix is an ``isinstance(payload, dict)`` guard plus
|
||||
routing the value through ``_coerce_to_bool``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
_API_V3_MOCKED_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
|
||||
|
||||
def _make_client():
|
||||
"""Minimal Flask app + mocked deps that exercises the api_v3 blueprint.
|
||||
|
||||
Returns ``(client, module, cleanup_fn)``. Callers (test ``setUp``
|
||||
methods) must register ``cleanup_fn`` with ``self.addCleanup(...)``
|
||||
so the original api_v3 singleton attributes are restored at the end
|
||||
of the test — otherwise the MagicMocks leak into later tests that
|
||||
import api_v3 expecting fresh state.
|
||||
"""
|
||||
from web_interface.blueprints import api_v3 as api_v3_module
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
# Snapshot the originals so we can restore them.
|
||||
_SENTINEL = object()
|
||||
originals = {
|
||||
name: getattr(api_v3, name, _SENTINEL) for name in _API_V3_MOCKED_ATTRS
|
||||
}
|
||||
|
||||
# Mocks for all the bits the reconcile / uninstall endpoints touch.
|
||||
api_v3.config_manager = MagicMock()
|
||||
api_v3.config_manager.get_raw_file_content.return_value = {}
|
||||
api_v3.config_manager.secrets_path = "/tmp/nonexistent_secrets.json"
|
||||
api_v3.plugin_manager = MagicMock()
|
||||
api_v3.plugin_manager.plugins = {}
|
||||
api_v3.plugin_manager.plugins_dir = "/tmp"
|
||||
api_v3.plugin_store_manager = MagicMock()
|
||||
api_v3.plugin_state_manager = MagicMock()
|
||||
api_v3.plugin_state_manager.get_all_states.return_value = {}
|
||||
api_v3.saved_repositories_manager = MagicMock()
|
||||
api_v3.schema_manager = MagicMock()
|
||||
api_v3.operation_queue = None # force the direct (non-queue) path
|
||||
api_v3.operation_history = MagicMock()
|
||||
api_v3.cache_manager = MagicMock()
|
||||
|
||||
def _cleanup():
|
||||
for name, original in originals.items():
|
||||
if original is _SENTINEL:
|
||||
# Attribute didn't exist before — remove it to match.
|
||||
if hasattr(api_v3, name):
|
||||
try:
|
||||
delattr(api_v3, name)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
setattr(api_v3, name, original)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.config['SECRET_KEY'] = 'test'
|
||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
return app.test_client(), api_v3_module, _cleanup
|
||||
|
||||
|
||||
class TestTransactionalUninstall(unittest.TestCase):
|
||||
"""Exercises ``_do_transactional_uninstall`` directly.
|
||||
|
||||
Using the direct (non-queue) code path via the Flask client gives us
|
||||
the full uninstall endpoint behavior end-to-end, including the
|
||||
rollback on mid-flight failures.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client, self.mod, _cleanup = _make_client()
|
||||
self.addCleanup(_cleanup)
|
||||
self.api_v3 = self.mod.api_v3
|
||||
|
||||
def test_cleanup_failure_aborts_before_file_removal(self):
|
||||
"""If cleanup_plugin_config raises, uninstall_plugin must NOT run."""
|
||||
self.api_v3.config_manager.cleanup_plugin_config.side_effect = RuntimeError("disk full")
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v3/plugins/uninstall',
|
||||
data=json.dumps({'plugin_id': 'thing'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
# File removal must NOT have been attempted — otherwise we'd have
|
||||
# deleted the plugin after failing to clean its config, leaving
|
||||
# the reconciler to potentially resurrect it later.
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||
|
||||
def test_file_removal_failure_restores_snapshot(self):
|
||||
"""If uninstall_plugin returns False after cleanup, snapshot must be restored."""
|
||||
# Start with the plugin in main config and in secrets.
|
||||
stored_main = {'thing': {'enabled': True, 'custom': 'stuff'}}
|
||||
stored_secrets = {'thing': {'api_key': 'secret'}}
|
||||
|
||||
# get_raw_file_content is called twice during snapshot (main +
|
||||
# secrets) and then again during restore. We track writes through
|
||||
# save_raw_file_content so we can assert the restore happened.
|
||||
def raw_get(file_type):
|
||||
if file_type == 'main':
|
||||
return dict(stored_main)
|
||||
if file_type == 'secrets':
|
||||
return dict(stored_secrets)
|
||||
return {}
|
||||
|
||||
self.api_v3.config_manager.get_raw_file_content.side_effect = raw_get
|
||||
self.api_v3.config_manager.secrets_path = __file__ # any existing file
|
||||
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = False
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v3/plugins/uninstall',
|
||||
data=json.dumps({'plugin_id': 'thing'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
# After the file removal returned False, the helper must have
|
||||
# written the snapshot back. Inspect save_raw_file_content calls.
|
||||
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
|
||||
file_types_written = [c.args[0] for c in calls]
|
||||
self.assertIn('main', file_types_written,
|
||||
f"main config was not restored after uninstall failure; calls={calls}")
|
||||
# Find the main restore call and confirm our snapshot entry is present.
|
||||
for c in calls:
|
||||
if c.args[0] == 'main':
|
||||
written = c.args[1]
|
||||
self.assertIn('thing', written,
|
||||
"main config was written without the restored snapshot entry")
|
||||
self.assertEqual(written['thing'], stored_main['thing'])
|
||||
break
|
||||
|
||||
def test_file_removal_raising_also_restores_snapshot(self):
|
||||
"""Same restore path, but triggered by an exception instead of False."""
|
||||
stored_main = {'thing': {'enabled': False}}
|
||||
|
||||
def raw_get(file_type):
|
||||
if file_type == 'main':
|
||||
return dict(stored_main)
|
||||
return {}
|
||||
|
||||
self.api_v3.config_manager.get_raw_file_content.side_effect = raw_get
|
||||
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.side_effect = OSError("rm failed")
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v3/plugins/uninstall',
|
||||
data=json.dumps({'plugin_id': 'thing'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
|
||||
self.assertTrue(
|
||||
any(c.args[0] == 'main' for c in calls),
|
||||
"main config was not restored after uninstall raised",
|
||||
)
|
||||
|
||||
def test_happy_path_succeeds(self):
|
||||
"""Sanity: the transactional rework did not break the happy path."""
|
||||
self.api_v3.config_manager.get_raw_file_content.return_value = {}
|
||||
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v3/plugins/uninstall',
|
||||
data=json.dumps({'plugin_id': 'thing'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.assert_called_once_with('thing')
|
||||
|
||||
def test_file_removal_failure_reloads_previously_loaded_plugin(self):
|
||||
"""Regression: rollback must restore BOTH config AND runtime state.
|
||||
|
||||
If the plugin was loaded at runtime before the uninstall
|
||||
request, and file removal fails after unload has already
|
||||
succeeded, the rollback must call ``load_plugin`` so the user
|
||||
doesn't end up in a state where the files exist and the config
|
||||
exists but the plugin is no longer loaded.
|
||||
"""
|
||||
# Plugin is currently loaded.
|
||||
self.api_v3.plugin_manager.plugins = {'thing': MagicMock()}
|
||||
self.api_v3.config_manager.get_raw_file_content.return_value = {
|
||||
'thing': {'enabled': True}
|
||||
}
|
||||
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||
self.api_v3.plugin_manager.unload_plugin.return_value = None
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = False
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v3/plugins/uninstall',
|
||||
data=json.dumps({'plugin_id': 'thing'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
# Unload did happen (it's part of the uninstall sequence)...
|
||||
self.api_v3.plugin_manager.unload_plugin.assert_called_once_with('thing')
|
||||
# ...and because file removal failed, the rollback must have
|
||||
# called load_plugin to restore runtime state.
|
||||
self.api_v3.plugin_manager.load_plugin.assert_called_once_with('thing')
|
||||
|
||||
def test_snapshot_survives_config_read_error(self):
|
||||
"""Regression: if get_raw_file_content raises an expected error
|
||||
(OSError / ConfigError) during snapshot, the uninstall should
|
||||
still proceed — we just won't have a rollback snapshot. Narrow
|
||||
exception list must still cover the realistic failure modes.
|
||||
"""
|
||||
from src.exceptions import ConfigError
|
||||
self.api_v3.config_manager.get_raw_file_content.side_effect = ConfigError(
|
||||
"file missing", config_path="/tmp/missing"
|
||||
)
|
||||
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v3/plugins/uninstall',
|
||||
data=json.dumps({'plugin_id': 'thing'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Uninstall should still succeed — snapshot failure is logged
|
||||
# but doesn't block the uninstall.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.assert_called_once_with('thing')
|
||||
|
||||
def test_snapshot_does_not_swallow_programmer_errors(self):
|
||||
"""Regression: unexpected exceptions (TypeError, AttributeError)
|
||||
must propagate out of the snapshot helper so bugs surface
|
||||
during development instead of being silently logged and
|
||||
ignored. Narrowing from ``except Exception`` to
|
||||
``(OSError, ConfigError)`` is what makes this work.
|
||||
"""
|
||||
# Raise an exception that is NOT in the narrow catch list.
|
||||
self.api_v3.config_manager.get_raw_file_content.side_effect = TypeError(
|
||||
"unexpected kwarg"
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v3/plugins/uninstall',
|
||||
data=json.dumps({'plugin_id': 'thing'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# The TypeError should propagate up to the endpoint's outer
|
||||
# try/except and produce a 500, NOT be silently swallowed like
|
||||
# the previous ``except Exception`` did.
|
||||
self.assertEqual(response.status_code, 500)
|
||||
# uninstall_plugin must NOT have been called — the snapshot
|
||||
# exception bubbled up before we got that far.
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||
|
||||
def test_unload_failure_restores_config_and_does_not_call_uninstall(self):
|
||||
"""If unload_plugin itself raises, config must be restored and
|
||||
uninstall_plugin must NOT be called."""
|
||||
self.api_v3.plugin_manager.plugins = {'thing': MagicMock()}
|
||||
self.api_v3.config_manager.get_raw_file_content.return_value = {
|
||||
'thing': {'enabled': True}
|
||||
}
|
||||
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||
self.api_v3.plugin_manager.unload_plugin.side_effect = RuntimeError("unload boom")
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v3/plugins/uninstall',
|
||||
data=json.dumps({'plugin_id': 'thing'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||
# Config should have been restored.
|
||||
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
|
||||
self.assertTrue(
|
||||
any(c.args[0] == 'main' for c in calls),
|
||||
"main config was not restored after unload_plugin raised",
|
||||
)
|
||||
# load_plugin must NOT have been called — unload didn't succeed,
|
||||
# so runtime state is still what it was.
|
||||
self.api_v3.plugin_manager.load_plugin.assert_not_called()
|
||||
|
||||
|
||||
class TestReconcileEndpointPayload(unittest.TestCase):
|
||||
"""``/plugins/state/reconcile`` must handle weird JSON payloads without
|
||||
crashing, and must accept string booleans for ``force``.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client, self.mod, _cleanup = _make_client()
|
||||
self.addCleanup(_cleanup)
|
||||
self.api_v3 = self.mod.api_v3
|
||||
# Stub the reconciler so we only test the payload plumbing, not
|
||||
# the full reconciliation. We patch StateReconciliation at the
|
||||
# module level where the endpoint imports it lazily.
|
||||
self._reconciler_instance = MagicMock()
|
||||
self._reconciler_instance.reconcile_state.return_value = MagicMock(
|
||||
inconsistencies_found=[],
|
||||
inconsistencies_fixed=[],
|
||||
inconsistencies_manual=[],
|
||||
message="ok",
|
||||
)
|
||||
# Patch the StateReconciliation class where it's imported inside
|
||||
# the reconcile endpoint.
|
||||
self._patcher = patch(
|
||||
'src.plugin_system.state_reconciliation.StateReconciliation',
|
||||
return_value=self._reconciler_instance,
|
||||
)
|
||||
self._patcher.start()
|
||||
self.addCleanup(self._patcher.stop)
|
||||
|
||||
def _post(self, body, content_type='application/json'):
|
||||
return self.client.post(
|
||||
'/api/v3/plugins/state/reconcile',
|
||||
data=body,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
def test_non_object_json_body_does_not_crash(self):
|
||||
"""A bare string JSON body must not raise AttributeError."""
|
||||
response = self._post('"just a string"')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# force must default to False.
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||
|
||||
def test_array_json_body_does_not_crash(self):
|
||||
response = self._post('[1, 2, 3]')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||
|
||||
def test_null_json_body_does_not_crash(self):
|
||||
response = self._post('null')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||
|
||||
def test_missing_force_key_defaults_to_false(self):
|
||||
response = self._post('{}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||
|
||||
def test_force_true_boolean(self):
|
||||
response = self._post(json.dumps({'force': True}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=True)
|
||||
|
||||
def test_force_false_boolean(self):
|
||||
response = self._post(json.dumps({'force': False}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||
|
||||
def test_force_string_false_coerced_correctly(self):
|
||||
"""``bool("false")`` is ``True`` — _coerce_to_bool must fix that."""
|
||||
response = self._post(json.dumps({'force': 'false'}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||
|
||||
def test_force_string_true_coerced_correctly(self):
|
||||
response = self._post(json.dumps({'force': 'true'}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self._reconciler_instance.reconcile_state.assert_called_once_with(force=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
334
test/test_wifi_manager_ap.py
Normal file
334
test/test_wifi_manager_ap.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Unit tests for WiFi AP mode — src.wifi_manager.
|
||||
|
||||
Each test exercises logic that can be verified through the subprocess calls the
|
||||
manager emits, without requiring root access, hardware, or a running Pi.
|
||||
|
||||
Scenarios covered:
|
||||
1. nmcli AP profile is created with no security parameters (open/passwordless).
|
||||
2. iptables PREROUTING and INPUT rules are added when the nmcli AP starts.
|
||||
3. iptables rules and ip_forward are reverted when the AP is torn down.
|
||||
4. LED matrix message includes the SSID, 'No password', and the setup URL.
|
||||
5. Known AP profile names are deleted before the new profile is created.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ok(stdout: str = "", stderr: str = "") -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.returncode = 0
|
||||
r.stdout = stdout
|
||||
r.stderr = stderr
|
||||
return r
|
||||
|
||||
|
||||
def _fail(stdout: str = "", stderr: str = "error") -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.returncode = 1
|
||||
r.stdout = stdout
|
||||
r.stderr = stderr
|
||||
return r
|
||||
|
||||
|
||||
def _find_path_side_effect(name: str) -> str:
|
||||
"""Deterministic fake for _find_command_path."""
|
||||
return {"iptables": "/usr/sbin/iptables", "sysctl": "/usr/sbin/sysctl"}.get(
|
||||
name, f"/usr/bin/{name}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def wifi_config(tmp_path: Path) -> Path:
|
||||
"""Minimal wifi_config.json in a temporary directory."""
|
||||
cfg_dir = tmp_path / "config"
|
||||
cfg_dir.mkdir()
|
||||
cfg = {
|
||||
"ap_ssid": "LEDMatrix-Setup",
|
||||
"ap_channel": 7,
|
||||
"auto_enable_ap_mode": True,
|
||||
"saved_networks": [],
|
||||
}
|
||||
p = cfg_dir / "wifi_config.json"
|
||||
p.write_text(json.dumps(cfg))
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def manager(wifi_config: Path, tmp_path: Path) -> WiFiManager:
|
||||
"""
|
||||
WiFiManager with all system calls stubbed out during construction and the
|
||||
ip_forward save file redirected to a per-test temporary path.
|
||||
"""
|
||||
with patch("src.wifi_manager.subprocess.run", return_value=_ok(stdout="wlan0\n")), \
|
||||
patch.object(WiFiManager, "_detect_trixie", return_value=False):
|
||||
mgr = WiFiManager(config_path=wifi_config)
|
||||
|
||||
# Force clean, deterministic state regardless of what __init__ inferred
|
||||
mgr._wifi_interface = "wlan0"
|
||||
mgr.has_nmcli = True
|
||||
mgr.has_hostapd = False
|
||||
mgr.has_dnsmasq = False
|
||||
mgr.has_iwlist = False
|
||||
mgr._is_trixie = False
|
||||
# Redirect the ip_forward save file to tmp so tests never share state
|
||||
mgr._IP_FORWARD_SAVE_PATH = tmp_path / "ip_fwd_saved"
|
||||
return mgr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. AP profile is open (no password)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_nmcli_ap_profile_has_no_security_params(manager: WiFiManager) -> None:
|
||||
"""
|
||||
The 'nmcli connection add' command must not include key-mgmt, psk, or any
|
||||
WPA-related parameter. On Bookworm/Trixie, NM creates a WPA2-protected
|
||||
hotspot even when those values are set to 'none'/empty via a later
|
||||
'connection modify', so the profile must be created without a security
|
||||
section from the start.
|
||||
"""
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def _run(cmd, **kw):
|
||||
captured.append(list(cmd))
|
||||
return _ok()
|
||||
|
||||
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||
patch.object(manager, "_get_ap_status_nmcli",
|
||||
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||
patch.object(manager, "_show_led_message"):
|
||||
|
||||
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||
|
||||
assert success, "AP enable should report success"
|
||||
|
||||
add_calls = [c for c in captured if "nmcli" in c and "connection" in c and "add" in c]
|
||||
assert add_calls, "Expected at least one 'nmcli connection add' invocation"
|
||||
|
||||
add_str = " ".join(add_calls[0])
|
||||
assert "key-mgmt" not in add_str, "AP profile must not set key-mgmt"
|
||||
assert "psk" not in add_str, "AP profile must not include a PSK/password"
|
||||
assert "wpa" not in add_str.lower(), "AP profile must not reference WPA"
|
||||
assert "802-11-wireless.mode" in add_str, "AP profile must declare wireless mode"
|
||||
# Verify the value for 802-11-wireless.mode is exactly "ap" — check the element
|
||||
# that immediately follows the key in the command list, not a loose substring match.
|
||||
cmd = add_calls[0]
|
||||
try:
|
||||
mode_idx = cmd.index("802-11-wireless.mode")
|
||||
assert cmd[mode_idx + 1] == "ap", \
|
||||
f"802-11-wireless.mode value must be exactly 'ap', got {cmd[mode_idx + 1]!r}"
|
||||
except ValueError:
|
||||
pytest.fail("802-11-wireless.mode not found as a list element in nmcli command")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. iptables NAT rules are added when the AP starts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_iptables_nat_rules_added_on_ap_start(manager: WiFiManager) -> None:
|
||||
"""
|
||||
_setup_iptables_redirect must add:
|
||||
- a PREROUTING REDIRECT rule that maps incoming TCP port 80 to port 5000, and
|
||||
- an INPUT ACCEPT rule for port 5000 (the post-redirect destination port,
|
||||
NOT port 80 which never hits the INPUT chain after PREROUTING rewrites it).
|
||||
"""
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def _run(cmd, **kw):
|
||||
captured.append(list(cmd))
|
||||
# iptables -C (check) → rc=1 so the -A (add) branch executes
|
||||
if "iptables" in " ".join(str(x) for x in cmd) and "-C" in cmd:
|
||||
return _fail()
|
||||
return _ok()
|
||||
|
||||
# Patch Path.read_text so /proc/sys/net/ipv4/ip_forward is readable on any OS
|
||||
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect), \
|
||||
patch("pathlib.Path.read_text", return_value="0\n"):
|
||||
|
||||
result = manager._setup_iptables_redirect()
|
||||
|
||||
assert result, "_setup_iptables_redirect must return True on success"
|
||||
|
||||
prerouting_adds = [c for c in captured if "iptables" in " ".join(c) and "-A" in c and "PREROUTING" in c]
|
||||
assert prerouting_adds, "Expected 'iptables -A PREROUTING' invocation"
|
||||
pr_str = " ".join(prerouting_adds[0])
|
||||
assert "--dport" in pr_str and "80" in pr_str, "PREROUTING rule must match dport 80"
|
||||
assert "5000" in pr_str, "PREROUTING rule must redirect to port 5000"
|
||||
assert "REDIRECT" in pr_str, "PREROUTING rule must use REDIRECT target"
|
||||
|
||||
input_adds = [c for c in captured if "iptables" in " ".join(c) and "-A" in c and "INPUT" in c]
|
||||
assert input_adds, "Expected 'iptables -A INPUT' invocation"
|
||||
in_str = " ".join(input_adds[0])
|
||||
assert "5000" in in_str, "INPUT rule must accept port 5000 (post-PREROUTING destination)"
|
||||
assert "ACCEPT" in in_str, "INPUT rule must use ACCEPT target"
|
||||
|
||||
# Port 80 must NOT be used in the INPUT rule (it is already redirected by PREROUTING)
|
||||
input_80 = [c for c in captured if "iptables" in " ".join(c) and "INPUT" in c and "--dport" in c and "80" in c]
|
||||
assert not input_80, "INPUT rule must target port 5000, not port 80"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3a. iptables rules and ip_forward reverted on teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_iptables_rules_and_ip_forward_reverted_on_teardown(manager: WiFiManager) -> None:
|
||||
"""
|
||||
_teardown_iptables_redirect must:
|
||||
- remove the PREROUTING and INPUT iptables rules, and
|
||||
- restore ip_forward to the exact value recorded in the save file.
|
||||
"""
|
||||
original_fwd = "0"
|
||||
manager._IP_FORWARD_SAVE_PATH.write_text(original_fwd)
|
||||
# Teardown dispatches on the backend recorded during setup
|
||||
manager._redirect_backend = "iptables"
|
||||
|
||||
captured: list[list[str]] = []
|
||||
|
||||
with patch("src.wifi_manager.subprocess.run",
|
||||
side_effect=lambda cmd, **kw: (captured.append(list(cmd)) or _ok())), \
|
||||
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect):
|
||||
|
||||
manager._teardown_iptables_redirect()
|
||||
|
||||
assert [c for c in captured if "iptables" in " ".join(c) and "-D" in c and "PREROUTING" in c], \
|
||||
"Expected 'iptables -D PREROUTING' invocation"
|
||||
|
||||
assert [c for c in captured if "iptables" in " ".join(c) and "-D" in c and "INPUT" in c], \
|
||||
"Expected 'iptables -D INPUT' invocation"
|
||||
|
||||
restore_calls = [
|
||||
c for c in captured
|
||||
if "sysctl" in " ".join(c) and f"ip_forward={original_fwd}" in " ".join(c)
|
||||
]
|
||||
assert restore_calls, f"Expected sysctl to restore ip_forward to {original_fwd!r}"
|
||||
|
||||
assert not manager._IP_FORWARD_SAVE_PATH.exists(), \
|
||||
"Save file must be removed after successful teardown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3b. ip_forward untouched when no save file exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ip_forward_not_restored_when_save_file_absent(manager: WiFiManager) -> None:
|
||||
"""
|
||||
When the save file is missing (setup never wrote it, e.g. because /proc was
|
||||
unreadable or the write failed), teardown must NOT call sysctl so it does not
|
||||
accidentally clobber ip_forward state owned by a VPN or NetworkManager.
|
||||
"""
|
||||
assert not manager._IP_FORWARD_SAVE_PATH.exists()
|
||||
|
||||
captured: list[list[str]] = []
|
||||
|
||||
with patch("src.wifi_manager.subprocess.run",
|
||||
side_effect=lambda cmd, **kw: (captured.append(list(cmd)) or _ok())), \
|
||||
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect):
|
||||
|
||||
manager._teardown_iptables_redirect()
|
||||
|
||||
sysctl_calls = [
|
||||
c for c in captured
|
||||
if "sysctl" in " ".join(c) and "ip_forward" in " ".join(c)
|
||||
]
|
||||
assert not sysctl_calls, \
|
||||
"sysctl must not be called when no ip_forward save file exists"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. LED message content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_led_message_shows_ssid_no_password_and_url(manager: WiFiManager) -> None:
|
||||
"""
|
||||
When the nmcli AP activates, the LED message must include:
|
||||
- the AP SSID ('LEDMatrix-Setup')
|
||||
- the string 'No password'
|
||||
- the AP IP address (192.168.4.1) and Flask port (5000)
|
||||
"""
|
||||
led_messages: list[str] = []
|
||||
|
||||
with patch("src.wifi_manager.subprocess.run", return_value=_ok()), \
|
||||
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||
patch.object(manager, "_get_ap_status_nmcli",
|
||||
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||
patch.object(manager, "_show_led_message",
|
||||
side_effect=lambda msg, **kw: led_messages.append(msg)):
|
||||
|
||||
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||
|
||||
assert success, "AP enable should report success"
|
||||
assert led_messages, "Expected at least one _show_led_message call"
|
||||
|
||||
combined = "\n".join(led_messages)
|
||||
assert "No password" in combined, "LED message must say 'No password'"
|
||||
assert "LEDMatrix-Setup" in combined, "LED message must include the AP SSID"
|
||||
assert "192.168.4.1" in combined, "LED message must include the AP IP address"
|
||||
assert "5000" in combined, "LED message must include the Flask port"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Stale AP profiles deleted before the new one is created
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_existing_ap_profiles_deleted_before_new_profile_created(manager: WiFiManager) -> None:
|
||||
"""
|
||||
Before 'nmcli connection add', the manager must issue
|
||||
'nmcli connection down/delete' for every known AP profile name so stale
|
||||
profiles (from a previous crash or partial setup) cannot block the new one.
|
||||
"""
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def _run(cmd, **kw):
|
||||
captured.append(list(cmd))
|
||||
return _ok()
|
||||
|
||||
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||
patch.object(manager, "_get_ap_status_nmcli",
|
||||
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||
patch.object(manager, "_show_led_message"):
|
||||
|
||||
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||
|
||||
assert success
|
||||
|
||||
cmd_strs = [" ".join(c) for c in captured]
|
||||
|
||||
for profile in ("LEDMatrix-Setup-AP", "Hotspot", "TickerSetup-AP"):
|
||||
assert any("connection delete" in s and profile in s for s in cmd_strs), \
|
||||
f"Expected 'nmcli connection delete {profile}' before creating the new profile"
|
||||
|
||||
add_indices = [i for i, s in enumerate(cmd_strs) if "connection add" in s]
|
||||
del_indices = [i for i, s in enumerate(cmd_strs) if "connection delete" in s]
|
||||
|
||||
assert add_indices, "Expected 'nmcli connection add' call"
|
||||
assert del_indices, "Expected 'nmcli connection delete' calls"
|
||||
assert max(del_indices) < min(add_indices), \
|
||||
"All connection deletions must complete before the new profile is created"
|
||||
@@ -342,6 +342,167 @@ class TestStateReconciliation(unittest.TestCase):
|
||||
self.assertEqual(state, {})
|
||||
|
||||
|
||||
class TestStateReconciliationUnrecoverable(unittest.TestCase):
|
||||
"""Tests for the unrecoverable-plugin cache and force reconcile.
|
||||
|
||||
Regression coverage for the infinite reinstall loop where a config
|
||||
entry referenced a plugin not present in the registry (e.g. legacy
|
||||
'github' / 'youtube' entries). The reconciler used to retry the
|
||||
install on every HTTP request; it now caches the failure for the
|
||||
process lifetime and only retries on an explicit ``force=True``
|
||||
reconcile call.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
self.plugins_dir = self.temp_dir / "plugins"
|
||||
self.plugins_dir.mkdir()
|
||||
|
||||
self.state_manager = Mock(spec=PluginStateManager)
|
||||
self.state_manager.get_all_states.return_value = {}
|
||||
self.config_manager = Mock()
|
||||
self.config_manager.load_config.return_value = {
|
||||
"ghost": {"enabled": True}
|
||||
}
|
||||
self.plugin_manager = Mock()
|
||||
self.plugin_manager.plugin_manifests = {}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Store manager with an empty registry — install_plugin always fails
|
||||
self.store_manager = Mock()
|
||||
self.store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
self.store_manager.install_plugin.return_value = False
|
||||
self.store_manager.was_recently_uninstalled.return_value = False
|
||||
|
||||
self.reconciler = StateReconciliation(
|
||||
state_manager=self.state_manager,
|
||||
config_manager=self.config_manager,
|
||||
plugin_manager=self.plugin_manager,
|
||||
plugins_dir=self.plugins_dir,
|
||||
store_manager=self.store_manager,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_not_in_registry_marks_unrecoverable_without_install(self):
|
||||
"""If the plugin isn't in the registry at all, skip install_plugin."""
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# One inconsistency, unfixable, no install attempt made.
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
self.assertEqual(len(result.inconsistencies_fixed), 0)
|
||||
self.store_manager.install_plugin.assert_not_called()
|
||||
self.assertIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
|
||||
|
||||
def test_subsequent_reconcile_does_not_retry(self):
|
||||
"""Second reconcile pass must not touch install_plugin or fetch_registry again."""
|
||||
self.reconciler.reconcile_state()
|
||||
self.store_manager.fetch_registry.reset_mock()
|
||||
self.store_manager.install_plugin.reset_mock()
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Still one inconsistency, still no install attempt, no new registry fetch
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
inc = result.inconsistencies_found[0]
|
||||
self.assertFalse(inc.can_auto_fix)
|
||||
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||
self.store_manager.install_plugin.assert_not_called()
|
||||
self.store_manager.fetch_registry.assert_not_called()
|
||||
|
||||
def test_force_reconcile_clears_unrecoverable_cache(self):
|
||||
"""force=True must re-attempt previously-failed plugins."""
|
||||
self.reconciler.reconcile_state()
|
||||
self.assertIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
|
||||
|
||||
# Now pretend the registry gained the plugin so the pre-check passes
|
||||
# and install_plugin is actually invoked.
|
||||
self.store_manager.fetch_registry.return_value = {
|
||||
"plugins": [{"id": "ghost"}]
|
||||
}
|
||||
self.store_manager.install_plugin.return_value = True
|
||||
self.store_manager.install_plugin.reset_mock()
|
||||
|
||||
# Config still references ghost; disk still missing it — the
|
||||
# reconciler should re-attempt install now that force=True cleared
|
||||
# the cache. Use assert_called_once_with so a future regression
|
||||
# that accidentally triggers a second install attempt on force=True
|
||||
# is caught.
|
||||
result = self.reconciler.reconcile_state(force=True)
|
||||
|
||||
self.store_manager.install_plugin.assert_called_once_with("ghost")
|
||||
|
||||
def test_registry_unreachable_does_not_mark_unrecoverable(self):
|
||||
"""Transient registry failures should not poison the cache."""
|
||||
self.store_manager.fetch_registry.side_effect = Exception("network down")
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
self.assertNotIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
|
||||
self.store_manager.install_plugin.assert_not_called()
|
||||
|
||||
def test_recently_uninstalled_skips_auto_repair(self):
|
||||
"""A freshly-uninstalled plugin must not be resurrected by the reconciler."""
|
||||
self.store_manager.was_recently_uninstalled.return_value = True
|
||||
self.store_manager.fetch_registry.return_value = {
|
||||
"plugins": [{"id": "ghost"}]
|
||||
}
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
inc = result.inconsistencies_found[0]
|
||||
self.assertFalse(inc.can_auto_fix)
|
||||
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||
self.store_manager.install_plugin.assert_not_called()
|
||||
|
||||
def test_real_store_manager_empty_registry_on_network_failure(self):
|
||||
"""Regression: using the REAL PluginStoreManager (not a Mock), verify
|
||||
the reconciler does NOT poison the unrecoverable cache when
|
||||
``fetch_registry`` fails with no stale cache available.
|
||||
|
||||
Previously, the default stale-cache fallback in ``fetch_registry``
|
||||
silently returned ``{"plugins": []}`` on network failure with no
|
||||
cache. The reconciler's ``_auto_repair_missing_plugin`` saw "no
|
||||
candidates in registry" and marked everything unrecoverable — a
|
||||
regression that would bite every user doing a fresh boot on flaky
|
||||
WiFi. The fix is ``fetch_registry(raise_on_failure=True)`` in
|
||||
``_auto_repair_missing_plugin`` so the reconciler can tell a real
|
||||
registry miss from a network error.
|
||||
"""
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
import requests as real_requests
|
||||
|
||||
real_store = PluginStoreManager(plugins_dir=str(self.plugins_dir))
|
||||
real_store.registry_cache = None # fresh boot, no cache
|
||||
real_store.registry_cache_time = None
|
||||
|
||||
# Stub the underlying HTTP so no real network call is made but the
|
||||
# real fetch_registry code path runs.
|
||||
real_store._http_get_with_retries = Mock(
|
||||
side_effect=real_requests.ConnectionError("wifi down")
|
||||
)
|
||||
|
||||
reconciler = StateReconciliation(
|
||||
state_manager=self.state_manager,
|
||||
config_manager=self.config_manager,
|
||||
plugin_manager=self.plugin_manager,
|
||||
plugins_dir=self.plugins_dir,
|
||||
store_manager=real_store,
|
||||
)
|
||||
|
||||
result = reconciler.reconcile_state()
|
||||
|
||||
# One inconsistency (ghost is in config, not on disk), but
|
||||
# because the registry lookup failed transiently, we must NOT
|
||||
# have marked it unrecoverable — a later reconcile (after the
|
||||
# network comes back) can still auto-repair.
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
self.assertNotIn("ghost", reconciler._unrecoverable_missing_on_disk)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -667,8 +667,20 @@ import threading as _threading
|
||||
_reconciliation_lock = _threading.Lock()
|
||||
|
||||
def _run_startup_reconciliation() -> None:
|
||||
"""Run state reconciliation in background to auto-repair missing plugins."""
|
||||
global _reconciliation_done, _reconciliation_started
|
||||
"""Run state reconciliation in background to auto-repair missing plugins.
|
||||
|
||||
Reconciliation runs exactly once per process lifetime, regardless of
|
||||
whether every inconsistency could be auto-fixed. Previously, a failed
|
||||
auto-repair (e.g. a config entry referencing a plugin that no longer
|
||||
exists in the registry) would reset ``_reconciliation_started`` to False,
|
||||
causing the ``@app.before_request`` hook to re-trigger reconciliation on
|
||||
every single HTTP request — an infinite install-retry loop that pegged
|
||||
the CPU and flooded the log. Unresolved issues are now left in place for
|
||||
the user to address via the UI; the reconciler itself also caches
|
||||
per-plugin unrecoverable failures internally so repeated reconcile calls
|
||||
stay cheap.
|
||||
"""
|
||||
global _reconciliation_done
|
||||
from src.logging_config import get_logger
|
||||
_logger = get_logger('reconciliation')
|
||||
|
||||
@@ -684,18 +696,22 @@ def _run_startup_reconciliation() -> None:
|
||||
result = reconciler.reconcile_state()
|
||||
if result.inconsistencies_found:
|
||||
_logger.info("[Reconciliation] %s", result.message)
|
||||
if result.reconciliation_successful:
|
||||
if result.inconsistencies_fixed:
|
||||
plugin_manager.discover_plugins()
|
||||
_reconciliation_done = True
|
||||
else:
|
||||
_logger.warning("[Reconciliation] Finished with unresolved issues, will retry")
|
||||
with _reconciliation_lock:
|
||||
_reconciliation_started = False
|
||||
if result.inconsistencies_fixed:
|
||||
plugin_manager.discover_plugins()
|
||||
if not result.reconciliation_successful:
|
||||
_logger.warning(
|
||||
"[Reconciliation] Finished with %d unresolved issue(s); "
|
||||
"will not retry automatically. Use the Plugin Store or the "
|
||||
"manual 'Reconcile' action to resolve.",
|
||||
len(result.inconsistencies_manual),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||
with _reconciliation_lock:
|
||||
_reconciliation_started = False
|
||||
finally:
|
||||
# Always mark done — we do not want an unhandled exception (or an
|
||||
# unresolved inconsistency) to cause the @before_request hook to
|
||||
# retrigger reconciliation on every subsequent request.
|
||||
_reconciliation_done = True
|
||||
|
||||
# Initialize health monitor and run reconciliation on first request
|
||||
@app.before_request
|
||||
@@ -710,4 +726,6 @@ def check_health_monitor():
|
||||
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
# threaded=True is Flask's default since 1.0 but stated explicitly so that
|
||||
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
|
||||
app.run(host='0.0.0.0', port=5000, debug=True, threaded=True)
|
||||
|
||||
@@ -2,18 +2,26 @@ from flask import Blueprint, request, jsonify, Response, send_from_directory
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import hashlib
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Dict, Any, Type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUDO_BIN = shutil.which("sudo") or "/usr/bin/sudo"
|
||||
SYSTEMCTL_BIN = shutil.which("systemctl") or "/usr/bin/systemctl"
|
||||
REBOOT_BIN = shutil.which("reboot") or "/usr/sbin/reboot"
|
||||
POWEROFF_BIN = shutil.which("poweroff") or "/usr/sbin/poweroff"
|
||||
|
||||
# Import new infrastructure
|
||||
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
||||
from src.web_interface.errors import ErrorCode
|
||||
@@ -216,7 +224,7 @@ def _ensure_display_service_running():
|
||||
if status.get('active'):
|
||||
status['started'] = False
|
||||
return status
|
||||
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix'])
|
||||
result = _run_systemctl_command([SUDO_BIN, SYSTEMCTL_BIN, 'start', 'ledmatrix.service'])
|
||||
service_status = _get_display_service_status()
|
||||
result['started'] = result.get('returncode') == 0
|
||||
result['active'] = service_status.get('active')
|
||||
@@ -225,7 +233,7 @@ def _ensure_display_service_running():
|
||||
|
||||
def _stop_display_service():
|
||||
"""Stop the ledmatrix display service."""
|
||||
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix'])
|
||||
result = _run_systemctl_command([SUDO_BIN, SYSTEMCTL_BIN, 'stop', 'ledmatrix.service'])
|
||||
status = _get_display_service_status()
|
||||
result['active'] = status.get('active')
|
||||
result['status'] = status
|
||||
@@ -1147,7 +1155,7 @@ def backup_export():
|
||||
'status': 'success',
|
||||
'filename': zip_path.name,
|
||||
'size': zip_path.stat().st_size,
|
||||
'created_at': datetime.utcnow().isoformat() + 'Z',
|
||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("[Backup] export failed")
|
||||
@@ -1167,7 +1175,7 @@ def backup_list():
|
||||
entries.append({
|
||||
'filename': path.name,
|
||||
'size': stat.st_size,
|
||||
'created_at': datetime.utcfromtimestamp(stat.st_mtime).isoformat() + 'Z',
|
||||
'created_at': datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
|
||||
})
|
||||
return jsonify({'status': 'success', 'data': entries})
|
||||
except Exception:
|
||||
@@ -1232,8 +1240,20 @@ def _save_uploaded_backup_to_temp() -> Tuple[Optional[Path], Optional[Tuple[Resp
|
||||
fd, tmp_name = _tempfile.mkstemp(prefix='ledmatrix_upload_', suffix='.zip')
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
max_bytes = 200 * 1024 * 1024
|
||||
try:
|
||||
upload.save(str(tmp_path))
|
||||
written = 0
|
||||
with open(tmp_path, 'wb') as fh:
|
||||
while True:
|
||||
chunk = upload.stream.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > max_bytes:
|
||||
fh.close()
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return None, (jsonify({'status': 'error', 'message': 'Backup file exceeds 200 MB limit'}), 413)
|
||||
fh.write(chunk)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
logger.exception("[Backup] Failed to save uploaded backup")
|
||||
@@ -1284,14 +1304,16 @@ def backup_restore():
|
||||
opts_dict = json.loads(raw_opts)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid options JSON'}), 400
|
||||
if not isinstance(opts_dict, dict):
|
||||
return jsonify({'status': 'error', 'message': 'options must be an object'}), 400
|
||||
|
||||
opts = backup_manager.RestoreOptions(
|
||||
restore_config=bool(opts_dict.get('restore_config', True)),
|
||||
restore_secrets=bool(opts_dict.get('restore_secrets', True)),
|
||||
restore_wifi=bool(opts_dict.get('restore_wifi', True)),
|
||||
restore_fonts=bool(opts_dict.get('restore_fonts', True)),
|
||||
restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||
reinstall_plugins=bool(opts_dict.get('reinstall_plugins', True)),
|
||||
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
|
||||
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
|
||||
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
|
||||
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
|
||||
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
|
||||
)
|
||||
|
||||
# Snapshot current config through the atomic manager so the pre-restore
|
||||
@@ -1299,7 +1321,9 @@ def backup_restore():
|
||||
if api_v3.config_manager and opts.restore_config:
|
||||
try:
|
||||
current = api_v3.config_manager.load_config()
|
||||
_save_config_atomic(api_v3.config_manager, current, create_backup=True)
|
||||
snapshot_ok, snapshot_err = _save_config_atomic(api_v3.config_manager, current, create_backup=True)
|
||||
if not snapshot_ok:
|
||||
logger.warning("[Backup] Pre-restore snapshot failed: %s (continuing)", snapshot_err)
|
||||
except Exception:
|
||||
logger.warning("[Backup] Pre-restore snapshot failed (continuing)", exc_info=True)
|
||||
|
||||
@@ -1324,6 +1348,13 @@ def backup_restore():
|
||||
try:
|
||||
ok = api_v3.plugin_store_manager.install_plugin(plugin_id)
|
||||
if ok:
|
||||
if api_v3.schema_manager:
|
||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||
if api_v3.plugin_manager:
|
||||
api_v3.plugin_manager.discover_plugins()
|
||||
api_v3.plugin_manager.load_plugin(plugin_id)
|
||||
if api_v3.plugin_state_manager:
|
||||
api_v3.plugin_state_manager.set_plugin_installed(plugin_id)
|
||||
result.plugins_installed.append(plugin_id)
|
||||
else:
|
||||
result.plugins_failed.append({'plugin_id': plugin_id, 'error': 'install returned False'})
|
||||
@@ -1332,12 +1363,12 @@ def backup_restore():
|
||||
result.plugins_failed.append({'plugin_id': plugin_id, 'error': str(install_err)})
|
||||
|
||||
# Clear font catalog cache so restored fonts show up.
|
||||
if 'fonts' in ' '.join(result.restored):
|
||||
if any(r.startswith("fonts") for r in result.restored):
|
||||
try:
|
||||
from web_interface.cache import delete_cached
|
||||
delete_cached('fonts_catalog')
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("[Backup] Failed to clear font cache", exc_info=True)
|
||||
|
||||
# Reload config_manager state so the UI picks up the new values
|
||||
# without a full service restart.
|
||||
@@ -1348,7 +1379,7 @@ def backup_restore():
|
||||
try:
|
||||
api_v3.config_manager.load_config()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
|
||||
except Exception:
|
||||
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
|
||||
|
||||
@@ -1602,6 +1633,71 @@ def get_system_version():
|
||||
logger.exception("[System] get_system_version failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get system version'}), 500
|
||||
|
||||
_update_check_cache: Dict = {}
|
||||
_UPDATE_CHECK_TTL = 300 # 5 minutes
|
||||
_update_check_lock = threading.Lock()
|
||||
|
||||
@api_v3.route('/system/check-update', methods=['GET'])
|
||||
def check_for_update():
|
||||
"""Check if a newer version is available on the remote."""
|
||||
now = time.time()
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
with _update_check_lock:
|
||||
if _update_check_cache.get('ts', 0) + _UPDATE_CHECK_TTL > now:
|
||||
return jsonify(_update_check_cache['data'])
|
||||
|
||||
try:
|
||||
fetch_result = subprocess.run(
|
||||
['git', 'fetch', 'origin', 'main'],
|
||||
capture_output=True, text=True, timeout=15, cwd=project_dir
|
||||
)
|
||||
if fetch_result.returncode != 0:
|
||||
raise RuntimeError(f"git fetch failed: {fetch_result.stderr.strip()}")
|
||||
|
||||
local_result = subprocess.run(
|
||||
['git', 'rev-parse', 'HEAD'],
|
||||
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||
)
|
||||
if local_result.returncode != 0:
|
||||
raise RuntimeError(f"git rev-parse HEAD failed: {local_result.stderr.strip()}")
|
||||
local_sha = local_result.stdout.strip()
|
||||
|
||||
remote_result = subprocess.run(
|
||||
['git', 'rev-parse', 'origin/main'],
|
||||
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||
)
|
||||
if remote_result.returncode != 0:
|
||||
raise RuntimeError(f"git rev-parse origin/main failed: {remote_result.stderr.strip()}")
|
||||
remote_sha = remote_result.stdout.strip()
|
||||
|
||||
if local_sha == remote_sha:
|
||||
data = {'status': 'success', 'update_available': False,
|
||||
'local_sha': local_sha[:8], 'remote_sha': remote_sha[:8]}
|
||||
else:
|
||||
log_result = subprocess.run(
|
||||
['git', 'log', 'HEAD..origin/main', '--oneline'],
|
||||
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||
)
|
||||
if log_result.returncode != 0:
|
||||
raise RuntimeError(f"git log failed: {log_result.stderr.strip()}")
|
||||
lines = [commit_line for commit_line in log_result.stdout.strip().split('\n') if commit_line]
|
||||
commits_behind = len(lines)
|
||||
data = {
|
||||
'status': 'success',
|
||||
'update_available': commits_behind > 0,
|
||||
'local_sha': local_sha[:8],
|
||||
'remote_sha': remote_sha[:8],
|
||||
'commits_behind': commits_behind,
|
||||
'latest_message': lines[0].split(' ', 1)[1] if lines else '',
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("[System] check-update failed: %s", e)
|
||||
data = {'status': 'error', 'update_available': False, 'message': str(e)}
|
||||
|
||||
_update_check_cache['ts'] = now
|
||||
_update_check_cache['data'] = data
|
||||
return jsonify(data)
|
||||
|
||||
@api_v3.route('/system/action', methods=['POST'])
|
||||
def execute_system_action():
|
||||
"""Execute system actions (start/stop/reboot/etc)"""
|
||||
@@ -1626,33 +1722,34 @@ def execute_system_action():
|
||||
if mode:
|
||||
# For on-demand modes, we would need to integrate with the display controller
|
||||
# For now, just start the display service
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, SYSTEMCTL_BIN, 'start', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
return jsonify({
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': f'Started display in {mode} mode',
|
||||
'message': f'Started display in {mode} mode' if result.returncode == 0
|
||||
else f'Failed to start display in {mode} mode: {result.stderr.strip() or "check sudo systemctl status ledmatrix.service"}',
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout,
|
||||
'stderr': result.stderr
|
||||
})
|
||||
else:
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, SYSTEMCTL_BIN, 'start', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
elif action == 'stop_display':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, SYSTEMCTL_BIN, 'stop', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
elif action == 'enable_autostart':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, SYSTEMCTL_BIN, 'enable', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
elif action == 'disable_autostart':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, SYSTEMCTL_BIN, 'disable', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
elif action == 'reboot_system':
|
||||
result = subprocess.run(['sudo', 'reboot'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, REBOOT_BIN],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'shutdown_system':
|
||||
result = subprocess.run(['sudo', 'poweroff'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, POWEROFF_BIN],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'git_pull':
|
||||
# Use PROJECT_ROOT instead of hardcoded path
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
@@ -1711,6 +1808,10 @@ def execute_system_action():
|
||||
cwd=project_dir
|
||||
)
|
||||
|
||||
# Invalidate update-check cache so the banner hides immediately
|
||||
with _update_check_lock:
|
||||
_update_check_cache.clear()
|
||||
|
||||
# Return custom response for git_pull
|
||||
if result.returncode == 0:
|
||||
pull_message = "Code updated successfully."
|
||||
@@ -1729,12 +1830,11 @@ def execute_system_action():
|
||||
'stderr': result.stderr
|
||||
})
|
||||
elif action == 'restart_display_service':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, SYSTEMCTL_BIN, 'restart', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
elif action == 'restart_web_service':
|
||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'],
|
||||
capture_output=True, text=True)
|
||||
result = subprocess.run([SUDO_BIN, SYSTEMCTL_BIN, 'restart', 'ledmatrix-web.service'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': f'Unknown action: {action}'}), 400
|
||||
|
||||
@@ -1746,6 +1846,13 @@ def execute_system_action():
|
||||
'stderr': result.stderr
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
if action == 'start_display' and mode:
|
||||
msg = f'Failed to start display in {mode} mode: timed out'
|
||||
else:
|
||||
msg = f'Action {action} timed out'
|
||||
logger.warning("[System] execute_system_action timed out: action=%s", action)
|
||||
return jsonify({'status': 'error', 'message': msg, 'returncode': -1, 'stdout': '', 'stderr': 'timeout'}), 500
|
||||
except Exception as e:
|
||||
logger.exception("[System] execute_system_action failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to execute system action'}), 500
|
||||
@@ -1975,9 +2082,23 @@ def get_installed_plugins():
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Re-discover plugins to ensure we have the latest list
|
||||
# This handles cases where plugins are added/removed after app startup
|
||||
api_v3.plugin_manager.discover_plugins()
|
||||
# Re-discover plugins only if the plugins directory has actually
|
||||
# changed since our last scan, or if the caller explicitly asked
|
||||
# for a refresh. The previous unconditional ``discover_plugins()``
|
||||
# call (plus a per-plugin manifest re-read) made this endpoint
|
||||
# O(plugins) in disk I/O on every page refresh, which on an SD-card
|
||||
# Pi4 with ~15 plugins was pegging the CPU and blocking the UI
|
||||
# "connecting to display" spinner for minutes.
|
||||
force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes')
|
||||
plugins_dir_path = Path(api_v3.plugin_manager.plugins_dir)
|
||||
try:
|
||||
current_mtime = plugins_dir_path.stat().st_mtime if plugins_dir_path.exists() else 0
|
||||
except OSError:
|
||||
current_mtime = 0
|
||||
last_mtime = getattr(api_v3, '_installed_plugins_dir_mtime', None)
|
||||
if force_refresh or last_mtime != current_mtime:
|
||||
api_v3.plugin_manager.discover_plugins()
|
||||
api_v3._installed_plugins_dir_mtime = current_mtime
|
||||
|
||||
# Get all installed plugin info from the plugin manager
|
||||
all_plugin_info = api_v3.plugin_manager.get_all_plugin_info()
|
||||
@@ -1990,17 +2111,10 @@ def get_installed_plugins():
|
||||
for plugin_info in all_plugin_info:
|
||||
plugin_id = plugin_info.get('id')
|
||||
|
||||
# Re-read manifest from disk to ensure we have the latest metadata
|
||||
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
fresh_manifest = json.load(f)
|
||||
# Update plugin_info with fresh manifest data
|
||||
plugin_info.update(fresh_manifest)
|
||||
except Exception as e:
|
||||
# If we can't read the fresh manifest, use the cached one
|
||||
logger.warning("[PluginStore] Could not read fresh manifest for %s: %s", plugin_id, e)
|
||||
# Note: we intentionally do NOT re-read manifest.json here.
|
||||
# discover_plugins() above already reparses manifests on change;
|
||||
# re-reading on every request added ~1 syscall+json.loads per
|
||||
# plugin per request for no benefit.
|
||||
|
||||
# Get enabled status from config (source of truth)
|
||||
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
||||
@@ -2630,14 +2744,30 @@ def reconcile_plugin_state():
|
||||
|
||||
from src.plugin_system.state_reconciliation import StateReconciliation
|
||||
|
||||
# Pass the store manager so auto-repair of missing-on-disk plugins
|
||||
# can actually run. Previously this endpoint silently degraded to
|
||||
# MANUAL_FIX_REQUIRED because store_manager was omitted.
|
||||
reconciler = StateReconciliation(
|
||||
state_manager=api_v3.plugin_state_manager,
|
||||
config_manager=api_v3.config_manager,
|
||||
plugin_manager=api_v3.plugin_manager,
|
||||
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
||||
plugins_dir=Path(api_v3.plugin_manager.plugins_dir),
|
||||
store_manager=api_v3.plugin_store_manager,
|
||||
)
|
||||
|
||||
result = reconciler.reconcile_state()
|
||||
# Allow the caller to force a retry of previously-unrecoverable
|
||||
# plugins (e.g. after the registry has been updated or a typo fixed).
|
||||
# Non-object JSON bodies (e.g. a bare string or array) must fall
|
||||
# through to the default False instead of raising AttributeError,
|
||||
# and string booleans like "false" must coerce correctly — hence
|
||||
# the isinstance guard plus _coerce_to_bool.
|
||||
force = False
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True)
|
||||
if isinstance(payload, dict):
|
||||
force = _coerce_to_bool(payload.get('force', False))
|
||||
|
||||
result = reconciler.reconcile_state(force=force)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -3060,6 +3190,181 @@ def update_plugin():
|
||||
status_code=500
|
||||
)
|
||||
|
||||
def _snapshot_plugin_config(plugin_id: str):
|
||||
"""Capture the plugin's current config and secrets entries for rollback.
|
||||
|
||||
Returns a tuple ``(main_entry, secrets_entry)`` where each element is
|
||||
the plugin's dict from the respective file, or ``None`` if the plugin
|
||||
was not present there. Used by the transactional uninstall path so we
|
||||
can restore state if file removal fails after config cleanup has
|
||||
already succeeded.
|
||||
"""
|
||||
main_entry = None
|
||||
secrets_entry = None
|
||||
# Narrow exception list: filesystem errors (FileNotFoundError is a
|
||||
# subclass of OSError, IOError is an alias for OSError in Python 3)
|
||||
# and ConfigError, which is what ``get_raw_file_content`` wraps all
|
||||
# load failures in. Programmer errors (TypeError, AttributeError,
|
||||
# etc.) are intentionally NOT caught — they should surface loudly.
|
||||
try:
|
||||
main_config = api_v3.config_manager.get_raw_file_content('main')
|
||||
if plugin_id in main_config:
|
||||
import copy as _copy
|
||||
main_entry = _copy.deepcopy(main_config[plugin_id])
|
||||
except (OSError, ConfigError) as e:
|
||||
logger.warning("[PluginUninstall] Could not snapshot main config for %s: %s", plugin_id, e)
|
||||
try:
|
||||
import os as _os
|
||||
if _os.path.exists(api_v3.config_manager.secrets_path):
|
||||
secrets_config = api_v3.config_manager.get_raw_file_content('secrets')
|
||||
if plugin_id in secrets_config:
|
||||
import copy as _copy
|
||||
secrets_entry = _copy.deepcopy(secrets_config[plugin_id])
|
||||
except (OSError, ConfigError) as e:
|
||||
logger.warning("[PluginUninstall] Could not snapshot secrets for %s: %s", plugin_id, e)
|
||||
return (main_entry, secrets_entry)
|
||||
|
||||
|
||||
def _restore_plugin_config(plugin_id: str, snapshot) -> None:
|
||||
"""Best-effort restoration of a snapshot taken by ``_snapshot_plugin_config``.
|
||||
|
||||
Called on the unhappy path when ``cleanup_plugin_config`` already
|
||||
succeeded but the subsequent file removal failed. If the restore
|
||||
itself fails, we log loudly — the caller still sees the original
|
||||
uninstall error and the user can reconcile manually.
|
||||
"""
|
||||
main_entry, secrets_entry = snapshot
|
||||
if main_entry is not None:
|
||||
try:
|
||||
main_config = api_v3.config_manager.get_raw_file_content('main')
|
||||
main_config[plugin_id] = main_entry
|
||||
api_v3.config_manager.save_raw_file_content('main', main_config)
|
||||
logger.warning("[PluginUninstall] Restored main config entry for %s after uninstall failure", plugin_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[PluginUninstall] FAILED to restore main config entry for %s after uninstall failure: %s",
|
||||
plugin_id, e, exc_info=True,
|
||||
)
|
||||
if secrets_entry is not None:
|
||||
try:
|
||||
import os as _os
|
||||
if _os.path.exists(api_v3.config_manager.secrets_path):
|
||||
secrets_config = api_v3.config_manager.get_raw_file_content('secrets')
|
||||
else:
|
||||
secrets_config = {}
|
||||
secrets_config[plugin_id] = secrets_entry
|
||||
api_v3.config_manager.save_raw_file_content('secrets', secrets_config)
|
||||
logger.warning("[PluginUninstall] Restored secrets entry for %s after uninstall failure", plugin_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[PluginUninstall] FAILED to restore secrets entry for %s after uninstall failure: %s",
|
||||
plugin_id, e, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _do_transactional_uninstall(plugin_id: str, preserve_config: bool) -> None:
|
||||
"""Run the full uninstall as a best-effort transaction.
|
||||
|
||||
Order:
|
||||
1. Mark tombstone (so any reconciler racing with us cannot resurrect
|
||||
the plugin mid-flight).
|
||||
2. Snapshot existing config + secrets entries (for rollback).
|
||||
3. Run ``cleanup_plugin_config``. If this raises, re-raise — files
|
||||
have NOT been touched, so aborting here leaves a fully consistent
|
||||
state: plugin is still installed and still in config.
|
||||
4. Unload the plugin from the running plugin manager.
|
||||
5. Call ``store_manager.uninstall_plugin``. If it returns False or
|
||||
raises, RESTORE the snapshot (so config matches disk) and then
|
||||
propagate the failure.
|
||||
6. Invalidate schema cache and remove from the state manager only
|
||||
after the file removal succeeds.
|
||||
|
||||
Raises on any failure so the caller can return an error to the user.
|
||||
"""
|
||||
if hasattr(api_v3.plugin_store_manager, 'mark_recently_uninstalled'):
|
||||
api_v3.plugin_store_manager.mark_recently_uninstalled(plugin_id)
|
||||
|
||||
snapshot = _snapshot_plugin_config(plugin_id) if not preserve_config else (None, None)
|
||||
|
||||
# Step 1: config cleanup. If this fails, bail out early — the plugin
|
||||
# files on disk are still intact and the caller will get a clear
|
||||
# error.
|
||||
if not preserve_config:
|
||||
try:
|
||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
||||
except Exception as cleanup_err:
|
||||
logger.error(
|
||||
"[PluginUninstall] Config cleanup failed for %s; aborting uninstall (files untouched): %s",
|
||||
plugin_id, cleanup_err, exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
# Remember whether the plugin was loaded *before* we touched runtime
|
||||
# state — we need this so we can reload it on rollback if file
|
||||
# removal fails after we've already unloaded it.
|
||||
was_loaded = bool(
|
||||
api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins
|
||||
)
|
||||
|
||||
def _rollback(reason_err):
|
||||
"""Undo both the config cleanup AND the unload."""
|
||||
if not preserve_config:
|
||||
_restore_plugin_config(plugin_id, snapshot)
|
||||
if was_loaded and api_v3.plugin_manager:
|
||||
try:
|
||||
api_v3.plugin_manager.load_plugin(plugin_id)
|
||||
except Exception as reload_err:
|
||||
logger.error(
|
||||
"[PluginUninstall] FAILED to reload %s after uninstall rollback: %s",
|
||||
plugin_id, reload_err, exc_info=True,
|
||||
)
|
||||
|
||||
# Step 2: unload if loaded. Also part of the rollback boundary — if
|
||||
# unload itself raises, restore config and surface the error.
|
||||
if was_loaded:
|
||||
try:
|
||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
||||
except Exception as unload_err:
|
||||
logger.error(
|
||||
"[PluginUninstall] unload_plugin raised for %s; restoring config snapshot: %s",
|
||||
plugin_id, unload_err, exc_info=True,
|
||||
)
|
||||
if not preserve_config:
|
||||
_restore_plugin_config(plugin_id, snapshot)
|
||||
# Plugin was never successfully unloaded, so no reload is
|
||||
# needed here — runtime state is still what it was before.
|
||||
raise
|
||||
|
||||
# Step 3: remove files. If this fails, roll back the config cleanup
|
||||
# AND reload the plugin so the user doesn't end up with an orphaned
|
||||
# install (files on disk + no config entry + plugin no longer
|
||||
# loaded at runtime).
|
||||
try:
|
||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
||||
except Exception as uninstall_err:
|
||||
logger.error(
|
||||
"[PluginUninstall] uninstall_plugin raised for %s; rolling back: %s",
|
||||
plugin_id, uninstall_err, exc_info=True,
|
||||
)
|
||||
_rollback(uninstall_err)
|
||||
raise
|
||||
|
||||
if not success:
|
||||
logger.error(
|
||||
"[PluginUninstall] uninstall_plugin returned False for %s; rolling back",
|
||||
plugin_id,
|
||||
)
|
||||
_rollback(None)
|
||||
raise RuntimeError(f"Failed to uninstall plugin {plugin_id}")
|
||||
|
||||
# Past this point the filesystem and config are both in the
|
||||
# "uninstalled" state. Clean up the cheap in-memory bookkeeping.
|
||||
if api_v3.schema_manager:
|
||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||
if api_v3.plugin_state_manager:
|
||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
||||
|
||||
|
||||
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
||||
def uninstall_plugin():
|
||||
"""Uninstall plugin"""
|
||||
@@ -3082,49 +3387,28 @@ def uninstall_plugin():
|
||||
# Use operation queue if available
|
||||
if api_v3.operation_queue:
|
||||
def uninstall_callback(operation):
|
||||
"""Callback to execute plugin uninstallation."""
|
||||
# Unload the plugin first if it's loaded
|
||||
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
||||
|
||||
# Uninstall the plugin
|
||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
||||
|
||||
if not success:
|
||||
error_msg = f'Failed to uninstall plugin {plugin_id}'
|
||||
"""Callback to execute plugin uninstallation transactionally."""
|
||||
try:
|
||||
_do_transactional_uninstall(plugin_id, preserve_config)
|
||||
except Exception as err:
|
||||
error_msg = f'Failed to uninstall plugin {plugin_id}: {err}'
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
plugin_id=plugin_id,
|
||||
status="failed",
|
||||
error=error_msg
|
||||
error=error_msg,
|
||||
)
|
||||
raise Exception(error_msg)
|
||||
# Re-raise so the operation_queue marks this op as failed.
|
||||
raise
|
||||
|
||||
# Invalidate schema cache
|
||||
if api_v3.schema_manager:
|
||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||
|
||||
# Clean up plugin configuration if not preserving
|
||||
if not preserve_config:
|
||||
try:
|
||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
||||
except Exception as cleanup_err:
|
||||
logger.warning("[PluginUninstall] Failed to cleanup config for %s: %s", plugin_id, cleanup_err)
|
||||
|
||||
# Remove from state manager
|
||||
if api_v3.plugin_state_manager:
|
||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
||||
|
||||
# Record in history
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
plugin_id=plugin_id,
|
||||
status="success",
|
||||
details={"preserve_config": preserve_config}
|
||||
details={"preserve_config": preserve_config},
|
||||
)
|
||||
|
||||
return {'success': True, 'message': f'Plugin {plugin_id} uninstalled successfully'}
|
||||
|
||||
# Enqueue operation
|
||||
@@ -3139,55 +3423,32 @@ def uninstall_plugin():
|
||||
message=f'Plugin {plugin_id} uninstallation queued'
|
||||
)
|
||||
else:
|
||||
# Fallback to direct uninstall
|
||||
# Unload the plugin first if it's loaded
|
||||
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
||||
|
||||
# Uninstall the plugin
|
||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
||||
|
||||
if success:
|
||||
# Invalidate schema cache
|
||||
if api_v3.schema_manager:
|
||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||
|
||||
# Clean up plugin configuration if not preserving
|
||||
if not preserve_config:
|
||||
try:
|
||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
||||
except Exception as cleanup_err:
|
||||
logger.warning("[PluginUninstall] Failed to cleanup config for %s: %s", plugin_id, cleanup_err)
|
||||
|
||||
# Remove from state manager
|
||||
if api_v3.plugin_state_manager:
|
||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
||||
|
||||
# Record in history
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
plugin_id=plugin_id,
|
||||
status="success",
|
||||
details={"preserve_config": preserve_config}
|
||||
)
|
||||
|
||||
return success_response(message=f'Plugin {plugin_id} uninstalled successfully')
|
||||
else:
|
||||
# Fallback to direct uninstall — same transactional helper.
|
||||
try:
|
||||
_do_transactional_uninstall(plugin_id, preserve_config)
|
||||
except Exception as err:
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
plugin_id=plugin_id,
|
||||
status="failed",
|
||||
error=f'Failed to uninstall plugin {plugin_id}'
|
||||
error=f'Failed to uninstall plugin {plugin_id}: {err}',
|
||||
)
|
||||
|
||||
return error_response(
|
||||
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
||||
f'Failed to uninstall plugin {plugin_id}',
|
||||
status_code=500
|
||||
f'Failed to uninstall plugin {plugin_id}: {err}',
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
plugin_id=plugin_id,
|
||||
status="success",
|
||||
details={"preserve_config": preserve_config},
|
||||
)
|
||||
return success_response(message=f'Plugin {plugin_id} uninstalled successfully')
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[PluginUninstall] Unhandled exception")
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
@@ -6472,18 +6733,146 @@ def upload_calendar_credentials():
|
||||
logger.exception("[PluginConfig] upload_calendar_credentials failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to upload calendar credentials'}), 500
|
||||
|
||||
def _load_calendar_plugin_dir():
|
||||
"""Resolve the calendar plugin's on-disk directory without requiring a running instance.
|
||||
|
||||
The web service and display service are separate processes — the web
|
||||
process discovers plugins but does not instantiate them, so
|
||||
plugin_manager.get_plugin('calendar') is typically None here.
|
||||
"""
|
||||
plugin_id = 'calendar'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
if plugin_dir and Path(plugin_dir).exists():
|
||||
return Path(plugin_dir)
|
||||
fallback = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
return fallback if fallback.exists() else None
|
||||
|
||||
|
||||
_GOOGLE_API_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
def _load_calendar_credentials(token_path):
|
||||
"""Load OAuth credentials from the plugin's token file.
|
||||
|
||||
The calendar plugin historically persists credentials with pickle
|
||||
(``token.pickle``). pickle.load is only applied to this specific file,
|
||||
which is owned by the same user as the web service, chmod 0600, and
|
||||
located inside the plugin install directory — it is not user-supplied
|
||||
input. We still constrain the unpickle to a reasonable size to reduce
|
||||
blast radius. New installs may use a JSON token (``token.json``)
|
||||
written via google-auth's safe serializer; prefer that when present.
|
||||
"""
|
||||
json_path = token_path.with_suffix('.json')
|
||||
if json_path.exists():
|
||||
from google.oauth2.credentials import Credentials
|
||||
return Credentials.from_authorized_user_file(str(json_path))
|
||||
|
||||
# Fall back to the pickle token the plugin writes today.
|
||||
# nosemgrep: python.lang.security.audit.avoid-pickle.avoid-pickle
|
||||
import pickle # noqa: S403
|
||||
try:
|
||||
size = token_path.stat().st_size
|
||||
except OSError as e:
|
||||
raise RuntimeError(f'Cannot stat token file: {e}') from e
|
||||
if size > 64 * 1024:
|
||||
raise RuntimeError('Token file is unexpectedly large; refusing to load.')
|
||||
with open(token_path, 'rb') as f:
|
||||
return pickle.load(f) # noqa: S301 # trusted file, owner-only perms
|
||||
|
||||
|
||||
def _list_google_calendars_from_disk():
|
||||
"""List calendars using the plugin's stored OAuth token.
|
||||
|
||||
Returns (calendars, error_message). calendars is a list of raw Google
|
||||
calendarList items on success; on failure calendars is None and
|
||||
error_message describes the problem.
|
||||
|
||||
Refreshed credentials are intentionally not persisted back to disk
|
||||
from this request path — the display service owns token.pickle and
|
||||
concurrent writes across processes could corrupt it. If refresh is
|
||||
needed, it happens only in memory for the duration of this request.
|
||||
"""
|
||||
try:
|
||||
import google_auth_httplib2
|
||||
import httplib2
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError:
|
||||
return None, 'Google API libraries not installed on this host.'
|
||||
|
||||
plugin_dir = _load_calendar_plugin_dir()
|
||||
if plugin_dir is None:
|
||||
return None, 'Calendar plugin directory not found.'
|
||||
|
||||
token_path = plugin_dir / 'token.pickle'
|
||||
if not token_path.exists() and not (plugin_dir / 'token.json').exists():
|
||||
return None, 'Not authenticated yet — complete the Google authentication step first.'
|
||||
|
||||
try:
|
||||
creds = _load_calendar_credentials(token_path)
|
||||
except Exception as e:
|
||||
logger.exception('list_calendar_calendars: failed to load stored credentials')
|
||||
return None, f'Failed to load stored authentication: {e}'
|
||||
|
||||
if not creds or not getattr(creds, 'valid', False):
|
||||
if creds and getattr(creds, 'expired', False) and getattr(creds, 'refresh_token', None):
|
||||
try:
|
||||
# In-memory refresh only; do not write back to shared token file.
|
||||
creds.refresh(Request(timeout=_GOOGLE_API_TIMEOUT_SECONDS))
|
||||
except (socket.timeout, TimeoutError) as e:
|
||||
logger.warning('list_calendar_calendars: token refresh timed out: %s', e)
|
||||
return None, 'Token refresh timed out. Please try again.'
|
||||
except Exception as e:
|
||||
logger.exception('list_calendar_calendars: token refresh failed')
|
||||
return None, f'Stored authentication expired and refresh failed: {e}. Re-run the Google authentication step.'
|
||||
else:
|
||||
return None, 'Stored authentication is invalid. Re-run the Google authentication step.'
|
||||
|
||||
try:
|
||||
# Build an Http with an explicit socket timeout so API calls cannot
|
||||
# hang the Flask worker on flaky connectivity.
|
||||
authed_http = google_auth_httplib2.AuthorizedHttp(
|
||||
creds, http=httplib2.Http(timeout=_GOOGLE_API_TIMEOUT_SECONDS)
|
||||
)
|
||||
service = build('calendar', 'v3', http=authed_http, cache_discovery=False)
|
||||
items = []
|
||||
page_token = None
|
||||
while True:
|
||||
response = service.calendarList().list(pageToken=page_token).execute(
|
||||
num_retries=1
|
||||
)
|
||||
items.extend(response.get('items', []))
|
||||
page_token = response.get('nextPageToken')
|
||||
if not page_token:
|
||||
break
|
||||
return items, None
|
||||
except (socket.timeout, TimeoutError) as e:
|
||||
logger.warning('list_calendar_calendars: Google API call timed out: %s', e)
|
||||
return None, 'Google Calendar request timed out. Please try again.'
|
||||
except Exception as e:
|
||||
logger.exception('list_calendar_calendars: Google API call failed')
|
||||
return None, f'Google Calendar API call failed: {e}'
|
||||
|
||||
|
||||
@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
|
||||
def list_calendar_calendars():
|
||||
"""Return Google Calendars accessible with the currently authenticated credentials."""
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not available'}), 500
|
||||
plugin = api_v3.plugin_manager.get_plugin('calendar')
|
||||
if not plugin:
|
||||
return jsonify({'status': 'error', 'message': 'Calendar plugin is not running. Enable it and save config first.'}), 404
|
||||
if not hasattr(plugin, 'get_calendars'):
|
||||
return jsonify({'status': 'error', 'message': 'Installed plugin version does not support calendar listing — update the plugin.'}), 400
|
||||
"""Return Google Calendars accessible with the currently authenticated credentials.
|
||||
|
||||
Reads credentials from the plugin directory directly so this works from the
|
||||
web process (which does not instantiate plugins).
|
||||
"""
|
||||
# Prefer a live plugin instance if one happens to exist (e.g. local dev where
|
||||
# web and display share a process); otherwise fall back to on-disk credentials.
|
||||
plugin = api_v3.plugin_manager.get_plugin('calendar') if api_v3.plugin_manager else None
|
||||
|
||||
try:
|
||||
raw = plugin.get_calendars()
|
||||
if plugin is not None and hasattr(plugin, 'get_calendars'):
|
||||
raw = plugin.get_calendars()
|
||||
else:
|
||||
raw, err = _list_google_calendars_from_disk()
|
||||
if raw is None:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
import collections.abc
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
logger.error('list_calendar_calendars: get_calendars() returned non-sequence type %r', type(raw))
|
||||
@@ -6757,9 +7146,14 @@ def connect_wifi():
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
# Propagate structured error type so the captive portal UI can show
|
||||
# "Wrong password — try again" instead of a generic failure message.
|
||||
error_type = "wrong_password" if (message or "").startswith("wrong_password:") else "connection_failed"
|
||||
clean_message = (message or "").removeprefix("wrong_password:").lstrip() or "Failed to connect to network"
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message or 'Failed to connect to network'
|
||||
'message': clean_message,
|
||||
'error_type': error_type
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.exception("[WiFi] Failed connecting to WiFi network")
|
||||
|
||||
@@ -120,7 +120,11 @@ def main():
|
||||
|
||||
# Run the web server with error handling for client disconnections
|
||||
try:
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
# threaded=True is Flask's default since 1.0, but set it explicitly
|
||||
# so it's self-documenting: the two /api/v3/stream/* SSE endpoints
|
||||
# hold long-lived connections and would starve other requests under
|
||||
# a single-threaded server.
|
||||
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
|
||||
except (OSError, BrokenPipeError) as e:
|
||||
# Suppress non-critical socket errors (client disconnections)
|
||||
if isinstance(e, OSError) and e.errno in (113, 32, 104): # No route to host, Broken pipe, Connection reset
|
||||
|
||||
@@ -1004,3 +1004,39 @@ button.bg-white {
|
||||
[data-theme="dark"] .theme-toggle-btn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* Update available banner */
|
||||
.update-banner {
|
||||
background-color: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.update-banner-action {
|
||||
background-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.update-banner-action:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
.update-banner-dismiss {
|
||||
color: #1e40af;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.update-banner-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .update-banner {
|
||||
background-color: #1e293b;
|
||||
border-color: #334155;
|
||||
color: #93c5fd;
|
||||
}
|
||||
[data-theme="dark"] .update-banner-action {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
[data-theme="dark"] .update-banner-action:hover {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
[data-theme="dark"] .update-banner-dismiss {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
}
|
||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -786,56 +786,25 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Alpine.js for reactive components -->
|
||||
<!-- Use local file when in AP mode (192.168.4.x) to avoid CDN dependency -->
|
||||
<!-- Alpine.js for reactive components.
|
||||
Load the local copy first (always works, no CDN round-trip, no AP-mode
|
||||
branch needed). `defer` on an HTML-parsed <script> is honored and runs
|
||||
after DOM parse but before DOMContentLoaded, which is exactly what
|
||||
Alpine wants — so no deferLoadingAlpine gymnastics are needed.
|
||||
The inline rescue below only fires if the local file is missing. -->
|
||||
<script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
// Prevent Alpine from auto-initializing by setting deferLoadingAlpine before it loads
|
||||
window.deferLoadingAlpine = function(callback) {
|
||||
// Wait for DOM to be ready
|
||||
function waitForReady() {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', waitForReady);
|
||||
return;
|
||||
}
|
||||
|
||||
// app() is already defined in head, so we can initialize Alpine
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback();
|
||||
} else if (window.Alpine && typeof window.Alpine.start === 'function') {
|
||||
// If callback not provided but Alpine is available, start it
|
||||
try {
|
||||
window.Alpine.start();
|
||||
} catch (e) {
|
||||
// Alpine may already be initialized, ignore
|
||||
console.warn('Alpine start error (may already be initialized):', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitForReady();
|
||||
};
|
||||
|
||||
// Detect AP mode by IP address
|
||||
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
||||
window.location.hostname.startsWith('192.168.4.');
|
||||
|
||||
const alpineSrc = isAPMode ? '/static/v3/js/alpinejs.min.js' : 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
|
||||
const alpineFallback = isAPMode ? 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js' : '/static/v3/js/alpinejs.min.js';
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = alpineSrc;
|
||||
script.onerror = function() {
|
||||
if (alpineSrc !== alpineFallback) {
|
||||
const fallback = document.createElement('script');
|
||||
fallback.defer = true;
|
||||
fallback.src = alpineFallback;
|
||||
document.head.appendChild(fallback);
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
// Rescue: if the local Alpine didn't load for any reason, pull the CDN
|
||||
// copy once on window load. This is a last-ditch fallback, not the
|
||||
// primary path.
|
||||
window.addEventListener('load', function() {
|
||||
if (typeof window.Alpine === 'undefined') {
|
||||
console.warn('[Alpine] Local file failed to load, falling back to CDN');
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
||||
@@ -931,6 +900,34 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Update available banner -->
|
||||
<div id="update-banner" style="display:none"
|
||||
class="update-banner border-b transition-all duration-300 ease-in-out">
|
||||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-arrow-circle-up text-lg"></i>
|
||||
<span class="text-sm font-medium" id="update-banner-text"
|
||||
aria-live="polite" aria-atomic="true">
|
||||
A new LEDMatrix update is available
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button onclick="applyUpdate()" id="update-banner-btn"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
|
||||
update-banner-action transition-colors duration-150">
|
||||
<i class="fas fa-download mr-1"></i> Update Now
|
||||
</button>
|
||||
<button type="button" onclick="dismissUpdateBanner()"
|
||||
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
|
||||
title="Dismiss" aria-label="Dismiss update">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
||||
<!-- Navigation tabs -->
|
||||
@@ -4905,6 +4902,77 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Update banner logic -->
|
||||
<script>
|
||||
(function() {
|
||||
var CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
function getDismissedSha() {
|
||||
try { return sessionStorage.getItem('update-sha-dismissed'); } catch(e) { return null; }
|
||||
}
|
||||
|
||||
function checkForUpdate() {
|
||||
fetch('/api/v3/system/check-update')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.update_available && getDismissedSha() !== data.remote_sha) {
|
||||
var n = data.commits_behind || 0;
|
||||
var msg = 'A new LEDMatrix update is available';
|
||||
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
||||
document.getElementById('update-banner-text').textContent = msg;
|
||||
document.getElementById('update-banner').style.display = '';
|
||||
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
||||
} else {
|
||||
document.getElementById('update-banner').style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
window.dismissUpdateBanner = function() {
|
||||
document.getElementById('update-banner').style.display = 'none';
|
||||
try {
|
||||
var sha = sessionStorage.getItem('update-sha');
|
||||
if (sha) sessionStorage.setItem('update-sha-dismissed', sha);
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
window.applyUpdate = function() {
|
||||
var btn = document.getElementById('update-banner-btn');
|
||||
var originalHTML = '<i class="fas fa-download mr-1"></i> Update Now';
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Updating...';
|
||||
btn.disabled = true;
|
||||
fetch('/api/v3/system/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'git_pull' })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
if (data.status === 'success') {
|
||||
document.getElementById('update-banner').style.display = 'none';
|
||||
try { sessionStorage.removeItem('update-sha-dismissed'); } catch(e) {}
|
||||
}
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(data.message || 'Update complete', data.status || 'success');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Update failed — check your connection', 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Initial check shortly after page load, then periodic
|
||||
setTimeout(checkForUpdate, 2000);
|
||||
setInterval(checkForUpdate, CHECK_INTERVAL);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -191,7 +191,10 @@ function doConnect() {
|
||||
// Poll for the new IP
|
||||
setTimeout(function() { checkNewIP(ssid); }, 3000);
|
||||
} else {
|
||||
showMsg(data.message || 'Connection failed', 'err');
|
||||
var msg = data.error_type === 'wrong_password'
|
||||
? 'Incorrect password — please try again'
|
||||
: (data.message || 'Connection failed');
|
||||
showMsg(msg, 'err');
|
||||
connecting = false;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Connect';
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let inspectedFile = null;
|
||||
|
||||
function notify(message, kind) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(message, kind || 'info');
|
||||
@@ -245,12 +247,14 @@
|
||||
notify('Choose a backup file first', 'error');
|
||||
return;
|
||||
}
|
||||
const file = input.files[0];
|
||||
const fd = new FormData();
|
||||
fd.append('backup_file', input.files[0]);
|
||||
fd.append('backup_file', file);
|
||||
try {
|
||||
const res = await fetch('/api/v3/backup/validate', { method: 'POST', body: fd });
|
||||
const payload = await res.json();
|
||||
if (payload.status !== 'success') throw new Error(payload.message || 'Validation failed');
|
||||
inspectedFile = file;
|
||||
renderRestorePreview(payload.data);
|
||||
} catch (err) {
|
||||
notify('Invalid backup: ' + err.message, 'error');
|
||||
@@ -273,15 +277,15 @@
|
||||
}
|
||||
|
||||
function clearRestore() {
|
||||
inspectedFile = null;
|
||||
document.getElementById('restore-preview').classList.add('hidden');
|
||||
document.getElementById('restore-result').classList.add('hidden');
|
||||
document.getElementById('restore-file-input').value = '';
|
||||
}
|
||||
|
||||
async function runRestore() {
|
||||
const input = document.getElementById('restore-file-input');
|
||||
if (!input.files || !input.files[0]) {
|
||||
notify('Choose a backup file first', 'error');
|
||||
if (!inspectedFile) {
|
||||
notify('Inspect the file before restoring', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Restore from this backup? Current configuration will be overwritten.')) return;
|
||||
@@ -295,7 +299,7 @@
|
||||
reinstall_plugins: document.getElementById('opt-reinstall').checked,
|
||||
};
|
||||
const fd = new FormData();
|
||||
fd.append('backup_file', input.files[0]);
|
||||
fd.append('backup_file', inspectedFile);
|
||||
fd.append('options', JSON.stringify(options));
|
||||
|
||||
const btn = document.getElementById('run-restore-btn');
|
||||
@@ -304,21 +308,27 @@
|
||||
try {
|
||||
const res = await fetch('/api/v3/backup/restore', { method: 'POST', body: fd });
|
||||
const payload = await res.json();
|
||||
if (payload.status !== 'success') {
|
||||
const msgs = (payload.data?.errors || []).join('; ');
|
||||
throw new Error(payload.message || msgs || 'Restore had errors');
|
||||
}
|
||||
const data = payload.data || {};
|
||||
const hasPartial = (data.plugins_failed || []).length > 0 || (data.errors || []).length > 0;
|
||||
const result = document.getElementById('restore-result');
|
||||
const ok = payload.status === 'success';
|
||||
result.className = (ok ? 'bg-green-50 border-green-200 text-green-800' : 'bg-yellow-50 border-yellow-200 text-yellow-800') + ' border rounded-md p-4';
|
||||
result.className = (hasPartial
|
||||
? 'bg-yellow-50 border-yellow-200 text-yellow-800'
|
||||
: 'bg-green-50 border-green-200 text-green-800') + ' border rounded-md p-4';
|
||||
result.classList.remove('hidden');
|
||||
result.innerHTML = `
|
||||
<h3 class="font-medium mb-2">${ok ? 'Restore complete' : 'Restore finished with warnings'}</h3>
|
||||
<h3 class="font-medium mb-2">${hasPartial ? 'Restore complete with warnings' : 'Restore complete'}</h3>
|
||||
<div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||
<div><strong>Skipped:</strong> ${(data.skipped || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||
<div><strong>Plugins installed:</strong> ${(data.plugins_installed || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||
<div><strong>Plugins failed:</strong> ${(data.plugins_failed || []).map(p => escapeHtml(p.plugin_id + ' (' + p.error + ')')).join(', ') || 'none'}</div>
|
||||
<div><strong>Errors:</strong> ${(data.errors || []).map(escapeHtml).join('; ') || 'none'}</div>
|
||||
<p class="mt-2">Restart the display service to apply all changes.</p>
|
||||
${((data.restored || []).length || (data.plugins_installed || []).length) ? '<p class="mt-2">Restart the display service to apply all changes.</p>' : ''}
|
||||
`;
|
||||
notify(ok ? 'Restore complete' : 'Restore finished with warnings', ok ? 'success' : 'info');
|
||||
notify(hasPartial ? 'Restore complete with warnings' : 'Restore complete', hasPartial ? 'warning' : 'success');
|
||||
} catch (err) {
|
||||
notify('Restore failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
@@ -334,6 +344,13 @@
|
||||
window.clearRestore = clearRestore;
|
||||
window.runRestore = runRestore;
|
||||
|
||||
// Clear inspection state whenever the user picks a new file.
|
||||
document.getElementById('restore-file-input').addEventListener('change', function () {
|
||||
inspectedFile = null;
|
||||
document.getElementById('restore-preview').classList.add('hidden');
|
||||
document.getElementById('restore-result').classList.add('hidden');
|
||||
});
|
||||
|
||||
// Initial load.
|
||||
loadPreview();
|
||||
loadBackupList();
|
||||
|
||||
Reference in New Issue
Block a user