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 @@