96659759b0
- Agent OS:agents 决策循环、runtime 编排、providers、governance/security/sandbox - app 服务层(认证转发到 server-core)、channels 19 路频道、memory、plugins
346 lines
12 KiB
Python
346 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""CLI command: run QwenPaw app on a free port in a native webview window."""
|
|
|
|
# pylint:disable=too-many-branches,too-many-statements,consider-using-with
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import webbrowser
|
|
from collections.abc import Mapping
|
|
|
|
import click
|
|
|
|
from ..constant import LOG_LEVEL_ENV, WORKING_DIR
|
|
from ..utils.logging import setup_logger
|
|
from ..utils.port import get_stable_port
|
|
|
|
try:
|
|
import webview
|
|
except ImportError:
|
|
webview = None # type: ignore[assignment]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WebViewAPI:
|
|
"""API exposed to the webview for external links and file downloads."""
|
|
|
|
def open_external_link(self, url: str) -> None:
|
|
"""Open URL in system's default browser."""
|
|
if not url.startswith(("http://", "https://")):
|
|
return
|
|
webbrowser.open(url)
|
|
|
|
def save_file(
|
|
self,
|
|
url: str,
|
|
filename: str,
|
|
headers: Mapping[str, str] | None = None,
|
|
) -> bool:
|
|
"""Download a file from *url* and save it via a native save dialog.
|
|
|
|
Shows the OS "Save As" dialog so the user can pick a destination,
|
|
then downloads the file and writes it there. This is the desktop
|
|
equivalent of the browser's ``<a download>`` click pattern which
|
|
pywebview/WebView2 does not support.
|
|
|
|
Args:
|
|
url: Full HTTP(S) URL of the file to download.
|
|
filename: Default filename shown in the save dialog.
|
|
headers: Optional request headers supplied by the web console.
|
|
|
|
Returns:
|
|
True if the file was saved successfully, False if the user
|
|
cancelled the dialog or an error occurred.
|
|
"""
|
|
import re
|
|
import shutil
|
|
import urllib.request
|
|
|
|
if not url.startswith(("http://", "https://")):
|
|
return False
|
|
|
|
# Sanitize filename: remove characters illegal on Windows
|
|
# (< > : " / \ | ? *) and trim leading/trailing whitespace/dots.
|
|
# Colons are common in backup names like "Backup 2026-04-22 17:36".
|
|
safe_name = re.sub(r'[<>:"/\\|?*]', "_", filename).strip(" .")
|
|
|
|
try:
|
|
# Show native OS save dialog via pywebview
|
|
result = webview.windows[0].create_file_dialog(
|
|
webview.SAVE_DIALOG,
|
|
save_filename=safe_name,
|
|
)
|
|
if not result:
|
|
return False # user cancelled
|
|
|
|
dest_path = result if isinstance(result, str) else result[0]
|
|
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={
|
|
str(key): str(value)
|
|
for key, value in (headers or {}).items()
|
|
if value is not None
|
|
},
|
|
)
|
|
|
|
# Download from the local backend and write to chosen path
|
|
with urllib.request.urlopen(request) as response:
|
|
with open(dest_path, "wb") as f:
|
|
shutil.copyfileobj(response, f)
|
|
|
|
return True
|
|
except Exception:
|
|
logger.exception("save_file failed")
|
|
return False
|
|
|
|
|
|
def _wait_for_http(host: str, port: int, timeout_sec: float = 300.0) -> bool:
|
|
"""Return True when something accepts TCP on host:port."""
|
|
deadline = time.monotonic() + timeout_sec
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.settimeout(2.0)
|
|
s.connect((host, port))
|
|
return True
|
|
except (OSError, socket.error):
|
|
time.sleep(1)
|
|
return False
|
|
|
|
|
|
def _stream_reader(in_stream, out_stream) -> None:
|
|
"""Read from in_stream line by line and write to out_stream.
|
|
|
|
Used on Windows to prevent subprocess buffer blocking. Runs in a
|
|
background thread to continuously drain the subprocess output.
|
|
"""
|
|
try:
|
|
for line in iter(in_stream.readline, ""):
|
|
if not line:
|
|
break
|
|
out_stream.write(line)
|
|
out_stream.flush()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
in_stream.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@click.command("desktop")
|
|
@click.option(
|
|
"--host",
|
|
default="127.0.0.1",
|
|
show_default=True,
|
|
help="Bind host for the app server.",
|
|
)
|
|
@click.option(
|
|
"--log-level",
|
|
default="info",
|
|
type=click.Choice(
|
|
["critical", "error", "warning", "info", "debug", "trace"],
|
|
case_sensitive=False,
|
|
),
|
|
show_default=True,
|
|
help="Log level for the app process.",
|
|
)
|
|
def desktop_cmd(
|
|
host: str,
|
|
log_level: str,
|
|
) -> None:
|
|
"""Run QwenPaw app on an auto-selected free port in a webview window.
|
|
|
|
Starts the FastAPI app in a subprocess on a free port, then opens a
|
|
native webview window loading that URL. Use for a dedicated desktop
|
|
window without conflicting with an existing QwenPaw app instance.
|
|
"""
|
|
# Setup logger for desktop command (separate from backend subprocess)
|
|
setup_logger(log_level)
|
|
|
|
# get_stable_port() returns (port, socket) — the socket is kept open
|
|
# to hold the port until the subprocess is about to bind it, minimizing
|
|
# the TOCTOU window. We close it just before Popen so the child can
|
|
# bind the same port.
|
|
port_file = str(WORKING_DIR / "desktop_port")
|
|
port, held_socket = get_stable_port(port_file, host)
|
|
url = f"http://{host}:{port}"
|
|
click.echo(f"Starting QwenPaw app on {url} (port {port})")
|
|
logger.info("Server subprocess starting...")
|
|
|
|
env = os.environ.copy()
|
|
env[LOG_LEVEL_ENV] = log_level
|
|
|
|
if "SSL_CERT_FILE" in env:
|
|
cert_file = env["SSL_CERT_FILE"]
|
|
if os.path.exists(cert_file):
|
|
logger.info(f"SSL certificate: {cert_file}")
|
|
else:
|
|
logger.warning(
|
|
f"SSL_CERT_FILE set but not found: {cert_file}",
|
|
)
|
|
else:
|
|
logger.warning("SSL_CERT_FILE not set on environment")
|
|
|
|
is_windows = sys.platform == "win32"
|
|
proc = None
|
|
manually_terminated = (
|
|
False # Track if we intentionally terminated the process
|
|
)
|
|
try:
|
|
# Release the held socket just before spawning the subprocess so
|
|
# the child can bind the same port. This keeps the TOCTOU window
|
|
# as small as possible (only between close() and the child's bind).
|
|
if held_socket:
|
|
held_socket.close()
|
|
|
|
proc = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"qwenpaw",
|
|
"app",
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
str(port),
|
|
"--log-level",
|
|
log_level,
|
|
],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE if is_windows else sys.stdout,
|
|
stderr=subprocess.PIPE if is_windows else sys.stderr,
|
|
env=env,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
)
|
|
try:
|
|
if is_windows:
|
|
stdout_thread = threading.Thread(
|
|
target=_stream_reader,
|
|
args=(proc.stdout, sys.stdout),
|
|
daemon=True,
|
|
)
|
|
stderr_thread = threading.Thread(
|
|
target=_stream_reader,
|
|
args=(proc.stderr, sys.stderr),
|
|
daemon=True,
|
|
)
|
|
stdout_thread.start()
|
|
stderr_thread.start()
|
|
logger.info("Waiting for HTTP ready...")
|
|
if _wait_for_http(host, port):
|
|
logger.info("HTTP ready, creating webview window...")
|
|
api = WebViewAPI()
|
|
webview.create_window(
|
|
"QwenPaw Desktop",
|
|
url,
|
|
width=1280,
|
|
height=800,
|
|
text_select=True,
|
|
js_api=api,
|
|
)
|
|
logger.info(
|
|
"Calling webview.start() (blocks until closed)...",
|
|
)
|
|
# Persist localStorage/cookies across restarts so the
|
|
# user's agent selection, chat history, and preferences
|
|
# survive window close. Without storage_path, WebView2
|
|
# may use a temp directory that is discarded on restart.
|
|
webview_storage = str(WORKING_DIR / "webview_data")
|
|
webview.start(
|
|
private_mode=False,
|
|
storage_path=webview_storage,
|
|
) # blocks until user closes the window
|
|
logger.info("webview.start() returned (window closed).")
|
|
else:
|
|
logger.error("Server did not become ready in time.")
|
|
click.echo(
|
|
"Server did not become ready in time; open manually: "
|
|
+ url,
|
|
err=True,
|
|
)
|
|
try:
|
|
proc.wait()
|
|
except KeyboardInterrupt:
|
|
pass # will be handled in finally
|
|
finally:
|
|
# Ensure backend process is always cleaned up
|
|
# Wrap all cleanup operations to handle race conditions:
|
|
# - Process may exit between poll() and terminate()
|
|
# - terminate()/kill() may raise ProcessLookupError/OSError
|
|
# - We must not let cleanup exceptions mask the original error
|
|
if proc and proc.poll() is None: # process still running
|
|
logger.info("Terminating backend server...")
|
|
manually_terminated = (
|
|
True # Mark that we're intentionally terminating
|
|
)
|
|
try:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5.0)
|
|
logger.info("Backend server terminated cleanly.")
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning(
|
|
"Backend did not exit in 5s, force killing...",
|
|
)
|
|
try:
|
|
proc.kill()
|
|
proc.wait()
|
|
logger.info("Backend server force killed.")
|
|
except (ProcessLookupError, OSError) as e:
|
|
# Process already exited, which is fine
|
|
logger.debug(
|
|
f"kill() raised {e.__class__.__name__} "
|
|
f"(process already exited)",
|
|
)
|
|
except (ProcessLookupError, OSError) as e:
|
|
# Process already exited between poll() and terminate()
|
|
logger.debug(
|
|
f"terminate() raised {e.__class__.__name__} "
|
|
f"(process already exited)",
|
|
)
|
|
elif proc:
|
|
logger.info(
|
|
f"Backend already exited with code {proc.returncode}",
|
|
)
|
|
|
|
# Only report errors if process exited unexpectedly
|
|
# (not manually terminated)
|
|
# On Windows, terminate() doesn't use signals so exit codes vary
|
|
# (1, 259, etc.)
|
|
# On Unix/Linux/macOS, terminate() sends SIGTERM (exit code -15)
|
|
# Using a flag is more reliable than checking specific exit codes
|
|
if proc and proc.returncode != 0 and not manually_terminated:
|
|
logger.error(
|
|
f"Backend process exited unexpectedly with code "
|
|
f"{proc.returncode}",
|
|
)
|
|
# Follow POSIX convention for exit codes:
|
|
# - Negative (signal): 128 + signal_number
|
|
# - Positive (normal): use as-is
|
|
# Example: -15 (SIGTERM) -> 143 (128+15), -11 (SIGSEGV) ->
|
|
# 139 (128+11)
|
|
if proc.returncode < 0:
|
|
sys.exit(128 + abs(proc.returncode))
|
|
else:
|
|
sys.exit(proc.returncode or 1)
|
|
except KeyboardInterrupt:
|
|
logger.warning("KeyboardInterrupt in main, cleaning up...")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Exception: {e!r}")
|
|
traceback.print_exc(file=sys.stderr)
|
|
sys.stderr.flush()
|
|
raise
|