Add Windows installer packaging (Setup.exe + portable exe)
PyInstaller onedir/onefile specs, Inno Setup script, and GitHub Actions workflow to produce JUC500-Setup-win64.exe and portable builds. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""JUC500 cross-platform file transfer for Apple Silicon Mac ↔ Windows 11."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Command-line interface for JUC500 file transfer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from .protocol import Session
|
||||
from .usb_link import list_devices, Juc500Link
|
||||
|
||||
|
||||
def _progress(prefix: str):
|
||||
last = [0.0]
|
||||
|
||||
def cb(done: int, total: int) -> None:
|
||||
now = time.monotonic()
|
||||
if now - last[0] < 0.1 and done < total:
|
||||
return
|
||||
last[0] = now
|
||||
pct = (done / total * 100) if total else 100.0
|
||||
mb = done / (1024 * 1024)
|
||||
tot = total / (1024 * 1024)
|
||||
print(f"\r{prefix} {mb:.2f}/{tot:.2f} MiB ({pct:5.1f}%)", end="", flush=True)
|
||||
if done >= total:
|
||||
print()
|
||||
|
||||
return cb
|
||||
|
||||
|
||||
def cmd_info(_args: argparse.Namespace) -> int:
|
||||
devices = list_devices()
|
||||
if not devices:
|
||||
print("연결된 JUC500 장치가 없습니다.")
|
||||
return 1
|
||||
for d in devices:
|
||||
print(f"bus={d.bus} addr={d.address} serial={d.serial!r} product={d.product!r}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_probe(args: argparse.Namespace) -> int:
|
||||
with Juc500Link(serial=args.serial) as link:
|
||||
print(f"열림: serial={link.serial!r}")
|
||||
link.drain(0.3)
|
||||
payload = b"JUC500-PROBE-" + os.urandom(8)
|
||||
n = link.write_data(payload)
|
||||
print(f"DATA OUT 기록: {n} bytes")
|
||||
# If Windows peer echoes or our own framed app is listening, we may see traffic
|
||||
for i in range(10):
|
||||
data = link.read_data(timeout_ms=300)
|
||||
if data:
|
||||
print(f"DATA IN[{i}]: {data[:64]!r} ({len(data)} bytes)")
|
||||
ctrl = link.read_ctrl(timeout_ms=50)
|
||||
if ctrl:
|
||||
print(f"CTRL IN[{i}]: {ctrl[:64]!r}")
|
||||
print("probe 완료 (상대 앱이 없으면 IN 데이터는 없을 수 있음)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_send(args: argparse.Namespace) -> int:
|
||||
path = Path(args.file)
|
||||
with Juc500Link(serial=args.serial) as link:
|
||||
session = Session(link, peer_name=args.name, on_log=print)
|
||||
session.start()
|
||||
try:
|
||||
peer = session.wait_peer(timeout=args.timeout)
|
||||
print(f"연결됨: {peer}")
|
||||
session.send_file(path, progress=_progress("전송"))
|
||||
finally:
|
||||
session.stop()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_recv(args: argparse.Namespace) -> int:
|
||||
dest = Path(args.dest)
|
||||
with Juc500Link(serial=args.serial) as link:
|
||||
session = Session(link, peer_name=args.name, on_log=print)
|
||||
session.start()
|
||||
try:
|
||||
peer = session.wait_peer(timeout=args.timeout)
|
||||
print(f"연결됨: {peer}")
|
||||
|
||||
def allow(name: str, size: int) -> bool:
|
||||
if args.yes:
|
||||
print(f"수신 수락: {name} ({size} bytes)")
|
||||
return True
|
||||
ans = input(f"수신할까요? {name} ({size} bytes) [y/N] ").strip().lower()
|
||||
return ans in ("y", "yes", "ㅛ")
|
||||
|
||||
session.receive_file(dest, allow=allow, progress=_progress("수신"), timeout=args.recv_timeout)
|
||||
finally:
|
||||
session.stop()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_gui(args: argparse.Namespace) -> int:
|
||||
from .gui_server import run_gui
|
||||
|
||||
run_gui(open_browser=not args.no_browser, port=args.port)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="juc500",
|
||||
description="Apple Silicon Mac ↔ Windows 11 JUC500(Smart Data Link) 파일 전송",
|
||||
)
|
||||
p.add_argument("--version", action="version", version=f"juc500-xfer {__version__}")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s = sub.add_parser("info", help="연결된 JUC500 장치 목록")
|
||||
s.set_defaults(func=cmd_info)
|
||||
|
||||
s = sub.add_parser("probe", help="USB 인터페이스 점유 및 송수신 프로브")
|
||||
s.add_argument("--serial", default=None)
|
||||
s.set_defaults(func=cmd_probe)
|
||||
|
||||
s = sub.add_parser("send", help="파일 전송")
|
||||
s.add_argument("file")
|
||||
s.add_argument("--serial", default=None)
|
||||
s.add_argument("--name", default=None, help="이 쪽 peer 이름")
|
||||
s.add_argument("--timeout", type=float, default=60.0, help="상대 연결 대기(초)")
|
||||
s.set_defaults(func=cmd_send)
|
||||
|
||||
s = sub.add_parser("recv", help="파일 수신")
|
||||
s.add_argument("--dest", default=".", help="저장 폴더")
|
||||
s.add_argument("--serial", default=None)
|
||||
s.add_argument("--name", default=None)
|
||||
s.add_argument("--timeout", type=float, default=60.0)
|
||||
s.add_argument("--recv-timeout", type=float, default=3600.0)
|
||||
s.add_argument("-y", "--yes", action="store_true", help="수락 확인 생략")
|
||||
s.set_defaults(func=cmd_recv)
|
||||
|
||||
s = sub.add_parser("gui", help="fileShare 스타일 웹 GUI 실행")
|
||||
s.add_argument("--port", type=int, default=None, help="GUI 포트 (기본 8765)")
|
||||
s.add_argument("--no-browser", action="store_true", help="브라우저 자동 실행 안 함")
|
||||
s.set_defaults(func=cmd_gui)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if getattr(args, "name", None) is None and args.cmd in ("send", "recv"):
|
||||
host = os.uname().nodename if hasattr(os, "uname") else "host"
|
||||
args.name = f"{host}"
|
||||
try:
|
||||
return args.func(args)
|
||||
except KeyboardInterrupt:
|
||||
print("\n중단됨")
|
||||
return 130
|
||||
except Exception as exc:
|
||||
print(f"오류: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""호환용 — 웹 GUI로 위임."""
|
||||
|
||||
from .gui_server import run_gui
|
||||
|
||||
__all__ = ["run_gui"]
|
||||
@@ -0,0 +1,637 @@
|
||||
"""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
|
||||
|
||||
DEFAULT_GUI_PORT = 8765
|
||||
|
||||
|
||||
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] = []
|
||||
|
||||
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:
|
||||
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":
|
||||
devices = list_devices()
|
||||
cable = devices[0] if devices else None
|
||||
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": 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),
|
||||
},
|
||||
"inputshare": {
|
||||
"supported": False,
|
||||
"enabled": False,
|
||||
"trusted": False,
|
||||
"status": "JUC500 빌드에서는 입력공유 미지원",
|
||||
},
|
||||
},
|
||||
)
|
||||
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 == "/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
|
||||
server = ThreadingHTTPServer((host, listen_port), Handler)
|
||||
url = f"http://{host}:{listen_port}/"
|
||||
print(f"JUC500 GUI: {url}")
|
||||
print("fileShare와 동일한 듀얼 패널 UI · 케이블 연결 후 「연결」을 누르세요.")
|
||||
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()
|
||||
@@ -0,0 +1,528 @@
|
||||
"""Framed packet protocol over the JUC500 vendor bulk bridge.
|
||||
|
||||
Frame layout (little-endian):
|
||||
magic : 4 bytes b"J5FX"
|
||||
type : u8
|
||||
flags : u8
|
||||
seq : u32
|
||||
length : u32
|
||||
payload : length bytes
|
||||
crc32 : u32 zlib.crc32(payload)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zlib
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from .usb_link import Juc500Link, MAX_PACKET
|
||||
|
||||
MAGIC = b"J5FX"
|
||||
HEADER_FMT = "<4sBBII"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FMT)
|
||||
CRC_SIZE = 4
|
||||
MAX_PAYLOAD = 16 * 1024 * 1024
|
||||
CHUNK_PAYLOAD = 60 * 1024
|
||||
|
||||
|
||||
class MsgType(IntEnum):
|
||||
HELLO = 1
|
||||
HELLO_ACK = 2
|
||||
PING = 3
|
||||
PONG = 4
|
||||
FILE_OFFER = 10
|
||||
FILE_ACCEPT = 11
|
||||
FILE_REJECT = 12
|
||||
FILE_CHUNK = 13
|
||||
FILE_DONE = 14
|
||||
FILE_ACK = 15
|
||||
RPC_REQ = 30
|
||||
RPC_RESP = 31
|
||||
ERROR = 255
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
type: MsgType
|
||||
flags: int
|
||||
seq: int
|
||||
payload: bytes
|
||||
|
||||
|
||||
def pack_frame(msg_type: MsgType, payload: bytes = b"", seq: int = 0, flags: int = 0) -> bytes:
|
||||
crc = zlib.crc32(payload) & 0xFFFFFFFF
|
||||
header = struct.pack(HEADER_FMT, MAGIC, int(msg_type), flags, seq & 0xFFFFFFFF, len(payload))
|
||||
return header + payload + struct.pack("<I", crc)
|
||||
|
||||
|
||||
class FrameReader:
|
||||
def __init__(self) -> None:
|
||||
self._buf = bytearray()
|
||||
|
||||
def feed(self, data: bytes) -> list[Frame]:
|
||||
self._buf.extend(data)
|
||||
frames: list[Frame] = []
|
||||
while True:
|
||||
if len(self._buf) < HEADER_SIZE + CRC_SIZE:
|
||||
break
|
||||
idx = self._buf.find(MAGIC)
|
||||
if idx < 0:
|
||||
self._buf = self._buf[-3:]
|
||||
break
|
||||
if idx > 0:
|
||||
del self._buf[:idx]
|
||||
if len(self._buf) < HEADER_SIZE + CRC_SIZE:
|
||||
break
|
||||
magic, typ, flags, seq, length = struct.unpack_from(HEADER_FMT, self._buf, 0)
|
||||
if magic != MAGIC or length > MAX_PAYLOAD:
|
||||
del self._buf[0:1]
|
||||
continue
|
||||
total = HEADER_SIZE + length + CRC_SIZE
|
||||
if len(self._buf) < total:
|
||||
break
|
||||
payload = bytes(self._buf[HEADER_SIZE : HEADER_SIZE + length])
|
||||
(crc,) = struct.unpack_from("<I", self._buf, HEADER_SIZE + length)
|
||||
del self._buf[:total]
|
||||
if (zlib.crc32(payload) & 0xFFFFFFFF) != crc:
|
||||
continue
|
||||
try:
|
||||
frames.append(Frame(MsgType(typ), flags, seq, payload))
|
||||
except ValueError:
|
||||
continue
|
||||
return frames
|
||||
|
||||
|
||||
def _default_peer_name() -> str:
|
||||
host = os.uname().nodename if hasattr(os, "uname") else "host"
|
||||
try:
|
||||
user = os.getlogin()
|
||||
except OSError:
|
||||
user = os.environ.get("USER") or os.environ.get("USERNAME") or "user"
|
||||
return f"{host}-{user}"
|
||||
|
||||
|
||||
class Session:
|
||||
"""Bidirectional session: HELLO + file frames + JSON RPC."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
link: Juc500Link,
|
||||
peer_name: Optional[str] = None,
|
||||
on_log: Optional[Callable[[str], None]] = None,
|
||||
local_root: Optional[Path] = None,
|
||||
):
|
||||
self.link = link
|
||||
self.peer_name = peer_name or _default_peer_name()
|
||||
self.on_log = on_log or (lambda m: None)
|
||||
self.local_root = (local_root or Path.home()).resolve()
|
||||
self._reader = FrameReader()
|
||||
self._seq = 0
|
||||
self._lock = threading.Lock()
|
||||
self._xfer_lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._rx_thread: Optional[threading.Thread] = None
|
||||
self._alive_thread: Optional[threading.Thread] = None
|
||||
self._handlers: dict[MsgType, Callable[[Frame], None]] = {}
|
||||
self._pending_accept = threading.Event()
|
||||
self._accept_result: Optional[bool] = None
|
||||
self._peer_hello: Optional[str] = None
|
||||
self._file_ack = threading.Event()
|
||||
self._connected = threading.Event()
|
||||
self._rpc_waiters: dict[str, Future] = {}
|
||||
self._rpc_lock = threading.Lock()
|
||||
# inbound file receive staging (when peer pushes)
|
||||
self._inbound: dict[str, Any] = {}
|
||||
self.inbound_dest = self.local_root
|
||||
self.on_progress: Optional[Callable[[int, int, str], None]] = None
|
||||
|
||||
def log(self, msg: str) -> None:
|
||||
self.on_log(msg)
|
||||
|
||||
def start(self) -> None:
|
||||
self._stop.clear()
|
||||
self._handlers[MsgType.HELLO] = self._on_hello
|
||||
self._handlers[MsgType.HELLO_ACK] = self._on_hello_ack
|
||||
self._handlers[MsgType.PING] = self._on_ping
|
||||
self._handlers[MsgType.PONG] = lambda _f: None
|
||||
self._handlers[MsgType.FILE_ACCEPT] = self._on_file_accept
|
||||
self._handlers[MsgType.FILE_REJECT] = self._on_file_reject
|
||||
self._handlers[MsgType.FILE_ACK] = self._on_file_ack
|
||||
self._handlers[MsgType.FILE_OFFER] = self._on_inbound_offer
|
||||
self._handlers[MsgType.FILE_CHUNK] = self._on_inbound_chunk
|
||||
self._handlers[MsgType.FILE_DONE] = self._on_inbound_done
|
||||
self._handlers[MsgType.RPC_REQ] = self._on_rpc_req
|
||||
self._handlers[MsgType.RPC_RESP] = self._on_rpc_resp
|
||||
self._rx_thread = threading.Thread(target=self._rx_loop, name="juc500-rx", daemon=True)
|
||||
self._alive_thread = threading.Thread(target=self._alive_loop, name="juc500-alive", daemon=True)
|
||||
self._rx_thread.start()
|
||||
self._alive_thread.start()
|
||||
self.send(MsgType.HELLO, self.peer_name.encode("utf-8"))
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._rx_thread:
|
||||
self._rx_thread.join(timeout=2)
|
||||
if self._alive_thread:
|
||||
self._alive_thread.join(timeout=2)
|
||||
with self._rpc_lock:
|
||||
for fut in self._rpc_waiters.values():
|
||||
if not fut.done():
|
||||
fut.set_exception(RuntimeError("session stopped"))
|
||||
self._rpc_waiters.clear()
|
||||
|
||||
def wait_peer(self, timeout: float = 30.0) -> str:
|
||||
deadline = time.monotonic() + timeout
|
||||
while not self._connected.is_set():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
"상대가 JUC500 전송 프로그램으로 연결되지 않았습니다. "
|
||||
"양쪽에서 GUI/앱을 실행 중인지 확인하세요."
|
||||
)
|
||||
try:
|
||||
self.send(MsgType.HELLO, self.peer_name.encode("utf-8"))
|
||||
except Exception as exc:
|
||||
self.log(f"HELLO 재전송 실패: {exc}")
|
||||
self._connected.wait(timeout=min(2.0, remaining))
|
||||
return self._peer_hello or "peer"
|
||||
|
||||
def send(self, msg_type: MsgType, payload: bytes = b"", flags: int = 0) -> int:
|
||||
with self._lock:
|
||||
self._seq = (self._seq + 1) & 0xFFFFFFFF
|
||||
seq = self._seq
|
||||
frame = pack_frame(msg_type, payload, seq=seq, flags=flags)
|
||||
return self.link.write_data(frame)
|
||||
|
||||
def rpc(self, op: str, timeout: float = 30.0, **kwargs: Any) -> Any:
|
||||
req_id = uuid.uuid4().hex
|
||||
body = {"id": req_id, "op": op, **kwargs}
|
||||
fut: Future = Future()
|
||||
with self._rpc_lock:
|
||||
self._rpc_waiters[req_id] = fut
|
||||
self.send(MsgType.RPC_REQ, json.dumps(body, ensure_ascii=False).encode("utf-8"))
|
||||
try:
|
||||
return fut.result(timeout=timeout)
|
||||
finally:
|
||||
with self._rpc_lock:
|
||||
self._rpc_waiters.pop(req_id, None)
|
||||
|
||||
def _rx_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
chunk = self.link.read_data(MAX_PACKET, timeout_ms=200)
|
||||
if not chunk:
|
||||
continue
|
||||
for frame in self._reader.feed(chunk):
|
||||
handler = self._handlers.get(frame.type)
|
||||
if handler:
|
||||
try:
|
||||
handler(frame)
|
||||
except Exception as exc:
|
||||
self.log(f"핸들러 오류 ({frame.type.name}): {exc}")
|
||||
|
||||
def _alive_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.link.write_ctrl(b"\x00ALIVE" + struct.pack("<d", time.time()), timeout_ms=500)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.link.read_ctrl(MAX_PACKET, timeout_ms=50)
|
||||
except Exception:
|
||||
pass
|
||||
self._stop.wait(1.0)
|
||||
if self._connected.is_set():
|
||||
try:
|
||||
self.send(MsgType.PING, struct.pack("<d", time.time()))
|
||||
except Exception:
|
||||
pass
|
||||
self._stop.wait(2.0)
|
||||
|
||||
def _on_hello(self, frame: Frame) -> None:
|
||||
name = frame.payload.decode("utf-8", errors="replace")
|
||||
self._peer_hello = name
|
||||
self.log(f"상대 HELLO: {name}")
|
||||
self.send(MsgType.HELLO_ACK, self.peer_name.encode("utf-8"))
|
||||
self._connected.set()
|
||||
|
||||
def _on_hello_ack(self, frame: Frame) -> None:
|
||||
name = frame.payload.decode("utf-8", errors="replace")
|
||||
self._peer_hello = name
|
||||
self.log(f"상대 HELLO_ACK: {name}")
|
||||
self._connected.set()
|
||||
|
||||
def _on_ping(self, frame: Frame) -> None:
|
||||
self.send(MsgType.PONG, frame.payload)
|
||||
|
||||
def _on_file_accept(self, _frame: Frame) -> None:
|
||||
self._accept_result = True
|
||||
self._pending_accept.set()
|
||||
|
||||
def _on_file_reject(self, _frame: Frame) -> None:
|
||||
self._accept_result = False
|
||||
self._pending_accept.set()
|
||||
|
||||
def _on_file_ack(self, _frame: Frame) -> None:
|
||||
self._file_ack.set()
|
||||
|
||||
def _safe_under_root(self, rel: str) -> Path:
|
||||
rel = (rel or "").replace("\\", "/").lstrip("/")
|
||||
target = (self.local_root / rel).resolve()
|
||||
try:
|
||||
target.relative_to(self.local_root)
|
||||
except ValueError as exc:
|
||||
raise PermissionError(f"경로가 루트를 벗어남: {rel}") from exc
|
||||
return target
|
||||
|
||||
def list_local(self, rel: str = "") -> dict[str, Any]:
|
||||
path = self._safe_under_root(rel)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(rel)
|
||||
if not path.is_dir():
|
||||
raise NotADirectoryError(rel)
|
||||
entries = []
|
||||
for child in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
||||
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 mkdir_local(self, rel: str) -> dict[str, Any]:
|
||||
path = self._safe_under_root(rel)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return {"path": rel.replace("\\", "/").strip("/")}
|
||||
|
||||
def _on_rpc_req(self, frame: Frame) -> None:
|
||||
try:
|
||||
req = json.loads(frame.payload.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
self._rpc_reply({"id": "", "ok": False, "error": f"bad json: {exc}"})
|
||||
return
|
||||
req_id = req.get("id", "")
|
||||
op = req.get("op")
|
||||
try:
|
||||
if op == "listdir":
|
||||
result = self.list_local(req.get("path") or "")
|
||||
elif op == "mkdir":
|
||||
result = self.mkdir_local(req.get("path") or "")
|
||||
elif op == "ping":
|
||||
result = {"pong": True, "name": self.peer_name}
|
||||
elif op == "pull":
|
||||
# Peer asks us to push a file/folder to them.
|
||||
rel = req.get("path") or ""
|
||||
# Respond OK first; actual bytes go via FILE_OFFER stream in a worker.
|
||||
self._rpc_reply({"id": req_id, "ok": True, "result": {"queued": True, "path": rel}})
|
||||
threading.Thread(
|
||||
target=self._push_path_for_pull,
|
||||
args=(rel,),
|
||||
daemon=True,
|
||||
name="juc500-pull-push",
|
||||
).start()
|
||||
return
|
||||
else:
|
||||
raise ValueError(f"unknown op: {op}")
|
||||
self._rpc_reply({"id": req_id, "ok": True, "result": result})
|
||||
except Exception as exc:
|
||||
self._rpc_reply({"id": req_id, "ok": False, "error": str(exc)})
|
||||
|
||||
def _rpc_reply(self, body: dict[str, Any]) -> None:
|
||||
self.send(MsgType.RPC_RESP, json.dumps(body, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def _on_rpc_resp(self, frame: Frame) -> None:
|
||||
try:
|
||||
body = json.loads(frame.payload.decode("utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
req_id = body.get("id")
|
||||
with self._rpc_lock:
|
||||
fut = self._rpc_waiters.get(req_id or "")
|
||||
if not fut or fut.done():
|
||||
return
|
||||
if body.get("ok"):
|
||||
fut.set_result(body.get("result"))
|
||||
else:
|
||||
fut.set_exception(RuntimeError(body.get("error") or "RPC failed"))
|
||||
|
||||
def _push_path_for_pull(self, rel: str) -> None:
|
||||
try:
|
||||
path = self._safe_under_root(rel)
|
||||
if path.is_file():
|
||||
self.send_file(path, remote_name=Path(rel).name)
|
||||
elif path.is_dir():
|
||||
for file_path in path.rglob("*"):
|
||||
if file_path.is_file():
|
||||
remote_name = str(file_path.relative_to(self.local_root)).replace("\\", "/")
|
||||
# When pulled, receiver picks dest; send basename under relative tree
|
||||
rel_under = str(file_path.relative_to(path)).replace("\\", "/")
|
||||
self.send_file(file_path, remote_name=rel_under)
|
||||
else:
|
||||
self.log(f"pull 대상 없음: {rel}")
|
||||
except Exception as exc:
|
||||
self.log(f"pull push 실패: {exc}")
|
||||
|
||||
# --- outbound send ---
|
||||
|
||||
def send_file(
|
||||
self,
|
||||
path: Path,
|
||||
remote_name: Optional[str] = None,
|
||||
progress: Optional[Callable[[int, int], None]] = None,
|
||||
accept_timeout: float = 120.0,
|
||||
) -> None:
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(path)
|
||||
with self._xfer_lock:
|
||||
self._send_file_locked(path, remote_name or path.name, progress, accept_timeout)
|
||||
|
||||
def _send_file_locked(
|
||||
self,
|
||||
path: Path,
|
||||
name: str,
|
||||
progress: Optional[Callable[[int, int], None]],
|
||||
accept_timeout: float,
|
||||
) -> None:
|
||||
size = path.stat().st_size
|
||||
sha = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
while True:
|
||||
b = f.read(1024 * 1024)
|
||||
if not b:
|
||||
break
|
||||
sha.update(b)
|
||||
digest = sha.hexdigest()
|
||||
meta = f"{name}\n{size}\n{digest}".encode("utf-8")
|
||||
self._pending_accept.clear()
|
||||
self._accept_result = None
|
||||
self.log(f"파일 제안: {name} ({size} bytes)")
|
||||
self.send(MsgType.FILE_OFFER, meta)
|
||||
if not self._pending_accept.wait(accept_timeout):
|
||||
raise TimeoutError("상대가 파일 수신을 수락하지 않았습니다.")
|
||||
if not self._accept_result:
|
||||
raise RuntimeError("상대가 파일 수신을 거부했습니다.")
|
||||
|
||||
sent = 0
|
||||
with path.open("rb") as f:
|
||||
while True:
|
||||
chunk = f.read(CHUNK_PAYLOAD)
|
||||
if not chunk:
|
||||
break
|
||||
self._file_ack.clear()
|
||||
self.send(MsgType.FILE_CHUNK, chunk)
|
||||
if not self._file_ack.wait(60.0):
|
||||
raise TimeoutError("청크 ACK 타임아웃")
|
||||
sent += len(chunk)
|
||||
if progress:
|
||||
progress(sent, size)
|
||||
if self.on_progress:
|
||||
self.on_progress(sent, size, name)
|
||||
self.send(MsgType.FILE_DONE, digest.encode("ascii"))
|
||||
self.log(f"전송 완료: {name}")
|
||||
|
||||
# --- inbound receive (auto-accept into inbound_dest) ---
|
||||
|
||||
def _on_inbound_offer(self, frame: Frame) -> None:
|
||||
# If we're mid outbound wait for ACCEPT, this is our counterpart — ignore auto path
|
||||
# Actually inbound offers always handled here; outbound waits on _pending_accept which is set by ACCEPT from peer.
|
||||
try:
|
||||
name, size_s, digest = frame.payload.decode("utf-8").split("\n", 2)
|
||||
size = int(size_s)
|
||||
except Exception:
|
||||
self.send(MsgType.FILE_REJECT, b"bad offer")
|
||||
return
|
||||
# sanitize name: no absolute, no ..
|
||||
name = name.replace("\\", "/").lstrip("/")
|
||||
parts = [p for p in name.split("/") if p and p != ".."]
|
||||
safe = "/".join(parts)
|
||||
if not safe:
|
||||
self.send(MsgType.FILE_REJECT, b"bad name")
|
||||
return
|
||||
out = (Path(self.inbound_dest) / safe).resolve()
|
||||
try:
|
||||
out.relative_to(Path(self.inbound_dest).resolve())
|
||||
except ValueError:
|
||||
self.send(MsgType.FILE_REJECT, b"path escape")
|
||||
return
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._inbound = {
|
||||
"path": out,
|
||||
"name": safe,
|
||||
"size": size,
|
||||
"digest": digest.strip(),
|
||||
"fh": out.open("wb"),
|
||||
"sha": hashlib.sha256(),
|
||||
"received": 0,
|
||||
}
|
||||
self.send(MsgType.FILE_ACCEPT, b"ok")
|
||||
self.log(f"수신 시작: {safe} ({size} bytes)")
|
||||
|
||||
def _on_inbound_chunk(self, frame: Frame) -> None:
|
||||
st = self._inbound
|
||||
fh = st.get("fh")
|
||||
if not fh:
|
||||
return
|
||||
fh.write(frame.payload)
|
||||
st["sha"].update(frame.payload)
|
||||
st["received"] = st.get("received", 0) + len(frame.payload)
|
||||
self.send(MsgType.FILE_ACK, b"")
|
||||
if self.on_progress:
|
||||
self.on_progress(st["received"], st["size"], st.get("name") or "")
|
||||
|
||||
def _on_inbound_done(self, frame: Frame) -> None:
|
||||
st = self._inbound
|
||||
fh = st.pop("fh", None)
|
||||
if fh:
|
||||
fh.close()
|
||||
digest = st.get("sha").hexdigest() if st.get("sha") else ""
|
||||
expected = st.get("digest", "")
|
||||
remote = frame.payload.decode("ascii", errors="replace")
|
||||
if digest != expected or (remote and remote != digest):
|
||||
self.log(f"해시 불일치: {st.get('name')}")
|
||||
else:
|
||||
self.log(f"수신 완료: {st.get('path')}")
|
||||
self._inbound = {}
|
||||
|
||||
# compatibility helpers used by CLI
|
||||
def receive_file(
|
||||
self,
|
||||
dest_dir: Path,
|
||||
allow: Optional[Callable[[str, int], bool]] = None,
|
||||
progress: Optional[Callable[[int, int], None]] = None,
|
||||
timeout: float = 600.0,
|
||||
) -> Path:
|
||||
"""Wait until an inbound transfer completes into dest_dir."""
|
||||
self.inbound_dest = Path(dest_dir)
|
||||
self.inbound_dest.mkdir(parents=True, exist_ok=True)
|
||||
deadline = time.monotonic() + timeout
|
||||
last_path = None
|
||||
while time.monotonic() < deadline:
|
||||
if self._inbound.get("fh") is None and last_path and not self._inbound:
|
||||
# completed
|
||||
if progress and last_path:
|
||||
pass
|
||||
return Path(last_path)
|
||||
if self._inbound.get("path"):
|
||||
last_path = self._inbound["path"]
|
||||
if progress and self._inbound.get("size"):
|
||||
progress(self._inbound.get("received", 0), self._inbound["size"])
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError("파일 수신 타임아웃")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>JUC500</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">JUC500</span>
|
||||
<span class="brand-sub" id="thisHost">Smart Data Link 파일 전송</span>
|
||||
</div>
|
||||
<div class="conn">
|
||||
<span id="autoBadge" class="auto-badge wait">미연결</span>
|
||||
<span id="inputBadge" class="auto-badge wait" title="마우스·키보드 공유">입력공유 미지원</span>
|
||||
<button id="inputShareBtn" type="button" class="ghost" disabled>입력공유</button>
|
||||
<div class="control-switch" title="입력공유 미지원">
|
||||
<button id="controlLocalBtn" type="button" class="ghost control-btn" disabled>이 PC</button>
|
||||
<button id="controlRemoteBtn" type="button" class="ghost control-btn" disabled>상대 PC</button>
|
||||
</div>
|
||||
<button id="connectBtn" type="button" class="primary">연결</button>
|
||||
<button id="disconnectBtn" type="button" class="ghost" disabled>끊기</button>
|
||||
<button id="settingsBtn" type="button" class="ghost settings-btn" title="설정" aria-label="설정">
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M10 6.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zm7.1 2.4-.9-.2a5.9 5.9 0 0 0-.5-1.2l.5-.8a.8.8 0 0 0-.1-.9l-1.4-1.4a.8.8 0 0 0-.9-.1l-.8.5c-.4-.2-.8-.4-1.2-.5l-.2-.9a.8.8 0 0 0-.8-.6H8.2a.8.8 0 0 0-.8.6l-.2.9c-.4.1-.8.3-1.2.5l-.8-.5a.8.8 0 0 0-.9.1L2.9 5.8a.8.8 0 0 0-.1.9l.5.8c-.2.4-.4.8-.5 1.2l-.9.2a.8.8 0 0 0-.6.8v2a.8.8 0 0 0 .6.8l.9.2c.1.4.3.8.5 1.2l-.5.8a.8.8 0 0 0 .1.9l1.4 1.4a.8.8 0 0 0 .9.1l.8-.5c.4.2.8.4 1.2.5l.2.9a.8.8 0 0 0 .8.6h2a.8.8 0 0 0 .8-.6l.2-.9c.4-.1.8-.3 1.2-.5l.8.5a.8.8 0 0 0 .9-.1l1.4-1.4a.8.8 0 0 0 .1-.9l-.5-.8c.2-.4.4-.8.5-1.2l.9-.2a.8.8 0 0 0 .6-.8v-2a.8.8 0 0 0-.6-.8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="settingsModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
|
||||
<div class="modal-backdrop" data-close-settings></div>
|
||||
<div class="modal-panel">
|
||||
<div class="modal-head">
|
||||
<h2 id="settingsTitle">설정</h2>
|
||||
<button type="button" class="ghost modal-close" data-close-settings aria-label="닫기">닫기</button>
|
||||
</div>
|
||||
<p class="modal-lead">JUC500 GUI 포트입니다. 변경 후 다음 실행 시 적용됩니다.</p>
|
||||
<div id="portsForm" class="ports-form"></div>
|
||||
<p id="portsConfigPath" class="ports-path muted"></p>
|
||||
<p id="settingsMsg" class="settings-msg" hidden></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="portsResetBtn" class="ghost">기본값</button>
|
||||
<button type="button" id="portsApplyBtn" class="primary">적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="peerBar" class="peer-bar">
|
||||
<span id="peerInfo">연결을 누르면 상대 PC 폴더가 표시됩니다</span>
|
||||
<span id="ifaceInfo"></span>
|
||||
</section>
|
||||
|
||||
<main class="main ftp-layout">
|
||||
<aside class="finder local-browser" aria-label="이 PC 파일">
|
||||
<div class="finder-titlebar">
|
||||
<span class="finder-title">이 PC</span>
|
||||
<span class="finder-subtitle" id="localRootHint">로컬 파일</span>
|
||||
</div>
|
||||
<div class="finder-toolbar">
|
||||
<div class="nav-group">
|
||||
<button id="localUpBtn" type="button" class="tool-btn" title="상위 폴더" disabled>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5 3 8.5h3.2V13h3.6V8.5H13L8 3.5z"/></svg>
|
||||
</button>
|
||||
<button id="localRefreshBtn" type="button" class="tool-btn" title="새로고침">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.4-3.6l.9.9V3.2h-2.1l1.1 1.1A4.2 4.2 0 1 0 12.2 8h1.3z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="localPathBar" class="address-bar" title="경로"></div>
|
||||
</div>
|
||||
<div class="finder-cols" aria-hidden="true">
|
||||
<button type="button" class="col-btn" data-sort-local="name">이름</button>
|
||||
<button type="button" class="col-btn" data-sort-local="mtime">수정한 날짜</button>
|
||||
<button type="button" class="col-btn" data-sort-local="size">크기</button>
|
||||
<button type="button" class="col-btn" data-sort-local="kind">종류</button>
|
||||
</div>
|
||||
<div class="finder-scroll">
|
||||
<table class="finder-table" aria-label="이 PC 목록">
|
||||
<tbody id="localDirList"></tbody>
|
||||
</table>
|
||||
<div id="localEmpty" class="finder-empty hidden">폴더가 비어 있습니다</div>
|
||||
</div>
|
||||
<div class="finder-status">
|
||||
<span id="localCount">0개 항목</span>
|
||||
<span id="localPathHint" class="path-hint">/</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="transfer-rail" aria-label="전송">
|
||||
<div class="rail-paths">
|
||||
<div class="rail-path">
|
||||
<span class="rail-label">이 PC</span>
|
||||
<strong id="railLocalPath">/</strong>
|
||||
</div>
|
||||
<div class="rail-path">
|
||||
<span class="rail-label">상대</span>
|
||||
<strong id="railRemotePath">/</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button id="uploadBtn" type="button" class="xfer-btn" disabled title="선택한 항목을 상대 현재 폴더로 전송">
|
||||
<span class="xfer-arrow">→</span>
|
||||
<span class="xfer-text">전송</span>
|
||||
</button>
|
||||
<button id="downloadBtn" type="button" class="xfer-btn" disabled title="선택한 항목을 이 PC 현재 폴더로 가져오기">
|
||||
<span class="xfer-arrow">←</span>
|
||||
<span class="xfer-text">가져오기</span>
|
||||
</button>
|
||||
<p class="rail-hint">선택 후 버튼<br/>또는 패널로 드래그</p>
|
||||
<div class="rail-progress">
|
||||
<div class="progress-wrap">
|
||||
<div id="progressBar" class="progress-bar"></div>
|
||||
</div>
|
||||
<div id="statusText" class="status">연결 버튼을 누르세요</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="finder remote-browser" aria-label="상대 PC 폴더">
|
||||
<div class="finder-titlebar">
|
||||
<span class="finder-title">상대 PC</span>
|
||||
<span class="finder-subtitle" id="remoteRootHint">연결 필요</span>
|
||||
</div>
|
||||
<div class="finder-toolbar">
|
||||
<div class="nav-group">
|
||||
<button id="upBtn" type="button" class="tool-btn" title="상위 폴더" disabled>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5 3 8.5h3.2V13h3.6V8.5H13L8 3.5z"/></svg>
|
||||
</button>
|
||||
<button id="refreshBtn" type="button" class="tool-btn" title="새로고침">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.4-3.6l.9.9V3.2h-2.1l1.1 1.1A4.2 4.2 0 1 0 12.2 8h1.3z"/></svg>
|
||||
</button>
|
||||
<button id="mkdirBtn" type="button" class="tool-btn wide" title="새 폴더">새 폴더</button>
|
||||
</div>
|
||||
<div id="pathBar" class="address-bar" title="경로"></div>
|
||||
</div>
|
||||
<div class="finder-cols" aria-hidden="true">
|
||||
<button type="button" class="col-btn" data-sort-remote="name">이름</button>
|
||||
<button type="button" class="col-btn" data-sort-remote="mtime">수정한 날짜</button>
|
||||
<button type="button" class="col-btn" data-sort-remote="size">크기</button>
|
||||
<button type="button" class="col-btn" data-sort-remote="kind">종류</button>
|
||||
</div>
|
||||
<div class="finder-scroll">
|
||||
<table class="finder-table" aria-label="상대 PC 목록">
|
||||
<tbody id="dirList"></tbody>
|
||||
</table>
|
||||
<div id="remoteEmpty" class="finder-empty">연결 후 여기에 상대 폴더가 표시됩니다</div>
|
||||
</div>
|
||||
<div class="finder-status">
|
||||
<span id="remoteCount">연결되지 않음</span>
|
||||
<span id="remotePathHint" class="path-hint">/</span>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,744 @@
|
||||
:root {
|
||||
--bg: #c9d1d8;
|
||||
--bg-deep: #b4c0c8;
|
||||
--window: #eceff1;
|
||||
--titlebar: linear-gradient(180deg, #f6f7f8 0%, #e3e6ea 100%);
|
||||
--toolbar: #e8ebef;
|
||||
--row: #ffffff;
|
||||
--row-alt: #f5f7f9;
|
||||
--row-hover: #e8f0fe;
|
||||
--row-selected: #0a5fff;
|
||||
--row-selected-text: #ffffff;
|
||||
--ink: #1c1c1e;
|
||||
--muted: #6b7280;
|
||||
--line: #c5CAD1;
|
||||
--accent: #0b5fff;
|
||||
--accent-2: #0047d9;
|
||||
--danger: #c0392b;
|
||||
--folder: #5cadff;
|
||||
--folder-dark: #2f7fd4;
|
||||
--file: #8e99a8;
|
||||
--shadow: 0 10px 28px rgba(20, 28, 36, 0.14);
|
||||
--radius: 10px;
|
||||
--font: -apple-system, "SF Pro Text", "Segoe UI", "Apple SD Gothic Neo", sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--ink);
|
||||
background:
|
||||
linear-gradient(180deg, #d7dee5 0%, #bbc5cf 100%);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 14px 14px;
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand-mark {
|
||||
display: block;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1;
|
||||
}
|
||||
.brand-sub { color: var(--muted); font-size: 0.88rem; }
|
||||
|
||||
.conn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auto-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: #e8f0fe;
|
||||
color: var(--accent);
|
||||
font-weight: 650;
|
||||
font-size: 0.88rem;
|
||||
border: 1px solid #c5d7f5;
|
||||
}
|
||||
.auto-badge.ok { background: #dff5e8; color: #0f6e56; border-color: #b7ddd1; }
|
||||
.auto-badge.wait { background: #eef1f4; color: var(--muted); border-color: var(--line); }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 9px 13px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
button.primary:hover:not(:disabled) { background: var(--accent-2); }
|
||||
button.primary.compact { padding: 6px 12px; font-size: 0.84rem; }
|
||||
button.ghost:hover:not(:disabled) { background: #e8ebef; }
|
||||
|
||||
.control-switch {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
.control-switch .control-btn {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 8px 11px;
|
||||
font-size: 0.84rem;
|
||||
background: transparent;
|
||||
}
|
||||
.control-switch .control-btn + .control-btn {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.control-switch .control-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 650;
|
||||
}
|
||||
.control-switch .control-btn:hover:not(:disabled):not(.active) {
|
||||
background: #e8ebef;
|
||||
}
|
||||
|
||||
.peer-bar {
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
box-shadow: var(--shadow);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 0.75fr;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; /* flex 자식이 늘어나지 않고 내부 스크롤 */
|
||||
overflow: hidden;
|
||||
}
|
||||
.main.ftp-layout {
|
||||
grid-template-columns: minmax(0, 1fr) 132px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
html, body { overflow: auto; }
|
||||
.app {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.main,
|
||||
.main.ftp-layout {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.finder,
|
||||
.transfer-rail {
|
||||
height: min(70dvh, 640px);
|
||||
max-height: 70dvh;
|
||||
min-height: 280px;
|
||||
}
|
||||
.transfer-rail {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.rail-paths { width: 100%; }
|
||||
.rail-progress { width: 100%; }
|
||||
}
|
||||
|
||||
/* ===== Finder / Explorer window ===== */
|
||||
.finder {
|
||||
background: var(--window);
|
||||
border: 1px solid #9aa3ad;
|
||||
border-radius: 11px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.finder-titlebar {
|
||||
background: var(--titlebar);
|
||||
border-bottom: 1px solid #c0c6cd;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
user-select: none;
|
||||
}
|
||||
.finder-title { font-weight: 700; font-size: 0.92rem; }
|
||||
.finder-subtitle { color: var(--muted); font-size: 0.78rem; }
|
||||
|
||||
.finder-toolbar {
|
||||
background: var(--toolbar);
|
||||
border-bottom: 1px solid #cfd4da;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tool-btn {
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(180deg, #fff, #eef1f4);
|
||||
border: 1px solid #b8c0c9;
|
||||
}
|
||||
.tool-btn.wide {
|
||||
width: auto;
|
||||
padding: 0 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: #3a4450;
|
||||
}
|
||||
.tool-btn:hover:not(:disabled) { background: #fff; }
|
||||
.tool-btn:active:not(:disabled) { background: #dde3ea; }
|
||||
|
||||
.address-bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #b8c0c9;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.path-sep { color: #9aa3ad; flex: 0 0 auto; }
|
||||
button.path-crumb {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 36%;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
button.path-crumb:hover {
|
||||
background: var(--row-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.finder-cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1.6fr) 140px 88px 90px;
|
||||
gap: 0;
|
||||
background: linear-gradient(180deg, #f4f5f7, #e7eaee);
|
||||
border-bottom: 1px solid #c5CAD1;
|
||||
user-select: none;
|
||||
}
|
||||
.col-btn {
|
||||
border: none;
|
||||
border-right: 1px solid #d0d5db;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
color: #4b5563;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.col-btn:last-child { border-right: none; }
|
||||
.col-btn:hover { background: rgba(255,255,255,0.55); }
|
||||
.col-btn.active { color: var(--accent); }
|
||||
|
||||
.finder.drop-target {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
background: #eef4ff;
|
||||
}
|
||||
.finder-table tr.drop-target {
|
||||
background: #d6e6ff !important;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
.finder-table tr.parent-row .name-cell span {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.finder-table tr.parent-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #eef3f8 !important;
|
||||
box-shadow: 0 1px 0 #c5cad1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.finder-table tr.parent-row:hover {
|
||||
background: #e0ebf8 !important;
|
||||
}
|
||||
.finder-table tr.parent-row-root {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.finder-table tr.parent-row:hover:not(.selected):not(.checked) {
|
||||
background: #e0ebf8 !important;
|
||||
}
|
||||
.finder.local-browser.drop-target .finder-empty,
|
||||
.finder.remote-browser.drop-target .finder-empty {
|
||||
color: var(--accent);
|
||||
}
|
||||
.finder-titlebar,
|
||||
.finder-toolbar,
|
||||
.finder-cols,
|
||||
.finder-status {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.finder-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; /* 핵심: 목록만 스크롤 */
|
||||
overflow: auto;
|
||||
background: var(--row);
|
||||
position: relative;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.finder-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.finder-table tr {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.finder-table tr:nth-child(even):not(.selected):not(.checked) {
|
||||
background: var(--row-alt);
|
||||
}
|
||||
.finder-table tr:hover:not(.selected):not(.checked) {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
.finder-table tr.selected,
|
||||
.finder-table tr.checked {
|
||||
background: var(--row-selected) !important;
|
||||
color: var(--row-selected-text);
|
||||
}
|
||||
.finder-table tr.selected .muted,
|
||||
.finder-table tr.checked .muted,
|
||||
.finder-table tr.selected .kind,
|
||||
.finder-table tr.checked .kind {
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
.finder-table td {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.84rem;
|
||||
border-bottom: 1px solid transparent;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.finder-table .name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.finder-table .name-cell span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.finder-table .col-name { width: auto; }
|
||||
.finder-table .col-date { width: 140px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.finder-table .col-size { width: 88px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.finder-table .col-kind { width: 90px; color: var(--muted); }
|
||||
|
||||
.item-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.item-icon.folder { color: var(--folder); }
|
||||
.item-icon.file { color: var(--file); }
|
||||
.finder-table tr.selected .item-icon,
|
||||
.finder-table tr.checked .item-icon { color: #fff; }
|
||||
|
||||
.finder-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
pointer-events: none;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.finder-empty.hidden { display: none; }
|
||||
|
||||
.finder-status {
|
||||
border-top: 1px solid #cfd4da;
|
||||
background: #eef1f4;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dest-hint strong {
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
.path-hint {
|
||||
font-family: var(--mono);
|
||||
color: var(--ink);
|
||||
max-width: 55%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* FTP 가운데 전송 레일 */
|
||||
.transfer-rail {
|
||||
background: var(--window);
|
||||
border: 1px solid #9aa3ad;
|
||||
border-radius: 11px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 10px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.rail-paths {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rail-path {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.72rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.rail-path strong {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xfer-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 12px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #b8c0c9;
|
||||
background: linear-gradient(180deg, #fff, #eef1f4);
|
||||
font-weight: 700;
|
||||
}
|
||||
.xfer-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: #e8f0fe;
|
||||
color: var(--accent);
|
||||
}
|
||||
.xfer-arrow {
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.xfer-text { font-size: 0.78rem; }
|
||||
.rail-hint {
|
||||
margin: 4px 0 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.rail-progress {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.rail-progress .status {
|
||||
font-size: 0.72rem;
|
||||
text-align: center;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.drop-pane {
|
||||
display: none;
|
||||
}
|
||||
.drop-zone {
|
||||
flex: 1;
|
||||
border: 2px dashed #9aa8b8;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #f7f9fb, #e8eef4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.drop-zone.dragover {
|
||||
border-color: var(--accent);
|
||||
background: #e8f0fe;
|
||||
}
|
||||
.drop-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.drop-hint {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
max-width: 26ch;
|
||||
line-height: 1.45;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.file-pick {
|
||||
display: inline-block;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.file-pick:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.progress-wrap {
|
||||
height: 8px;
|
||||
background: #d5dbe3;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--accent), #5cadff);
|
||||
transition: width 0.12s linear;
|
||||
}
|
||||
.status {
|
||||
min-height: 1.35em;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.status.error { color: var(--danger); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.settings-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
.settings-btn svg { display: block; }
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(18, 24, 32, 0.38);
|
||||
}
|
||||
.modal-panel {
|
||||
position: relative;
|
||||
width: min(520px, 100%);
|
||||
max-height: min(86vh, 720px);
|
||||
overflow: auto;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px 18px 16px;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.modal-lead {
|
||||
margin: 0 0 14px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ports-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.port-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 118px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.port-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
.port-label {
|
||||
display: block;
|
||||
font-weight: 650;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.port-desc {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.port-proto {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: #4b5563;
|
||||
background: #eef1f4;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.port-input {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95rem;
|
||||
text-align: right;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.port-input:focus {
|
||||
outline: 2px solid #c5d7f5;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ports-path {
|
||||
margin: 12px 0 0;
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.settings-msg {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
.settings-msg.error { color: var(--danger); }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.port-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.port-input { text-align: left; }
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Low-level USB access to the j5create JUC500 Smart Data Link cable.
|
||||
|
||||
The cable enumerates as USB VID:PID 0711:7500 with six interfaces:
|
||||
|
||||
0 CDC Communication (control notifications)
|
||||
1 CDC Data (bulk 0x02 / 0x83) – proprietary control path
|
||||
2 Mass Storage (WORMHOLE virtual CD, claimed by OS)
|
||||
3 HID mouse (KM switch, claimed by OS)
|
||||
4 HID keyboard (KM switch, claimed by OS)
|
||||
5 Vendor Specific (bulk 0x08/0x89 + 0x0A/0x8B) – data bridge
|
||||
|
||||
Official Wormhole software is Intel/x86_64-only and uses the deprecated
|
||||
IOUSBDevice stack, which does not work reliably on Apple Silicon. This
|
||||
module opens interfaces 1 and 5 via libusb for a Silicon-native pathway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
VID = 0x0711
|
||||
PID = 0x7500
|
||||
|
||||
# Interface numbers
|
||||
IF_CDC_COMM = 0
|
||||
IF_CDC_DATA = 1
|
||||
IF_VENDOR = 5
|
||||
|
||||
# Vendor interface endpoints (file payload)
|
||||
EP_DATA_OUT = 0x08
|
||||
EP_DATA_IN = 0x89
|
||||
|
||||
# Vendor interface endpoints (keepalive / control mux)
|
||||
EP_CTRL_OUT = 0x0A
|
||||
EP_CTRL_IN = 0x8B
|
||||
|
||||
# CDC data endpoints (alternate / reserved)
|
||||
EP_CDC_OUT = 0x02
|
||||
EP_CDC_IN = 0x83
|
||||
|
||||
MAX_PACKET = 512
|
||||
|
||||
|
||||
def _libusb_backend():
|
||||
"""Locate libusb-1.0 on macOS / Windows / Linux."""
|
||||
import usb.backend.libusb1
|
||||
|
||||
candidates = []
|
||||
if getattr(sys, "frozen", False):
|
||||
mei = getattr(sys, "_MEIPASS", None)
|
||||
if mei:
|
||||
candidates += [
|
||||
os.path.join(mei, "libusb-1.0.dll"),
|
||||
os.path.join(mei, "libusb-1.0.dylib"),
|
||||
os.path.join(mei, "libusb-1.0.so"),
|
||||
]
|
||||
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||
candidates += [
|
||||
os.path.join(exe_dir, "libusb-1.0.dll"),
|
||||
os.path.join(exe_dir, "_internal", "libusb-1.0.dll"),
|
||||
]
|
||||
if sys.platform == "darwin":
|
||||
candidates += [
|
||||
os.path.expanduser("~/homebrew/lib/libusb-1.0.dylib"),
|
||||
"/opt/homebrew/lib/libusb-1.0.dylib",
|
||||
"/usr/local/lib/libusb-1.0.dylib",
|
||||
]
|
||||
elif sys.platform == "win32":
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
root = os.path.dirname(here)
|
||||
candidates += [
|
||||
os.path.join(root, "vendor", "libusb-1.0.dll"),
|
||||
os.path.join(os.environ.get("PROGRAMFILES", ""), "libusb", "libusb-1.0.dll"),
|
||||
"libusb-1.0.dll",
|
||||
]
|
||||
else:
|
||||
candidates += ["libusb-1.0.so", "libusb-1.0.so.0"]
|
||||
|
||||
for path in candidates:
|
||||
if path and (path.endswith((".dll", ".dylib", ".so", ".so.0")) or os.path.exists(path)):
|
||||
if path.endswith((".dll", ".dylib", ".so", ".so.0")) and not os.path.exists(path):
|
||||
# bare DLL name: allow loader PATH search via find_library
|
||||
if os.path.sep in path or (len(path) > 1 and path[1] == ":"):
|
||||
continue
|
||||
be = usb.backend.libusb1.get_backend(find_library=lambda _n, p=path: p)
|
||||
if be is not None:
|
||||
return be
|
||||
|
||||
be = usb.backend.libusb1.get_backend()
|
||||
if be is None:
|
||||
raise RuntimeError(
|
||||
"libusb-1.0 를 찾을 수 없습니다. "
|
||||
"macOS: brew install libusb / Windows: vendor/libusb-1.0.dll 배치"
|
||||
)
|
||||
return be
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
bus: int
|
||||
address: int
|
||||
serial: str
|
||||
product: str
|
||||
manufacturer: str
|
||||
|
||||
|
||||
def list_devices() -> list[DeviceInfo]:
|
||||
backend = _libusb_backend()
|
||||
out: list[DeviceInfo] = []
|
||||
for dev in usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend):
|
||||
try:
|
||||
serial = usb.util.get_string(dev, dev.iSerialNumber) or ""
|
||||
except Exception:
|
||||
serial = ""
|
||||
try:
|
||||
product = usb.util.get_string(dev, dev.iProduct) or "Smart Data Link"
|
||||
except Exception:
|
||||
product = "Smart Data Link"
|
||||
try:
|
||||
manufacturer = usb.util.get_string(dev, dev.iManufacturer) or ""
|
||||
except Exception:
|
||||
manufacturer = ""
|
||||
out.append(
|
||||
DeviceInfo(
|
||||
bus=dev.bus,
|
||||
address=dev.address,
|
||||
serial=serial.strip(),
|
||||
product=product.strip(),
|
||||
manufacturer=manufacturer.strip(),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class Juc500Link:
|
||||
"""Claim CDC+Vendor interfaces and provide framed bulk I/O."""
|
||||
|
||||
def __init__(self, serial: Optional[str] = None, timeout_ms: int = 2000):
|
||||
self.timeout_ms = timeout_ms
|
||||
self._dev: Optional[usb.core.Device] = None
|
||||
self._claimed: list[int] = []
|
||||
self._serial = serial
|
||||
self.open()
|
||||
|
||||
def open(self) -> None:
|
||||
backend = _libusb_backend()
|
||||
matches = list(
|
||||
usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend)
|
||||
)
|
||||
if not matches:
|
||||
raise RuntimeError(
|
||||
"JUC500(0711:7500 Smart Data Link) 를 찾지 못했습니다. "
|
||||
"케이블이 연결되어 있는지 확인하세요."
|
||||
)
|
||||
|
||||
chosen = None
|
||||
for dev in matches:
|
||||
if self._serial:
|
||||
try:
|
||||
s = (usb.util.get_string(dev, dev.iSerialNumber) or "").strip()
|
||||
except Exception:
|
||||
s = ""
|
||||
if s != self._serial.strip():
|
||||
continue
|
||||
chosen = dev
|
||||
break
|
||||
if chosen is None:
|
||||
raise RuntimeError(f"시리얼 '{self._serial}' 에 해당하는 JUC500 이 없습니다.")
|
||||
|
||||
self._dev = chosen
|
||||
# Avoid set_configuration() if the OS already activated config 1
|
||||
# (mass-storage CD is mounted). Re-setting can disrupt the volume.
|
||||
try:
|
||||
cfg = self._dev.get_active_configuration()
|
||||
except usb.core.USBError:
|
||||
self._dev.set_configuration()
|
||||
cfg = self._dev.get_active_configuration()
|
||||
if cfg.bConfigurationValue != 1:
|
||||
self._dev.set_configuration(1)
|
||||
|
||||
for ifnum in (IF_CDC_DATA, IF_VENDOR):
|
||||
self._claim(ifnum)
|
||||
# CDC Comm is optional (interrupt notifications)
|
||||
try:
|
||||
self._claim(IF_CDC_COMM)
|
||||
except usb.core.USBError:
|
||||
pass
|
||||
|
||||
def _claim(self, ifnum: int) -> None:
|
||||
assert self._dev is not None
|
||||
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)
|
||||
|
||||
@property
|
||||
def serial(self) -> str:
|
||||
assert self._dev is not None
|
||||
try:
|
||||
return (usb.util.get_string(self._dev, self._dev.iSerialNumber) or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def write_data(self, payload: bytes, timeout_ms: Optional[int] = None) -> int:
|
||||
return self._bulk_write(EP_DATA_OUT, payload, timeout_ms)
|
||||
|
||||
def read_data(self, size: int = MAX_PACKET, timeout_ms: Optional[int] = None) -> bytes:
|
||||
return self._bulk_read(EP_DATA_IN, size, timeout_ms)
|
||||
|
||||
def write_ctrl(self, payload: bytes, timeout_ms: Optional[int] = None) -> int:
|
||||
return self._bulk_write(EP_CTRL_OUT, payload, timeout_ms)
|
||||
|
||||
def read_ctrl(self, size: int = MAX_PACKET, timeout_ms: Optional[int] = None) -> bytes:
|
||||
return self._bulk_read(EP_CTRL_IN, size, timeout_ms)
|
||||
|
||||
def _bulk_write(self, ep: int, payload: bytes, timeout_ms: Optional[int]) -> int:
|
||||
assert self._dev is not None
|
||||
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
||||
# Split into max-packet chunks for bridge chips that dislike large URBs
|
||||
sent = 0
|
||||
view = memoryview(payload)
|
||||
while sent < len(payload):
|
||||
chunk = view[sent : sent + MAX_PACKET]
|
||||
n = self._dev.write(ep, chunk.tobytes(), timeout=to)
|
||||
sent += n
|
||||
if n == 0:
|
||||
break
|
||||
return sent
|
||||
|
||||
def _bulk_read(self, ep: int, size: int, timeout_ms: Optional[int]) -> bytes:
|
||||
assert self._dev is not None
|
||||
to = self.timeout_ms if timeout_ms is None else timeout_ms
|
||||
try:
|
||||
data = self._dev.read(ep, size, timeout=to)
|
||||
return bytes(data)
|
||||
except usb.core.USBTimeoutError:
|
||||
return b""
|
||||
|
||||
def drain(self, duration_s: float = 0.2) -> None:
|
||||
deadline = time.monotonic() + duration_s
|
||||
while time.monotonic() < deadline:
|
||||
got = False
|
||||
for reader in (self.read_data, self.read_ctrl):
|
||||
chunk = reader(MAX_PACKET, timeout_ms=50)
|
||||
if chunk:
|
||||
got = True
|
||||
if not got:
|
||||
break
|
||||
|
||||
def close(self) -> None:
|
||||
if self._dev is None:
|
||||
return
|
||||
for ifnum in list(self._claimed):
|
||||
try:
|
||||
usb.util.release_interface(self._dev, ifnum)
|
||||
except Exception:
|
||||
pass
|
||||
self._claimed.clear()
|
||||
try:
|
||||
usb.util.dispose_resources(self._dev)
|
||||
except Exception:
|
||||
pass
|
||||
self._dev = None
|
||||
|
||||
def __enter__(self) -> "Juc500Link":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
Reference in New Issue
Block a user