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:
joyharam
2026-07-15 10:26:10 +09:00
commit 8774a4843e
31 changed files with 4677 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""JUC500 cross-platform file transfer for Apple Silicon Mac ↔ Windows 11."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+162
View File
@@ -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())
+5
View File
@@ -0,0 +1,5 @@
"""호환용 — 웹 GUI로 위임."""
from .gui_server import run_gui
__all__ = ["run_gui"]
+637
View File
@@ -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()
+528
View File
@@ -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
+158
View File
@@ -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>
+744
View File
@@ -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; }
}
+279
View File
@@ -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()