From c95689402592f974239d0bbfe58f0f5e426fa2e8 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 20:19:46 +0800 Subject: [PATCH] feat(service): remove Windows service support and update documentation --- astrbot/cli/commands/cmd_service.py | 301 +--------------------------- docs/en/deploy/astrbot/package.md | 6 +- docs/en/use/cli.md | 8 +- docs/zh/deploy/astrbot/package.md | 6 +- docs/zh/use/cli.md | 8 +- tests/test_cli_service.py | 46 +---- 6 files changed, 35 insertions(+), 340 deletions(-) diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py index c0f444a46..077a36fb7 100644 --- a/astrbot/cli/commands/cmd_service.py +++ b/astrbot/cli/commands/cmd_service.py @@ -1,4 +1,3 @@ -import base64 import copy import getpass import json @@ -8,7 +7,6 @@ import plistlib import shutil import subprocess import sys -import tempfile import time from collections import deque from dataclasses import dataclass @@ -16,7 +14,6 @@ from pathlib import Path from textwrap import dedent from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen -from xml.etree import ElementTree import click @@ -27,7 +24,6 @@ DEFAULT_DASHBOARD_PORT = 6185 DEFAULT_STATUS_TIMEOUT_SECONDS = 2.0 DEFAULT_LOG_LINES = 200 MACOS_LABEL_PREFIX = "app.astrbot" -WINDOWS_TASK_XML_NS = "http://schemas.microsoft.com/windows/2004/02/mit/task" @dataclass(frozen=True) @@ -244,8 +240,6 @@ def _service_log_paths(service_name: str) -> tuple[Path, Path]: system = platform.system() if system == "Darwin": log_dir = _macos_log_dir() - elif system == "Windows": - return _windows_service_log_paths(service_name) else: log_dir = get_astrbot_root() / "data" / "logs" return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" @@ -309,187 +303,6 @@ def _install_launch_agent( return plist_path -def _task_element( - parent: ElementTree.Element, - name: str, - text: str | None = None, - attrib: dict[str, str] | None = None, -) -> ElementTree.Element: - child = ElementTree.SubElement( - parent, f"{{{WINDOWS_TASK_XML_NS}}}{name}", attrib or {} - ) - if text is not None: - child.text = text - return child - - -def _windows_log_dir() -> Path: - local_app_data = os.environ.get("LOCALAPPDATA") - if local_app_data: - return Path(local_app_data) / "AstrBot" / "Logs" - return Path.home() / "AppData" / "Local" / "AstrBot" / "Logs" - - -def _windows_service_log_paths(service_name: str) -> tuple[Path, Path]: - log_dir = _windows_log_dir() - return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" - - -def _windows_powershell_executable() -> str: - return "powershell.exe" - - -def _quote_windows_cmd_arg(value: Path | str) -> str: - escaped = str(value).replace('"', '""') - return f'"{escaped}"' - - -def _quote_powershell_literal(value: Path | str) -> str: - escaped = str(value).replace("'", "''") - return f"'{escaped}'" - - -def _build_windows_cmd_line(service_name: str, executable: Path) -> str: - out_log, err_log = _windows_service_log_paths(service_name) - return ( - f"{_quote_windows_cmd_arg(executable)} run " - f">> {_quote_windows_cmd_arg(out_log)} " - f"2>> {_quote_windows_cmd_arg(err_log)}" - ) - - -def _build_windows_powershell_arguments( - service_name: str, - executable: Path, - workdir: Path, -) -> str: - out_log, err_log = _windows_service_log_paths(service_name) - script = ( - "$ErrorActionPreference = 'Stop'\n" - "$env:PYTHONUNBUFFERED = '1'\n" - "$env:PYTHONUTF8 = '1'\n" - "$env:PYTHONIOENCODING = 'utf-8'\n" - f"Set-Location -LiteralPath {_quote_powershell_literal(workdir)}\n" - f"$stdoutLog = {_quote_powershell_literal(out_log)}\n" - f"$stderrLog = {_quote_powershell_literal(err_log)}\n" - "New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stdoutLog) | Out-Null\n" - f"& {_quote_powershell_literal(executable)} run 1>> $stdoutLog 2>> $stderrLog\n" - "exit $LASTEXITCODE\n" - ) - encoded_script = base64.b64encode(script.encode("utf-16le")).decode("ascii") - return ( - "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass " - f"-WindowStyle Hidden -EncodedCommand {encoded_script}" - ) - - -def _build_windows_task_xml( - service_name: str, - executable: Path, - workdir: Path, -) -> bytes: - ElementTree.register_namespace("", WINDOWS_TASK_XML_NS) - task = ElementTree.Element( - f"{{{WINDOWS_TASK_XML_NS}}}Task", - {"version": "1.4"}, - ) - - registration_info = _task_element(task, "RegistrationInfo") - _task_element(registration_info, "Description", "AstrBot Service") - - triggers = _task_element(task, "Triggers") - logon_trigger = _task_element(triggers, "LogonTrigger") - _task_element(logon_trigger, "Enabled", "true") - - principals = _task_element(task, "Principals") - principal = _task_element(principals, "Principal", attrib={"id": "Author"}) - _task_element(principal, "LogonType", "InteractiveToken") - _task_element(principal, "RunLevel", "LeastPrivilege") - - settings = _task_element(task, "Settings") - _task_element(settings, "MultipleInstancesPolicy", "IgnoreNew") - _task_element(settings, "DisallowStartIfOnBatteries", "false") - _task_element(settings, "StopIfGoingOnBatteries", "false") - _task_element(settings, "AllowHardTerminate", "true") - _task_element(settings, "StartWhenAvailable", "true") - _task_element(settings, "RunOnlyIfNetworkAvailable", "false") - _task_element(settings, "AllowStartOnDemand", "true") - _task_element(settings, "Enabled", "true") - _task_element(settings, "Hidden", "true") - _task_element(settings, "RunOnlyIfIdle", "false") - _task_element(settings, "WakeToRun", "false") - _task_element(settings, "ExecutionTimeLimit", "PT0S") - _task_element(settings, "Priority", "7") - restart = _task_element(settings, "RestartOnFailure") - _task_element(restart, "Interval", "PT1M") - _task_element(restart, "Count", "3") - - actions = _task_element(task, "Actions", attrib={"Context": "Author"}) - exec_action = _task_element(actions, "Exec") - _task_element(exec_action, "Command", _windows_powershell_executable()) - _task_element( - exec_action, - "Arguments", - _build_windows_powershell_arguments(service_name, executable, workdir), - ) - _task_element(exec_action, "WorkingDirectory", str(workdir)) - - ElementTree.indent(task, space=" ") - return ElementTree.tostring(task, encoding="utf-16", xml_declaration=True) - - -def _windows_task_exists(service_name: str) -> bool: - result = subprocess.run( - ["schtasks", "/Query", "/TN", service_name], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return result.returncode == 0 - - -def _install_windows_task( - service_name: str, - executable: Path, - workdir: Path, - *, - force: bool, - now: bool, -) -> None: - if platform.system() != "Windows": - raise click.ClickException( - "Windows scheduled task installation is only available on Windows" - ) - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if _windows_task_exists(service_name) and not force: - raise click.ClickException( - f"Scheduled task {service_name} already exists. Use --force to overwrite" - ) - - _windows_log_dir().mkdir(parents=True, exist_ok=True) - task_xml = _build_windows_task_xml(service_name, executable, workdir) - temp_path = None - try: - with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f: - temp_path = Path(f.name) - f.write(task_xml) - - command = ["schtasks", "/Create", "/TN", service_name, "/XML", str(temp_path)] - if force: - command.append("/F") - _run_checked(command, "Failed to create the Windows scheduled task") - finally: - if temp_path is not None: - temp_path.unlink(missing_ok=True) - - if now: - _run_checked( - ["schtasks", "/Run", "/TN", service_name], - "Failed to start the Windows scheduled task", - ) - - def _first_output_line(result: subprocess.CompletedProcess[str]) -> str | None: text = (result.stdout or result.stderr).strip() if not text: @@ -594,58 +407,12 @@ def _get_launchd_state(service_name: str) -> ServiceState: ) -def _parse_schtasks_field(output: str, field_name: str) -> str | None: - prefix = f"{field_name}:" - for line in output.splitlines(): - if line.startswith(prefix): - return line.removeprefix(prefix).strip() - return None - - -def _get_windows_task_state(service_name: str) -> ServiceState: - if shutil.which("schtasks") is None: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="unknown", - detail="schtasks was not found", - ) - - result = _run_capture( - ["schtasks", "/Query", "/TN", service_name, "/FO", "LIST", "/V"] - ) - if result is None: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="unknown", - detail="schtasks was not found", - ) - if result.returncode != 0: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="not-installed", - detail=_first_output_line(result), - ) - - status = _parse_schtasks_field(result.stdout or "", "Status") or "unknown" - return ServiceState( - manager="Task Scheduler", - installed=True, - state=status.lower(), - detail=_parse_schtasks_field(result.stdout or "", "Task To Run"), - ) - - def _get_service_state(service_name: str) -> ServiceState: system = platform.system() if system == "Linux": return _get_systemd_state(service_name) if system == "Darwin": return _get_launchd_state(service_name) - if system == "Windows": - return _get_windows_task_state(service_name) return ServiceState( manager="unknown", installed=False, @@ -835,25 +602,6 @@ def _stop_launch_agent(service_name: str, *, allow_missing: bool = False) -> Non _wait_for_launch_agent_state(service_name, loaded=False) -def _control_windows_task(service_name: str, action: str) -> None: - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if not _windows_task_exists(service_name): - raise click.ClickException( - f"Scheduled task {service_name} does not exist. Run 'service install' first" - ) - - match action: - case "start": - command = ["schtasks", "/Run", "/TN", service_name] - case "stop": - command = ["schtasks", "/End", "/TN", service_name] - case _: - raise click.ClickException(f"Unsupported Windows task action: {action}") - - _run_checked(command, f"Failed to {action} the Windows scheduled task") - - def _control_service(service_name: str, action: str) -> None: system = platform.system() if system == "Linux": @@ -873,17 +621,6 @@ def _control_service(service_name: str, action: str) -> None: raise click.ClickException(f"Unsupported launchd action: {action}") return - if system == "Windows": - match action: - case "start" | "stop": - _control_windows_task(service_name, action) - case "restart": - _control_windows_task(service_name, "stop") - _control_windows_task(service_name, "start") - case _: - raise click.ClickException(f"Unsupported Windows task action: {action}") - return - raise click.ClickException(f"Unsupported platform: {system}") @@ -924,27 +661,12 @@ def _uninstall_launch_agent(service_name: str) -> Path: return plist_path -def _uninstall_windows_task(service_name: str) -> str: - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if not _windows_task_exists(service_name): - raise click.ClickException(f"Scheduled task {service_name} does not exist") - - _run_checked( - ["schtasks", "/Delete", "/TN", service_name, "/F"], - "Failed to delete the Windows scheduled task", - ) - return service_name - - -def _uninstall_service(service_name: str) -> Path | str: +def _uninstall_service(service_name: str) -> Path: system = platform.system() if system == "Linux": return _uninstall_systemd_service(service_name) if system == "Darwin": return _uninstall_launch_agent(service_name) - if system == "Windows": - return _uninstall_windows_task(service_name) raise click.ClickException(f"Unsupported platform: {system}") @@ -1130,7 +852,7 @@ def _show_service_logs( _show_journal_logs(service_name, lines, follow) return - if system in {"Darwin", "Windows"}: + if system == "Darwin": out_log, err_log = _service_log_paths(service_name) paths = [out_log] if include_stderr: @@ -1171,9 +893,12 @@ def install( ) -> None: """Install AstrBot as a user-level background service.""" service_name = _validate_service_name(name) + system = platform.system() + if system not in {"Linux", "Darwin"}: + raise click.ClickException(f"Unsupported platform: {system}") + astrbot_root = _resolve_workdir(workdir) astrbot_executable = _resolve_astrbot_executable(executable) - system = platform.system() if system == "Linux": service_path = _install_systemd_user_service( @@ -1203,18 +928,6 @@ def install( click.echo(f"LaunchAgent label: {_macos_label(service_name)}") return - if system == "Windows": - _install_windows_task( - service_name, - astrbot_executable, - astrbot_root, - force=force, - now=now, - ) - click.echo(f"Installed Windows scheduled task: {service_name}") - click.echo(f"Manage it with: schtasks /Query /TN {service_name}") - return - raise click.ClickException(f"Unsupported platform: {system}") @@ -1319,7 +1032,7 @@ def uninstall(name: str, force: bool) -> None: @click.option( "--include-stderr", is_flag=True, - help="Also show stderr logs on macOS and Windows.", + help="Also show stderr logs on macOS.", ) def logs( ctx: click.Context, diff --git a/docs/en/deploy/astrbot/package.md b/docs/en/deploy/astrbot/package.md index ceda780a2..36a7e84f1 100644 --- a/docs/en/deploy/astrbot/package.md +++ b/docs/en/deploy/astrbot/package.md @@ -36,7 +36,9 @@ The command uses the `astrbot` executable found on `PATH` (usually generated by - Linux: `systemd --user` - macOS: `LaunchAgent` -- Windows: Task Scheduler + +> [!NOTE] +> `astrbot service` is not supported on Windows. Use `astrbot run` in the foreground or another process manager. To specify the AstrBot working directory or executable path explicitly: @@ -68,7 +70,7 @@ astrbot service logs astrbot service logs -f ``` -On macOS and Windows, this shows stdout logs by default. To include stderr: +On macOS, this shows stdout logs by default. To include stderr: ```bash astrbot service logs --include-stderr diff --git a/docs/en/use/cli.md b/docs/en/use/cli.md index a06a341c5..6f0a7a252 100644 --- a/docs/en/use/cli.md +++ b/docs/en/use/cli.md @@ -90,7 +90,9 @@ Each platform uses its native service manager: | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | -| Windows | Task Scheduler | + +> [!NOTE] +> `astrbot service` is not supported on Windows. Use `astrbot run` in the foreground or another process manager. ### Install @@ -190,9 +192,9 @@ Common options: | `--name ` | Service name. | | `-n, --lines ` | Show the latest N lines. Default: 200. | | `-f, --follow` | Follow log output. | -| `--include-stderr` | Also show stderr logs on macOS and Windows. | +| `--include-stderr` | Also show stderr logs on macOS. | -On macOS and Windows, `astrbot service logs` shows stdout logs by default, which are the `.out.log` files. Add `--include-stderr` when you also need error output. +On macOS, `astrbot service logs` shows stdout logs by default, which are the `.out.log` files. Add `--include-stderr` when you also need error output. ### Application Log File diff --git a/docs/zh/deploy/astrbot/package.md b/docs/zh/deploy/astrbot/package.md index 6ad7c4df7..b84160995 100644 --- a/docs/zh/deploy/astrbot/package.md +++ b/docs/zh/deploy/astrbot/package.md @@ -35,7 +35,9 @@ astrbot service install --now - Linux:`systemd --user` - macOS:`LaunchAgent` -- Windows:任务计划程序 + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。 如果需要指定 AstrBot 工作目录或可执行文件路径,可以使用: @@ -67,7 +69,7 @@ astrbot service logs astrbot service logs -f ``` -macOS 和 Windows 下默认只显示标准输出日志;如需同时查看 stderr: +macOS 下默认只显示标准输出日志;如需同时查看 stderr: ```bash astrbot service logs --include-stderr diff --git a/docs/zh/use/cli.md b/docs/zh/use/cli.md index 96174bd09..adb2837a0 100644 --- a/docs/zh/use/cli.md +++ b/docs/zh/use/cli.md @@ -90,7 +90,9 @@ astrbot run --reset-password | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | -| Windows | 任务计划程序 | + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。 ### 安装服务 @@ -190,9 +192,9 @@ astrbot service logs -f | `--name ` | 指定服务名。 | | `-n, --lines ` | 显示最近 N 行,默认 200。 | | `-f, --follow` | 持续跟随日志输出。 | -| `--include-stderr` | 在 macOS 和 Windows 上同时显示 stderr 日志。 | +| `--include-stderr` | 在 macOS 上同时显示 stderr 日志。 | -macOS 和 Windows 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 +macOS 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 ### 启用应用日志文件 diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py index 97c458122..8668447d1 100644 --- a/tests/test_cli_service.py +++ b/tests/test_cli_service.py @@ -1,4 +1,3 @@ -import base64 import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -13,7 +12,6 @@ from astrbot.cli.commands.cmd_service import ( WebUIStatus, _build_launchd_plist, _build_systemd_unit, - _build_windows_task_xml, _check_webui, _get_app_log_config, _health_label, @@ -23,12 +21,6 @@ from astrbot.cli.commands.cmd_service import ( ) -def _decode_windows_encoded_command(task_xml: str) -> str: - marker = "-EncodedCommand " - encoded_command = task_xml.split(marker, 1)[1].split("<", 1)[0] - return base64.b64decode(encoded_command).decode("utf-16le") - - class _HealthyHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) @@ -70,6 +62,16 @@ def test_service_install_requires_initialized_root(monkeypatch, tmp_path): assert "Use 'astrbot init' before installing the service" in result.output +def test_service_install_rejects_windows(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cmd_service.platform, "system", lambda: "Windows") + + result = CliRunner().invoke(service, ["install", "--executable", "astrbot"]) + + assert result.exit_code == 1 + assert "Unsupported platform: Windows" in result.output + + def test_systemd_unit_uses_astrbot_executable_and_working_directory(): unit = _build_systemd_unit( "astrbot", @@ -135,34 +137,6 @@ def test_launch_agent_start_waits_until_loaded_before_kickstart(monkeypatch, tmp assert events.index("bootstrap") < events.index("kickstart") -def test_windows_task_xml_uses_astrbot_executable_and_working_directory(): - task_xml = _build_windows_task_xml( - "astrbot", - Path("C:\\Users\\astrbot\\.local\\bin\\astrbot.exe"), - Path("C:\\Users\\astrbot\\AstrBot"), - ).decode("utf-16") - powershell_script = _decode_windows_encoded_command(task_xml) - - assert "powershell.exe" in task_xml - assert "true" in task_xml - assert "-WindowStyle Hidden" in task_xml - assert "$env:PYTHONUNBUFFERED = '1'" in powershell_script - assert "$env:PYTHONUTF8 = '1'" in powershell_script - assert "$env:PYTHONIOENCODING = 'utf-8'" in powershell_script - assert "Set-Location -LiteralPath 'C:\\Users\\astrbot\\AstrBot'" in powershell_script - assert "New-Item -ItemType Directory -Force" in powershell_script - assert "& 'C:\\Users\\astrbot\\.local\\bin\\astrbot.exe' run" in powershell_script - assert "1>> $stdoutLog 2>> $stderrLog" in powershell_script - assert "Start-Process" not in powershell_script - assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in powershell_script - assert "run" in powershell_script - assert "astrbot.out.log" in powershell_script - assert "astrbot.err.log" in powershell_script - assert ( - "C:\\Users\\astrbot\\AstrBot" in task_xml - ) - - def test_load_dashboard_port_reads_cmd_config(tmp_path): config_path = tmp_path / "data" / "cmd_config.json" config_path.parent.mkdir()