"""fileShare 스타일 로컬 웹 GUI — JUC500 USB bulk 브릿지.""" from __future__ import annotations import json import mimetypes import os import socket import tempfile import threading import time import urllib.parse import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path 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 = 8766 # 8765는 fileShare 등과 자주 충돌 def _static_root() -> Path: """개발/PyInstaller frozen 모두에서 static 루트를 찾는다.""" import sys candidates: list[Path] = [] if getattr(sys, "frozen", False): mei = getattr(sys, "_MEIPASS", None) if mei: candidates += [ Path(mei) / "static", Path(mei) / "juc500_xfer" / "static", ] candidates.append(Path(sys.executable).resolve().parent / "static") here = Path(__file__).resolve().parent candidates += [here / "static", here.parent / "static"] for c in candidates: if (c / "index.html").is_file(): return c.resolve() return (here / "static").resolve() def _static_file(rel: str) -> Path | None: root = _static_root() name = (rel or "").lstrip("/").replace("\\", "/") if not name or ".." in name.split("/"): return None target = (root / name).resolve() try: target.relative_to(root) except ValueError: return None return target if target.is_file() else None class GuiState: def __init__(self) -> None: self.lock = threading.Lock() self.link: Juc500Link | None = None self.session: Session | None = None self.peer_name: str | None = None self.local_root = Path.home().resolve() self.serial: str | None = None self.gui_port = DEFAULT_GUI_PORT self.auto_status = "JUC500 케이블을 연결하세요" 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) self.log_lines = self.log_lines[-200:] print(msg) def set_progress(self, done: int, total: int, label: str) -> None: self.last_progress = { "done": done, "total": total, "label": label, "error": None, "busy": total > 0 and done < total, } STATE = GuiState() def _session_alive() -> bool: return STATE.session is not None and STATE.session._connected.is_set() def _safe_local(rel: str) -> Path: rel = (rel or "").replace("\\", "/").lstrip("/") target = (STATE.local_root / rel).resolve() try: target.relative_to(STATE.local_root) except ValueError as exc: raise PermissionError("경로가 로컬 루트를 벗어납니다") from exc return target def _list_local_entries(rel: str) -> dict[str, Any]: path = _safe_local(rel) if not path.exists(): raise FileNotFoundError(rel or "/") if not path.is_dir(): raise NotADirectoryError(rel) entries = [] for child in path.iterdir(): try: st = child.stat() entries.append( { "name": child.name, "is_dir": child.is_dir(), "size": 0 if child.is_dir() else int(st.st_size), "mtime": int(st.st_mtime), } ) except OSError: continue return {"path": rel.replace("\\", "/").strip("/"), "entries": entries} def connect_usb(timeout: float = 45.0) -> dict[str, Any]: with STATE.lock: if _session_alive(): return { "ok": True, "host": STATE.peer_name or "peer", "hostname": STATE.peer_name or "peer", "port": 0, "start_path": "", "receive_path": "", } if STATE.connecting: raise RuntimeError("이미 연결 중입니다") STATE.connecting = True 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) 장치가 없습니다. 케이블을 연결하세요.") link = Juc500Link(serial=STATE.serial) session = Session( link, on_log=STATE.log, local_root=STATE.local_root, ) session.inbound_dest = STATE.local_root session.on_progress = STATE.set_progress session.start() STATE.auto_status = "상대 프로그램 대기 중…" peer = session.wait_peer(timeout=timeout) with STATE.lock: STATE.link = link STATE.session = session STATE.peer_name = peer STATE.auto_status = f"연결됨 · {peer}" STATE.connecting = False return { "ok": True, "host": peer, "hostname": peer, "port": 0, "start_path": "", "receive_path": "", } except Exception: with STATE.lock: STATE.connecting = False if STATE.session: try: STATE.session.stop() except Exception: pass if STATE.link: try: STATE.link.close() except Exception: pass STATE.session = None STATE.link = None STATE.auto_status = "연결 실패 — 다시 시도하세요" raise def disconnect_usb() -> None: with STATE.lock: session = STATE.session link = STATE.link STATE.session = None STATE.link = None STATE.peer_name = None STATE.auto_status = "연결 해제됨" STATE.connecting = False if session: try: session.stop() except Exception: pass if link: try: link.close() except Exception: pass def _require_session() -> Session: if not STATE.session or not STATE.session._connected.is_set(): raise RuntimeError("연결되지 않았습니다") return STATE.session def _send_local_paths(paths: list[str], dest_dir: str) -> dict[str, Any]: session = _require_session() dest = (dest_dir or "").replace("\\", "/").strip("/") count = 0 def ensure_remote_dir(rel_dir: str) -> None: rel_dir = (rel_dir or "").replace("\\", "/").strip("/") if not rel_dir or rel_dir == ".": return try: session.rpc("mkdir", path=rel_dir, timeout=15) except Exception: pass for rel in paths: local = _safe_local(rel) if local.is_file(): remote_name = f"{dest}/{local.name}".strip("/") if dest else local.name ensure_remote_dir(str(Path(remote_name).parent.as_posix())) STATE.set_progress(0, local.stat().st_size, local.name) session.on_progress = lambda done, total, name=local.name: STATE.set_progress(done, total, name) session.send_file(local, remote_name=remote_name) count += 1 elif local.is_dir(): base = f"{dest}/{local.name}".strip("/") if dest else local.name ensure_remote_dir(base) for file_path in local.rglob("*"): if not file_path.is_file(): continue rel_under = file_path.relative_to(local).as_posix() remote_name = f"{base}/{rel_under}".strip("/") ensure_remote_dir(str(Path(remote_name).parent.as_posix())) STATE.set_progress(0, file_path.stat().st_size, remote_name) session.on_progress = lambda done, total, name=remote_name: STATE.set_progress( done, total, name ) session.send_file(file_path, remote_name=remote_name) count += 1 else: raise FileNotFoundError(rel) STATE.last_progress = {"done": 1, "total": 1, "label": "완료", "error": None, "busy": False} return {"count": count, "dest_dir": dest} def _fetch_remote_paths(paths: list[str], dest_dir: str) -> dict[str, Any]: session = _require_session() dest_rel = (dest_dir or "").replace("\\", "/").strip("/") dest_abs = _safe_local(dest_rel) dest_abs.mkdir(parents=True, exist_ok=True) session.inbound_dest = dest_abs count = 0 for rel in paths: rel = rel.replace("\\", "/").lstrip("/") # Ask peer what it is — listdir parent and find, or just pull # Pull: peer pushes file(s). For directories, peer walks. STATE.set_progress(0, 1, rel) session.rpc("pull", path=rel, timeout=30) # Wait until inbound idle after activity deadline = time.monotonic() + 3600 saw = False idle = 0 while time.monotonic() < deadline: if session._inbound.get("fh"): saw = True idle = 0 elif saw: idle += 1 if idle >= 8: # ~0.8s quiet after transfer break time.sleep(0.1) count += 1 session.inbound_dest = STATE.local_root STATE.last_progress = {"done": 1, "total": 1, "label": "완료", "error": None, "busy": False} return {"count": count, "dest_dir": dest_rel} class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, fmt: str, *args: Any) -> None: return def _json(self, code: int, payload: dict[str, Any]) -> None: raw = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(raw))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(raw) def _read_json(self) -> dict[str, Any]: length = int(self.headers.get("Content-Length") or 0) if length <= 0: return {} return json.loads(self.rfile.read(length).decode("utf-8")) def _read_multipart(self) -> tuple[dict[str, str], list[tuple[str, bytes]]]: ctype = self.headers.get("Content-Type", "") if "multipart/form-data" not in ctype: raise ValueError("multipart required") length = int(self.headers.get("Content-Length") or 0) body = self.rfile.read(length) boundary = None for part in ctype.split(";"): part = part.strip() if part.startswith("boundary="): boundary = part.split("=", 1)[1].strip().strip('"') if not boundary: raise ValueError("no boundary") fields: dict[str, str] = {} files: list[tuple[str, bytes]] = [] for chunk in body.split(b"--" + boundary.encode()): if not chunk or chunk in (b"--\r\n", b"--"): continue if chunk.startswith(b"\r\n"): chunk = chunk[2:] if chunk.endswith(b"\r\n"): chunk = chunk[:-2] if b"\r\n\r\n" not in chunk: continue header_blob, data = chunk.split(b"\r\n\r\n", 1) if data.endswith(b"\r\n"): data = data[:-2] headers = header_blob.decode("utf-8", errors="replace") name = None filename = None for line in headers.split("\r\n"): if line.lower().startswith("content-disposition:"): for token in line.split(";"): token = token.strip() if token.startswith("name="): name = token.split("=", 1)[1].strip().strip('"') if token.startswith("filename="): filename = token.split("=", 1)[1].strip().strip('"') if not name: continue if filename is not None: files.append((filename, data)) else: fields[name] = data.decode("utf-8", errors="replace") return fields, files def do_GET(self) -> None: # noqa: N802 parsed = urllib.parse.urlparse(self.path) path = parsed.path qs = urllib.parse.parse_qs(parsed.query) if path in ("/", "/index.html"): target = _static_file("index.html") if not target: self._json(500, {"error": "index.html missing"}) return data = target.read_bytes() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) return if path.startswith("/api/"): try: self._api_get(path, qs) except Exception as exc: self._json(400, {"error": str(exc)}) return rel = path.lstrip("/") target = _static_file(rel) if not target: self._json(404, {"error": "not found"}) return data = target.read_bytes() mime = mimetypes.guess_type(str(target))[0] or "application/octet-stream" self.send_response(200) self.send_header("Content-Type", mime) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _api_get(self, path: str, qs: dict[str, list[str]]) -> None: if path == "/api/status": try: devices = list_devices() list_err = None except Exception as exc: # noqa: BLE001 devices = [] list_err = str(exc) cable = devices[0] if devices else None if devices: auto = STATE.auto_status if _session_alive() else "케이블 감지됨 — 연결을 누르세요" elif list_err: auto = f"USB 오류: {list_err}" else: auto = ( "JUC500 미감지 — python -m juc500_xfer doctor 로 진단하세요" if not STATE.connecting else STATE.auto_status ) self._json( 200, { "connected": _session_alive(), "connecting": STATE.connecting, "hostname": STATE.peer_name, "host": STATE.peer_name or "", "port": 0, "bind_ip": None, "cable_ip": cable.serial if cable else None, "cable_only": True, "this_host": socket.gethostname(), "local_root": str(STATE.local_root), "local_start": "", "root": STATE.peer_name or "JUC500", "remote_start": "", "remote_receive": "", "auto_status": auto if not _session_alive() else STATE.auto_status, "progress": STATE.last_progress, "device": { "serial": cable.serial if cable else None, "product": cable.product if cable else None, "count": len(devices), "vid": f"{cable.vid:04x}" if cable else None, "pid": f"{cable.pid:04x}" if cable else None, }, "usb_error": list_err, "wormhole": wormhole_status(), "inputshare": { "supported": False, "enabled": False, "trusted": False, "status": "JUC500 빌드에서는 입력공유 미지원", }, }, ) return if path == "/api/doctor": 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( 200, { "config_path": str(STATE.local_root), "ports": [ { "key": "gui", "label": "GUI 포트", "description": "로컬 웹 UI 포트 (재시작 시 적용)", "protocol": "HTTP", "default": DEFAULT_GUI_PORT, "value": STATE.gui_port, } ], }, ) return if path == "/api/inputshare": self._json( 200, { "supported": False, "enabled": False, "trusted": False, "status": "미지원", }, ) return if path == "/api/progress": self._json(200, STATE.last_progress) return if path == "/api/interfaces": devices = [ { "name": d.product, "ip": d.serial, "usb": True, } for d in list_devices() ] self._json(200, {"interfaces": devices}) return if path == "/api/discover": devices = list_devices() peers = [] if devices and _session_alive(): peers.append({"host": STATE.peer_name or "peer", "tcp_port": 0}) self._json(200, {"peers": peers}) return if path == "/api/local/listdir": rel = (qs.get("path") or [""])[0] self._json(200, _list_local_entries(rel)) return if path == "/api/listdir": session = _require_session() rel = (qs.get("path") or [""])[0] result = session.rpc("listdir", path=rel, timeout=20) self._json(200, result) return self._json(404, {"error": f"unknown api {path}"}) def do_POST(self) -> None: # noqa: N802 parsed = urllib.parse.urlparse(self.path) path = parsed.path try: self._api_post(path) except Exception as e: self._json(400, {"error": str(e), "ok": False, "code": type(e).__name__}) def _api_post(self, path: str) -> None: if path in ("/api/connect", "/api/connect/auto"): try: data = connect_usb() self._json(200, data) except TimeoutError as e: self._json(404, {"ok": False, "error": str(e), "code": "PEER_NOT_FOUND"}) return if path == "/api/disconnect": disconnect_usb() 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 if path == "/api/mkdir": body = self._read_json() session = _require_session() result = session.rpc("mkdir", path=body.get("path") or "", timeout=15) self._json(200, result) return if path == "/api/local/send": body = self._read_json() result = _send_local_paths(body.get("paths") or [], body.get("dest_dir") or "") self._json(200, result) return if path == "/api/remote/fetch": body = self._read_json() result = _fetch_remote_paths(body.get("paths") or [], body.get("dest_dir") or "") self._json(200, result) return if path == "/api/upload": fields, files = self._read_multipart() dest = fields.get("dest_dir") or "" session = _require_session() # write temp then send count = 0 with tempfile.TemporaryDirectory() as td: for name, data in files: tmp = Path(td) / Path(name).name tmp.write_bytes(data) remote_name = f"{dest}/{tmp.name}".strip("/") if dest else tmp.name parent = Path(remote_name).parent.as_posix() if parent and parent != ".": try: session.rpc("mkdir", path=parent, timeout=15) except Exception: pass session.send_file(tmp, remote_name=remote_name) count += 1 self._json(200, {"count": count, "dest_dir": dest}) return if path == "/api/local/write": fields, files = self._read_multipart() dest = fields.get("dest_dir") or "" dest_abs = _safe_local(dest) dest_abs.mkdir(parents=True, exist_ok=True) count = 0 for name, data in files: (dest_abs / Path(name).name).write_bytes(data) count += 1 self._json(200, {"count": count, "dest_dir": dest}) return if path == "/api/settings/ports": body = self._read_json() ports = body.get("ports") or {} if "gui" in ports: STATE.gui_port = int(ports["gui"]) self._json( 200, { "ok": True, "gui_port": STATE.gui_port, "redirect": f"http://127.0.0.1:{STATE.gui_port}/", "message": "포트는 다음 실행 시 적용됩니다 (재시작 없음)", }, ) return if path.startswith("/api/inputshare"): self._json(200, {"supported": False, "ok": False, "error": "입력공유 미지원"}) return self._json(404, {"error": f"unknown api {path}"}) def _pick_port(host: str, preferred: int) -> int: for port in [preferred, *range(preferred + 1, preferred + 20)]: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: s.bind((host, port)) return port except OSError: continue raise RuntimeError("GUI 포트를 열 수 없습니다") def run_gui(open_browser: bool = True, port: int | None = None, host: str = "127.0.0.1") -> None: 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}/" from . import __version__ from .usb_link import Juc500Link backend = hasattr(Juc500Link, "_backend_bulk_write") print(f"JUC500 GUI v{__version__} (backend_io={backend}): {url}") print("fileShare와 동일한 듀얼 패널 UI · 케이블 연결 후 「연결」을 누르세요.") if listen_port != preferred: print(f"주의: 기본 포트 {preferred} 사용 중 → {listen_port} 으로 열었습니다. 브라우저 주소를 확인하세요.") if open_browser: threading.Timer(0.4, lambda: webbrowser.open(url)).start() try: server.serve_forever() except KeyboardInterrupt: print("\nGUI 종료") finally: disconnect_usb() server.server_close()