diff --git a/gui/electron/index.html b/gui/electron/index.html index f4fd356..fc372ed 100644 --- a/gui/electron/index.html +++ b/gui/electron/index.html @@ -77,8 +77,15 @@ el.innerHTML = `${label}`; } - 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() { diff --git a/tests/test_cli.py b/tests/test_cli.py index 6e6101f..84424f6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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" diff --git a/voxvera/cli.py b/voxvera/cli.py index 756cb53..e40ee37 100644 --- a/voxvera/cli.py +++ b/voxvera/cli.py @@ -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": diff --git a/voxvera/platforms/base.py b/voxvera/platforms/base.py index acd9b70..c49b389 100644 --- a/voxvera/platforms/base.py +++ b/voxvera/platforms/base.py @@ -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, } diff --git a/voxvera/platforms/linux.py b/voxvera/platforms/linux.py index 4754f93..a04fb74 100644 --- a/voxvera/platforms/linux.py +++ b/voxvera/platforms/linux.py @@ -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 diff --git a/voxvera/platforms/macos.py b/voxvera/platforms/macos.py index 843c197..96d6011 100644 --- a/voxvera/platforms/macos.py +++ b/voxvera/platforms/macos.py @@ -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}") diff --git a/voxvera/platforms/windows.py b/voxvera/platforms/windows.py index 96985a5..4aaeba7 100644 --- a/voxvera/platforms/windows.py +++ b/voxvera/platforms/windows.py @@ -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.")