From d9c0b8078c3f6f714a59f0f865583c54c859a1da Mon Sep 17 00:00:00 2001 From: joyharam <150105283@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:58:35 +0900 Subject: [PATCH] Add Wormhole auto-kill so Interface 5 can be claimed Cable autorun holds IF5; kill on launch/connect and via GUI button/CLI. Co-authored-by: Cursor --- README.md | 2 + docs/WINDOWS-ACCESS-DENIED.md | 5 +- docs/WINDOWS.md | 4 +- juc500_xfer/cli.py | 21 +++ juc500_xfer/gui_server.py | 27 ++++ juc500_xfer/static/app.js | 49 +++++- juc500_xfer/static/index.html | 2 + juc500_xfer/usb_link.py | 20 ++- juc500_xfer/wormhole_kill.py | 274 ++++++++++++++++++++++++++++++++++ 9 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 juc500_xfer/wormhole_kill.py diff --git a/README.md b/README.md index 9e9a37d..e7ccf82 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ python -m juc500_xfer doctor ``` Zadig로 **Interface 5 (MI_05)** 에 WinUSB 설치 여부를 확인하세요. +케이블 가상 CD의 공식 **Wormhole**가 IF5를 점유하면 Access denied가 납니다. +GUI **Wormhole 종료** 버튼 또는 `python -m juc500_xfer kill-wormhole` 로 종료할 수 있습니다 (연결·실행 시에도 자동 종료). ## 사용법 diff --git a/docs/WINDOWS-ACCESS-DENIED.md b/docs/WINDOWS-ACCESS-DENIED.md index 128034a..2cfd887 100644 --- a/docs/WINDOWS-ACCESS-DENIED.md +++ b/docs/WINDOWS-ACCESS-DENIED.md @@ -13,7 +13,10 @@ USB interface 5 점유 실패: [Errno 13] Access denied (insufficient permission ### 1. 공식 앱 종료/제거 -- `Wormhole`, `SmartDataLink`, `j5create` 관련 프로세스 종료 +이 앱은 실행·연결 시 Wormhole를 **자동 종료**합니다. 그래도 Access denied면: + +- GUI 상단 **Wormhole 종료** 버튼, 또는 `python -m juc500_xfer kill-wormhole` +- 작업 관리자에서 `Wormhole` / `SmartDataLink` / `j5create` 수동 종료 - 설정 → 앱에서 j5create Wormhole 제거 (가능하면) ### 2. Zadig로 Interface 5만 WinUSB 변경 diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 1f96473..4c7638c 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -9,7 +9,7 @@ USB interface 5 점유 실패: [Errno 13] Access denied → 케이블은 보이지만 Interface 5가 WinUSB가 아닙니다. 자세한 단계: [`WINDOWS-ACCESS-DENIED.md`](WINDOWS-ACCESS-DENIED.md) -요약: Zadig → **List All Devices** → **Smart Data Link (Interface 5) / MI_05** → **WinUSB** → Replace Driver → 케이블 재연결. +요약: GUI **Wormhole 종료** → Zadig → **List All Devices** → **Smart Data Link (Interface 5) / MI_05** → **WinUSB** → Replace Driver → 케이블 재연결. ## 소스에서 실행 (오프라인 wheel 포함) @@ -48,7 +48,7 @@ Mac에서 빌드 산출물 받기: 5. 마찬가지로 **Interface 1** (CDC Data) 권장 6. Interface 2(Mass Storage), 3·4(HID) 는 그대로 두세요. -공식 Wormhole가 설치돼 있으면 제거하거나 프로세스를 종료한 뒤 Zadig를 실행하세요. +공식 Wormhole가 설치돼 있으면 GUI의 **Wormhole 종료** 또는 `python -m juc500_xfer kill-wormhole` 후 Zadig를 실행하세요. ## 2. libusb DLL diff --git a/juc500_xfer/cli.py b/juc500_xfer/cli.py index 89f6cd7..1008558 100644 --- a/juc500_xfer/cli.py +++ b/juc500_xfer/cli.py @@ -11,6 +11,7 @@ from pathlib import Path from . import __version__ from .protocol import Session from .usb_link import list_devices, Juc500Link, doctor +from .wormhole_kill import kill_wormhole_processes, wormhole_status def _progress(prefix: str): @@ -61,12 +62,29 @@ def cmd_doctor(_args: argparse.Namespace) -> int: print("Windows PnP VID_0711:") for row in rep.pnp_vid0711: print(f" - {row}") + wh = wormhole_status() + print(f"Wormhole 프로세스: {wh['count']} — {wh['message']}") + for p in wh.get("processes") or []: + print(f" - {p.get('name')} pid={p.get('pid')}") print("안내:") for h in rep.hints: print(f" * {h}") return 0 if rep.ok else 1 +def cmd_kill_wormhole(_args: argparse.Namespace) -> int: + result = kill_wormhole_processes(force=True) + print(result.get("message") or "") + if result.get("killed"): + for p in result["killed"]: + print(f" 종료: {p.get('name')} pid={p.get('pid')}") + if result.get("remaining"): + for p in result["remaining"]: + print(f" 잔여: {p.get('name')} pid={p.get('pid')}") + return 1 + return 0 + + def cmd_probe(args: argparse.Namespace) -> int: with Juc500Link(serial=args.serial) as link: print(f"열림: serial={link.serial!r}") @@ -143,6 +161,9 @@ def build_parser() -> argparse.ArgumentParser: s = sub.add_parser("doctor", help="USB/libusb/WinUSB 진단") s.set_defaults(func=cmd_doctor) + s = sub.add_parser("kill-wormhole", help="공식 Wormhole/GoBridge 프로세스 종료") + s.set_defaults(func=cmd_kill_wormhole) + s = sub.add_parser("probe", help="USB 인터페이스 점유 및 송수신 프로브") s.add_argument("--serial", default=None) s.set_defaults(func=cmd_probe) diff --git a/juc500_xfer/gui_server.py b/juc500_xfer/gui_server.py index 0025369..ca13194 100644 --- a/juc500_xfer/gui_server.py +++ b/juc500_xfer/gui_server.py @@ -17,6 +17,7 @@ from typing import Any, Optional from .protocol import Session from .usb_link import Juc500Link, list_devices, doctor +from .wormhole_kill import kill_wormhole_processes, wormhole_status DEFAULT_GUI_PORT = 8765 @@ -68,6 +69,7 @@ class GuiState: self.last_progress = {"done": 0, "total": 0, "label": "", "error": None, "busy": False} self.connecting = False self.log_lines: list[str] = [] + self.auto_kill_wormhole = True def log(self, msg: str) -> None: self.log_lines.append(msg) @@ -141,6 +143,13 @@ def connect_usb(timeout: float = 45.0) -> dict[str, Any]: STATE.auto_status = "USB 링크 여는 중…" try: + if STATE.auto_kill_wormhole: + STATE.auto_status = "공식 Wormhole 종료 중…" + STATE.log("공식 Wormhole 프로세스 종료 시도") + result = kill_wormhole_processes(force=True) + STATE.log(result.get("message") or "") + if result.get("killed"): + time.sleep(0.4) devices = list_devices() if not devices: raise RuntimeError("JUC500(Smart Data Link) 장치가 없습니다. 케이블을 연결하세요.") @@ -442,6 +451,7 @@ class Handler(BaseHTTPRequestHandler): "pid": f"{cable.pid:04x}" if cable else None, }, "usb_error": list_err, + "wormhole": wormhole_status(), "inputshare": { "supported": False, "enabled": False, @@ -456,6 +466,10 @@ class Handler(BaseHTTPRequestHandler): self._json(200, doctor().to_dict()) return + if path == "/api/wormhole": + self._json(200, wormhole_status()) + return + if path == "/api/settings/ports": # Reuse settings modal: expose local_root / serial as "ports"-like rows self._json( @@ -548,6 +562,12 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True}) return + if path in ("/api/wormhole/kill", "/api/wormhole/stop"): + result = kill_wormhole_processes(force=True) + STATE.log(result.get("message") or "wormhole kill") + self._json(200, result) + return + if path == "/api/auto/start": self._json(200, {"ok": True}) return @@ -644,6 +664,13 @@ def run_gui(open_browser: bool = True, port: int | None = None, host: str = "127 preferred = port or STATE.gui_port or DEFAULT_GUI_PORT listen_port = _pick_port(host, preferred) STATE.gui_port = listen_port + # Cable autorun Wormhole often starts with the volume — clear it on launch. + try: + result = kill_wormhole_processes(force=True) + if result.get("killed"): + print(result.get("message")) + except Exception as exc: # noqa: BLE001 + print(f"Wormhole 자동 종료 건너뜀: {exc}") server = ThreadingHTTPServer((host, listen_port), Handler) url = f"http://{host}:{listen_port}/" print(f"JUC500 GUI: {url}") diff --git a/juc500_xfer/static/app.js b/juc500_xfer/static/app.js index 94f073e..6f99a3a 100644 --- a/juc500_xfer/static/app.js +++ b/juc500_xfer/static/app.js @@ -450,6 +450,47 @@ function updateBadge(connected, text) { badge.className = connected ? "auto-badge ok" : "auto-badge wait"; } +function updateWormholeUI(info) { + const badge = $("wormholeBadge"); + const btn = $("killWormholeBtn"); + if (!badge) return; + if (!info) { + badge.textContent = "Wormhole —"; + badge.className = "auto-badge wait"; + return; + } + if (info.running) { + badge.textContent = `Wormhole ${info.count}개 실행중`; + badge.className = "auto-badge wait"; + badge.title = (info.processes || []) + .map((p) => `${p.name} (${p.pid})`) + .join("\n") || info.message || ""; + if (btn) btn.disabled = false; + } else { + badge.textContent = "Wormhole 없음"; + badge.className = "auto-badge ok"; + badge.title = info.message || "공식 Wormhole 프로세스 없음"; + if (btn) btn.disabled = false; + } +} + +async function killWormhole() { + setStatus("공식 Wormhole 종료 중…"); + try { + const data = await api("/api/wormhole/kill", { method: "POST" }); + updateWormholeUI({ + running: (data.remaining || []).length > 0, + count: (data.remaining || []).length, + processes: data.remaining || [], + message: data.message, + }); + setStatus(data.message || "Wormhole 종료 완료"); + await refreshStatus(); + } catch (e) { + setStatus(e.message || String(e), true); + } +} + function sortEntries(entries, sort) { const dir = sort.dir; const key = sort.key; @@ -503,6 +544,7 @@ async function refreshStatus() { if (s.local_root) $("localRootHint").textContent = s.local_root; updateInputShareUI(s.inputshare); + updateWormholeUI(s.wormhole); if (state.connected) { updateBadge(true, "연결됨"); @@ -536,8 +578,8 @@ async function connectOneClick() { state.connecting = true; $("connectBtn").disabled = true; updateBadge(false, "연결 중…"); - setStatus("상대 JUC500 프로그램 대기 중…"); - $("peerInfo").textContent = "USB 핸드셰이크 중… 상대에서도 GUI를 실행하세요"; + setStatus("공식 Wormhole 종료 후 상대 프로그램 대기 중…"); + $("peerInfo").textContent = "Wormhole 정리 · USB 핸드셰이크 중…"; try { const data = await api("/api/connect/auto", { method: "POST" }); @@ -1099,6 +1141,9 @@ function bindSort(prefix, getSort, setSort, rerender) { function bindUi() { $("connectBtn").onclick = () => connectOneClick(); $("disconnectBtn").onclick = () => disconnect().catch((e) => setStatus(e.message, true)); + if ($("killWormholeBtn")) { + $("killWormholeBtn").onclick = () => killWormhole(); + } $("upBtn").onclick = () => goUp().catch((e) => setStatus(e.message, true)); $("refreshBtn").onclick = () => { if (!state.connected) { diff --git a/juc500_xfer/static/index.html b/juc500_xfer/static/index.html index b928684..93bfc65 100644 --- a/juc500_xfer/static/index.html +++ b/juc500_xfer/static/index.html @@ -15,6 +15,8 @@
미연결 + Wormhole — + 입력공유 미지원
diff --git a/juc500_xfer/usb_link.py b/juc500_xfer/usb_link.py index cce54f0..d1c3dd1 100644 --- a/juc500_xfer/usb_link.py +++ b/juc500_xfer/usb_link.py @@ -203,7 +203,7 @@ def _claim_failure_message(ifnum: int, exc: BaseException) -> str: f"{base}\n\n" "Windows가 Interface 5를 다른 드라이버(공식 Wormhole 등)로 붙잡고 있습니다.\n" "아래를 순서대로 하세요:\n" - "1) 작업 관리자에서 Wormhole / SmartDataLink / j5create 관련 프로그램 모두 종료\n" + "1) GUI의 'Wormhole 종료' 버튼 또는 `python -m juc500_xfer kill-wormhole`\n" "2) Zadig(https://zadig.akeo.ie/) 실행 (관리자)\n" "3) Options → List All Devices\n" "4) 목록에서 'Smart Data Link (Interface 5)' 또는 InstanceId에 MI_05 가 있는 항목 선택\n" @@ -464,6 +464,24 @@ class Juc500Link: try: usb.util.claim_interface(self._dev, ifnum) except usb.core.USBError as exc: + # Official Wormhole often holds IF5 — kill and retry once. + if _is_access_denied(exc) and ifnum == IF_VENDOR: + try: + from .wormhole_kill import kill_wormhole_processes + + kill_wormhole_processes(force=True) + time.sleep(0.6) + if not _IS_WIN: + try: + if self._dev.is_kernel_driver_active(ifnum): + self._dev.detach_kernel_driver(ifnum) + except (NotImplementedError, usb.core.USBError): + pass + usb.util.claim_interface(self._dev, ifnum) + self._claimed.append(ifnum) + return + except Exception: + pass if required: raise RuntimeError(_claim_failure_message(ifnum, exc)) from exc return diff --git a/juc500_xfer/wormhole_kill.py b/juc500_xfer/wormhole_kill.py new file mode 100644 index 0000000..0796878 --- /dev/null +++ b/juc500_xfer/wormhole_kill.py @@ -0,0 +1,274 @@ +"""Detect and terminate official j5create / OTi Wormhole processes. + +The JUC500 virtual CD (WORMHOLE) auto-runs Wormhole.exe / WormholeShell which +exclusively claims USB Interface 5. Our stack needs that interface free. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from dataclasses import asdict, dataclass +from typing import Any + +_IS_WIN = sys.platform == "win32" + +# Exact / substring match against process image name (case-insensitive) +PROCESS_NAMES = ( + "wormhole.exe", + "wormhold.exe", # typo safety + "wormholeld.exe", + "wormholeshell", + "wormhole", + "u3wormhole.exe", + "u3wormholeshell", + "u3wormhole", + "gobridge.exe", + "gobridgedemon.exe", + "gobridgedemon", + "gobridgedemonu3.exe", + "gobridgedemonu3", + "downloadupdatetool.exe", + "downloadupdatetool", + "smartdatalink.exe", + "smartlink.exe", + "oti_kmloader.exe", + "otigoBridge.exe", + "gosuite.exe", +) + +# Broader name hints (substring in process name) +NAME_HINTS = ( + "wormhole", + "gobridge", + "kaijet", + "j5create", + "smartdatalink", + "smart data link", + "oti_", + "otisync", +) + +# Do not kill ourselves +SELF_HINTS = ("juc500", "juc500_xfer", "juc500-portable") + + +@dataclass +class ProcInfo: + pid: int + name: str + path: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _is_self(name: str, path: str = "") -> bool: + blob = f"{name} {path}".lower() + return any(s in blob for s in SELF_HINTS) + + +def _matches_wormhole(name: str, path: str = "") -> bool: + n = (name or "").lower().strip() + p = (path or "").lower() + if not n: + return False + if _is_self(n, p): + return False + base = n if n.endswith(".exe") else n + # exact-ish + for cand in PROCESS_NAMES: + c = cand.lower() + if base == c or base == c.replace(".exe", "") or n == c: + return True + # path / hint + blob = f"{n} {p}" + return any(h in blob for h in NAME_HINTS) + + +def list_wormhole_processes() -> list[ProcInfo]: + if _IS_WIN: + return _list_windows() + return _list_posix() + + +def _list_windows() -> list[ProcInfo]: + # CSV: "name","pid","session","mem" + try: + proc = subprocess.run( + ["tasklist", "/FO", "CSV", "/NH"], + capture_output=True, + text=True, + timeout=20, + check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except Exception: + return [] + out: list[ProcInfo] = [] + for line in (proc.stdout or "").splitlines(): + line = line.strip() + if not line: + continue + # "Wormhole.exe","1234","Console","1","12,345 K" + parts = [x.strip().strip('"') for x in line.split('","')] + if len(parts) < 2: + # fallback split + parts = [x.strip().strip('"') for x in line.split(",")] + if len(parts) < 2: + continue + name, pid_s = parts[0], parts[1] + try: + pid = int(pid_s) + except ValueError: + continue + if _matches_wormhole(name): + out.append(ProcInfo(pid=pid, name=name)) + # Enrich path via WMIC / PowerShell (optional) + return _dedupe(out) + + +def _list_posix() -> list[ProcInfo]: + try: + proc = subprocess.run( + ["ps", "-ax", "-o", "pid=,comm=,command="], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except Exception: + return [] + out: list[ProcInfo] = [] + my_pid = os.getpid() + for line in (proc.stdout or "").splitlines(): + line = line.strip() + if not line: + continue + try: + pid_s, rest = line.split(None, 1) + pid = int(pid_s) + except ValueError: + continue + if pid == my_pid: + continue + # comm is first token of rest sometimes; keep full command + tokens = rest.split(None, 1) + comm = tokens[0] + cmd = tokens[1] if len(tokens) > 1 else "" + if _matches_wormhole(comm, cmd) or _matches_wormhole(os.path.basename(comm), cmd): + out.append(ProcInfo(pid=pid, name=os.path.basename(comm) or comm, path=cmd[:240])) + return _dedupe(out) + + +def _dedupe(items: list[ProcInfo]) -> list[ProcInfo]: + seen: set[int] = set() + out: list[ProcInfo] = [] + for p in items: + if p.pid in seen: + continue + seen.add(p.pid) + out.append(p) + return out + + +def kill_wormhole_processes(*, force: bool = True) -> dict[str, Any]: + """Terminate official Wormhole processes. Returns status dict.""" + before = list_wormhole_processes() + killed: list[dict[str, Any]] = [] + errors: list[str] = [] + + for info in before: + try: + _kill_one(info.pid, force=force) + killed.append(info.to_dict()) + except Exception as exc: # noqa: BLE001 + errors.append(f"{info.name}({info.pid}): {exc}") + + # brief settle so USB handles are released + if killed: + time.sleep(0.8) + + after = list_wormhole_processes() + # second pass for stubborn ones + if after and force: + for info in after: + try: + _kill_one(info.pid, force=True) + if info.to_dict() not in killed: + killed.append(info.to_dict()) + except Exception as exc: # noqa: BLE001 + errors.append(f"retry {info.name}({info.pid}): {exc}") + time.sleep(0.5) + after = list_wormhole_processes() + + return { + "ok": len(after) == 0, + "found": [p.to_dict() for p in before], + "killed": killed, + "remaining": [p.to_dict() for p in after], + "errors": errors, + "message": _summary(before, killed, after), + } + + +def _summary(before: list[ProcInfo], killed: list[dict], after: list[ProcInfo]) -> str: + if not before: + return "실행 중인 Wormhole 관련 프로세스가 없습니다." + if not after: + names = ", ".join(sorted({k.get("name", "?") for k in killed})) or "프로세스" + return f"Wormhole 종료 완료: {names}" + rem = ", ".join(f"{p.name}({p.pid})" for p in after) + return f"일부 종료되지 않음: {rem} (관리자 권한으로 다시 시도하거나 작업 관리자에서 종료)" + + +def _kill_one(pid: int, *, force: bool) -> None: + if _IS_WIN: + args = ["taskkill", "/PID", str(pid)] + if force: + args.append("/F") + proc = subprocess.run( + args, + capture_output=True, + text=True, + timeout=15, + check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "taskkill failed").strip() + # 128 = not found — treat as OK + if proc.returncode == 128 or "not found" in err.lower(): + return + raise RuntimeError(err) + return + + sig = signal.SIGKILL if force else signal.SIGTERM + try: + os.kill(pid, sig) + except ProcessLookupError: + return + if not force: + time.sleep(0.4) + try: + os.kill(pid, 0) + except ProcessLookupError: + return + os.kill(pid, signal.SIGKILL) + + +def wormhole_status() -> dict[str, Any]: + procs = list_wormhole_processes() + return { + "running": len(procs) > 0, + "count": len(procs), + "processes": [p.to_dict() for p in procs], + "message": ( + f"Wormhole 관련 프로세스 {len(procs)}개 실행 중 (Interface 5 점유 → 종료 권장)" + if procs + else "Wormhole 프로세스 없음" + ), + }