mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 09:40:40 +00:00
feat: add autostart lifecycle controls
This commit is contained in:
1 parent
2dd0cf4ed8
commit
2de7ea073c
7 files changed
+152
-4
No files matched your search
+10
-3
@@ -77,8 +77,15 @@
|
||||
el.innerHTML = `<span class="status-badge ${className}">${label}</span>`;
|
||||
}
|
||||
|
||||
function summarizeDoctorChecks(checks) {
|
||||
if (!Array.isArray(checks) || checks.length === 0) return 'No diagnostics available.';
|
||||
function summarizeDoctorChecks(doctor) {
|
||||
const sections = doctor && doctor.sections ? Object.values(doctor.sections) : [];
|
||||
if (sections.length > 0) {
|
||||
const failedSections = sections.filter(section => section.checks && section.checks.length > 0 && !section.ok);
|
||||
if (failedSections.length === 0) return 'All doctor sections passed.';
|
||||
return `Doctor warnings in: ${failedSections.map(section => section.title).join(', ')}`;
|
||||
}
|
||||
const checks = doctor && Array.isArray(doctor.checks) ? doctor.checks : [];
|
||||
if (checks.length === 0) return 'No diagnostics available.';
|
||||
const failed = checks.filter(check => !check.ok);
|
||||
if (failed.length === 0) return 'All doctor checks passed.';
|
||||
return `Doctor reports ${failed.length} failing check(s): ${failed.map(check => check.name).join(', ')}`;
|
||||
@@ -102,7 +109,7 @@
|
||||
} else if (autostart && autostart.error) {
|
||||
document.getElementById('autostart-state').textContent = `Autostart status unavailable: ${autostart.error}`;
|
||||
}
|
||||
document.getElementById('doctor-summary').textContent = summarizeDoctorChecks(doctor.checks);
|
||||
document.getElementById('doctor-summary').textContent = summarizeDoctorChecks(doctor);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
|
||||
@@ -315,6 +315,9 @@ def test_doctor_json_output(tmp_path, monkeypatch, capsys):
|
||||
captured = capsys.readouterr()
|
||||
report = json.loads(captured.out)
|
||||
assert report["platform_id"] == "linux_cli_systemd"
|
||||
assert "summary" in report
|
||||
assert "sections" in report
|
||||
assert "dependencies" in report["sections"]
|
||||
assert "checks" in report
|
||||
|
||||
|
||||
@@ -375,6 +378,29 @@ def test_autostart_status_json_output(tmp_path, monkeypatch, capsys):
|
||||
assert len(status["artifacts"]) == 2
|
||||
|
||||
|
||||
def test_linux_autostart_uninstall_removes_artifacts(tmp_path, monkeypatch, capsys):
|
||||
_setup_tmp(monkeypatch, tmp_path)
|
||||
fake_home = tmp_path / "home"
|
||||
commands = []
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr(platform_linux.Path, "home", lambda: fake_home)
|
||||
monkeypatch.setattr(platform_linux.subprocess, "run", lambda args, **kwargs: commands.append(args))
|
||||
systemd_dir = fake_home / ".config" / "systemd" / "user"
|
||||
systemd_dir.mkdir(parents=True, exist_ok=True)
|
||||
service = systemd_dir / "voxvera-start.service"
|
||||
timer = systemd_dir / "voxvera-start.timer"
|
||||
service.write_text("service", encoding="utf-8")
|
||||
timer.write_text("timer", encoding="utf-8")
|
||||
|
||||
cli.main(["autostart", "uninstall"])
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert "Removed systemd user service" in captured.out
|
||||
assert not service.exists()
|
||||
assert not timer.exists()
|
||||
assert ["systemctl", "--user", "disable", "--now", "voxvera-start.timer"] in commands
|
||||
|
||||
|
||||
def test_macos_autostart_status_and_render(tmp_path, monkeypatch):
|
||||
_setup_tmp(monkeypatch, tmp_path)
|
||||
fake_home = tmp_path / "home"
|
||||
@@ -395,6 +421,25 @@ def test_macos_autostart_status_and_render(tmp_path, monkeypatch):
|
||||
assert any(check["name"] == "launchd_loaded" for check in status["checks"])
|
||||
|
||||
|
||||
def test_macos_autostart_uninstall_removes_plist(tmp_path, monkeypatch, capsys):
|
||||
_setup_tmp(monkeypatch, tmp_path)
|
||||
fake_home = tmp_path / "home"
|
||||
commands = []
|
||||
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
|
||||
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda args, stderr=None: commands.append(args) or b"")
|
||||
plist = fake_home / "Library" / "LaunchAgents" / "org.voxvera.start-all.plist"
|
||||
plist.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist.write_text("plist", encoding="utf-8")
|
||||
|
||||
cli.main(["autostart", "uninstall"])
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert "Removed LaunchAgent" in captured.out
|
||||
assert not plist.exists()
|
||||
assert ["launchctl", "unload", str(plist)] in commands
|
||||
|
||||
|
||||
def test_windows_autostart_status_and_render(monkeypatch):
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda *args, **kwargs: b"TaskName: VoxVera Start All")
|
||||
@@ -412,6 +457,18 @@ def test_windows_autostart_status_and_render(monkeypatch):
|
||||
assert any(check["name"] == "scheduled_task_present" for check in status["checks"])
|
||||
|
||||
|
||||
def test_windows_autostart_uninstall_uses_schtasks(monkeypatch, capsys):
|
||||
commands = []
|
||||
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda args, stderr=None: commands.append(args) or b"SUCCESS")
|
||||
|
||||
cli.main(["autostart", "uninstall"])
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert "removed" in captured.out.lower()
|
||||
assert ["schtasks", "/Delete", "/TN", "VoxVera Start All", "/F"] in commands
|
||||
|
||||
|
||||
def test_build_download_zip(tmp_path, monkeypatch):
|
||||
_setup_tmp(monkeypatch, tmp_path)
|
||||
res_root = tmp_path / "data" / "voxvera"
|
||||
|
||||
+19
-1
@@ -274,6 +274,13 @@ def print_report(report_type: str, json_output: bool = False):
|
||||
console.print(f"Tier: {report['tier']}")
|
||||
if report["hosting_model"]:
|
||||
console.print(f"Hosting model: {report['hosting_model']}")
|
||||
summary = report.get("summary", {})
|
||||
if summary:
|
||||
console.print(f"Overall: {'OK' if summary.get('ok') else 'WARN'}")
|
||||
for section in report.get("sections", {}).values():
|
||||
if not section.get("checks"):
|
||||
continue
|
||||
console.print(f"{section['title']}: {'OK' if section.get('ok') else 'WARN'}")
|
||||
for check in report["checks"]:
|
||||
style = "green" if check["ok"] else "red"
|
||||
state = "OK" if check["ok"] else "FAIL"
|
||||
@@ -1701,6 +1708,15 @@ def install_autostart():
|
||||
"Run 'voxvera start-all' manually or add it to your init system.")
|
||||
|
||||
|
||||
def uninstall_autostart():
|
||||
"""Remove the platform-appropriate autostart mechanism."""
|
||||
adapter = get_platform_adapter(cli_module=sys.modules[__name__])
|
||||
try:
|
||||
adapter.uninstall_autostart()
|
||||
except NotImplementedError:
|
||||
print(f"Autostart uninstall not supported on {adapter.label}.")
|
||||
|
||||
|
||||
def _find_voxvera_bin() -> str:
|
||||
"""Return the absolute path to the voxvera binary/script."""
|
||||
voxvera_bin = shutil.which("voxvera")
|
||||
@@ -1989,7 +2005,7 @@ def main(argv=None):
|
||||
sub.add_parser("start-all", help="Start all configured sites (non-interactive)")
|
||||
sub.add_parser("stop-all", help="Stop all running sites (non-interactive)")
|
||||
p_autostart = sub.add_parser("autostart", help="Install or inspect platform autostart support")
|
||||
p_autostart.add_argument("action", nargs="?", choices=["install", "status"], default="install")
|
||||
p_autostart.add_argument("action", nargs="?", choices=["install", "status", "uninstall"], default="install")
|
||||
p_autostart.add_argument("--json", action="store_true", help="Emit machine-readable JSON for status")
|
||||
sub.add_parser("rebuild-all", help="Rebuild all existing sites from the current template (preserves keys and config)")
|
||||
sub.add_parser("_internal_onionshare", help=argparse.SUPPRESS)
|
||||
@@ -2073,6 +2089,8 @@ def main(argv=None):
|
||||
elif args.command == "autostart":
|
||||
if args.action == "status":
|
||||
print_autostart_status(json_output=args.json)
|
||||
elif args.action == "uninstall":
|
||||
uninstall_autostart()
|
||||
else:
|
||||
install_autostart()
|
||||
elif args.command == "quickstart":
|
||||
|
||||
@@ -77,6 +77,9 @@ class PlatformAdapter:
|
||||
def install_autostart(self) -> None:
|
||||
raise NotImplementedError("Autostart is not implemented for this platform.")
|
||||
|
||||
def uninstall_autostart(self) -> None:
|
||||
raise NotImplementedError("Autostart uninstall is not implemented for this platform.")
|
||||
|
||||
def platform_status(self) -> dict:
|
||||
entry = self.support_entry()
|
||||
matrix = self.load_support_matrix()
|
||||
@@ -92,6 +95,23 @@ class PlatformAdapter:
|
||||
"next_gaps": entry.get("next_gaps", []),
|
||||
}
|
||||
|
||||
def _doctor_section(self, title: str, check_names: list[str], checks: list[dict]) -> dict:
|
||||
section_checks = [check for check in checks if check["name"] in check_names]
|
||||
return {
|
||||
"title": title,
|
||||
"ok": all(check.get("ok") for check in section_checks) if section_checks else True,
|
||||
"checks": section_checks,
|
||||
}
|
||||
|
||||
def build_doctor_sections(self, checks: list[dict]) -> dict:
|
||||
return {
|
||||
"platform_contract": self._doctor_section("Platform contract", ["platform"], checks),
|
||||
"dependencies": self._doctor_section("Dependencies", ["voxvera_cli", "onionshare_cli"], checks),
|
||||
"network": self._doctor_section("Network", [], checks),
|
||||
"autostart": self._doctor_section("Autostart", [], checks),
|
||||
"content_state": self._doctor_section("Content state", [], checks),
|
||||
}
|
||||
|
||||
def doctor_report(self) -> dict:
|
||||
checks = []
|
||||
voxvera_path = shutil.which("voxvera")
|
||||
@@ -120,6 +140,7 @@ class PlatformAdapter:
|
||||
}
|
||||
)
|
||||
|
||||
sections = self.build_doctor_sections(checks)
|
||||
return {
|
||||
"platform_id": self.platform_id,
|
||||
"label": platform_status["label"],
|
||||
@@ -130,6 +151,11 @@ class PlatformAdapter:
|
||||
"required_capabilities": platform_status["required_capabilities"],
|
||||
"current_notes": platform_status["current_notes"],
|
||||
"next_gaps": platform_status["next_gaps"],
|
||||
"summary": {
|
||||
"ok": all(check.get("ok") for check in checks),
|
||||
"failing_checks": [check["name"] for check in checks if not check.get("ok")],
|
||||
},
|
||||
"sections": sections,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,23 @@ Persistent=true
|
||||
print(" Manage with: systemctl --user [start|stop|status] voxvera-start.service")
|
||||
print(" Timer status: systemctl --user status voxvera-start.timer")
|
||||
|
||||
def uninstall_autostart(self) -> None:
|
||||
commands = [
|
||||
["systemctl", "--user", "disable", "--now", "voxvera-start.timer"],
|
||||
["systemctl", "--user", "stop", "voxvera-start.service"],
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
]
|
||||
for command in commands:
|
||||
try:
|
||||
subprocess.run(command, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.service_path.unlink(missing_ok=True)
|
||||
self.timer_path.unlink(missing_ok=True)
|
||||
print(f"Removed systemd user service: {self.service_path}")
|
||||
print(f"Removed systemd user timer: {self.timer_path}")
|
||||
|
||||
def doctor_report(self) -> dict:
|
||||
report = super().doctor_report()
|
||||
checks = report["checks"]
|
||||
@@ -191,4 +208,15 @@ Persistent=true
|
||||
"detail": str(host_root) if host_root.exists() else f"missing: {host_root}",
|
||||
}
|
||||
)
|
||||
report["summary"] = {
|
||||
"ok": all(check.get("ok") for check in checks),
|
||||
"failing_checks": [check["name"] for check in checks if not check.get("ok")],
|
||||
}
|
||||
report["sections"] = {
|
||||
"platform_contract": self._doctor_section("Platform contract", ["platform"], checks),
|
||||
"dependencies": self._doctor_section("Dependencies", ["voxvera_cli", "onionshare_cli", "tor_binary"], checks),
|
||||
"network": self._doctor_section("Network", ["tor_socks_reachable"], checks),
|
||||
"autostart": self._doctor_section("Autostart", ["autostart_timer_enabled"], checks),
|
||||
"content_state": self._doctor_section("Content state", ["host_root"], checks),
|
||||
}
|
||||
return report
|
||||
@@ -88,3 +88,8 @@ class MacOSPlatformAdapter(PlatformAdapter):
|
||||
print(f"LaunchAgent installed: {self.plist_path}")
|
||||
print("Sites will start automatically on login.")
|
||||
print(f" Disable with: launchctl unload {self.plist_path}")
|
||||
|
||||
def uninstall_autostart(self) -> None:
|
||||
self._run_capture(["launchctl", "unload", str(self.plist_path)])
|
||||
self.plist_path.unlink(missing_ok=True)
|
||||
print(f"Removed LaunchAgent: {self.plist_path}")
|
||||
@@ -52,3 +52,10 @@ class WindowsPlatformAdapter(PlatformAdapter):
|
||||
print(f' Remove with: schtasks /Delete /TN "{self.task_name}" /F')
|
||||
return
|
||||
print("Failed to create scheduled task. Try running as Administrator.")
|
||||
|
||||
def uninstall_autostart(self) -> None:
|
||||
ok, _output = self._run_capture(["schtasks", "/Delete", "/TN", self.task_name, "/F"])
|
||||
if ok:
|
||||
print(f"Windows scheduled task '{self.task_name}' removed.")
|
||||
return
|
||||
print(f"Windows scheduled task '{self.task_name}' could not be removed.")
|
||||
Reference in new issue
Block a user