mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-21 20:18:36 +00:00
Compare commits
1 Commits
fix/post-i
...
a84b65fffb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84b65fffb |
Binary file not shown.
|
Before Width: | Height: | Size: 657 KiB After Width: | Height: | Size: 105 KiB |
@@ -1,9 +1,17 @@
|
|||||||
{
|
{
|
||||||
|
"ledmatrix-weather": {
|
||||||
|
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||||
|
},
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
"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": {
|
"github": {
|
||||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,13 +599,9 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
|||||||
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
||||||
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
||||||
{
|
{
|
||||||
"youtube": {
|
"weather": {
|
||||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
}
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
# Check if service runs as root and set ownership accordingly
|
# Check if service runs as root and set ownership accordingly
|
||||||
@@ -1086,7 +1082,6 @@ SYSTEMCTL_PATH=$(which systemctl)
|
|||||||
REBOOT_PATH=$(which reboot)
|
REBOOT_PATH=$(which reboot)
|
||||||
POWEROFF_PATH=$(which poweroff)
|
POWEROFF_PATH=$(which poweroff)
|
||||||
BASH_PATH=$(which bash)
|
BASH_PATH=$(which bash)
|
||||||
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
|
||||||
|
|
||||||
# Create sudoers content
|
# Create sudoers content
|
||||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||||
@@ -1102,22 +1097,10 @@ $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 enable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable 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 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: $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/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/stop_display.sh
|
||||||
EOF
|
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
|
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
||||||
echo "Sudoers configuration already up to date"
|
echo "Sudoers configuration already up to date"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
Pillow>=10.3.0
|
Pillow>=9.1.0
|
||||||
pytz>=2022.1
|
pytz>=2022.1
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
|
|||||||
@@ -35,24 +35,24 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
"""Initialize the Web UI Info plugin."""
|
"""Initialize the Web UI Info plugin."""
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
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
|
# Get device hostname
|
||||||
try:
|
try:
|
||||||
self.device_id = socket.gethostname()
|
self.device_id = socket.gethostname()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
||||||
self.device_id = "localhost"
|
self.device_id = "localhost"
|
||||||
|
|
||||||
# Get device IP address
|
# Get device IP address
|
||||||
self.device_ip = self._get_local_ip()
|
self.device_ip = self._get_local_ip()
|
||||||
|
|
||||||
# IP refresh tracking
|
# IP refresh tracking
|
||||||
self.last_ip_refresh = time.time()
|
self.last_ip_refresh = time.time()
|
||||||
self.ip_refresh_interval = 300.0
|
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
|
||||||
|
|
||||||
# Rotation state
|
# Rotation state
|
||||||
self.current_display_mode = "hostname" # "hostname" or "ip"
|
self.current_display_mode = "hostname" # "hostname" or "ip"
|
||||||
@@ -200,7 +200,9 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
elif current_interface == "wlan0":
|
elif current_interface == "wlan0":
|
||||||
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
||||||
return ip
|
return ip
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
||||||
try:
|
try:
|
||||||
ip = socket.gethostbyname(socket.gethostname())
|
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 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"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
|
||||||
|
|
||||||
# Optional: journalctl (non-critical — skip if not found)
|
# Optional: journalctl (non-critical — skip if not found)
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add project root to path (parent of scripts/utils/)
|
# Add project root to path (parent of scripts/utils/)
|
||||||
@@ -44,11 +43,7 @@ class WiFiMonitorDaemon:
|
|||||||
self.wifi_manager = WiFiManager()
|
self.wifi_manager = WiFiManager()
|
||||||
self.running = True
|
self.running = True
|
||||||
self.last_state = None
|
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
|
# Register signal handlers for graceful shutdown
|
||||||
signal.signal(signal.SIGINT, self._signal_handler)
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
@@ -127,43 +122,6 @@ class WiFiMonitorDaemon:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
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 Exception 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
|
# Sleep until next check
|
||||||
time.sleep(self.check_interval)
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ used from scripts.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -188,7 +189,7 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
with state_file.open("r", encoding="utf-8") as f:
|
with state_file.open("r", encoding="utf-8") as f:
|
||||||
state = json.load(f)
|
state = json.load(f)
|
||||||
raw_plugins = state.get("states", {}) if isinstance(state, dict) else {}
|
raw_plugins = state.get("plugins", {}) if isinstance(state, dict) else {}
|
||||||
if isinstance(raw_plugins, dict):
|
if isinstance(raw_plugins, dict):
|
||||||
for plugin_id, info in raw_plugins.items():
|
for plugin_id, info in raw_plugins.items():
|
||||||
if not isinstance(info, dict):
|
if not isinstance(info, dict):
|
||||||
@@ -289,54 +290,53 @@ def create_backup(
|
|||||||
|
|
||||||
contents: List[str] = []
|
contents: List[str] = []
|
||||||
|
|
||||||
# Stream directly to a temp file so we never hold the whole ZIP in memory.
|
# 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.
|
||||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||||
try:
|
tmp_path.write_bytes(buffer.getvalue())
|
||||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
os.replace(tmp_path, zip_path)
|
||||||
# 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)
|
logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size)
|
||||||
return zip_path
|
return zip_path
|
||||||
|
|
||||||
@@ -395,17 +395,15 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
return False, "Backup is missing manifest.json", {}
|
return False, "Backup is missing manifest.json", {}
|
||||||
|
|
||||||
total = 0
|
total = 0
|
||||||
with tempfile.TemporaryDirectory() as _sandbox:
|
for info in zf.infolist():
|
||||||
sandbox = Path(_sandbox)
|
if info.file_size > _MAX_MEMBER_BYTES:
|
||||||
for info in zf.infolist():
|
return False, f"Member {info.filename} is too large", {}
|
||||||
if info.file_size > _MAX_MEMBER_BYTES:
|
total += info.file_size
|
||||||
return False, f"Member {info.filename} is too large", {}
|
if total > _MAX_TOTAL_BYTES:
|
||||||
total += info.file_size
|
return False, "Backup exceeds maximum allowed size", {}
|
||||||
if total > _MAX_TOTAL_BYTES:
|
# Safety: reject members with unsafe paths up front.
|
||||||
return False, "Backup exceeds maximum allowed size", {}
|
if _safe_extract_path(Path("/tmp/_zip_check"), info.filename) is None:
|
||||||
# Safety: reject members with unsafe paths up front.
|
return False, f"Unsafe path in backup: {info.filename}", {}
|
||||||
if _safe_extract_path(sandbox, info.filename) is None:
|
|
||||||
return False, f"Unsafe path in backup: {info.filename}", {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||||
@@ -431,10 +429,7 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
detected.append("wifi")
|
detected.append("wifi")
|
||||||
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||||
detected.append("fonts")
|
detected.append("fonts")
|
||||||
if any(
|
if any(n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") for n in names):
|
||||||
n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") and "/uploads/" in n
|
|
||||||
for n in names
|
|
||||||
):
|
|
||||||
detected.append("plugin_uploads")
|
detected.append("plugin_uploads")
|
||||||
|
|
||||||
plugins: List[Dict[str, Any]] = []
|
plugins: List[Dict[str, Any]] = []
|
||||||
@@ -574,9 +569,6 @@ def restore_backup(
|
|||||||
for name in files:
|
for name in files:
|
||||||
src = Path(root) / name
|
src = Path(root) / name
|
||||||
rel = src.relative_to(tmp_dir)
|
rel = src.relative_to(tmp_dir)
|
||||||
if "/uploads/" not in rel.as_posix():
|
|
||||||
result.errors.append(f"Rejected unexpected plugin path: {rel}")
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
_copy_file(src, project_root / rel)
|
_copy_file(src, project_root / rel)
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
@@ -677,44 +677,6 @@ class PluginManager:
|
|||||||
# Default: 60 seconds
|
# Default: 60 seconds
|
||||||
return 60.0
|
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:
|
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Trigger plugin updates based on their defined update intervals.
|
Trigger plugin updates based on their defined update intervals.
|
||||||
@@ -772,10 +734,16 @@ class PluginManager:
|
|||||||
if self.health_tracker:
|
if self.health_tracker:
|
||||||
self.health_tracker.record_success(plugin_id)
|
self.health_tracker.record_success(plugin_id)
|
||||||
else:
|
else:
|
||||||
self._record_update_failure(plugin_id)
|
# 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"))
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||||
self._record_update_failure(plugin_id, exc=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)
|
||||||
|
|
||||||
def update_all_plugins(self) -> None:
|
def update_all_plugins(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -801,12 +769,14 @@ class PluginManager:
|
|||||||
if success:
|
if success:
|
||||||
self.plugin_last_update[plugin_id] = time.time()
|
self.plugin_last_update[plugin_id] = time.time()
|
||||||
self.state_manager.record_update(plugin_id)
|
self.state_manager.record_update(plugin_id)
|
||||||
|
# Update state back to ENABLED
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||||
else:
|
else:
|
||||||
self._record_update_failure(plugin_id)
|
# Execution failed
|
||||||
|
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||||
self._record_update_failure(plugin_id, exc=exc)
|
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
||||||
|
|
||||||
def get_plugin_health_metrics(self) -> Dict[str, Any]:
|
def get_plugin_health_metrics(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Manages plugin state machine (loaded → enabled → running → error)
|
|||||||
with state transitions and queries.
|
with state transitions and queries.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -35,7 +34,6 @@ class PluginStateManager:
|
|||||||
logger: Optional logger instance
|
logger: Optional logger instance
|
||||||
"""
|
"""
|
||||||
self.logger = logger or get_logger(__name__)
|
self.logger = logger or get_logger(__name__)
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._states: Dict[str, PluginState] = {}
|
self._states: Dict[str, PluginState] = {}
|
||||||
self._state_history: Dict[str, list] = {}
|
self._state_history: Dict[str, list] = {}
|
||||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -50,44 +48,44 @@ class PluginStateManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Set plugin state and record transition.
|
Set plugin state and record transition.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
state: New state
|
state: New state
|
||||||
error: Optional error if transitioning to ERROR state
|
error: Optional error if transitioning to ERROR state
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
self._states[plugin_id] = state
|
||||||
self._states[plugin_id] = state
|
|
||||||
|
# Record state transition
|
||||||
if plugin_id not in self._state_history:
|
if plugin_id not in self._state_history:
|
||||||
self._state_history[plugin_id] = []
|
self._state_history[plugin_id] = []
|
||||||
|
|
||||||
transition = {
|
transition = {
|
||||||
'timestamp': datetime.now(),
|
'timestamp': datetime.now(),
|
||||||
'from': old_state.value,
|
'from': old_state.value,
|
||||||
'to': state.value,
|
'to': state.value,
|
||||||
'error': str(error) if error else None
|
'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()
|
||||||
}
|
}
|
||||||
self._state_history[plugin_id].append(transition)
|
elif state != PluginState.ERROR:
|
||||||
|
# Clear error info when leaving ERROR state
|
||||||
# Store error info if transitioning to ERROR state
|
self._error_info.pop(plugin_id, None)
|
||||||
if state == PluginState.ERROR and error:
|
|
||||||
self._error_info[plugin_id] = {
|
self.logger.debug(
|
||||||
'error': str(error),
|
"Plugin %s state transition: %s → %s",
|
||||||
'error_type': type(error).__name__,
|
plugin_id,
|
||||||
'timestamp': datetime.now()
|
old_state.value,
|
||||||
}
|
state.value
|
||||||
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:
|
def get_state(self, plugin_id: str) -> PluginState:
|
||||||
"""
|
"""
|
||||||
@@ -138,82 +136,17 @@ class PluginStateManager:
|
|||||||
"""
|
"""
|
||||||
return self._state_history.get(plugin_id, [])
|
return self._state_history.get(plugin_id, [])
|
||||||
|
|
||||||
def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None:
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
error_info: Arbitrary dict describing the error
|
|
||||||
"""
|
|
||||||
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]]:
|
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get error information for a plugin.
|
Get error information for a plugin in ERROR state.
|
||||||
|
|
||||||
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:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Copy of the error information dict, or None
|
Error information dict or None
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
return self._error_info.get(plugin_id)
|
||||||
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:
|
def record_update(self, plugin_id: str) -> None:
|
||||||
"""Record that plugin update() was called."""
|
"""Record that plugin update() was called."""
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Detects and fixes inconsistencies between:
|
|||||||
- State manager state
|
- State manager state
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, List, Optional, Set
|
from typing import Dict, Any, List, Optional
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -86,38 +86,16 @@ class StateReconciliation:
|
|||||||
self.plugins_dir = Path(plugins_dir)
|
self.plugins_dir = Path(plugins_dir)
|
||||||
self.store_manager = store_manager
|
self.store_manager = store_manager
|
||||||
self.logger = get_logger(__name__)
|
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, force: bool = False) -> ReconciliationResult:
|
def reconcile_state(self) -> ReconciliationResult:
|
||||||
"""
|
"""
|
||||||
Perform state reconciliation.
|
Perform state reconciliation.
|
||||||
|
|
||||||
Compares state from all sources and fixes safe inconsistencies.
|
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:
|
Returns:
|
||||||
ReconciliationResult with findings and fixes
|
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")
|
self.logger.info("Starting state reconciliation")
|
||||||
|
|
||||||
inconsistencies = []
|
inconsistencies = []
|
||||||
@@ -302,26 +280,7 @@ class StateReconciliation:
|
|||||||
|
|
||||||
# Check: Plugin in config but not on disk
|
# Check: Plugin in config but not on disk
|
||||||
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
|
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
|
||||||
# Skip plugins that previously failed auto-repair in this process.
|
can_repair = self.store_manager is not None
|
||||||
# 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(
|
inconsistencies.append(Inconsistency(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
|
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
|
||||||
@@ -383,13 +342,7 @@ class StateReconciliation:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _auto_repair_missing_plugin(self, plugin_id: str) -> bool:
|
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:
|
if not self.store_manager:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -398,43 +351,6 @@ class StateReconciliation:
|
|||||||
if plugin_id.startswith('ledmatrix-'):
|
if plugin_id.startswith('ledmatrix-'):
|
||||||
candidates.append(plugin_id[len('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:
|
for candidate_id in candidates:
|
||||||
try:
|
try:
|
||||||
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
|
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
|
||||||
@@ -450,11 +366,6 @@ class StateReconciliation:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
|
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
|
||||||
|
|
||||||
self.logger.warning(
|
self.logger.warning("[AutoRepair] Could not reinstall %s from store", plugin_id)
|
||||||
"[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
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ import zipfile
|
|||||||
import tempfile
|
import tempfile
|
||||||
import requests
|
import requests
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional, Any, Tuple
|
from typing import List, Dict, Optional, Any
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.common.permission_utils import sudo_remove_directory
|
from src.common.permission_utils import sudo_remove_directory
|
||||||
@@ -53,89 +52,19 @@ class PluginStoreManager:
|
|||||||
self.registry_cache = None
|
self.registry_cache = None
|
||||||
self.registry_cache_time = None # Timestamp of when registry was cached
|
self.registry_cache_time = None # Timestamp of when registry was cached
|
||||||
self.github_cache = {} # Cache for GitHub API responses
|
self.github_cache = {} # Cache for GitHub API responses
|
||||||
self.cache_timeout = 3600 # 1 hour cache timeout (repo info: stars, default_branch)
|
self.cache_timeout = 3600 # 1 hour cache timeout
|
||||||
# 15 minutes for registry cache. Long enough that the plugin list
|
self.registry_cache_timeout = 300 # 5 minutes for registry cache
|
||||||
# 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_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
||||||
# 30 minutes for commit/manifest caches. Plugin Store users browse
|
self.commit_cache_timeout = 300 # 5 minutes (same as registry)
|
||||||
# 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 = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
||||||
self.manifest_cache_timeout = 1800
|
self.manifest_cache_timeout = 300 # 5 minutes
|
||||||
self.github_token = self._load_github_token()
|
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 = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)}
|
||||||
self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation
|
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
|
# Ensure plugins directory exists
|
||||||
self.plugins_dir.mkdir(exist_ok=True)
|
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]:
|
def _load_github_token(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Load GitHub API token from config_secrets.json if available.
|
Load GitHub API token from config_secrets.json if available.
|
||||||
@@ -379,25 +308,7 @@ class PluginStoreManager:
|
|||||||
if self.github_token:
|
if self.github_token:
|
||||||
headers['Authorization'] = f'token {self.github_token}'
|
headers['Authorization'] = f'token {self.github_token}'
|
||||||
|
|
||||||
try:
|
response = requests.get(api_url, headers=headers, timeout=10)
|
||||||
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:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
|
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
|
||||||
@@ -417,20 +328,7 @@ class PluginStoreManager:
|
|||||||
self.github_cache[cache_key] = (time.time(), repo_info)
|
self.github_cache[cache_key] = (time.time(), repo_info)
|
||||||
return repo_info
|
return repo_info
|
||||||
elif response.status_code == 403:
|
elif response.status_code == 403:
|
||||||
# Rate limit or authentication issue. If we have a
|
# Rate limit or authentication issue
|
||||||
# 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:
|
if not self.github_token:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"GitHub API rate limit likely exceeded (403). "
|
f"GitHub API rate limit likely exceeded (403). "
|
||||||
@@ -444,10 +342,6 @@ class PluginStoreManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"GitHub API request failed: {response.status_code} for {api_url}")
|
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 {
|
return {
|
||||||
'stars': 0,
|
'stars': 0,
|
||||||
@@ -548,34 +442,23 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
|
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def fetch_registry(self, force_refresh: bool = False, raise_on_failure: bool = False) -> Dict:
|
def fetch_registry(self, force_refresh: bool = False) -> Dict:
|
||||||
"""
|
"""
|
||||||
Fetch the plugin registry from GitHub.
|
Fetch the plugin registry from GitHub.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
force_refresh: Force refresh even if cached
|
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:
|
Returns:
|
||||||
Registry data with list of available plugins
|
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)
|
# Check if cache is still valid (within timeout)
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if (self.registry_cache and self.registry_cache_time and
|
if (self.registry_cache and self.registry_cache_time and
|
||||||
not force_refresh and
|
not force_refresh and
|
||||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||||
@@ -586,30 +469,9 @@ class PluginStoreManager:
|
|||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
self.logger.error(f"Error fetching registry: {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": []}
|
return {"plugins": []}
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
self.logger.error(f"Error parsing registry JSON: {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": []}
|
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]:
|
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]:
|
||||||
@@ -655,95 +517,68 @@ class PluginStoreManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
|
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
|
||||||
|
|
||||||
# First pass: apply cheap filters (category/tags/query) so we only
|
results = []
|
||||||
# fetch GitHub metadata for plugins that will actually be returned.
|
|
||||||
filtered: List[Dict] = []
|
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
|
# Category filter
|
||||||
if category and plugin.get('category') != category:
|
if category and plugin.get('category') != category:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Tags filter (match any tag)
|
||||||
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
|
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Query search (case-insensitive)
|
||||||
if query:
|
if query:
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
searchable_text = ' '.join([
|
searchable_text = ' '.join([
|
||||||
plugin.get('name', ''),
|
plugin.get('name', ''),
|
||||||
plugin.get('description', ''),
|
plugin.get('description', ''),
|
||||||
plugin.get('id', ''),
|
plugin.get('id', ''),
|
||||||
plugin.get('author', ''),
|
plugin.get('author', '')
|
||||||
]).lower()
|
]).lower()
|
||||||
|
|
||||||
if query_lower not in searchable_text:
|
if query_lower not in searchable_text:
|
||||||
continue
|
continue
|
||||||
filtered.append(plugin)
|
|
||||||
|
|
||||||
def _enrich(plugin: Dict) -> Dict:
|
# Enhance plugin data with GitHub metadata
|
||||||
"""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()
|
enhanced_plugin = plugin.copy()
|
||||||
|
|
||||||
|
# Get real GitHub stars
|
||||||
repo_url = plugin.get('repo', '')
|
repo_url = plugin.get('repo', '')
|
||||||
if not repo_url:
|
if repo_url:
|
||||||
return enhanced_plugin
|
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')
|
||||||
|
|
||||||
github_info = self._get_github_repo_info(repo_url)
|
if fetch_commit_info:
|
||||||
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
|
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
|
||||||
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 fetch_commit_info:
|
commit_info = self._get_latest_commit_info(repo_url, branch)
|
||||||
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
|
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')
|
||||||
|
|
||||||
commit_info = self._get_latest_commit_info(repo_url, branch)
|
# Fetch manifest from GitHub for additional metadata (description, etc.)
|
||||||
if commit_info:
|
plugin_subpath = plugin.get('plugin_path', '')
|
||||||
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
|
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
|
||||||
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
|
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel)
|
||||||
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
|
if github_manifest:
|
||||||
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
|
if 'last_updated' in github_manifest and not enhanced_plugin.get('last_updated'):
|
||||||
enhanced_plugin['last_commit_message'] = commit_info.get('message')
|
enhanced_plugin['last_updated'] = github_manifest['last_updated']
|
||||||
enhanced_plugin['last_commit_author'] = commit_info.get('author')
|
if 'description' in github_manifest:
|
||||||
enhanced_plugin['branch'] = commit_info.get('branch', branch)
|
enhanced_plugin['description'] = github_manifest['description']
|
||||||
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
|
|
||||||
|
|
||||||
# Intentionally NO per-plugin manifest.json fetch here.
|
results.append(enhanced_plugin)
|
||||||
# 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 enhanced_plugin
|
return results
|
||||||
|
|
||||||
# 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]:
|
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
@@ -841,28 +676,7 @@ class PluginStoreManager:
|
|||||||
last_error = None
|
last_error = None
|
||||||
for branch_name in branches_to_try:
|
for branch_name in branches_to_try:
|
||||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
|
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
|
||||||
try:
|
response = requests.get(api_url, headers=headers, timeout=10)
|
||||||
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:
|
if response.status_code == 200:
|
||||||
commit_data = response.json()
|
commit_data = response.json()
|
||||||
commit_sha_full = commit_data.get('sha', '')
|
commit_sha_full = commit_data.get('sha', '')
|
||||||
@@ -892,23 +706,7 @@ class PluginStoreManager:
|
|||||||
if last_error:
|
if last_error:
|
||||||
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
|
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
|
||||||
|
|
||||||
# All branches returned a non-200 response (e.g. 404 on every
|
# Cache negative result to avoid repeated failing calls
|
||||||
# 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)
|
self.commit_info_cache[cache_key] = (time.time(), None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1762,93 +1560,12 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
||||||
return False
|
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]]:
|
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'
|
git_dir = plugin_path / '.git'
|
||||||
if not git_dir.exists():
|
if not git_dir.exists():
|
||||||
return None
|
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:
|
try:
|
||||||
sha_result = subprocess.run(
|
sha_result = subprocess.run(
|
||||||
['git', '-C', str(plugin_path), 'rev-parse', 'HEAD'],
|
['git', '-C', str(plugin_path), 'rev-parse', 'HEAD'],
|
||||||
@@ -1906,8 +1623,6 @@ class PluginStoreManager:
|
|||||||
result['date_iso'] = commit_date_iso
|
result['date_iso'] = commit_date_iso
|
||||||
result['date'] = self._iso_to_date(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
|
return result
|
||||||
except subprocess.CalledProcessError as err:
|
except subprocess.CalledProcessError as err:
|
||||||
self.logger.debug(f"Failed to read git info for {plugin_path.name}: {err}")
|
self.logger.debug(f"Failed to read git info for {plugin_path.name}: {err}")
|
||||||
|
|||||||
@@ -60,16 +60,12 @@ def get_wifi_config_path():
|
|||||||
|
|
||||||
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
|
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
|
||||||
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.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"
|
HOSTAPD_SERVICE = "hostapd"
|
||||||
DNSMASQ_SERVICE = "dnsmasq"
|
DNSMASQ_SERVICE = "dnsmasq"
|
||||||
|
|
||||||
# Default AP settings
|
# Default AP settings
|
||||||
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
||||||
|
DEFAULT_AP_PASSWORD = "ledmatrix123"
|
||||||
DEFAULT_AP_CHANNEL = 7
|
DEFAULT_AP_CHANNEL = 7
|
||||||
|
|
||||||
# LED status message file (for display_controller integration)
|
# LED status message file (for display_controller integration)
|
||||||
@@ -142,11 +138,6 @@ class WiFiManager:
|
|||||||
self._disconnected_checks = 0
|
self._disconnected_checks = 0
|
||||||
self._disconnected_checks_required = 3 # Require 3 consecutive disconnected checks (90 seconds at 30s interval)
|
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}, "
|
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
||||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||||
@@ -210,24 +201,6 @@ class WiFiManager:
|
|||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
return False
|
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:
|
def _discover_wifi_interface(self) -> str:
|
||||||
"""
|
"""
|
||||||
Discover the primary WiFi interface name dynamically.
|
Discover the primary WiFi interface name dynamically.
|
||||||
@@ -330,6 +303,7 @@ class WiFiManager:
|
|||||||
else:
|
else:
|
||||||
self.config = {
|
self.config = {
|
||||||
"ap_ssid": DEFAULT_AP_SSID,
|
"ap_ssid": DEFAULT_AP_SSID,
|
||||||
|
"ap_password": DEFAULT_AP_PASSWORD,
|
||||||
"ap_channel": DEFAULT_AP_CHANNEL,
|
"ap_channel": DEFAULT_AP_CHANNEL,
|
||||||
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
||||||
"saved_networks": []
|
"saved_networks": []
|
||||||
@@ -684,286 +658,7 @@ class WiFiManager:
|
|||||||
return False
|
return False
|
||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
return False
|
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]:
|
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
|
||||||
"""
|
"""
|
||||||
Scan for available WiFi networks.
|
Scan for available WiFi networks.
|
||||||
@@ -1598,27 +1293,12 @@ class WiFiManager:
|
|||||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
logger.error(f"Failed to connect to {ssid}: {error_msg}")
|
logger.error(f"Failed to connect to {ssid}: {error_msg}")
|
||||||
self._show_led_message("Connection failed", duration=5)
|
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
|
return False, error_msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error connecting with nmcli: {e}")
|
logger.error(f"Error connecting with nmcli: {e}")
|
||||||
self._show_led_message("Connection error", duration=5)
|
self._show_led_message("Connection error", duration=5)
|
||||||
return False, str(e)
|
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]:
|
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||||
"""Connect using wpa_supplicant (fallback)"""
|
"""Connect using wpa_supplicant (fallback)"""
|
||||||
try:
|
try:
|
||||||
@@ -1890,18 +1570,14 @@ class WiFiManager:
|
|||||||
if self.has_hostapd and self.has_dnsmasq:
|
if self.has_hostapd and self.has_dnsmasq:
|
||||||
result = self._enable_ap_mode_hostapd()
|
result = self._enable_ap_mode_hostapd()
|
||||||
if result[0]:
|
if result[0]:
|
||||||
self._ap_enabled_at = time.time()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||||
if self.has_nmcli:
|
if self.has_nmcli:
|
||||||
logger.info("hostapd/dnsmasq failed or unavailable, trying nmcli hotspot fallback...")
|
logger.info("hostapd/dnsmasq failed or unavailable, trying nmcli hotspot fallback...")
|
||||||
self._show_led_message("Setup Mode", duration=5)
|
self._show_led_message("Setup Mode", duration=5)
|
||||||
result = self._enable_ap_mode_nmcli_hotspot()
|
return 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)"
|
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in enable_ap_mode: {e}")
|
logger.error(f"Error in enable_ap_mode: {e}")
|
||||||
@@ -1973,21 +1649,63 @@ class WiFiManager:
|
|||||||
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
|
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
|
||||||
return False, f"Failed to start dnsmasq: {result.stderr}"
|
return False, f"Failed to start dnsmasq: {result.stderr}"
|
||||||
|
|
||||||
# Set up iptables port forwarding (port 80 → 5000) and save ip_forward state
|
# Set up iptables port forwarding: redirect port 80 to 5000
|
||||||
if not self._setup_iptables_redirect():
|
# This makes the captive portal work on standard HTTP port
|
||||||
logger.error("Captive-portal redirect setup failed; stopping AP services")
|
try:
|
||||||
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE],
|
# Check if iptables is available
|
||||||
capture_output=True, timeout=10)
|
iptables_check = subprocess.run(
|
||||||
subprocess.run(["sudo", "systemctl", "stop", DNSMASQ_SERVICE],
|
["which", "iptables"],
|
||||||
capture_output=True, timeout=10)
|
capture_output=True,
|
||||||
return False, "AP started but captive-portal redirect setup failed"
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
logger.info("AP mode enabled successfully")
|
||||||
# Use the validated SSID so the displayed name matches what hostapd broadcast
|
self._show_led_message("Setup Mode Active", duration=5)
|
||||||
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"
|
return True, "AP mode enabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting AP services: {e}")
|
logger.error(f"Error starting AP services: {e}")
|
||||||
@@ -1998,120 +1716,245 @@ class WiFiManager:
|
|||||||
|
|
||||||
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enable AP mode using nmcli as an open (passwordless) access point.
|
Enable AP mode using nmcli hotspot.
|
||||||
|
|
||||||
Uses 'nmcli connection add type wifi 802-11-wireless.mode ap' instead of
|
This method is optimized for both Bookworm and Trixie:
|
||||||
'nmcli device wifi hotspot' because the hotspot subcommand always creates a
|
- Trixie: Uses Netplan, connections stored in /run/NetworkManager/system-connections
|
||||||
WPA2-protected network on Bookworm/Trixie and silently ignores attempts to
|
- Bookworm: Traditional NetworkManager, connections in /etc/NetworkManager/system-connections
|
||||||
strip security after creation.
|
|
||||||
|
|
||||||
Tested for both Bookworm and Trixie (Netplan-based NetworkManager).
|
On Trixie, we also disable PMF (Protected Management Frames) which can cause
|
||||||
|
connection issues with certain WiFi adapters and clients.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Stop any existing connection
|
# Stop any existing connection
|
||||||
self.disconnect_from_network()
|
self.disconnect_from_network()
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
ap_ssid, ap_channel = self._validate_ap_config()
|
# 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 ""
|
||||||
|
|
||||||
# Delete only the specific application-managed AP profiles by name.
|
# Delete if:
|
||||||
# Never delete by SSID — that would destroy a user's saved home network.
|
# 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)
|
||||||
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
||||||
subprocess.run(["nmcli", "connection", "down", conn_name],
|
subprocess.run(
|
||||||
capture_output=True, timeout=5)
|
["nmcli", "connection", "down", conn_name],
|
||||||
subprocess.run(["nmcli", "connection", "delete", conn_name],
|
capture_output=True,
|
||||||
capture_output=True, timeout=10)
|
timeout=5
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["nmcli", "connection", "delete", conn_name],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a moment for deletions to complete
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Create an open AP connection profile from scratch.
|
# Get AP settings from config
|
||||||
# Using 'connection add' instead of 'device wifi hotspot' because the
|
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||||
# hotspot subcommand always attaches a WPA2 PSK on Bookworm/Trixie and
|
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
||||||
# ignores post-creation security modifications.
|
|
||||||
logger.info(f"Creating open AP with nmcli connection add: {ap_ssid} on "
|
# Use nmcli hotspot command (simpler, works with Broadcom chips)
|
||||||
f"{self._wifi_interface} (no password)")
|
# 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
|
||||||
cmd = [
|
cmd = [
|
||||||
"nmcli", "connection", "add",
|
"nmcli", "device", "wifi", "hotspot",
|
||||||
"type", "wifi",
|
|
||||||
"con-name", "LEDMatrix-Setup-AP",
|
|
||||||
"ifname", self._wifi_interface,
|
"ifname", self._wifi_interface,
|
||||||
|
"con-name", "LEDMatrix-Setup-AP",
|
||||||
"ssid", ap_ssid,
|
"ssid", ap_ssid,
|
||||||
"802-11-wireless.mode", "ap",
|
"band", "bg", # 2.4GHz for maximum compatibility
|
||||||
"802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
|
"channel", str(ap_channel),
|
||||||
"802-11-wireless.channel", str(ap_channel),
|
# Don't pass password parameter - we'll remove security after creation
|
||||||
"ipv4.method", "shared",
|
|
||||||
"ipv4.addresses", "192.168.4.1/24",
|
|
||||||
# No 802-11-wireless-security section → open network
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# PMF (Protected Management Frames) is only meaningful for WPA2/WPA3.
|
result = subprocess.run(
|
||||||
# An open AP has no security section, so adding 802-11-wireless-security.pmf
|
cmd,
|
||||||
# would cause NM to require key-mgmt too, breaking the connection add on
|
capture_output=True,
|
||||||
# Trixie NM 1.52+. Leave PMF untouched — open APs have no frame protection.
|
text=True,
|
||||||
|
timeout=30
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
|
||||||
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 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)
|
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
|
||||||
|
|
||||||
# NM's ipv4.method=shared manages ip_forward automatically, so we only
|
# Remove all possible security settings
|
||||||
# need to add the iptables port-redirect rules for the captive portal.
|
security_settings = [
|
||||||
if not self._setup_iptables_redirect():
|
("802-11-wireless-security.key-mgmt", "none"),
|
||||||
logger.error("Captive-portal redirect setup failed; rolling back AP profile")
|
("802-11-wireless-security.psk", ""),
|
||||||
self._remove_nm_dnsmasq_captive_conf()
|
("802-11-wireless-security.wep-key", ""),
|
||||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
("802-11-wireless-security.wep-key-type", ""),
|
||||||
capture_output=True, timeout=10)
|
("802-11-wireless-security.auth-alg", "open"),
|
||||||
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
|
# On Trixie, also disable PMF (Protected Management Frames)
|
||||||
status = self._get_ap_status_nmcli()
|
# This can cause connection issues with certain WiFi adapters and clients
|
||||||
if status.get('active'):
|
if self._is_trixie:
|
||||||
ip = status.get('ip', '192.168.4.1')
|
security_settings.append(("802-11-wireless-security.pmf", "disable"))
|
||||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
logger.info("Trixie detected: disabling PMF for better client compatibility")
|
||||||
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"
|
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:
|
else:
|
||||||
logger.error("AP mode started but not verified by status check — rolling back")
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
self._teardown_iptables_redirect()
|
logger.error(f"Failed to start AP mode via nmcli: {error_msg}")
|
||||||
self._remove_nm_dnsmasq_captive_conf()
|
self._show_led_message("AP mode failed", duration=5)
|
||||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
return False, f"Failed to start AP mode: {error_msg}"
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error starting AP mode with nmcli: {e}")
|
logger.error(f"Error starting AP mode with nmcli hotspot: {e}")
|
||||||
self._remove_nm_dnsmasq_captive_conf()
|
|
||||||
self._show_led_message("Setup mode error", duration=5)
|
self._show_led_message("Setup mode error", duration=5)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
@@ -2133,12 +1976,7 @@ class WiFiManager:
|
|||||||
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
parts = line.split(':')
|
parts = line.split(':')
|
||||||
if len(parts) < 2:
|
if len(parts) >= 2 and 'hotspot' in parts[1].lower():
|
||||||
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)
|
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
||||||
ip = '192.168.4.1'
|
ip = '192.168.4.1'
|
||||||
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
||||||
@@ -2234,9 +2072,45 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
||||||
|
|
||||||
# Remove iptables redirect rules and restore ip_forward state (hostapd mode only)
|
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
|
||||||
if hostapd_active:
|
if hostapd_active:
|
||||||
self._teardown_iptables_redirect()
|
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
|
||||||
|
|
||||||
# Clean up WiFi interface IP configuration
|
# Clean up WiFi interface IP configuration
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -2279,17 +2153,14 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
|
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
|
||||||
else:
|
else:
|
||||||
# nmcli AP mode — NM's ipv4.method=shared manages ip_forward automatically,
|
# nmcli hotspot mode - restart not needed, just ensure WiFi radio is enabled
|
||||||
# so we only need to remove the iptables redirect rules we added.
|
logger.info("Skipping NetworkManager restart (nmcli hotspot mode, restart not needed)")
|
||||||
logger.info("Skipping NetworkManager restart (nmcli AP mode, restart not needed)")
|
# Still ensure WiFi radio is enabled (may have been disabled by nmcli operations)
|
||||||
self._teardown_iptables_redirect()
|
# Use retries for safety
|
||||||
self._remove_nm_dnsmasq_captive_conf()
|
|
||||||
# Ensure WiFi radio is enabled after nmcli operations
|
|
||||||
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
|
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
|
||||||
if not wifi_enabled:
|
if not wifi_enabled:
|
||||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
logger.warning("WiFi radio may be disabled after nmcli hotspot cleanup")
|
||||||
|
|
||||||
self._ap_enabled_at = None
|
|
||||||
logger.info("AP mode disabled successfully")
|
logger.info("AP mode disabled successfully")
|
||||||
return True, "AP mode disabled"
|
return True, "AP mode disabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2304,11 +2175,9 @@ class WiFiManager:
|
|||||||
try:
|
try:
|
||||||
config_dir = HOSTAPD_CONFIG_PATH.parent
|
config_dir = HOSTAPD_CONFIG_PATH.parent
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Use validated values — strips invalid chars and ensures channel is an int.
|
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||||
# Also strip newlines from SSID to prevent config-file injection.
|
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
||||||
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
|
# Open network configuration (no password) for easy setup access
|
||||||
config_content = f"""interface={self._wifi_interface}
|
config_content = f"""interface={self._wifi_interface}
|
||||||
@@ -2436,21 +2305,22 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
f"Ethernet={ethernet_connected}, AP_active={ap_active}, "
|
f"Ethernet={ethernet_connected}, AP_active={ap_active}, "
|
||||||
f"auto_enable={auto_enable}, disconnected_checks={self._disconnected_checks}")
|
f"auto_enable={auto_enable}, disconnected_checks={self._disconnected_checks}")
|
||||||
|
|
||||||
# Determine if we should have AP mode active.
|
# Determine if we should have AP mode active
|
||||||
# AP-enable uses only the nmcli association state (fast, no network calls).
|
# AP mode should only be auto-enabled if:
|
||||||
# This keeps the same reliable behaviour as before: momentary packet loss
|
# - auto_enable_ap_mode is True AND
|
||||||
# while on working WiFi does NOT trigger AP mode. The internet-reachability
|
# - WiFi is NOT connected AND
|
||||||
# check is performed separately in the daemon watchdog for NM recovery.
|
# - Ethernet is NOT connected AND
|
||||||
|
# - We've had multiple consecutive disconnected checks (grace period)
|
||||||
is_disconnected = not status.connected and not ethernet_connected
|
is_disconnected = not status.connected and not ethernet_connected
|
||||||
|
|
||||||
if is_disconnected:
|
if is_disconnected:
|
||||||
# Increment disconnected check counter
|
# Increment disconnected check counter
|
||||||
self._disconnected_checks += 1
|
self._disconnected_checks += 1
|
||||||
logger.debug(f"Network disconnected (check {self._disconnected_checks}/{self._disconnected_checks_required})")
|
logger.debug(f"Network disconnected (check {self._disconnected_checks}/{self._disconnected_checks_required})")
|
||||||
else:
|
else:
|
||||||
# Reset counter if we're associated
|
# Reset counter if we're connected
|
||||||
if self._disconnected_checks > 0:
|
if self._disconnected_checks > 0:
|
||||||
logger.debug("Network connected, resetting disconnected check counter")
|
logger.debug(f"Network connected, resetting disconnected check counter")
|
||||||
self._disconnected_checks = 0
|
self._disconnected_checks = 0
|
||||||
|
|
||||||
# Only enable AP if we've had enough consecutive disconnected checks
|
# Only enable AP if we've had enough consecutive disconnected checks
|
||||||
@@ -2495,24 +2365,6 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
# AP is active but auto_enable is disabled - this means it was manually enabled
|
# AP is active but auto_enable is disabled - this means it was manually enabled
|
||||||
# Don't disable it automatically, let it stay active
|
# Don't disable it automatically, let it stay active
|
||||||
logger.debug("AP mode is active (manually enabled), keeping 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
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Wants=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
|
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
StandardOutput=syslog
|
StandardOutput=syslog
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -11,7 +12,6 @@ import pytest
|
|||||||
from src import backup_manager
|
from src import backup_manager
|
||||||
from src.backup_manager import (
|
from src.backup_manager import (
|
||||||
BUNDLED_FONTS,
|
BUNDLED_FONTS,
|
||||||
SCHEMA_VERSION,
|
|
||||||
RestoreOptions,
|
RestoreOptions,
|
||||||
create_backup,
|
create_backup,
|
||||||
list_installed_plugins,
|
list_installed_plugins,
|
||||||
@@ -66,11 +66,10 @@ def _make_project(root: Path) -> Path:
|
|||||||
(root / "data" / "plugin_state.json").write_text(
|
(root / "data" / "plugin_state.json").write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"version": 1,
|
"plugins": {
|
||||||
"states": {
|
|
||||||
"my-plugin": {"version": "1.2.3", "enabled": True},
|
"my-plugin": {"version": "1.2.3", "enabled": True},
|
||||||
"other-plugin": {"version": "0.1.0", "enabled": False},
|
"other-plugin": {"version": "0.1.0", "enabled": False},
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -205,7 +204,7 @@ def test_validate_backup_bad_schema_version(tmp_path: Path) -> None:
|
|||||||
def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None:
|
def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None:
|
||||||
zip_path = tmp_path / "malicious.zip"
|
zip_path = tmp_path / "malicious.zip"
|
||||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []}))
|
zf.writestr("manifest.json", json.dumps({"schema_version": 1, "contents": []}))
|
||||||
zf.writestr("../../etc/passwd", "x")
|
zf.writestr("../../etc/passwd", "x")
|
||||||
ok, err, _ = validate_backup(zip_path)
|
ok, err, _ = validate_backup(zip_path)
|
||||||
assert not ok
|
assert not ok
|
||||||
@@ -215,7 +214,7 @@ def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None:
|
|||||||
def test_validate_backup_not_a_zip(tmp_path: Path) -> None:
|
def test_validate_backup_not_a_zip(tmp_path: Path) -> None:
|
||||||
p = tmp_path / "nope.zip"
|
p = tmp_path / "nope.zip"
|
||||||
p.write_text("hello", encoding="utf-8")
|
p.write_text("hello", encoding="utf-8")
|
||||||
ok, _err, _ = validate_backup(p)
|
ok, err, _ = validate_backup(p)
|
||||||
assert not ok
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
@@ -276,7 +275,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:
|
def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> None:
|
||||||
zip_path = tmp_path / "bad.zip"
|
zip_path = tmp_path / "bad.zip"
|
||||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []}))
|
zf.writestr("manifest.json", json.dumps({"schema_version": 1, "contents": []}))
|
||||||
zf.writestr("../escape.txt", "x")
|
zf.writestr("../escape.txt", "x")
|
||||||
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||||
# validate_backup catches it before extraction.
|
# validate_backup catches it before extraction.
|
||||||
|
|||||||
@@ -1,747 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
"""
|
|
||||||
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,167 +342,6 @@ class TestStateReconciliation(unittest.TestCase):
|
|||||||
self.assertEqual(state, {})
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
@@ -667,20 +667,8 @@ import threading as _threading
|
|||||||
_reconciliation_lock = _threading.Lock()
|
_reconciliation_lock = _threading.Lock()
|
||||||
|
|
||||||
def _run_startup_reconciliation() -> None:
|
def _run_startup_reconciliation() -> None:
|
||||||
"""Run state reconciliation in background to auto-repair missing plugins.
|
"""Run state reconciliation in background to auto-repair missing plugins."""
|
||||||
|
global _reconciliation_done, _reconciliation_started
|
||||||
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
|
from src.logging_config import get_logger
|
||||||
_logger = get_logger('reconciliation')
|
_logger = get_logger('reconciliation')
|
||||||
|
|
||||||
@@ -696,22 +684,18 @@ def _run_startup_reconciliation() -> None:
|
|||||||
result = reconciler.reconcile_state()
|
result = reconciler.reconcile_state()
|
||||||
if result.inconsistencies_found:
|
if result.inconsistencies_found:
|
||||||
_logger.info("[Reconciliation] %s", result.message)
|
_logger.info("[Reconciliation] %s", result.message)
|
||||||
if result.inconsistencies_fixed:
|
if result.reconciliation_successful:
|
||||||
plugin_manager.discover_plugins()
|
if result.inconsistencies_fixed:
|
||||||
if not result.reconciliation_successful:
|
plugin_manager.discover_plugins()
|
||||||
_logger.warning(
|
_reconciliation_done = True
|
||||||
"[Reconciliation] Finished with %d unresolved issue(s); "
|
else:
|
||||||
"will not retry automatically. Use the Plugin Store or the "
|
_logger.warning("[Reconciliation] Finished with unresolved issues, will retry")
|
||||||
"manual 'Reconcile' action to resolve.",
|
with _reconciliation_lock:
|
||||||
len(result.inconsistencies_manual),
|
_reconciliation_started = False
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||||
finally:
|
with _reconciliation_lock:
|
||||||
# Always mark done — we do not want an unhandled exception (or an
|
_reconciliation_started = False
|
||||||
# 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
|
# Initialize health monitor and run reconciliation on first request
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -726,6 +710,4 @@ def check_health_monitor():
|
|||||||
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# threaded=True is Flask's default since 1.0 but stated explicitly so that
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
# 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,15 +2,13 @@ from flask import Blueprint, request, jsonify, Response, send_from_directory
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import threading
|
from datetime import datetime
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple, Dict, Any, Type
|
from typing import Optional, Tuple, Dict, Any, Type
|
||||||
|
|
||||||
@@ -218,7 +216,7 @@ def _ensure_display_service_running():
|
|||||||
if status.get('active'):
|
if status.get('active'):
|
||||||
status['started'] = False
|
status['started'] = False
|
||||||
return status
|
return status
|
||||||
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service'])
|
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix'])
|
||||||
service_status = _get_display_service_status()
|
service_status = _get_display_service_status()
|
||||||
result['started'] = result.get('returncode') == 0
|
result['started'] = result.get('returncode') == 0
|
||||||
result['active'] = service_status.get('active')
|
result['active'] = service_status.get('active')
|
||||||
@@ -227,7 +225,7 @@ def _ensure_display_service_running():
|
|||||||
|
|
||||||
def _stop_display_service():
|
def _stop_display_service():
|
||||||
"""Stop the ledmatrix display service."""
|
"""Stop the ledmatrix display service."""
|
||||||
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service'])
|
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix'])
|
||||||
status = _get_display_service_status()
|
status = _get_display_service_status()
|
||||||
result['active'] = status.get('active')
|
result['active'] = status.get('active')
|
||||||
result['status'] = status
|
result['status'] = status
|
||||||
@@ -1149,7 +1147,7 @@ def backup_export():
|
|||||||
'status': 'success',
|
'status': 'success',
|
||||||
'filename': zip_path.name,
|
'filename': zip_path.name,
|
||||||
'size': zip_path.stat().st_size,
|
'size': zip_path.stat().st_size,
|
||||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
'created_at': datetime.utcnow().isoformat() + 'Z',
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[Backup] export failed")
|
logger.exception("[Backup] export failed")
|
||||||
@@ -1169,7 +1167,7 @@ def backup_list():
|
|||||||
entries.append({
|
entries.append({
|
||||||
'filename': path.name,
|
'filename': path.name,
|
||||||
'size': stat.st_size,
|
'size': stat.st_size,
|
||||||
'created_at': datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
|
'created_at': datetime.utcfromtimestamp(stat.st_mtime).isoformat() + 'Z',
|
||||||
})
|
})
|
||||||
return jsonify({'status': 'success', 'data': entries})
|
return jsonify({'status': 'success', 'data': entries})
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1234,20 +1232,8 @@ def _save_uploaded_backup_to_temp() -> Tuple[Optional[Path], Optional[Tuple[Resp
|
|||||||
fd, tmp_name = _tempfile.mkstemp(prefix='ledmatrix_upload_', suffix='.zip')
|
fd, tmp_name = _tempfile.mkstemp(prefix='ledmatrix_upload_', suffix='.zip')
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
tmp_path = Path(tmp_name)
|
tmp_path = Path(tmp_name)
|
||||||
max_bytes = 200 * 1024 * 1024
|
|
||||||
try:
|
try:
|
||||||
written = 0
|
upload.save(str(tmp_path))
|
||||||
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:
|
except Exception:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
logger.exception("[Backup] Failed to save uploaded backup")
|
logger.exception("[Backup] Failed to save uploaded backup")
|
||||||
@@ -1298,16 +1284,14 @@ def backup_restore():
|
|||||||
opts_dict = json.loads(raw_opts)
|
opts_dict = json.loads(raw_opts)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid options JSON'}), 400
|
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(
|
opts = backup_manager.RestoreOptions(
|
||||||
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
|
restore_config=bool(opts_dict.get('restore_config', True)),
|
||||||
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
|
restore_secrets=bool(opts_dict.get('restore_secrets', True)),
|
||||||
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
|
restore_wifi=bool(opts_dict.get('restore_wifi', True)),
|
||||||
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
|
restore_fonts=bool(opts_dict.get('restore_fonts', True)),
|
||||||
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
|
restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||||
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
|
reinstall_plugins=bool(opts_dict.get('reinstall_plugins', True)),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Snapshot current config through the atomic manager so the pre-restore
|
# Snapshot current config through the atomic manager so the pre-restore
|
||||||
@@ -1315,9 +1299,7 @@ def backup_restore():
|
|||||||
if api_v3.config_manager and opts.restore_config:
|
if api_v3.config_manager and opts.restore_config:
|
||||||
try:
|
try:
|
||||||
current = api_v3.config_manager.load_config()
|
current = api_v3.config_manager.load_config()
|
||||||
snapshot_ok, snapshot_err = _save_config_atomic(api_v3.config_manager, current, create_backup=True)
|
_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:
|
except Exception:
|
||||||
logger.warning("[Backup] Pre-restore snapshot failed (continuing)", exc_info=True)
|
logger.warning("[Backup] Pre-restore snapshot failed (continuing)", exc_info=True)
|
||||||
|
|
||||||
@@ -1342,13 +1324,6 @@ def backup_restore():
|
|||||||
try:
|
try:
|
||||||
ok = api_v3.plugin_store_manager.install_plugin(plugin_id)
|
ok = api_v3.plugin_store_manager.install_plugin(plugin_id)
|
||||||
if ok:
|
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)
|
result.plugins_installed.append(plugin_id)
|
||||||
else:
|
else:
|
||||||
result.plugins_failed.append({'plugin_id': plugin_id, 'error': 'install returned False'})
|
result.plugins_failed.append({'plugin_id': plugin_id, 'error': 'install returned False'})
|
||||||
@@ -1357,12 +1332,12 @@ def backup_restore():
|
|||||||
result.plugins_failed.append({'plugin_id': plugin_id, 'error': str(install_err)})
|
result.plugins_failed.append({'plugin_id': plugin_id, 'error': str(install_err)})
|
||||||
|
|
||||||
# Clear font catalog cache so restored fonts show up.
|
# Clear font catalog cache so restored fonts show up.
|
||||||
if any(r.startswith("fonts") for r in result.restored):
|
if 'fonts' in ' '.join(result.restored):
|
||||||
try:
|
try:
|
||||||
from web_interface.cache import delete_cached
|
from web_interface.cache import delete_cached
|
||||||
delete_cached('fonts_catalog')
|
delete_cached('fonts_catalog')
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("[Backup] Failed to clear font cache", exc_info=True)
|
pass
|
||||||
|
|
||||||
# Reload config_manager state so the UI picks up the new values
|
# Reload config_manager state so the UI picks up the new values
|
||||||
# without a full service restart.
|
# without a full service restart.
|
||||||
@@ -1373,7 +1348,7 @@ def backup_restore():
|
|||||||
try:
|
try:
|
||||||
api_v3.config_manager.load_config()
|
api_v3.config_manager.load_config()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
|
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
|
||||||
|
|
||||||
@@ -1627,71 +1602,6 @@ def get_system_version():
|
|||||||
logger.exception("[System] get_system_version failed")
|
logger.exception("[System] get_system_version failed")
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to get system version'}), 500
|
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'])
|
@api_v3.route('/system/action', methods=['POST'])
|
||||||
def execute_system_action():
|
def execute_system_action():
|
||||||
"""Execute system actions (start/stop/reboot/etc)"""
|
"""Execute system actions (start/stop/reboot/etc)"""
|
||||||
@@ -1716,34 +1626,33 @@ def execute_system_action():
|
|||||||
if mode:
|
if mode:
|
||||||
# For on-demand modes, we would need to integrate with the display controller
|
# For on-demand modes, we would need to integrate with the display controller
|
||||||
# For now, just start the display service
|
# For now, just start the display service
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if result.returncode == 0 else 'error',
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
'message': f'Started display in {mode} mode' if result.returncode == 0
|
'message': f'Started display in {mode} mode',
|
||||||
else f'Failed to start display in {mode} mode: {result.stderr.strip() or "check sudo systemctl status ledmatrix.service"}',
|
|
||||||
'returncode': result.returncode,
|
'returncode': result.returncode,
|
||||||
'stdout': result.stdout,
|
'stdout': result.stdout,
|
||||||
'stderr': result.stderr
|
'stderr': result.stderr
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True)
|
||||||
elif action == 'stop_display':
|
elif action == 'stop_display':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True)
|
||||||
elif action == 'enable_autostart':
|
elif action == 'enable_autostart':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True)
|
||||||
elif action == 'disable_autostart':
|
elif action == 'disable_autostart':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True)
|
||||||
elif action == 'reboot_system':
|
elif action == 'reboot_system':
|
||||||
result = subprocess.run(['sudo', 'reboot'],
|
result = subprocess.run(['sudo', 'reboot'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True)
|
||||||
elif action == 'shutdown_system':
|
elif action == 'shutdown_system':
|
||||||
result = subprocess.run(['sudo', 'poweroff'],
|
result = subprocess.run(['sudo', 'poweroff'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True)
|
||||||
elif action == 'git_pull':
|
elif action == 'git_pull':
|
||||||
# Use PROJECT_ROOT instead of hardcoded path
|
# Use PROJECT_ROOT instead of hardcoded path
|
||||||
project_dir = str(PROJECT_ROOT)
|
project_dir = str(PROJECT_ROOT)
|
||||||
@@ -1802,10 +1711,6 @@ def execute_system_action():
|
|||||||
cwd=project_dir
|
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
|
# Return custom response for git_pull
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
pull_message = "Code updated successfully."
|
pull_message = "Code updated successfully."
|
||||||
@@ -1824,11 +1729,12 @@ def execute_system_action():
|
|||||||
'stderr': result.stderr
|
'stderr': result.stderr
|
||||||
})
|
})
|
||||||
elif action == 'restart_display_service':
|
elif action == 'restart_display_service':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True)
|
||||||
elif action == 'restart_web_service':
|
elif action == 'restart_web_service':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||||
capture_output=True, text=True, timeout=15)
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'],
|
||||||
|
capture_output=True, text=True)
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': f'Unknown action: {action}'}), 400
|
return jsonify({'status': 'error', 'message': f'Unknown action: {action}'}), 400
|
||||||
|
|
||||||
@@ -2069,23 +1975,9 @@ def get_installed_plugins():
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Re-discover plugins only if the plugins directory has actually
|
# Re-discover plugins to ensure we have the latest list
|
||||||
# changed since our last scan, or if the caller explicitly asked
|
# This handles cases where plugins are added/removed after app startup
|
||||||
# for a refresh. The previous unconditional ``discover_plugins()``
|
api_v3.plugin_manager.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
|
# Get all installed plugin info from the plugin manager
|
||||||
all_plugin_info = api_v3.plugin_manager.get_all_plugin_info()
|
all_plugin_info = api_v3.plugin_manager.get_all_plugin_info()
|
||||||
@@ -2098,10 +1990,17 @@ def get_installed_plugins():
|
|||||||
for plugin_info in all_plugin_info:
|
for plugin_info in all_plugin_info:
|
||||||
plugin_id = plugin_info.get('id')
|
plugin_id = plugin_info.get('id')
|
||||||
|
|
||||||
# Note: we intentionally do NOT re-read manifest.json here.
|
# Re-read manifest from disk to ensure we have the latest metadata
|
||||||
# discover_plugins() above already reparses manifests on change;
|
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||||
# re-reading on every request added ~1 syscall+json.loads per
|
if manifest_path.exists():
|
||||||
# plugin per request for no benefit.
|
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)
|
||||||
|
|
||||||
# Get enabled status from config (source of truth)
|
# 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
|
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
||||||
@@ -2731,30 +2630,14 @@ def reconcile_plugin_state():
|
|||||||
|
|
||||||
from src.plugin_system.state_reconciliation import StateReconciliation
|
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(
|
reconciler = StateReconciliation(
|
||||||
state_manager=api_v3.plugin_state_manager,
|
state_manager=api_v3.plugin_state_manager,
|
||||||
config_manager=api_v3.config_manager,
|
config_manager=api_v3.config_manager,
|
||||||
plugin_manager=api_v3.plugin_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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Allow the caller to force a retry of previously-unrecoverable
|
result = reconciler.reconcile_state()
|
||||||
# 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(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
@@ -3177,181 +3060,6 @@ def update_plugin():
|
|||||||
status_code=500
|
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'])
|
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
||||||
def uninstall_plugin():
|
def uninstall_plugin():
|
||||||
"""Uninstall plugin"""
|
"""Uninstall plugin"""
|
||||||
@@ -3374,28 +3082,49 @@ def uninstall_plugin():
|
|||||||
# Use operation queue if available
|
# Use operation queue if available
|
||||||
if api_v3.operation_queue:
|
if api_v3.operation_queue:
|
||||||
def uninstall_callback(operation):
|
def uninstall_callback(operation):
|
||||||
"""Callback to execute plugin uninstallation transactionally."""
|
"""Callback to execute plugin uninstallation."""
|
||||||
try:
|
# Unload the plugin first if it's loaded
|
||||||
_do_transactional_uninstall(plugin_id, preserve_config)
|
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
||||||
except Exception as err:
|
api_v3.plugin_manager.unload_plugin(plugin_id)
|
||||||
error_msg = f'Failed to uninstall plugin {plugin_id}: {err}'
|
|
||||||
|
# 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}'
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
status="failed",
|
status="failed",
|
||||||
error=error_msg,
|
error=error_msg
|
||||||
)
|
)
|
||||||
# Re-raise so the operation_queue marks this op as failed.
|
raise Exception(error_msg)
|
||||||
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:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
status="success",
|
status="success",
|
||||||
details={"preserve_config": preserve_config},
|
details={"preserve_config": preserve_config}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {'success': True, 'message': f'Plugin {plugin_id} uninstalled successfully'}
|
return {'success': True, 'message': f'Plugin {plugin_id} uninstalled successfully'}
|
||||||
|
|
||||||
# Enqueue operation
|
# Enqueue operation
|
||||||
@@ -3410,32 +3139,55 @@ def uninstall_plugin():
|
|||||||
message=f'Plugin {plugin_id} uninstallation queued'
|
message=f'Plugin {plugin_id} uninstallation queued'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback to direct uninstall — same transactional helper.
|
# Fallback to direct uninstall
|
||||||
try:
|
# Unload the plugin first if it's loaded
|
||||||
_do_transactional_uninstall(plugin_id, preserve_config)
|
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
||||||
except Exception as err:
|
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:
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
status="failed",
|
status="failed",
|
||||||
error=f'Failed to uninstall plugin {plugin_id}: {err}',
|
error=f'Failed to uninstall plugin {plugin_id}'
|
||||||
)
|
)
|
||||||
|
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
||||||
f'Failed to uninstall plugin {plugin_id}: {err}',
|
f'Failed to uninstall plugin {plugin_id}',
|
||||||
status_code=500,
|
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:
|
except Exception as e:
|
||||||
logger.exception("[PluginUninstall] Unhandled exception")
|
logger.exception("[PluginUninstall] Unhandled exception")
|
||||||
from src.web_interface.errors import WebInterfaceError
|
from src.web_interface.errors import WebInterfaceError
|
||||||
@@ -6720,146 +6472,18 @@ def upload_calendar_credentials():
|
|||||||
logger.exception("[PluginConfig] upload_calendar_credentials failed")
|
logger.exception("[PluginConfig] upload_calendar_credentials failed")
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to upload calendar credentials'}), 500
|
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'])
|
@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
|
||||||
def list_calendar_calendars():
|
def list_calendar_calendars():
|
||||||
"""Return Google Calendars accessible with the currently authenticated credentials.
|
"""Return Google Calendars accessible with the currently authenticated credentials."""
|
||||||
|
if not api_v3.plugin_manager:
|
||||||
Reads credentials from the plugin directory directly so this works from the
|
return jsonify({'status': 'error', 'message': 'Plugin manager not available'}), 500
|
||||||
web process (which does not instantiate plugins).
|
plugin = api_v3.plugin_manager.get_plugin('calendar')
|
||||||
"""
|
if not plugin:
|
||||||
# Prefer a live plugin instance if one happens to exist (e.g. local dev where
|
return jsonify({'status': 'error', 'message': 'Calendar plugin is not running. Enable it and save config first.'}), 404
|
||||||
# web and display share a process); otherwise fall back to on-disk credentials.
|
if not hasattr(plugin, 'get_calendars'):
|
||||||
plugin = api_v3.plugin_manager.get_plugin('calendar') if api_v3.plugin_manager else None
|
return jsonify({'status': 'error', 'message': 'Installed plugin version does not support calendar listing — update the plugin.'}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if plugin is not None and hasattr(plugin, 'get_calendars'):
|
raw = 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
|
import collections.abc
|
||||||
if not isinstance(raw, (list, tuple)):
|
if not isinstance(raw, (list, tuple)):
|
||||||
logger.error('list_calendar_calendars: get_calendars() returned non-sequence type %r', type(raw))
|
logger.error('list_calendar_calendars: get_calendars() returned non-sequence type %r', type(raw))
|
||||||
@@ -7133,14 +6757,9 @@ def connect_wifi():
|
|||||||
'message': message
|
'message': message
|
||||||
})
|
})
|
||||||
else:
|
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({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': clean_message,
|
'message': message or 'Failed to connect to network'
|
||||||
'error_type': error_type
|
|
||||||
}), 400
|
}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[WiFi] Failed connecting to WiFi network")
|
logger.exception("[WiFi] Failed connecting to WiFi network")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from html import escape as html_escape
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
|
||||||
@@ -355,7 +354,7 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||||
|
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
return f'<div class="text-red-500 p-4">Plugin "{html_escape(plugin_id)}" not found</div>', 404
|
return f'<div class="text-red-500 p-4">Plugin "{plugin_id}" not found</div>', 404
|
||||||
|
|
||||||
# Get plugin instance (may be None if not loaded)
|
# Get plugin instance (may be None if not loaded)
|
||||||
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
||||||
|
|||||||
@@ -120,11 +120,7 @@ def main():
|
|||||||
|
|
||||||
# Run the web server with error handling for client disconnections
|
# Run the web server with error handling for client disconnections
|
||||||
try:
|
try:
|
||||||
# threaded=True is Flask's default since 1.0, but set it explicitly
|
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||||
# 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:
|
except (OSError, BrokenPipeError) as e:
|
||||||
# Suppress non-critical socket errors (client disconnections)
|
# 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
|
if isinstance(e, OSError) and e.errno in (113, 32, 104): # No route to host, Broken pipe, Connection reset
|
||||||
|
|||||||
@@ -1004,39 +1004,3 @@ button.bg-white {
|
|||||||
[data-theme="dark"] .theme-toggle-btn {
|
[data-theme="dark"] .theme-toggle-btn {
|
||||||
color: #fbbf24;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
const xOptions = config['x-options'] || config['x_options'] || {};
|
const xOptions = config['x-options'] || config['x_options'] || {};
|
||||||
const requestedFormat = xOptions.format || 'long';
|
const requestedFormat = xOptions.format || 'long';
|
||||||
// Validate format exists in DAY_LABELS, default to 'long' if not
|
// Validate format exists in DAY_LABELS, default to 'long' if not
|
||||||
const format = Object.prototype.hasOwnProperty.call(DAY_LABELS, requestedFormat) ? requestedFormat : 'long';
|
const format = DAY_LABELS.hasOwnProperty(requestedFormat) ? requestedFormat : 'long';
|
||||||
const layout = xOptions.layout || 'horizontal';
|
const layout = xOptions.layout || 'horizontal';
|
||||||
const showSelectAll = xOptions.selectAll !== false;
|
const showSelectAll = xOptions.selectAll !== false;
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,6 @@
|
|||||||
}
|
}
|
||||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
|
||||||
if (fileInput) fileInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -898,10 +898,6 @@ window.currentPluginConfig = null;
|
|||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
let storeFilteredList = [];
|
let storeFilteredList = [];
|
||||||
|
|
||||||
function storeCacheExpired() {
|
|
||||||
return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Plugin Store Filter State ───────────────────────────────────────────
|
// ── Plugin Store Filter State ───────────────────────────────────────────
|
||||||
const storeFilterState = {
|
const storeFilterState = {
|
||||||
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
||||||
@@ -1169,11 +1165,9 @@ function initializePluginPageWhenReady() {
|
|||||||
if (target.id === 'plugins-content' ||
|
if (target.id === 'plugins-content' ||
|
||||||
target.querySelector('#installed-plugins-grid')) {
|
target.querySelector('#installed-plugins-grid')) {
|
||||||
console.log('HTMX swap detected for plugins, initializing...');
|
console.log('HTMX swap detected for plugins, initializing...');
|
||||||
// Reset all initialization flags so the fresh empty DOM gets populated
|
// Reset initialization flag to allow re-initialization after HTMX swap
|
||||||
window.pluginManager.initialized = false;
|
window.pluginManager.initialized = false;
|
||||||
window.pluginManager.initializing = false;
|
window.pluginManager.initializing = false;
|
||||||
window.pluginManager._reswap = true; // signal: use cached store, don't re-fetch GitHub
|
|
||||||
pluginsInitialized = false;
|
|
||||||
initTimer = setTimeout(attemptInit, 100);
|
initTimer = setTimeout(attemptInit, 100);
|
||||||
}
|
}
|
||||||
}, { once: false }); // Allow multiple swaps
|
}, { once: false }); // Allow multiple swaps
|
||||||
@@ -1217,19 +1211,9 @@ function initializePlugins() {
|
|||||||
console.warn('[INIT] checkGitHubAuthStatus not available yet');
|
console.warn('[INIT] checkGitHubAuthStatus not available yet');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load both installed plugins and plugin store.
|
// Load both installed plugins and plugin store
|
||||||
// On HTMX re-swaps with a still-warm cache, skip GitHub metadata to avoid
|
loadInstalledPlugins();
|
||||||
// re-hitting the API on every tab switch. If the cache TTL has expired even
|
searchPluginStore(true); // Load plugin store with fresh metadata from GitHub
|
||||||
// during a re-swap, fetch fresh data including GitHub commit/version info.
|
|
||||||
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
|
|
||||||
window.pluginManager._reswap = false;
|
|
||||||
// Await the installed-plugins fetch so window.installedPlugins is populated before
|
|
||||||
// searchPluginStore renders Installed/Reinstall badges against it.
|
|
||||||
loadInstalledPlugins().then(() => {
|
|
||||||
searchPluginStore(!isReswapWarm);
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('[PluginStore] loadInstalledPlugins failed:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup search functionality (with guard against duplicate listeners)
|
// Setup search functionality (with guard against duplicate listeners)
|
||||||
const searchInput = document.getElementById('plugin-search');
|
const searchInput = document.getElementById('plugin-search');
|
||||||
@@ -5143,13 +5127,10 @@ function refreshPlugins() {
|
|||||||
pluginStoreCache = null;
|
pluginStoreCache = null;
|
||||||
cacheTimestamp = null;
|
cacheTimestamp = null;
|
||||||
|
|
||||||
// refreshInstalledPlugins() is async (returns a Promise via loadInstalledPlugins).
|
loadInstalledPlugins();
|
||||||
// Only search the store and notify after window.installedPlugins is updated so
|
// Fetch latest metadata from GitHub when refreshing
|
||||||
// that Installed/Reinstall badges reflect the freshly fetched state.
|
searchPluginStore(true);
|
||||||
refreshInstalledPlugins().then(() => {
|
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
||||||
searchPluginStore(true);
|
|
||||||
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartDisplay() {
|
function restartDisplay() {
|
||||||
@@ -7180,13 +7161,6 @@ window.getSchemaProperty = getSchemaProperty;
|
|||||||
window.escapeHtml = escapeHtml;
|
window.escapeHtml = escapeHtml;
|
||||||
window.escapeAttribute = escapeAttribute;
|
window.escapeAttribute = escapeAttribute;
|
||||||
|
|
||||||
// Expose GitHub install handlers. These must be assigned inside the IIFE —
|
|
||||||
// from outside the IIFE, `typeof attachInstallButtonHandler` evaluates to
|
|
||||||
// 'undefined' and the fallback path at the bottom of this file fires a
|
|
||||||
// [FALLBACK] attachInstallButtonHandler not available on window warning.
|
|
||||||
window.attachInstallButtonHandler = attachInstallButtonHandler;
|
|
||||||
window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
|
|
||||||
|
|
||||||
})(); // End IIFE
|
})(); // End IIFE
|
||||||
|
|
||||||
// Functions to handle array-of-objects
|
// Functions to handle array-of-objects
|
||||||
@@ -7416,8 +7390,16 @@ if (typeof loadInstalledPlugins !== 'undefined') {
|
|||||||
if (typeof renderInstalledPlugins !== 'undefined') {
|
if (typeof renderInstalledPlugins !== 'undefined') {
|
||||||
window.renderInstalledPlugins = renderInstalledPlugins;
|
window.renderInstalledPlugins = renderInstalledPlugins;
|
||||||
}
|
}
|
||||||
// GitHub install handlers are now exposed inside the IIFE (see above).
|
// Expose GitHub install handlers for debugging and manual testing
|
||||||
// searchPluginStore is also exposed inside the IIFE after its definition.
|
if (typeof setupGitHubInstallHandlers !== 'undefined') {
|
||||||
|
window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
|
||||||
|
console.log('[GLOBAL] setupGitHubInstallHandlers exposed to window');
|
||||||
|
}
|
||||||
|
if (typeof attachInstallButtonHandler !== 'undefined') {
|
||||||
|
window.attachInstallButtonHandler = attachInstallButtonHandler;
|
||||||
|
console.log('[GLOBAL] attachInstallButtonHandler exposed to window');
|
||||||
|
}
|
||||||
|
// searchPluginStore is now exposed inside the IIFE after its definition
|
||||||
|
|
||||||
// Verify critical functions are available
|
// Verify critical functions are available
|
||||||
if (_PLUGIN_DEBUG_EARLY) {
|
if (_PLUGIN_DEBUG_EARLY) {
|
||||||
|
|||||||
@@ -786,25 +786,56 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Alpine.js for reactive components.
|
<!-- Alpine.js for reactive components -->
|
||||||
Load the local copy first (always works, no CDN round-trip, no AP-mode
|
<!-- Use local file when in AP mode (192.168.4.x) to avoid CDN dependency -->
|
||||||
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>
|
<script>
|
||||||
// Rescue: if the local Alpine didn't load for any reason, pull the CDN
|
(function() {
|
||||||
// copy once on window load. This is a last-ditch fallback, not the
|
// Prevent Alpine from auto-initializing by setting deferLoadingAlpine before it loads
|
||||||
// primary path.
|
window.deferLoadingAlpine = function(callback) {
|
||||||
window.addEventListener('load', function() {
|
// Wait for DOM to be ready
|
||||||
if (typeof window.Alpine === 'undefined') {
|
function waitForReady() {
|
||||||
console.warn('[Alpine] Local file failed to load, falling back to CDN');
|
if (document.readyState === 'loading') {
|
||||||
const s = document.createElement('script');
|
document.addEventListener('DOMContentLoaded', waitForReady);
|
||||||
s.src = 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
|
return;
|
||||||
document.head.appendChild(s);
|
}
|
||||||
}
|
|
||||||
});
|
// 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);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
||||||
@@ -900,34 +931,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 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%;">
|
<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 -->
|
<!-- Navigation tabs -->
|
||||||
@@ -4902,77 +4905,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -191,10 +191,7 @@ function doConnect() {
|
|||||||
// Poll for the new IP
|
// Poll for the new IP
|
||||||
setTimeout(function() { checkNewIP(ssid); }, 3000);
|
setTimeout(function() { checkNewIP(ssid); }, 3000);
|
||||||
} else {
|
} else {
|
||||||
var msg = data.error_type === 'wrong_password'
|
showMsg(data.message || 'Connection failed', 'err');
|
||||||
? 'Incorrect password — please try again'
|
|
||||||
: (data.message || 'Connection failed');
|
|
||||||
showMsg(msg, 'err');
|
|
||||||
connecting = false;
|
connecting = false;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = 'Connect';
|
btn.innerHTML = 'Connect';
|
||||||
|
|||||||
@@ -117,8 +117,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
let inspectedFile = null;
|
|
||||||
|
|
||||||
function notify(message, kind) {
|
function notify(message, kind) {
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification(message, kind || 'info');
|
showNotification(message, kind || 'info');
|
||||||
@@ -247,14 +245,12 @@
|
|||||||
notify('Choose a backup file first', 'error');
|
notify('Choose a backup file first', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const file = input.files[0];
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('backup_file', file);
|
fd.append('backup_file', input.files[0]);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v3/backup/validate', { method: 'POST', body: fd });
|
const res = await fetch('/api/v3/backup/validate', { method: 'POST', body: fd });
|
||||||
const payload = await res.json();
|
const payload = await res.json();
|
||||||
if (payload.status !== 'success') throw new Error(payload.message || 'Validation failed');
|
if (payload.status !== 'success') throw new Error(payload.message || 'Validation failed');
|
||||||
inspectedFile = file;
|
|
||||||
renderRestorePreview(payload.data);
|
renderRestorePreview(payload.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify('Invalid backup: ' + err.message, 'error');
|
notify('Invalid backup: ' + err.message, 'error');
|
||||||
@@ -277,15 +273,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearRestore() {
|
function clearRestore() {
|
||||||
inspectedFile = null;
|
|
||||||
document.getElementById('restore-preview').classList.add('hidden');
|
document.getElementById('restore-preview').classList.add('hidden');
|
||||||
document.getElementById('restore-result').classList.add('hidden');
|
document.getElementById('restore-result').classList.add('hidden');
|
||||||
document.getElementById('restore-file-input').value = '';
|
document.getElementById('restore-file-input').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRestore() {
|
async function runRestore() {
|
||||||
if (!inspectedFile) {
|
const input = document.getElementById('restore-file-input');
|
||||||
notify('Inspect the file before restoring', 'error');
|
if (!input.files || !input.files[0]) {
|
||||||
|
notify('Choose a backup file first', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm('Restore from this backup? Current configuration will be overwritten.')) return;
|
if (!confirm('Restore from this backup? Current configuration will be overwritten.')) return;
|
||||||
@@ -299,7 +295,7 @@
|
|||||||
reinstall_plugins: document.getElementById('opt-reinstall').checked,
|
reinstall_plugins: document.getElementById('opt-reinstall').checked,
|
||||||
};
|
};
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('backup_file', inspectedFile);
|
fd.append('backup_file', input.files[0]);
|
||||||
fd.append('options', JSON.stringify(options));
|
fd.append('options', JSON.stringify(options));
|
||||||
|
|
||||||
const btn = document.getElementById('run-restore-btn');
|
const btn = document.getElementById('run-restore-btn');
|
||||||
@@ -308,27 +304,21 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v3/backup/restore', { method: 'POST', body: fd });
|
const res = await fetch('/api/v3/backup/restore', { method: 'POST', body: fd });
|
||||||
const payload = await res.json();
|
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 data = payload.data || {};
|
||||||
const hasPartial = (data.plugins_failed || []).length > 0 || (data.errors || []).length > 0;
|
|
||||||
const result = document.getElementById('restore-result');
|
const result = document.getElementById('restore-result');
|
||||||
result.className = (hasPartial
|
const ok = payload.status === 'success';
|
||||||
? 'bg-yellow-50 border-yellow-200 text-yellow-800'
|
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';
|
||||||
: 'bg-green-50 border-green-200 text-green-800') + ' border rounded-md p-4';
|
|
||||||
result.classList.remove('hidden');
|
result.classList.remove('hidden');
|
||||||
result.innerHTML = `
|
result.innerHTML = `
|
||||||
<h3 class="font-medium mb-2">${hasPartial ? 'Restore complete with warnings' : 'Restore complete'}</h3>
|
<h3 class="font-medium mb-2">${ok ? 'Restore complete' : 'Restore finished with warnings'}</h3>
|
||||||
<div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div>
|
<div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||||
<div><strong>Skipped:</strong> ${(data.skipped || []).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 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>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>
|
<div><strong>Errors:</strong> ${(data.errors || []).map(escapeHtml).join('; ') || 'none'}</div>
|
||||||
${((data.restored || []).length || (data.plugins_installed || []).length) ? '<p class="mt-2">Restart the display service to apply all changes.</p>' : ''}
|
<p class="mt-2">Restart the display service to apply all changes.</p>
|
||||||
`;
|
`;
|
||||||
notify(hasPartial ? 'Restore complete with warnings' : 'Restore complete', hasPartial ? 'warning' : 'success');
|
notify(ok ? 'Restore complete' : 'Restore finished with warnings', ok ? 'success' : 'info');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify('Restore failed: ' + err.message, 'error');
|
notify('Restore failed: ' + err.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -344,13 +334,6 @@
|
|||||||
window.clearRestore = clearRestore;
|
window.clearRestore = clearRestore;
|
||||||
window.runRestore = runRestore;
|
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.
|
// Initial load.
|
||||||
loadPreview();
|
loadPreview();
|
||||||
loadBackupList();
|
loadBackupList();
|
||||||
|
|||||||
Reference in New Issue
Block a user