Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 43 additions & 3 deletions channels/weixin_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import uuid
from pathlib import Path
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion taskboard-electron/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
});

Expand Down
2 changes: 1 addition & 1 deletion taskboard.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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={},
Expand Down
51 changes: 50 additions & 1 deletion tests/test_weixin_more.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading