From b1e1f5e6e4ef55758dbfd180e4e33a8698a47803 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 17:58:20 +0800 Subject: [PATCH] chore: remove windows --- astrbot/cli/commands/cmd_service.py | 305 ++-------------------------- 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 | 33 +-- 6 files changed, 36 insertions(+), 330 deletions(-) diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py index 91e28f2dd..4c9da5acd 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,10 @@ 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" +WINDOWS_SERVICE_UNSUPPORTED_MESSAGE = ( + "AstrBot service management is not supported on Windows yet. " + "Use 'astrbot run' to start AstrBot in the foreground." +) @dataclass(frozen=True) @@ -64,6 +64,12 @@ class AppLogConfig: @click.group(name="service") def service() -> None: """Install and manage AstrBot as a background service.""" + _ensure_service_platform_supported() + + +def _ensure_service_platform_supported() -> None: + if platform.system() == "Windows": + raise click.ClickException(WINDOWS_SERVICE_UNSUPPORTED_MESSAGE) def _validate_service_name(name: str) -> str: @@ -244,8 +250,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,188 +313,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: - script = ( - "$ErrorActionPreference = 'Stop'\n" - "$env:PYTHONUNBUFFERED = '1'\n" - "$cmdExe = if ($env:COMSPEC) { $env:COMSPEC } else { 'cmd.exe' }\n" - f"$astrbotCommand = {_quote_powershell_literal(_build_windows_cmd_line(service_name, executable))}\n" - "$process = Start-Process " - "-FilePath $cmdExe " - "-ArgumentList @('/d', '/c', $astrbotCommand) " - f"-WorkingDirectory {_quote_powershell_literal(workdir)} " - "-WindowStyle Hidden " - "-PassThru " - "-Wait\n" - "exit $process.ExitCode\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: @@ -595,58 +417,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, @@ -836,25 +612,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": @@ -874,17 +631,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}") @@ -925,27 +671,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: 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}") @@ -1131,7 +862,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: @@ -1204,18 +935,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}") @@ -1320,7 +1039,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..1aad02fe6 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 yet. Use `astrbot run` to start AstrBot in the foreground. 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 7a6a016c4..04ec2599b 100644 --- a/docs/en/use/cli.md +++ b/docs/en/use/cli.md @@ -88,7 +88,9 @@ Each platform uses its native service manager: | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | -| Windows | Task Scheduler | + +> [!NOTE] +> `astrbot service` is not supported on Windows yet. Use `astrbot run` to start AstrBot in the foreground. ### Install @@ -188,9 +190,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..433b47032 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 b71c4bee9..19b715ec6 100644 --- a/docs/zh/use/cli.md +++ b/docs/zh/use/cli.md @@ -88,7 +88,9 @@ astrbot run --reload | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | -| Windows | 任务计划程序 | + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请先使用 `astrbot run` 前台启动。 ### 安装服务 @@ -188,9 +190,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 a76d040ed..d4a53e534 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) @@ -135,26 +127,13 @@ 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) +def test_service_command_rejects_windows(monkeypatch): + monkeypatch.setattr(cmd_service.platform, "system", lambda: "Windows") - assert "powershell.exe" in task_xml - assert "true" in task_xml - assert "-WindowStyle Hidden" in task_xml - assert "Start-Process" in powershell_script - assert "-WindowStyle Hidden" 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 - ) + result = CliRunner().invoke(service, ["start"]) + + assert result.exit_code == 1 + assert "not supported on Windows yet" in result.output def test_load_dashboard_port_reads_cmd_config(tmp_path):