ss
This commit is contained in:
122
d2ypp2/tools/render_neko_compose.py
Normal file
122
d2ypp2/tools/render_neko_compose.py
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a dynamic Neko docker-compose file from the current environment."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def env(name: str, default: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
return value if value not in {None, ""} else default
|
||||
|
||||
|
||||
def q(value: str) -> str:
|
||||
return json.dumps(str(value), ensure_ascii=False)
|
||||
|
||||
|
||||
def instance_value(prefix: str, idx: int, default: str) -> str:
|
||||
return env(f"{prefix}_{idx}", default)
|
||||
|
||||
|
||||
def default_udp_range(idx: int) -> str:
|
||||
if idx == 1:
|
||||
return "52000-52099"
|
||||
if idx == 2:
|
||||
return "52101-52199"
|
||||
if idx == 3:
|
||||
return "52201-52299"
|
||||
start = 52400 + ((idx - 4) * 100)
|
||||
return f"{start}-{start + 99}"
|
||||
|
||||
|
||||
def render(count: int) -> str:
|
||||
image = env("NEKO_IMAGE", "ghcr.io/m1k1o/neko/chromium:3.0.0")
|
||||
shm = env("NEKO_SHM_SIZE", "512mb")
|
||||
screen = env("NEKO_DESKTOP_SCREEN", "1280x720@15")
|
||||
user_pass = env("NEKO_USER_PASS", "12345678")
|
||||
admin_pass = env("NEKO_ADMIN_PASS", "12345678")
|
||||
icelite = env("NEKO_WEBRTC_ICELITE", "0")
|
||||
nat1to1 = env("NEKO_WEBRTC_NAT1TO1", "")
|
||||
max_fps = env("NEKO_MAX_FPS", "15")
|
||||
video_bitrate = env("NEKO_VIDEO_BITRATE", "1200")
|
||||
plugins_enabled = env("NEKO_PLUGINS_ENABLED", "true")
|
||||
start_url = env("NEKO_START_URL", "https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com/")
|
||||
render_mode = env("NEKO_BROWSER_RENDER_MODE", "auto")
|
||||
supervisor_conf = env("NEKO_BROWSER_SUPERVISOR_CONF", "chromium.conf")
|
||||
base_profile = env("NEKO_PROFILE_HOST_DIR", str(Path.cwd() / "data" / "chromium-profile-v2"))
|
||||
http_base = int(env("NEKO_HTTP_PORT", "9200"))
|
||||
mux_base = int(env("NEKO_WEBRTC_TCPMUX", "52300"))
|
||||
|
||||
lines = [
|
||||
"# Generated by tools/render_neko_compose.py - do not hand-edit",
|
||||
"services:",
|
||||
]
|
||||
for idx in range(1, count + 1):
|
||||
http_port = instance_value("NEKO_HTTP_PORT", idx, str(http_base + idx - 1))
|
||||
tcp_mux = instance_value("NEKO_WEBRTC_TCPMUX", idx, str(mux_base + idx - 1))
|
||||
udp_mux = instance_value("NEKO_WEBRTC_UDPMUX", idx, str(mux_base + idx - 1))
|
||||
udp_range = instance_value("NEKO_WEBRTC_UDP_RANGE", idx, default_udp_range(idx))
|
||||
epr = instance_value("NEKO_WEBRTC_EPR", idx, udp_range)
|
||||
profile = instance_value("NEKO_PROFILE_HOST_DIR", idx, base_profile if idx == 1 else f"{base_profile}-{idx}")
|
||||
lines.extend(
|
||||
[
|
||||
f" neko{idx}:",
|
||||
f" image: {q(image)}",
|
||||
" restart: unless-stopped",
|
||||
f" shm_size: {q(shm)}",
|
||||
" cap_add:",
|
||||
" - SYS_ADMIN",
|
||||
f" container_name: live-neko-{idx}",
|
||||
" environment:",
|
||||
' DISPLAY: ":99.0"',
|
||||
f" NEKO_DESKTOP_SCREEN: {q(screen)}",
|
||||
' NEKO_SERVER_BIND: ":8080"',
|
||||
' NEKO_MEMBER_PROVIDER: "multiuser"',
|
||||
f" NEKO_MEMBER_MULTIUSER_USER_PASSWORD: {q(user_pass)}",
|
||||
f" NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: {q(admin_pass)}",
|
||||
f" NEKO_WEBRTC_ICELITE: {q(icelite)}",
|
||||
f" NEKO_WEBRTC_NAT1TO1: {q(nat1to1)}",
|
||||
f" NEKO_MAX_FPS: {q(max_fps)}",
|
||||
f" NEKO_VIDEO_BITRATE: {q(video_bitrate)}",
|
||||
f" NEKO_PLUGINS_ENABLED: {q(plugins_enabled)}",
|
||||
' NEKO_PLUGINS_DIR: "/etc/neko/plugins/"',
|
||||
f" NEKO_START_URL: {q(start_url)}",
|
||||
f" NEKO_BROWSER_RENDER_MODE: {q(render_mode)}",
|
||||
f" NEKO_WEBRTC_EPR: {q(epr)}",
|
||||
f" NEKO_WEBRTC_TCPMUX: {q(tcp_mux)}",
|
||||
f" NEKO_WEBRTC_UDPMUX: {q(udp_mux)}",
|
||||
" ports:",
|
||||
f" - {q(f'{http_port}:8080')}",
|
||||
f" - {q(f'{tcp_mux}:{tcp_mux}/tcp')}",
|
||||
f" - {q(f'{udp_mux}:{udp_mux}/udp')}",
|
||||
f" - {q(f'{udp_range}:{udp_range}/udp')}",
|
||||
" volumes:",
|
||||
f" - {q(f'{profile}:/home/neko/.config/chromium')}",
|
||||
f" - {q(f'{profile}:/home/neko/.config/google-chrome')}",
|
||||
' - "./policies:/etc/chromium/policies/managed:ro"',
|
||||
' - "./policies:/etc/opt/chrome/policies/managed:ro"',
|
||||
' - "./provisioning:/var/www/provisioning:ro"',
|
||||
' - "./browser-launch.sh:/etc/neko/browser-launch.sh:ro"',
|
||||
f" - {q(f'./{supervisor_conf}:/etc/neko/supervisord/{supervisor_conf}:ro')}",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Render Neko docker compose")
|
||||
parser.add_argument("--count", type=int, required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
count = max(2, min(64, args.count))
|
||||
output = Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(render(count), encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -53,15 +54,17 @@ def tm_hash(value: Any, seen: list[int] | None = None) -> str:
|
||||
|
||||
|
||||
def build_payload(script_text: str) -> dict[str, Any]:
|
||||
encoded_source = base64.b64encode(script_text.encode("utf-8")).decode("ascii")
|
||||
return {
|
||||
"version": "1",
|
||||
"scripts": [
|
||||
{
|
||||
"name": "YouTube Studio Auto Dismiss",
|
||||
"file_url": GREASYFORK_URL,
|
||||
"source": script_text,
|
||||
"source": encoded_source,
|
||||
"enabled": True,
|
||||
"position": 1,
|
||||
"options": {},
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
535
d2ypp2/tools/verify_local_install.py
Executable file
535
d2ypp2/tools/verify_local_install.py
Executable file
@@ -0,0 +1,535 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local production verifier for SHD2YPP2 installs.
|
||||
|
||||
This is intentionally stdlib-only so it can run immediately after install.sh on
|
||||
fresh Debian/Ubuntu/Armbian hosts. It checks the real delivery path, not only a
|
||||
single process: files, systemd, API, edge proxy, Docker/Neko, Tampermonkey
|
||||
provisioning, and Android screenshot/control readiness when devices exist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_INSTALL_DIR = Path("/opt/live/d2ypp")
|
||||
PNG_SIG = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def run(cmd: list[str], *, timeout: float = 8.0) -> tuple[int, str, str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
errors="replace",
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return 127, "", str(exc)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
out = exc.stdout if isinstance(exc.stdout, str) else ""
|
||||
err = exc.stderr if isinstance(exc.stderr, str) else ""
|
||||
return 124, out, err or f"timeout after {timeout}s"
|
||||
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
||||
|
||||
|
||||
def read_env(path: Path) -> dict[str, str]:
|
||||
env: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return env
|
||||
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key:
|
||||
env[key] = value
|
||||
return env
|
||||
|
||||
|
||||
def env_flag(env: dict[str, str], key: str, default: bool = True) -> bool:
|
||||
raw = env.get(key)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return raw.strip().lower() not in {"0", "false", "no", "off", "disable", "disabled"}
|
||||
|
||||
|
||||
def env_int(env: dict[str, str], key: str, default: int) -> int:
|
||||
try:
|
||||
return int(str(env.get(key, default)).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def check(
|
||||
checks: list[dict[str, Any]],
|
||||
check_id: str,
|
||||
label: str,
|
||||
ok: bool,
|
||||
*,
|
||||
required: bool = True,
|
||||
detail: str = "",
|
||||
hint: str = "",
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
item = {
|
||||
"id": check_id,
|
||||
"label": label,
|
||||
"ok": bool(ok),
|
||||
"required": bool(required),
|
||||
"detail": detail,
|
||||
}
|
||||
if hint:
|
||||
item["hint"] = hint
|
||||
if data:
|
||||
item.update(data)
|
||||
checks.append(item)
|
||||
return item
|
||||
|
||||
|
||||
def http_bytes(url: str, *, timeout: float = 5.0) -> tuple[bool, int, bytes, str, float]:
|
||||
start = time.monotonic()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
body = resp.read()
|
||||
return True, int(resp.status), body, "", round((time.monotonic() - start) * 1000, 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read() if hasattr(exc, "read") else b""
|
||||
return False, int(exc.code), body, str(exc), round((time.monotonic() - start) * 1000, 1)
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
return False, 0, b"", str(exc), round((time.monotonic() - start) * 1000, 1)
|
||||
|
||||
|
||||
def http_json(url: str, *, timeout: float = 5.0) -> tuple[bool, int, dict[str, Any], str, float]:
|
||||
ok, code, body, err, latency = http_bytes(url, timeout=timeout)
|
||||
if not body:
|
||||
return ok, code, {}, err, latency
|
||||
try:
|
||||
return ok and 200 <= code < 300, code, json.loads(body.decode("utf-8", errors="replace")), err, latency
|
||||
except json.JSONDecodeError as exc:
|
||||
return False, code, {}, f"invalid JSON: {exc}", latency
|
||||
|
||||
|
||||
def http_json_post(url: str, payload: dict[str, Any], *, timeout: float = 5.0) -> tuple[bool, int, dict[str, Any], str, float]:
|
||||
start = time.monotonic()
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
latency = round((time.monotonic() - start) * 1000, 1)
|
||||
data = json.loads(raw.decode("utf-8", errors="replace")) if raw else {}
|
||||
return True, int(resp.status), data, "", latency
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read() if hasattr(exc, "read") else b""
|
||||
latency = round((time.monotonic() - start) * 1000, 1)
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8", errors="replace")) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
return False, int(exc.code), data, str(exc), latency
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
return False, 0, {}, str(exc), round((time.monotonic() - start) * 1000, 1)
|
||||
|
||||
|
||||
def systemd_active(unit: str) -> tuple[bool, str]:
|
||||
code, out, err = run(["systemctl", "is-active", unit], timeout=5)
|
||||
state = (out or err).strip()
|
||||
return code == 0 and state == "active", state or f"exit={code}"
|
||||
|
||||
|
||||
def docker_ps_names() -> tuple[bool, list[str], str]:
|
||||
if not shutil.which("docker"):
|
||||
return False, [], "docker missing"
|
||||
code, out, err = run(["docker", "ps", "--format", "{{.Names}}"], timeout=8)
|
||||
if code != 0:
|
||||
return False, [], err.strip() or out.strip() or f"exit={code}"
|
||||
return True, [line.strip() for line in out.splitlines() if line.strip()], ""
|
||||
|
||||
|
||||
def docker_exec(container: str, command: str, *, timeout: float = 8.0) -> tuple[int, str, str]:
|
||||
return run(["docker", "exec", container, "sh", "-lc", command], timeout=timeout)
|
||||
|
||||
|
||||
def verify_files(checks: list[dict[str, Any]], install_dir: Path) -> None:
|
||||
check(
|
||||
checks,
|
||||
"install_dir",
|
||||
"Runtime install directory",
|
||||
install_dir.is_dir(),
|
||||
detail=str(install_dir),
|
||||
hint="Run install.sh again; it syncs the project to /opt/live/d2ypp by default.",
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
"stack_env",
|
||||
"system-stack.env",
|
||||
(install_dir / "config" / "system-stack.env").is_file(),
|
||||
detail=str(install_dir / "config" / "system-stack.env"),
|
||||
hint="Copy config/system-stack.env.example or rerun the installer.",
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
"web_assets",
|
||||
"Exported web-console assets",
|
||||
(install_dir / "web-console" / "out" / "index.html").is_file(),
|
||||
detail=str(install_dir / "web-console" / "out" / "index.html"),
|
||||
hint="Run sudo bash upgrade-live.sh or rebuild web-console.",
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
"start_script",
|
||||
"start.sh executable",
|
||||
os.access(install_dir / "start.sh", os.X_OK),
|
||||
detail=str(install_dir / "start.sh"),
|
||||
hint="Run chmod +x start.sh and refresh live-console.service.",
|
||||
)
|
||||
|
||||
|
||||
def verify_systemd(checks: list[dict[str, Any]], env: dict[str, str]) -> None:
|
||||
ok, detail = systemd_active("live-console.service")
|
||||
check(
|
||||
checks,
|
||||
"live_console_service",
|
||||
"live-console.service",
|
||||
ok,
|
||||
detail=detail,
|
||||
hint="Inspect: journalctl -u live-console.service -n 120 --no-pager",
|
||||
)
|
||||
if env_flag(env, "ENABLE_LIVE_EDGE_PROXY", True):
|
||||
ok, detail = systemd_active("live-edge.service")
|
||||
check(
|
||||
checks,
|
||||
"live_edge_service",
|
||||
"live-edge.service",
|
||||
ok,
|
||||
detail=detail,
|
||||
hint="Run scripts/linux/reinstall_live_edge.sh or restart live-edge.service.",
|
||||
)
|
||||
ok, detail = systemd_active("live-edge-watch.service")
|
||||
check(
|
||||
checks,
|
||||
"live_edge_watch_service",
|
||||
"live-edge-watch.service",
|
||||
ok,
|
||||
detail=detail,
|
||||
hint="Run scripts/linux/reinstall_live_edge.sh to install the live.local watchdog.",
|
||||
)
|
||||
|
||||
|
||||
def verify_http(checks: list[dict[str, Any]], env: dict[str, str], *, timeout: float) -> None:
|
||||
port = env.get("PORT", os.environ.get("PORT", "8001"))
|
||||
mdns_host = f"{env.get('HOSTNAME_ALIAS', 'live')}.local"
|
||||
ok, code, data, err, latency = http_json(f"http://127.0.0.1:{port}/health", timeout=timeout)
|
||||
check(
|
||||
checks,
|
||||
"api_health",
|
||||
"FastAPI /health",
|
||||
ok and data.get("status") == "ok",
|
||||
detail=f"HTTP {code}, {latency} ms" if code else err,
|
||||
hint="live-console is not serving the control plane.",
|
||||
data={"url": f"http://127.0.0.1:{port}/health"},
|
||||
)
|
||||
ok, code, data, err, latency = http_json(f"http://127.0.0.1:{port}/deploy/check", timeout=timeout + 4)
|
||||
check(
|
||||
checks,
|
||||
"deploy_check",
|
||||
"FastAPI /deploy/check",
|
||||
ok and bool(data.get("ready")),
|
||||
detail=(
|
||||
f"{'READY' if data.get('ready') else 'DEGRADED'} "
|
||||
f"({data.get('pass_count', 0)}/{data.get('total_count', 0)}), {latency} ms"
|
||||
if code
|
||||
else err
|
||||
),
|
||||
hint="Use deploy/check failing_checks for the exact live.local or edge proxy failure.",
|
||||
data={"url": f"http://127.0.0.1:{port}/deploy/check", "report": data},
|
||||
)
|
||||
if env_flag(env, "ENABLE_LIVE_EDGE_PROXY", True):
|
||||
ok_b, code_b, _body, err_b, latency_b = http_bytes("http://127.0.0.1/health", timeout=timeout)
|
||||
check(
|
||||
checks,
|
||||
"edge_health",
|
||||
"live-edge /health",
|
||||
ok_b and code_b == 200,
|
||||
detail=f"HTTP {code_b}, {latency_b} ms" if code_b else err_b,
|
||||
hint="Port 80 should serve http://live.local through live-edge.service.",
|
||||
data={"url": "http://127.0.0.1/health", "mdns_url": f"http://{mdns_host}/"},
|
||||
)
|
||||
|
||||
|
||||
def neko_required_count(env: dict[str, str]) -> int:
|
||||
max_count = max(2, min(64, env_int(env, "NEKO_MAX_INSTANCE_COUNT", 64)))
|
||||
return max(2, min(max_count, env_int(env, "NEKO_INSTANCE_COUNT", 2)))
|
||||
|
||||
|
||||
def verify_neko(checks: list[dict[str, Any]], env: dict[str, str], *, timeout: float) -> None:
|
||||
if not env_flag(env, "ENABLE_NEKO", True):
|
||||
check(checks, "neko_enabled", "Neko enabled", True, required=False, detail="disabled")
|
||||
return
|
||||
docker_ok, names, err = docker_ps_names()
|
||||
check(
|
||||
checks,
|
||||
"docker_daemon",
|
||||
"Docker daemon",
|
||||
docker_ok,
|
||||
detail="running" if docker_ok else err,
|
||||
hint="Start Docker before Neko/Redroid/SRS services.",
|
||||
)
|
||||
if not docker_ok:
|
||||
return
|
||||
ok, detail = systemd_active("live-neko-ip-watch.service")
|
||||
check(
|
||||
checks,
|
||||
"neko_ip_watch_service",
|
||||
"live-neko-ip-watch.service",
|
||||
ok,
|
||||
detail=detail,
|
||||
hint="Run scripts/linux/upgrade_live_console.sh or reinstall the Neko LAN IP watcher.",
|
||||
)
|
||||
required = neko_required_count(env)
|
||||
running = [name for name in names if name.startswith("live-neko-")]
|
||||
check(
|
||||
checks,
|
||||
"neko_containers",
|
||||
"Neko containers",
|
||||
len(running) >= required,
|
||||
detail=f"running={len(running)} required={required}: {', '.join(running) or '-'}",
|
||||
hint="Run sudo bash scripts/linux/service_ctl.sh neko restart.",
|
||||
)
|
||||
for idx in range(1, required + 1):
|
||||
name = f"live-neko-{idx}"
|
||||
if name not in running:
|
||||
continue
|
||||
code, out, err2 = docker_exec(
|
||||
name,
|
||||
"supervisorctl status google-chrome 2>/dev/null || supervisorctl status chromium 2>/dev/null",
|
||||
timeout=timeout,
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
f"neko_{idx}_browser",
|
||||
f"{name} browser process",
|
||||
code == 0 and "RUNNING" in out,
|
||||
detail=(out or err2).strip(),
|
||||
hint="Make services/neko/browser-launch.sh executable and restart Neko.",
|
||||
)
|
||||
code, out, err2 = docker_exec(name, "supervisorctl status tampermonkey-provisioning", timeout=timeout)
|
||||
check(
|
||||
checks,
|
||||
f"neko_{idx}_tm_server",
|
||||
f"{name} Tampermonkey provisioning server",
|
||||
code == 0 and "RUNNING" in out,
|
||||
detail=(out or err2).strip(),
|
||||
hint="The Neko supervisor must run tampermonkey-provisioning on 127.0.0.1:18080.",
|
||||
)
|
||||
code, out, err2 = docker_exec(
|
||||
name,
|
||||
"curl -fsS http://127.0.0.1:18080/tampermonkey-provisioning.json | grep -q 'YouTube Studio Auto Dismiss'",
|
||||
timeout=timeout,
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
f"neko_{idx}_tm_payload",
|
||||
f"{name} Tampermonkey default script payload",
|
||||
code == 0,
|
||||
detail="payload contains YouTube Studio Auto Dismiss" if code == 0 else (err2 or out).strip(),
|
||||
hint="Regenerate services/neko/provisioning/tampermonkey-provisioning.json and restart Neko.",
|
||||
)
|
||||
code, out, err2 = docker_exec(
|
||||
name,
|
||||
"grep -R -a -l 'YouTube Studio Auto Dismiss' /home/neko/.config/google-chrome/Default /home/neko/.config/chromium/Default 2>/dev/null | head -1",
|
||||
timeout=timeout,
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
f"neko_{idx}_tm_storage",
|
||||
f"{name} Tampermonkey script imported",
|
||||
code == 0 and bool(out.strip()),
|
||||
detail=out.strip() or err2.strip() or "not found",
|
||||
hint="Open Neko once or restart the browser so Chrome policy imports the provisioning payload.",
|
||||
)
|
||||
|
||||
|
||||
def verify_youtube_pro_capacity(
|
||||
checks: list[dict[str, Any]],
|
||||
env: dict[str, str],
|
||||
*,
|
||||
port: str,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
ok, code, data, err, latency = http_json(f"http://127.0.0.1:{port}/youtube_pro_channels", timeout=timeout)
|
||||
channels = data.get("channels") if isinstance(data, dict) else []
|
||||
if not isinstance(channels, list):
|
||||
channels = []
|
||||
desired = max(2, min(64, len(channels) or 2))
|
||||
current = neko_required_count(env)
|
||||
check(
|
||||
checks,
|
||||
"youtube_pro_neko_capacity",
|
||||
"YouTube PRO Neko capacity",
|
||||
ok and current >= desired,
|
||||
required=env_flag(env, "ENABLE_NEKO", True),
|
||||
detail=(
|
||||
f"HTTP {code}, channels={len(channels)}, neko={current}, required={desired}, {latency} ms"
|
||||
if code
|
||||
else err
|
||||
),
|
||||
hint="Saving YouTube PRO channels should raise NEKO_INSTANCE_COUNT and restart Neko.",
|
||||
)
|
||||
|
||||
|
||||
def verify_android(checks: list[dict[str, Any]], env: dict[str, str], *, port: str, timeout: float) -> None:
|
||||
ok, code, data, err, latency = http_json(f"http://127.0.0.1:{port}/android/devices", timeout=timeout)
|
||||
devices = data.get("devices") if isinstance(data, dict) else []
|
||||
if not isinstance(devices, list):
|
||||
devices = []
|
||||
panel_required = env_flag(env, "ENABLE_ANDROID_PANEL", True)
|
||||
check(
|
||||
checks,
|
||||
"android_devices",
|
||||
"Android device API",
|
||||
ok and 200 <= code < 300 and isinstance(data, dict),
|
||||
required=panel_required,
|
||||
detail=f"HTTP {code}, devices={len(devices)}, {latency} ms" if code else err,
|
||||
hint="The control plane must expose /android/devices for Redroid and physical ADB devices.",
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
"android_device_presence",
|
||||
"ADB Android device presence",
|
||||
bool(data.get("available")) and bool(devices),
|
||||
required=False,
|
||||
detail=f"devices={len(devices)}",
|
||||
hint="Run adb devices -l; for Redroid run scripts/linux/service_ctl.sh redroid start.",
|
||||
)
|
||||
if not devices:
|
||||
return
|
||||
serial = str(devices[0].get("serial") or "")
|
||||
if not serial:
|
||||
return
|
||||
query = urllib.parse.urlencode({"serial": serial, "fast": "1"})
|
||||
ok_b, code_b, body, err_b, latency_b = http_bytes(
|
||||
f"http://127.0.0.1:{port}/android/screenshot?{query}",
|
||||
timeout=max(timeout, 12.0),
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
"android_screenshot",
|
||||
"Android screenshot PNG",
|
||||
ok_b and code_b == 200 and body.startswith(PNG_SIG),
|
||||
required=True,
|
||||
detail=f"HTTP {code_b}, bytes={len(body)}, {latency_b} ms" if code_b else err_b,
|
||||
hint="The Web panel needs a valid PNG fallback even when scrcpy/WebCodecs fails.",
|
||||
data={"serial": serial},
|
||||
)
|
||||
ok_a, code_a, action_data, err_a, latency_a = http_json_post(
|
||||
f"http://127.0.0.1:{port}/android/action",
|
||||
{"serial": serial, "action": "wake", "payload": {}},
|
||||
timeout=max(timeout, 12.0),
|
||||
)
|
||||
check(
|
||||
checks,
|
||||
"android_action",
|
||||
"Android web control action",
|
||||
ok_a and code_a == 200 and isinstance(action_data, dict) and not action_data.get("error"),
|
||||
required=True,
|
||||
detail=(
|
||||
f"HTTP {code_a}, {action_data.get('message', 'action ok')}, {latency_a} ms"
|
||||
if code_a
|
||||
else err_a
|
||||
),
|
||||
hint="The Web panel touch path requires /android/action to send ADB input events.",
|
||||
data={"serial": serial},
|
||||
)
|
||||
|
||||
|
||||
def summarize(checks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
required = [item for item in checks if item.get("required", True)]
|
||||
failed = [item for item in required if not item.get("ok")]
|
||||
return {
|
||||
"ready": not failed,
|
||||
"pass_count": sum(1 for item in checks if item.get("ok")),
|
||||
"total_count": len(checks),
|
||||
"required_total": len(required),
|
||||
"checks": checks,
|
||||
"failing_checks": failed,
|
||||
"hints": [item["hint"] for item in failed if item.get("hint")],
|
||||
}
|
||||
|
||||
|
||||
def print_human(report: dict[str, Any]) -> None:
|
||||
status = "READY" if report["ready"] else "DEGRADED"
|
||||
print(f"SHD2YPP2 local verification: {status} ({report['pass_count']}/{report['total_count']})")
|
||||
for item in report["checks"]:
|
||||
mark = "OK" if item.get("ok") else ("FAIL" if item.get("required", True) else "WARN")
|
||||
req = "required" if item.get("required", True) else "optional"
|
||||
print(f"{mark:4} {item['id']:28} {req:8} {item['detail']}")
|
||||
if report["hints"]:
|
||||
print("Hints:")
|
||||
for hint in report["hints"]:
|
||||
print(f"- {hint}")
|
||||
|
||||
|
||||
def build_report(args: argparse.Namespace) -> dict[str, Any]:
|
||||
install_dir = Path(args.install_dir).resolve()
|
||||
env = read_env(install_dir / "config" / "system-stack.env")
|
||||
port = str(args.port or env.get("PORT") or os.environ.get("PORT") or "8001")
|
||||
checks: list[dict[str, Any]] = []
|
||||
verify_files(checks, install_dir)
|
||||
verify_systemd(checks, env)
|
||||
verify_http(checks, env, timeout=args.timeout)
|
||||
if not args.skip_neko:
|
||||
verify_neko(checks, env, timeout=args.timeout)
|
||||
verify_youtube_pro_capacity(checks, env, port=port, timeout=args.timeout)
|
||||
if not args.skip_android:
|
||||
verify_android(checks, env, port=port, timeout=args.timeout)
|
||||
report = summarize(checks)
|
||||
report.update(
|
||||
{
|
||||
"install_dir": str(install_dir),
|
||||
"port": port,
|
||||
"mdns_host": f"{env.get('HOSTNAME_ALIAS', 'live')}.local",
|
||||
}
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify a local SHD2YPP2 installation")
|
||||
parser.add_argument("--install-dir", default=str(DEFAULT_INSTALL_DIR), help="Runtime project directory")
|
||||
parser.add_argument("--port", default="", help="Control plane port; defaults to system-stack.env PORT")
|
||||
parser.add_argument("--timeout", type=float, default=5.0, help="HTTP and command timeout seconds")
|
||||
parser.add_argument("--skip-neko", action="store_true", help="Skip Docker/Neko/Tampermonkey checks")
|
||||
parser.add_argument("--skip-android", action="store_true", help="Skip Android/ADB screenshot checks")
|
||||
parser.add_argument("--json", action="store_true", help="Print JSON report")
|
||||
args = parser.parse_args()
|
||||
|
||||
report = build_report(args)
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print_human(report)
|
||||
return 0 if report["ready"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user