diff --git a/Makefile b/Makefile index 1927583..de82100 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ build-backend: --distpath $(ELECTRON_DIR)/resources \ --hidden-import croniter --hidden-import dateutil --hidden-import pytz \ --add-data vendor/skill-creator:vendor/skill-creator \ + --add-data channels/weixin_bridge:channels/weixin_bridge \ $(BACKEND_SRC) @echo "后端二进制文件位置: $(BACKEND_BINARY)" @ls -lh $(BACKEND_BINARY) diff --git a/channels/weixin_channel.py b/channels/weixin_channel.py index a34894a..4869f7b 100644 --- a/channels/weixin_channel.py +++ b/channels/weixin_channel.py @@ -11,7 +11,9 @@ import json import os import re +import shutil import subprocess +import sys import threading import uuid from pathlib import Path @@ -20,6 +22,23 @@ from taskboard_bus import Channel, MessageBus, OutboundMessage, OutboundMessageType + +def _find_node_executable() -> Optional[str]: + """Locate the Node.js binary. + + macOS apps launched from Finder/Dock inherit a minimal PATH that excludes + Homebrew (`/opt/homebrew/bin`), so ``shutil.which("node")`` misses even when + node is installed. Fall back to the common install locations. + """ + found = shutil.which("node") + if found: + return found + for candidate in ("/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"): + if os.path.exists(candidate): + return candidate + return None + + if TYPE_CHECKING: from taskboard import TaskDB, TaskScheduler @@ -74,9 +93,18 @@ def __init__( bus.subscribe_outbound(self._on_outbound) + def _bridge_script_path(self) -> Path: + # In the PyInstaller bundle the bridge is shipped under sys._MEIPASS + # (via --add-data), not beside the frozen source module. + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + return Path(sys._MEIPASS) / "channels" / "weixin_bridge" / "index.mjs" + return Path(__file__).resolve().parent / "weixin_bridge" / "index.mjs" + def _default_bridge_cmd(self) -> list[str]: - bridge_path = Path(__file__).resolve().parent / "weixin_bridge" / "index.mjs" - return ["node", str(bridge_path)] + # Resolve node to a full path so it's found even under the minimal PATH + # a packaged macOS app inherits. Falls back to bare "node" when missing; + # start() then surfaces the FileNotFoundError as an error status. + return [_find_node_executable() or "node", str(self._bridge_script_path())] def start(self) -> None: self._running = True @@ -102,9 +130,21 @@ def start(self) -> None: bufsize=1, env=env, ) + except FileNotFoundError: + self._running = False + self._bridge_proc = None + msg = ( + "Node.js not found. Install Node.js (https://nodejs.org) to use the Weixin channel." + ) + print(f"[Weixin] {msg}") + self._update_status(login_status="error", last_error=msg) + return except Exception as exc: self._running = False - print(f"[Weixin] Failed to start bridge: {exc}") + self._bridge_proc = None + msg = f"Failed to start Weixin bridge: {exc}" + print(f"[Weixin] {msg}") + self._update_status(login_status="error", last_error=msg) return self._reader_thread = threading.Thread(target=self._read_bridge_events, daemon=True) diff --git a/taskboard-electron/src/main.js b/taskboard-electron/src/main.js index 88e9228..3842536 100644 --- a/taskboard-electron/src/main.js +++ b/taskboard-electron/src/main.js @@ -71,12 +71,22 @@ function killPortSync(port) { } } +// macOS apps launched from Finder/Dock inherit a minimal PATH (no Homebrew), +// so the Python backend can't find tools like `node` (needed by the Weixin +// bridge). Prepend the common install dirs so child processes resolve them. +function augmentedPath() { + const extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]; + const current = process.env.PATH || ""; + const merged = [...extra, ...current.split(":")].filter(Boolean); + return [...new Set(merged)].join(":"); +} + function startPythonBackend() { killPortSync(9712); const { cmd, args, cwd } = getPythonCommand(); pythonProcess = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, + env: { ...process.env, PATH: augmentedPath() }, ...(cwd ? { cwd } : {}), }); diff --git a/taskboard.spec b/taskboard.spec index f664d85..c07e15e 100644 --- a/taskboard.spec +++ b/taskboard.spec @@ -5,7 +5,7 @@ a = Analysis( ['taskboard.py'], pathex=[], binaries=[], - datas=[('vendor/skill-creator', 'vendor/skill-creator')], + datas=[('vendor/skill-creator', 'vendor/skill-creator'), ('channels/weixin_bridge', 'channels/weixin_bridge')], hiddenimports=['croniter', 'dateutil', 'pytz'], hookspath=[], hooksconfig={}, diff --git a/tests/test_weixin_more.py b/tests/test_weixin_more.py index ad52c16..29eeb58 100644 --- a/tests/test_weixin_more.py +++ b/tests/test_weixin_more.py @@ -89,10 +89,59 @@ def _make_channel(): def test_default_bridge_cmd_points_at_index_mjs(): channel = _make_channel() cmd = channel._default_bridge_cmd() - assert cmd[0] == "node" + # cmd[0] is now a resolved node path (or bare "node" when none is found) + assert cmd[0] == "node" or cmd[0].endswith("/node") assert cmd[1].endswith("weixin_bridge/index.mjs") +def test_default_bridge_cmd_resolves_full_node_path(monkeypatch): + channel = _make_channel() + monkeypatch.setattr("channels.weixin_channel.shutil.which", lambda exe: "/fake/bin/node") + cmd = channel._default_bridge_cmd() + assert cmd[0] == "/fake/bin/node" + assert cmd[1].endswith("weixin_bridge/index.mjs") + + +def test_default_bridge_cmd_falls_back_to_homebrew_when_node_not_on_path(monkeypatch): + """macOS GUI-launched apps inherit a minimal PATH without Homebrew, so + `shutil.which("node")` misses — we must fall back to common install dirs.""" + channel = _make_channel() + monkeypatch.setattr("channels.weixin_channel.shutil.which", lambda exe: None) + monkeypatch.setattr( + "channels.weixin_channel.os.path.exists", + lambda p: p == "/opt/homebrew/bin/node", + ) + cmd = channel._default_bridge_cmd() + assert cmd[0] == "/opt/homebrew/bin/node" + + +def test_bridge_script_path_uses_meipass_when_frozen(monkeypatch): + """In the PyInstaller bundle the bridge lives under sys._MEIPASS, not next + to the (frozen) source module.""" + channel = _make_channel() + monkeypatch.setattr("channels.weixin_channel.sys.frozen", True, raising=False) + monkeypatch.setattr("channels.weixin_channel.sys._MEIPASS", "/tmp/meipass", raising=False) + path = channel._bridge_script_path() + assert str(path) == "/tmp/meipass/channels/weixin_bridge/index.mjs" + + +def test_start_surfaces_missing_node_as_error_status(monkeypatch): + """When node is genuinely absent, Popen raises FileNotFoundError. Instead of + silently failing (blank QR), the channel must report an error status.""" + channel = _make_channel() + + def no_node(cmd, **kwargs): + raise FileNotFoundError("node") + + monkeypatch.setattr("channels.weixin_channel.subprocess.Popen", no_node) + channel.start() + assert channel._running is False + assert channel._bridge_proc is None + snap = channel.get_status_snapshot() + assert snap["login_status"] == "error" + assert "Node" in (snap["last_error"] or "") + + def test_explicit_bridge_cmd_overrides_default(): from channels.weixin_channel import WeixinChannel