feat(service): remove Windows service support and update documentation

This commit is contained in:
Soulter
2026-05-22 20:19:46 +08:00
parent 85e2560bf3
commit c956894025
6 changed files with 35 additions and 340 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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 <NAME>` | Service name. |
| `-n, --lines <N>` | 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

View File

@@ -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

View File

@@ -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 <NAME>` | 指定服务名。 |
| `-n, --lines <N>` | 显示最近 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`。
### 启用应用日志文件

View File

@@ -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 "<Command>powershell.exe</Command>" in task_xml
assert "<Hidden>true</Hidden>" 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 (
"<WorkingDirectory>C:\\Users\\astrbot\\AstrBot</WorkingDirectory>" 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()