ppx-py 6.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ppx_py/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """PPX V6 public runtime API."""
2
+
3
+ from .application import Application
4
+ from .bridge import Bridge, BridgeError, api_method
5
+ from .settings import Settings, SettingsError
6
+ from .runtime import create_application, run_project
7
+ from .paths import app_data_path, resource_path
8
+
9
+ __all__ = [
10
+ "Application",
11
+ "Bridge",
12
+ "BridgeError",
13
+ "Settings",
14
+ "SettingsError",
15
+ "api_method",
16
+ "app_data_path",
17
+ "create_application",
18
+ "resource_path",
19
+ "run_project",
20
+ ]
21
+ __version__ = "6.0.0"
ppx_py/application.py ADDED
@@ -0,0 +1,95 @@
1
+ """PPX V6 application lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ from pathlib import Path
7
+ from typing import Any, Union
8
+
9
+ from .bridge import Bridge, JavascriptAPI
10
+ from .services import SystemService
11
+ from .settings import Settings
12
+ from .storage import JsonStorage
13
+ from .update import ApplicationUpdater
14
+
15
+
16
+ class Application:
17
+ def __init__(self, settings: Settings) -> None:
18
+ self.settings = settings
19
+ self.bridge = Bridge()
20
+ self.window: Any = None
21
+ self.storage = JsonStorage(settings.app_data_dir / settings.storage.filename)
22
+ self.system = SystemService(settings, lambda: self.window)
23
+ self.application_updater = ApplicationUpdater(
24
+ settings, lambda progress: self.bridge.emit("applicationUpdate.progress", progress)
25
+ )
26
+ self._register_core_api()
27
+
28
+ @classmethod
29
+ def from_config(cls, path: Union[str, Path] = "ppx.toml") -> "Application":
30
+ return cls(Settings.load(path))
31
+
32
+ def register_api(self, api: object) -> "Application":
33
+ self.bridge.register_api(api)
34
+ return self
35
+
36
+ def _register_core_api(self) -> None:
37
+ methods = {
38
+ "system.getAppInfo": self.system.get_app_info,
39
+ "system.getOwner": self.system.get_owner,
40
+ "system.openPath": self.system.open_path,
41
+ "system.openFileDialog": self.system.open_file_dialog,
42
+ "system.saveFileDialog": self.system.save_file_dialog,
43
+ "system.selectDirectory": self.system.select_directory,
44
+ "window.getState": self.system.get_window_state,
45
+ "window.minimize": self.system.minimize_window,
46
+ "window.maximize": self.system.maximize_window,
47
+ "window.restore": self.system.restore_window,
48
+ "window.toggleFullscreen": self.system.toggle_fullscreen,
49
+ "window.close": self.system.close_window,
50
+ "storage.get": self.storage.get,
51
+ "storage.set": self.storage.set,
52
+ "storage.delete": self.storage.delete,
53
+ "applicationUpdate.check": self.application_updater.check,
54
+ "applicationUpdate.download": self.application_updater.download,
55
+ "applicationUpdate.cancel": self.application_updater.cancel,
56
+ }
57
+ for name, handler in methods.items():
58
+ self.bridge.register(name, handler)
59
+
60
+ def run(self, *, dev: bool = False, cef: bool = False) -> None:
61
+ import webview
62
+
63
+ if dev:
64
+ url = f"http://127.0.0.1:{self.settings.development.port}/"
65
+ else:
66
+ url = str(self.settings.frontend_dir / "index.html")
67
+ if not Path(url).is_file():
68
+ raise FileNotFoundError(f"前端产物不存在,请先执行 ppx build: {url}")
69
+ mimetypes.add_type("application/javascript", ".js")
70
+
71
+ screen = webview.screens[0]
72
+ width = int(screen.width * self.settings.window.width_ratio)
73
+ height = int(screen.height * self.settings.window.height_ratio)
74
+ self.window = webview.create_window(
75
+ title=self.settings.project.name,
76
+ url=url,
77
+ js_api=JavascriptAPI(self.bridge),
78
+ width=width,
79
+ height=height,
80
+ min_size=(
81
+ int(width * self.settings.window.min_width_ratio),
82
+ int(height * self.settings.window.min_height_ratio),
83
+ ),
84
+ resizable=self.settings.window.resizable,
85
+ fullscreen=self.settings.window.fullscreen,
86
+ on_top=self.settings.window.always_on_top,
87
+ confirm_close=self.settings.window.confirm_close,
88
+ background_color=self.settings.window.background_color,
89
+ )
90
+ self.bridge.set_window(self.window)
91
+ self.window.events.shown += self._on_shown
92
+ webview.start(debug=dev, http_server=True, gui="cef" if cef else None)
93
+
94
+ def _on_shown(self, *_args: Any) -> None:
95
+ self.storage.initialize()
ppx_py/bridge.py ADDED
@@ -0,0 +1,147 @@
1
+ """A small, stable Python/JavaScript RPC boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import logging
9
+ import threading
10
+ import uuid
11
+ from collections.abc import Callable, Mapping, Sequence
12
+ from typing import Any, Optional
13
+
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ class BridgeError(Exception):
19
+ def __init__(self, code: str, message: str):
20
+ super().__init__(message)
21
+ self.code = code
22
+
23
+
24
+ def api_method(name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
25
+ """Explicitly expose a user method under a stable RPC name."""
26
+
27
+ if not name or "." not in name:
28
+ raise ValueError("PPX API 名称必须包含命名空间,例如 user.greet")
29
+
30
+ def decorate(function: Callable[..., Any]) -> Callable[..., Any]:
31
+ setattr(function, "__ppx_api_method__", name)
32
+ return function
33
+
34
+ return decorate
35
+
36
+
37
+ class Bridge:
38
+ """The only object exposed to ``window.pywebview.api``."""
39
+
40
+ def __init__(self) -> None:
41
+ self._methods: dict[str, Callable[..., Any]] = {}
42
+ self._window: Any = None
43
+
44
+ def set_window(self, window: Any) -> None:
45
+ self._window = window
46
+
47
+ def register(self, name: str, handler: Callable[..., Any]) -> None:
48
+ if name in self._methods:
49
+ raise ValueError(f"PPX API 已注册: {name}")
50
+ self._methods[name] = handler
51
+
52
+ def register_api(self, api: object) -> None:
53
+ found = 0
54
+ for _, member in inspect.getmembers(api, predicate=callable):
55
+ name = getattr(member, "__ppx_api_method__", None)
56
+ if name:
57
+ self.register(name, member)
58
+ found += 1
59
+ if not found:
60
+ raise ValueError("业务 API 没有使用 @api_method 注册任何方法")
61
+
62
+ def call(self, method: str, params: Any = None, request_id: Optional[str] = None) -> dict[str, Any]:
63
+ request_id = request_id or uuid.uuid4().hex
64
+ try:
65
+ handler = self._methods.get(method)
66
+ if handler is None:
67
+ raise BridgeError("METHOD_NOT_FOUND", f"未注册的 API: {method}")
68
+ if params is None:
69
+ arguments, keywords = (), {}
70
+ elif isinstance(params, Mapping):
71
+ arguments, keywords = (), dict(params)
72
+ elif isinstance(params, Sequence) and not isinstance(params, (str, bytes, bytearray)):
73
+ arguments, keywords = tuple(params), {}
74
+ else:
75
+ raise BridgeError("INVALID_PARAMS", "params 必须是对象、数组或 null")
76
+ try:
77
+ inspect.signature(handler).bind(*arguments, **keywords)
78
+ except TypeError as exc:
79
+ raise BridgeError("INVALID_PARAMS", str(exc)) from exc
80
+ data = _resolve_awaitable(handler(*arguments, **keywords))
81
+ try:
82
+ json.dumps(data, allow_nan=False)
83
+ except (TypeError, ValueError) as exc:
84
+ raise BridgeError(
85
+ "INVALID_RESULT", "Python API 返回值必须是可序列化的 JSON 数据"
86
+ ) from exc
87
+ return {"ok": True, "data": data, "error": None, "requestId": request_id}
88
+ except BridgeError as exc:
89
+ return self._failure(exc.code, str(exc), request_id)
90
+ except Exception:
91
+ LOGGER.exception("PPX Python API 执行失败: method=%s requestId=%s", method, request_id)
92
+ return self._failure("INTERNAL_ERROR", "Python API 执行失败", request_id)
93
+
94
+ def emit(self, event: str, data: Any) -> None:
95
+ if self._window is None:
96
+ return
97
+ # ASCII escaping also protects JavaScript parsing from U+2028/U+2029 separators.
98
+ event_json = json.dumps(event)
99
+ data_json = json.dumps(data, allow_nan=False)
100
+ self._window.evaluate_js(
101
+ f"if (typeof window.__ppxDispatch === 'function') window.__ppxDispatch({event_json}, {data_json})"
102
+ )
103
+
104
+ @staticmethod
105
+ def _failure(code: str, message: str, request_id: str) -> dict[str, Any]:
106
+ return {
107
+ "ok": False,
108
+ "data": None,
109
+ "error": {"code": code, "message": message},
110
+ "requestId": request_id,
111
+ }
112
+
113
+
114
+ def _resolve_awaitable(value: Any) -> Any:
115
+ """Resolve async business methods behind pywebview's synchronous JS API."""
116
+ if not inspect.isawaitable(value):
117
+ return value
118
+ try:
119
+ asyncio.get_running_loop()
120
+ except RuntimeError:
121
+ return asyncio.run(value)
122
+
123
+ result: list[Any] = []
124
+ failure: list[BaseException] = []
125
+
126
+ def run() -> None:
127
+ try:
128
+ result.append(asyncio.run(value))
129
+ except BaseException as exc: # Re-raised on the calling thread below.
130
+ failure.append(exc)
131
+
132
+ worker = threading.Thread(target=run, name="ppx-async-api", daemon=True)
133
+ worker.start()
134
+ worker.join()
135
+ if failure:
136
+ raise failure[0]
137
+ return result[0]
138
+
139
+
140
+ class JavascriptAPI:
141
+ """Only expose RPC dispatch; registration and event emission stay in Python."""
142
+
143
+ def __init__(self, bridge: Bridge) -> None:
144
+ self._bridge = bridge
145
+
146
+ def call(self, method: str, params: Any = None, request_id: Optional[str] = None) -> dict[str, Any]:
147
+ return self._bridge.call(method, params, request_id)
ppx_py/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ """PPX command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import List, Optional
8
+
9
+ from . import __version__
10
+ from .commands import build, dev, doctor, icon, update
11
+ from .scaffold import create_project, initialize_project
12
+
13
+
14
+ def _new(args: object) -> int:
15
+ try:
16
+ target = create_project(
17
+ str(getattr(args, "name")),
18
+ getattr(args, "directory", None),
19
+ str(getattr(args, "frontend", "vanilla")),
20
+ )
21
+ print(f"PPX 项目已创建: {target}")
22
+ print(f"下一步: cd {target.name} && ppx init && ppx dev")
23
+ return 0
24
+ except Exception as exc:
25
+ print(f"[失败] {exc}")
26
+ return 1
27
+
28
+
29
+ def _init(_args: object) -> int:
30
+ return initialize_project()
31
+
32
+
33
+ def make_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(prog="ppx", description="PPX developer tools")
35
+ parser.add_argument("--version", action="version", version=f"PPX {__version__}")
36
+ commands = parser.add_subparsers(dest="command", required=True)
37
+
38
+ new_parser = commands.add_parser("new", help="创建只有业务代码与可配置资源的新项目")
39
+ new_parser.add_argument("name", help="项目名称")
40
+ new_parser.add_argument("--directory", help="目标目录,默认使用项目名生成目录")
41
+ new_parser.add_argument(
42
+ "--frontend",
43
+ choices=("vanilla", "vue", "react"),
44
+ default="vanilla",
45
+ help="前端模板,默认 vanilla",
46
+ )
47
+ new_parser.set_defaults(handler=_new)
48
+
49
+ init_parser = commands.add_parser("init", help="安装业务依赖并检查项目")
50
+ init_parser.set_defaults(handler=_init)
51
+
52
+ doctor_parser = commands.add_parser("doctor", help="检查项目和开发环境")
53
+ doctor_parser.add_argument("--json", action="store_true", dest="json_output", help="输出机器可读 JSON")
54
+ doctor_parser.set_defaults(handler=doctor.run)
55
+
56
+ icon_parser = commands.add_parser("icon", help="从一张方形主图生成三端应用图标")
57
+ icon_parser.add_argument("source", help="至少 512×512 的方形 PNG/JPEG/WebP 图片")
58
+ icon_parser.add_argument("--output", help="输出目录,默认使用 ppx.toml 的 paths.assets")
59
+ icon_parser.set_defaults(handler=icon.run)
60
+
61
+ update_parser = commands.add_parser("update", help="安全更新 PPX 框架依赖")
62
+ mode = update_parser.add_mutually_exclusive_group()
63
+ mode.add_argument("--check", action="store_true", help="只检查可用更新")
64
+ mode.add_argument("--dry-run", action="store_true", help="显示更新计划,不修改文件或环境")
65
+ update_parser.add_argument("--to", metavar="VERSION", help="更新到指定 V6 版本")
66
+ update_parser.set_defaults(handler=update.run)
67
+
68
+ dev_parser = commands.add_parser("dev", help="同时启动前端与桌面窗口")
69
+ dev_parser.add_argument("--cef", action="store_true", help="使用 CEF(仅 Windows)")
70
+ dev_parser.add_argument("--skip-frontend", action="store_true", help="前端服务已启动时不重复启动")
71
+ dev_parser.set_defaults(handler=dev.run)
72
+
73
+ build_parser = commands.add_parser("build", help="构建前端并用 PyInstaller 打包")
74
+ build_parser.add_argument("--console", action="store_true", help="保留控制台窗口")
75
+ build_parser.add_argument("--skip-frontend", action="store_true", help="不重复构建前端")
76
+ build_parser.set_defaults(handler=build.run)
77
+ return parser
78
+
79
+
80
+ def main(argv: Optional[List[str]] = None) -> int:
81
+ # Windows redirects stdout/stderr using the legacy system code page.
82
+ # CLI output is UTF-8 so Chinese diagnostics also work in pipes and logs.
83
+ for stream in (sys.stdout, sys.stderr):
84
+ if stream is not None and hasattr(stream, "reconfigure") and not stream.isatty():
85
+ stream.reconfigure(encoding="utf-8", errors="backslashreplace")
86
+ args = make_parser().parse_args(argv)
87
+ return int(args.handler(args))
88
+
89
+
90
+ if __name__ == "__main__":
91
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """CLI command implementations."""
@@ -0,0 +1,47 @@
1
+ """Build the frontend and package the desktop application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import shutil
7
+ import sys
8
+
9
+ from ..packaging import create_installer, create_spec
10
+ from ..project import find_project_root, load_settings
11
+
12
+
13
+ def run(args: object) -> int:
14
+ root = find_project_root()
15
+ settings = load_settings(root)
16
+ if not getattr(args, "skip_frontend", False):
17
+ result = subprocess.run(
18
+ [shutil.which("pnpm") or "pnpm", "-C", settings.paths.frontend, "run", "build"], cwd=root, check=False
19
+ )
20
+ if result.returncode:
21
+ return result.returncode
22
+ spec = create_spec(root, settings, bool(getattr(args, "console", False)))
23
+ command = [
24
+ sys.executable,
25
+ "-m",
26
+ "PyInstaller",
27
+ "--clean",
28
+ "--noconfirm",
29
+ "--distpath",
30
+ str(root / "build"),
31
+ "--workpath",
32
+ str(root / "build" / "cache" / "work"),
33
+ str(spec),
34
+ ]
35
+ result = subprocess.call(command, cwd=root)
36
+ if result:
37
+ return result
38
+ if getattr(args, "console", False):
39
+ print(f"调试应用已生成: {root / 'build'}")
40
+ return 0
41
+ try:
42
+ installer = create_installer(root, settings)
43
+ print(f"安装包已生成并验证: {installer}")
44
+ return 0
45
+ except Exception as exc:
46
+ print(f"[失败] {exc}")
47
+ return 1
ppx_py/commands/dev.py ADDED
@@ -0,0 +1,78 @@
1
+ """Run Vite and the PPX desktop runtime as one developer command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import socket
7
+ import time
8
+ import os
9
+ import signal
10
+ import shutil
11
+
12
+ from ..project import find_project_root, load_settings
13
+ from ..runtime import run_project
14
+
15
+
16
+ def _wait_for_port(port: int, process: subprocess.Popen[bytes], timeout: float = 30.0) -> None:
17
+ deadline = time.monotonic() + timeout
18
+ while time.monotonic() < deadline:
19
+ if process.poll() is not None:
20
+ raise RuntimeError(f"前端开发服务提前退出,退出码 {process.returncode}")
21
+ try:
22
+ with socket.create_connection(("127.0.0.1", port), timeout=0.2):
23
+ return
24
+ except OSError:
25
+ time.sleep(0.1)
26
+ raise TimeoutError(f"等待前端开发服务端口 {port} 超时")
27
+
28
+
29
+ def _stop(process: subprocess.Popen[bytes]) -> None:
30
+ if os.name == "nt":
31
+ if process.poll() is None:
32
+ subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True, check=False)
33
+ return
34
+ try:
35
+ os.killpg(process.pid, signal.SIGTERM)
36
+ except ProcessLookupError:
37
+ return
38
+ try:
39
+ process.wait(timeout=5)
40
+ except subprocess.TimeoutExpired:
41
+ try:
42
+ os.killpg(process.pid, signal.SIGKILL)
43
+ except ProcessLookupError:
44
+ pass
45
+ process.wait()
46
+
47
+
48
+ def run(args: object) -> int:
49
+ root = find_project_root()
50
+ settings = load_settings(root)
51
+ process = None
52
+ try:
53
+ if not getattr(args, "skip_frontend", False):
54
+ try:
55
+ with socket.create_connection(("127.0.0.1", settings.development.port), timeout=0.2):
56
+ raise RuntimeError(
57
+ f"端口 {settings.development.port} 已被占用。请修改 development.port;"
58
+ "如果该端口是你已启动的当前项目前端,请使用 --skip-frontend。"
59
+ )
60
+ except OSError:
61
+ pass
62
+ process = subprocess.Popen(
63
+ [shutil.which("pnpm") or "pnpm", "-C", settings.paths.frontend, "run", "dev",
64
+ "--host", "127.0.0.1", "--port", str(settings.development.port), "--strictPort"],
65
+ cwd=root,
66
+ start_new_session=os.name != "nt",
67
+ )
68
+ _wait_for_port(settings.development.port, process)
69
+ run_project(root / "ppx.toml", dev=True, cef=bool(getattr(args, "cef", False)))
70
+ return 0
71
+ except KeyboardInterrupt:
72
+ return 130
73
+ except Exception as exc:
74
+ print(f"[失败] {exc}")
75
+ return 1
76
+ finally:
77
+ if process is not None:
78
+ _stop(process)
@@ -0,0 +1,159 @@
1
+ """Validate a PPX V6 project and its current-platform toolchain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import json
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from dataclasses import asdict, dataclass
12
+ from pathlib import Path
13
+ from typing import List, Optional
14
+
15
+ from packaging.version import InvalidVersion, Version
16
+
17
+ from ..project import find_project_root, load_settings, read_lock
18
+
19
+
20
+ @dataclass
21
+ class Check:
22
+ ok: bool
23
+ name: str
24
+ detail: str
25
+
26
+
27
+ def _command_version(command: str, args: List[str]) -> Optional[str]:
28
+ executable = shutil.which(command)
29
+ if not executable:
30
+ return None
31
+ result = subprocess.run([executable, *args], capture_output=True, text=True, check=False)
32
+ value = (result.stdout or result.stderr).strip()
33
+ return value.splitlines()[0] if value else "unknown"
34
+
35
+
36
+ def _at_least(value: Optional[str], required: str) -> bool:
37
+ if value is None:
38
+ return False
39
+ try:
40
+ return Version(value.lstrip("vV")) >= Version(required)
41
+ except InvalidVersion:
42
+ return False
43
+
44
+
45
+ def _in_range(value: Optional[str], minimum: str, maximum_exclusive: str) -> bool:
46
+ if value is None:
47
+ return False
48
+ try:
49
+ parsed = Version(value.lstrip("vV"))
50
+ return Version(minimum) <= parsed < Version(maximum_exclusive)
51
+ except InvalidVersion:
52
+ return False
53
+
54
+
55
+ def collect_checks(root: Path) -> List[Check]:
56
+ settings = load_settings(root)
57
+ checks = [
58
+ Check(settings.project.project_format == 6, "项目格式", f"V{settings.project.project_format}"),
59
+ Check(sys.version_info >= (3, 10), "Python", sys.version.split()[0]),
60
+ ]
61
+ node = _command_version("node", ["--version"])
62
+ pnpm = _command_version("pnpm", ["--version"])
63
+ checks.extend([
64
+ Check(_at_least(node, "22.13.0"), "Node.js", node or "未安装"),
65
+ Check(_in_range(pnpm, "11.0.0", "12.0.0"), "pnpm", pnpm or "未安装"),
66
+ ])
67
+ try:
68
+ installed = importlib.metadata.version("ppx-py")
69
+ checks.append(Check(installed == settings.framework.python, "ppx-py", f"已安装 {installed},配置 {settings.framework.python}"))
70
+ except importlib.metadata.PackageNotFoundError:
71
+ checks.append(Check(False, "ppx-py", "未安装"))
72
+
73
+ js_manifest = settings.frontend_source_dir / "node_modules" / "ppx-js" / "package.json"
74
+ if js_manifest.is_file():
75
+ installed_js = str(json.loads(js_manifest.read_text(encoding="utf-8")).get("version", "未知"))
76
+ checks.append(Check(installed_js == settings.framework.javascript, "ppx-js", f"已安装 {installed_js},配置 {settings.framework.javascript}"))
77
+ else:
78
+ checks.append(Check(False, "ppx-js", "未安装,请执行 ppx init"))
79
+
80
+ for name, target in (
81
+ ("api", root / "api"),
82
+ ("gui", settings.frontend_source_dir),
83
+ ("ppx/assets", settings.asset_dir),
84
+ ("api/requirements.txt", root / "api/requirements.txt"),
85
+ ):
86
+ checks.append(Check(target.exists(), name, "存在" if target.exists() else "缺失"))
87
+ for filename in ("logo.png", "logo.ico", "logo.icns", "dmg-background.png"):
88
+ target = settings.asset_dir / filename
89
+ checks.append(Check(target.is_file(), f"资源 {filename}", "存在" if target.is_file() else "缺失"))
90
+
91
+ try:
92
+ lock = read_lock(root)
93
+ framework = lock.get("framework", {})
94
+ compatibility = lock.get("compatibility", {})
95
+ lock_ok = (
96
+ lock.get("projectFormat") == 6
97
+ and framework.get("python") == settings.framework.python
98
+ and framework.get("javascript") == settings.framework.javascript
99
+ and compatibility.get("pythonApi") == settings.compatibility.python_api
100
+ and compatibility.get("javascriptApi") == settings.compatibility.javascript_api
101
+ and compatibility.get("dataSchema") == settings.compatibility.data_schema
102
+ )
103
+ checks.append(Check(lock_ok, "ppx.lock", "与 ppx.toml 一致" if lock_ok else "与 ppx.toml 不一致"))
104
+ except Exception as exc:
105
+ checks.append(Check(False, "ppx.lock", str(exc)))
106
+
107
+ system = platform.system()
108
+ if system == "Darwin":
109
+ checks.extend([
110
+ Check(shutil.which("hdiutil") is not None, "hdiutil", shutil.which("hdiutil") or "未安装"),
111
+ Check(_module_available("dmgbuild"), "dmgbuild", "已安装" if _module_available("dmgbuild") else "未安装"),
112
+ ])
113
+ elif system == "Windows":
114
+ from ..packaging.installer import _find_iscc
115
+ iscc = _find_iscc()
116
+ checks.append(Check(iscc is not None, "Inno Setup 6", str(iscc) if iscc else "未安装"))
117
+ elif system == "Linux":
118
+ checks.append(Check(shutil.which("dpkg-deb") is not None, "dpkg-deb", shutil.which("dpkg-deb") or "未安装"))
119
+ return checks
120
+
121
+
122
+ def _module_available(name: str) -> bool:
123
+ try:
124
+ importlib.metadata.version(name)
125
+ return True
126
+ except importlib.metadata.PackageNotFoundError:
127
+ return False
128
+
129
+
130
+ def run(args: object) -> int:
131
+ json_output = bool(getattr(args, "json_output", False))
132
+ try:
133
+ root = find_project_root()
134
+ checks = collect_checks(root)
135
+ except Exception as exc:
136
+ if json_output:
137
+ print(json.dumps({
138
+ "ok": False,
139
+ "project": str(locals().get("root", "")),
140
+ "summary": {"passed": 0, "failed": 1, "total": 1},
141
+ "checks": [asdict(Check(False, "项目", str(exc)))],
142
+ }, ensure_ascii=False, indent=2))
143
+ return 1
144
+ print(f"[失败] 项目: {exc}")
145
+ return 1
146
+ failed = sum(not item.ok for item in checks)
147
+ if json_output:
148
+ print(json.dumps({
149
+ "ok": failed == 0,
150
+ "project": str(root),
151
+ "summary": {"passed": len(checks) - failed, "failed": failed, "total": len(checks)},
152
+ "checks": [asdict(check) for check in checks],
153
+ }, ensure_ascii=False, indent=2))
154
+ return 1 if failed else 0
155
+ print(f"PPX Doctor: {root}")
156
+ for check in checks:
157
+ print(f"[{'通过' if check.ok else '失败'}] {check.name}: {check.detail}")
158
+ print(f"检查完成:{len(checks) - failed} 项通过,{failed} 项失败")
159
+ return 1 if failed else 0