juc500 정상동작

This commit is contained in:
Macbook
2026-07-21 21:04:25 +09:00
parent a423db0556
commit bb9874b931
2917 changed files with 708025 additions and 668 deletions
@@ -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,216 @@
"""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, doctor
from .wormhole_kill import kill_wormhole_processes, wormhole_status
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 장치가 없습니다. 진단: python -m juc500_xfer doctor")
return 1
for d in devices:
print(
f"{d.vid:04x}:{d.pid:04x} bus={d.bus} addr={d.address} "
f"serial={d.serial!r} product={d.product!r}"
)
return 0
def cmd_doctor(_args: argparse.Namespace) -> int:
rep = doctor()
print(f"libusb: {rep.libusb_path or '(없음)'}")
if rep.libusb_error:
print(f"libusb 오류: {rep.libusb_error}")
print(f"USB 장치 수(libusb): {rep.usb_device_count}")
print(f"JUC500 후보: {len(rep.juc500)}")
for j in rep.juc500:
print(f" - {j}")
if rep.sample_devices:
print("USB 샘플:")
for line in rep.sample_devices:
print(f" {line}")
if rep.pnp_vid0711:
print("Windows PnP VID_0711:")
for row in rep.pnp_vid0711:
print(f" - {row}")
if rep.ioreg_vid0711:
print("macOS IORegistry VID_0711:")
for row in rep.ioreg_vid0711:
print(f" - {row}")
wh = wormhole_status()
print(f"Wormhole 프로세스: {wh['count']} — {wh['message']}")
for p in wh.get("processes") or []:
print(f" - {p.get('name')} pid={p.get('pid')}")
print("안내:")
for h in rep.hints:
print(f" * {h}")
return 0 if rep.ok else 1
def cmd_kill_wormhole(_args: argparse.Namespace) -> int:
result = kill_wormhole_processes(force=True)
print(result.get("message") or "")
if result.get("killed"):
for p in result["killed"]:
print(f" 종료: {p.get('name')} pid={p.get('pid')}")
if result.get("remaining"):
for p in result["remaining"]:
print(f" 잔여: {p.get('name')} pid={p.get('pid')}")
return 1
return 0
def cmd_probe(args: argparse.Namespace) -> int:
with Juc500Link(serial=args.serial) as link:
print(f"열림: serial={link.serial!r}")
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("doctor", help="USB/libusb/WinUSB 진단")
s.set_defaults(func=cmd_doctor)
s = sub.add_parser("kill-wormhole", help="공식 Wormhole/GoBridge 프로세스 종료")
s.set_defaults(func=cmd_kill_wormhole)
s = sub.add_parser("probe", help="USB 인터페이스 점유 및 송수신 프로브")
s.add_argument("--serial", default=None)
s.set_defaults(func=cmd_probe)
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,298 @@
"""Read/write OS clipboard (file paths + plain text)."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
_IS_WIN = sys.platform == "win32"
_IS_MAC = sys.platform == "darwin"
def list_clipboard_files() -> list[Path]:
"""Return existing file/dir paths currently on the clipboard."""
raw: list[str] = []
if _IS_MAC:
raw = _mac_filenames()
elif _IS_WIN:
raw = _win_filenames()
out: list[Path] = []
seen: set[str] = set()
for item in raw:
p = _normalize_path(item)
if p is None:
continue
key = str(p)
if key in seen:
continue
seen.add(key)
out.append(p)
return out
def clipboard_preview(limit: int = 8) -> dict[str, Any]:
files = list_clipboard_files()
names = [p.name for p in files[:limit]]
return {
"count": len(files),
"names": names,
"truncated": len(files) > limit,
"paths": [str(p) for p in files[:limit]],
}
def get_clipboard_text() -> str:
"""Best-effort plain text from the OS clipboard."""
if _IS_MAC:
return _mac_get_text()
if _IS_WIN:
return _win_get_text()
return ""
def set_clipboard_text(text: str) -> bool:
"""Set peer OS clipboard to plain text (for remote paste)."""
if text is None:
return False
if _IS_MAC:
return _mac_set_text(text)
if _IS_WIN:
return _win_set_text(text)
return False
def _normalize_path(item: str) -> Path | None:
s = (item or "").strip()
if not s:
return None
if s.startswith("file:"):
parsed = urlparse(s)
path = unquote(parsed.path or "")
if _IS_MAC and parsed.netloc and parsed.netloc not in ("", "localhost"):
path = f"/{parsed.netloc}{path}"
s = path
if _IS_WIN and s.startswith("/") and len(s) > 2 and s[2] == ":":
s = s[1:]
try:
p = Path(s)
if p.exists():
return p.resolve()
except OSError:
return None
return None
def _mac_filenames() -> list[str]:
# Prefer AppKit NSFilenamesPboardType
try:
from AppKit import NSPasteboard # type: ignore
pb = NSPasteboard.generalPasteboard()
for typ in (
"NSFilenamesPboardType",
"public.file-url",
):
try:
if typ == "NSFilenamesPboardType":
from AppKit import NSFilenamesPboardType # type: ignore
items = pb.propertyListForType_(NSFilenamesPboardType)
if items:
return [str(x) for x in items]
else:
# public.file-url may be multiple items
classes_out = []
try:
from Foundation import NSURL # type: ignore
objs = pb.readObjectsForClasses_options_([NSURL], None)
if objs:
for u in objs:
try:
if hasattr(u, "isFileURL") and u.isFileURL():
classes_out.append(str(u.path()))
except Exception:
continue
except Exception:
pass
if classes_out:
return classes_out
except Exception:
continue
except Exception:
pass
return _mac_filenames_osascript()
def _mac_filenames_osascript() -> list[str]:
ascript = """
try
set clip to the clipboard as «class furl»
return POSIX path of clip
on error
try
set out to {}
set clip to the clipboard
if class of clip is list then
repeat with i in clip
try
set end of out to POSIX path of (i as alias)
end try
end repeat
end if
set AppleScript's text item delimiters to linefeed
return out as text
on error
return ""
end try
end try
"""
try:
proc = subprocess.run(
["osascript", "-e", ascript],
capture_output=True,
text=True,
timeout=3,
check=False,
)
text = (proc.stdout or "").strip()
if not text:
return []
return [ln.strip() for ln in text.splitlines() if ln.strip()]
except Exception:
return []
def _win_filenames() -> list[str]:
try:
import ctypes
user32 = ctypes.windll.user32
shell32 = ctypes.windll.shell32
CF_HDROP = 15
if not user32.OpenClipboard(None):
return []
try:
handle = user32.GetClipboardData(CF_HDROP)
if not handle:
return []
count = shell32.DragQueryFileW(handle, 0xFFFFFFFF, None, 0)
out: list[str] = []
buf = ctypes.create_unicode_buffer(1024)
for i in range(int(count)):
n = shell32.DragQueryFileW(handle, i, buf, 1024)
if n:
out.append(buf.value)
return out
finally:
user32.CloseClipboard()
except Exception:
return []
def _mac_get_text() -> str:
try:
from AppKit import NSPasteboard, NSPasteboardTypeString # type: ignore
pb = NSPasteboard.generalPasteboard()
s = pb.stringForType_(NSPasteboardTypeString)
if s:
return str(s)
except Exception:
pass
try:
proc = subprocess.run(
["pbpaste"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
return proc.stdout or ""
except Exception:
return ""
def _mac_set_text(text: str) -> bool:
try:
from AppKit import NSPasteboard, NSPasteboardTypeString # type: ignore
pb = NSPasteboard.generalPasteboard()
pb.clearContents()
return bool(pb.setString_forType_(text, NSPasteboardTypeString))
except Exception:
pass
try:
proc = subprocess.run(
["pbcopy"],
input=text,
text=True,
timeout=2,
check=False,
)
return proc.returncode == 0
except Exception:
return False
def _win_get_text() -> str:
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
CF_UNICODETEXT = 13
if not user32.OpenClipboard(None):
return ""
try:
handle = user32.GetClipboardData(CF_UNICODETEXT)
if not handle:
return ""
ptr = kernel32.GlobalLock(handle)
if not ptr:
return ""
try:
return ctypes.wstring_at(ptr)
finally:
kernel32.GlobalUnlock(handle)
finally:
user32.CloseClipboard()
except Exception:
return ""
def _win_set_text(text: str) -> bool:
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
CF_UNICODETEXT = 13
GMEM_MOVEABLE = 0x0002
data = (text.replace("\n", "\r\n") + "\0").encode("utf-16-le")
if not user32.OpenClipboard(None):
return False
try:
user32.EmptyClipboard()
h = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data))
if not h:
return False
ptr = kernel32.GlobalLock(h)
if not ptr:
kernel32.GlobalFree(h)
return False
ctypes.memmove(ptr, data, len(data))
kernel32.GlobalUnlock(h)
if not user32.SetClipboardData(CF_UNICODETEXT, h):
kernel32.GlobalFree(h)
return False
return True
finally:
user32.CloseClipboard()
except Exception:
return False
@@ -0,0 +1,5 @@
"""호환용 — 웹 GUI로 위임."""
from .gui_server import run_gui
__all__ = ["run_gui"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,189 @@
"""Dev-only: push local project sources to the USB peer (manual sync_peer.sh)."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Callable, Optional
from .protocol import Session
_ROOT = Path(__file__).resolve().parent.parent
# Keep in sync with scripts/sync_peer.sh default set.
SYNC_XFER = (
"juc500_xfer/gui_server.py",
"juc500_xfer/usb_link.py",
"juc500_xfer/inputshare.py",
"juc500_xfer/protocol.py",
"juc500_xfer/peer_sync.py",
)
SYNC_STATIC = (
"juc500_xfer/static/app.js",
"juc500_xfer/static/index.html",
"juc500_xfer/static/styles.css",
)
def _use_mac_peer_paths() -> bool:
return os.environ.get("USE_MAC_PEER_PATHS", "").strip() in ("1", "true", "yes")
def peer_dest_dirs() -> dict[str, str]:
"""Destination directories on the peer filesystem."""
if _use_mac_peer_paths():
base = "dev/juc500/juc500"
return {
"xfer": f"{base}/juc500_xfer",
"static": f"{base}/juc500_xfer/static",
}
root = os.environ.get("JUC500_PEER_ROOT", "C:/dev/juc500").replace("\\", "/").rstrip("/")
alt = os.environ.get("JUC500_PEER_ROOT_ALT", "C:/dev/juc500/juc500").replace("\\", "/").rstrip("/")
return {
"xfer": f"{root}/juc500_xfer",
"static": f"{root}/juc500_xfer/static",
"xfer_alt": f"{alt}/juc500_xfer",
"static_alt": f"{alt}/juc500_xfer/static",
}
def _local_paths(rel_paths: tuple[str, ...]) -> list[Path]:
out: list[Path] = []
for rel in rel_paths:
p = (_ROOT / rel).resolve()
if p.is_file():
out.append(p)
return out
def _send_batch(
session: Session,
dest_dir: str,
files: list[Path],
*,
on_log: Callable[[str], None],
on_progress: Optional[Callable[[int, int, str], None]] = None,
) -> int:
if not files:
return 0
dest = dest_dir.replace("\\", "/").rstrip("/")
count = 0
for local in files:
remote_name = f"{dest}/{local.name}" if dest else local.name
size = local.stat().st_size
if on_progress:
on_progress(0, size, local.name)
session.on_progress = (
(lambda done, total, name=local.name: on_progress(done, total, name))
if on_progress
else None
)
timeout = 180.0 if size > 32 * 1024 * 1024 else 120.0
session.send_file(local, remote_name=remote_name, accept_timeout=timeout)
count += 1
on_log(f"상대 동기화: {local.name} → {dest}")
return count
def peer_ping(session: Session, *, timeout: float = 10.0) -> dict[str, Any]:
try:
result = session.rpc("ping", timeout=timeout)
return result if isinstance(result, dict) else {}
except Exception:
return {}
def local_build_ids() -> tuple[str, str]:
try:
from .inputshare import INPUTSHARE_BUILD
inputshare = INPUTSHARE_BUILD
except Exception:
inputshare = "unknown"
try:
from .usb_link import XFER_BUILD
xfer = XFER_BUILD
except Exception:
xfer = "unknown"
return inputshare, xfer
def peer_needs_restart(session: Session) -> bool:
ping = peer_ping(session)
local_inputshare, local_xfer = local_build_ids()
peer_inputshare = str(ping.get("inputshare_build") or "unknown")
peer_xfer = str(ping.get("xfer_build") or "unknown")
if peer_xfer == "unknown":
return True
return peer_inputshare != local_inputshare or peer_xfer != local_xfer
def request_peer_restart(session: Session, *, on_log: Callable[[str], None]) -> bool:
try:
session.rpc("restart_gui", timeout=12.0)
on_log("상대편 GUI 재시작 예약됨 — 프로세스가 새 코드를 로드합니다")
return True
except Exception as exc:
on_log(f"상대 재시작 RPC 실패: {exc} — 상대 PC에서 ./start.sh 수동 실행 필요")
return False
def push_sources_only(
session: Session,
*,
on_log: Callable[[str], None],
on_progress: Optional[Callable[[int, int, str], None]] = None,
) -> int:
xfer_files = _local_paths(SYNC_XFER)
static_files = _local_paths(SYNC_STATIC)
dests = peer_dest_dirs()
total = 0
for key in ("xfer", "static"):
files = xfer_files if key == "xfer" else static_files
dest = dests.get(key)
if dest:
total += _send_batch(session, dest, files, on_log=on_log, on_progress=on_progress)
for key, alt_key in (("xfer", "xfer_alt"), ("static", "static_alt")):
alt = dests.get(alt_key)
if not alt or alt == dests.get(key):
continue
files = xfer_files if key == "xfer" else static_files
total += _send_batch(session, alt, files, on_log=on_log, on_progress=on_progress)
return total
def sync_sources_to_peer(
session: Session,
*,
on_log: Callable[[str], None],
on_progress: Optional[Callable[[int, int, str], None]] = None,
restart_if_stale: bool = False,
) -> dict[str, Any]:
"""Send default source bundle to peer. Optionally restart peer to load new code."""
on_log("상대편 소스 자동 동기화 시작…")
total = push_sources_only(session, on_log=on_log, on_progress=on_progress)
if total <= 0:
return {"ok": False, "error": "동기화할 로컬 파일 없음", "count": 0}
local_inputshare, local_xfer = local_build_ids()
ping = peer_ping(session)
peer_inputshare = str(ping.get("inputshare_build") or "unknown")
peer_xfer = str(ping.get("xfer_build") or "unknown")
restart = False
if restart_if_stale and (
peer_xfer != local_xfer or peer_inputshare != local_inputshare
):
on_log(
f"상대 build 불일치 (xfer 로컬 {local_xfer} · 상대 {peer_xfer}) — 재시작 요청"
)
restart = request_peer_restart(session, on_log=on_log)
on_log(f"상대편 소스 동기화 완료 ({total}개 파일)")
return {
"ok": True,
"count": total,
"local_xfer_build": local_xfer,
"peer_xfer_build": peer_xfer,
"restart_requested": restart,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
<!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">
<div class="brand-row">
<span class="brand-mark" id="brandModel">JUC</span>
<span class="brand-version" id="appVersion" title="앱 버전">—</span>
</div>
<span class="brand-sub" id="thisHost">Smart Data Link 파일 전송</span>
</div>
<div class="conn">
<span id="autoBadge" class="auto-badge wait">미연결</span>
<span id="wormholeBadge" class="auto-badge wait" title="공식 Wormhole 프로세스">Wormhole —</span>
<button id="killWormholeBtn" type="button" class="ghost" title="케이블 자동실행 Wormhole 종료">Wormhole 종료</button>
<span id="inputBadge" class="auto-badge wait" title="마우스·키보드 공유">입력공유 꺼짐</span>
<span id="pasteBadge" class="auto-badge wait" title="클립보드 파일 → 상대 폴더 (Ctrl+Shift+V)">붙여넣기 —</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">한쪽에서 「연결」을 누르면 양쪽이 자동 연결됩니다</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/>또는 패널로 드래그<br/>Ctrl+Shift+V 붙여넣기</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,771 @@
: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 {
width: 100%;
max-width: none;
margin: 0;
/* 좌·우 여백 = 하단과 동일 (14px) */
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-row {
display: flex;
align-items: baseline;
gap: 10px;
}
.brand-version {
font-family: var(--mono);
font-size: 0.72rem;
font-weight: 500;
color: var(--muted);
letter-spacing: -0.02em;
white-space: nowrap;
}
.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;
}
.cable-switch .control-btn {
min-width: 4.6rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.cable-switch .control-btn.cable-hidden {
display: none;
}
.cable-switch.cable-single {
gap: 0;
}
.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; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
"""Detect and terminate official j5create / OTi Wormhole processes.
The JUC500 virtual CD (WORMHOLE) auto-runs Wormhole.exe / WormholeShell which
exclusively claims USB Interface 5. Our stack needs that interface free.
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from typing import Any
_IS_WIN = sys.platform == "win32"
# Exact / substring match against process image name (case-insensitive)
PROCESS_NAMES = (
"wormhole.exe",
"wormhold.exe", # typo safety
"wormholeld.exe",
"wormholeshell",
"wormhole",
"u3wormhole.exe",
"u3wormholeshell",
"u3wormhole",
"gobridge.exe",
"gobridgedemon.exe",
"gobridgedemon",
"gobridgedemonu3.exe",
"gobridgedemonu3",
"downloadupdatetool.exe",
"downloadupdatetool",
"smartdatalink.exe",
"smartlink.exe",
"oti_kmloader.exe",
"otigoBridge.exe",
"gosuite.exe",
)
# Broader name hints (substring in process name)
NAME_HINTS = (
"wormhole",
"gobridge",
"kaijet",
"j5create",
"smartdatalink",
"smart data link",
"oti_",
"otisync",
)
# Do not kill ourselves
SELF_HINTS = ("juc500", "juc500_xfer", "juc500-portable")
# Other copies of this app also hold Interface 5 exclusively
# Keep markers specific — do not match scripts that merely import juc500_xfer.
SELF_PROCESS_MARKERS = (
"juc500_app.py",
"juc500-portable.exe",
"juc500_portable.exe",
"juc500-portable",
)
@dataclass
class ProcInfo:
pid: int
name: str
path: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def _is_self(name: str, path: str = "") -> bool:
blob = f"{name} {path}".lower()
return any(s in blob for s in SELF_HINTS)
def _matches_wormhole(name: str, path: str = "") -> bool:
n = (name or "").lower().strip()
p = (path or "").lower()
if not n:
return False
if _is_self(n, p):
return False
base = n if n.endswith(".exe") else n
# exact-ish
for cand in PROCESS_NAMES:
c = cand.lower()
if base == c or base == c.replace(".exe", "") or n == c:
return True
# path / hint
blob = f"{n} {p}"
return any(h in blob for h in NAME_HINTS)
def list_wormhole_processes() -> list[ProcInfo]:
if _IS_WIN:
return _list_windows()
return _list_posix()
def _list_windows() -> list[ProcInfo]:
# CSV: "name","pid","session","mem"
try:
proc = subprocess.run(
["tasklist", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
timeout=20,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
return []
out: list[ProcInfo] = []
for line in (proc.stdout or "").splitlines():
line = line.strip()
if not line:
continue
# "Wormhole.exe","1234","Console","1","12,345 K"
parts = [x.strip().strip('"') for x in line.split('","')]
if len(parts) < 2:
# fallback split
parts = [x.strip().strip('"') for x in line.split(",")]
if len(parts) < 2:
continue
name, pid_s = parts[0], parts[1]
try:
pid = int(pid_s)
except ValueError:
continue
if _matches_wormhole(name):
out.append(ProcInfo(pid=pid, name=name))
# Enrich path via WMIC / PowerShell (optional)
return _dedupe(out)
def _list_posix() -> list[ProcInfo]:
try:
proc = subprocess.run(
["ps", "-ax", "-o", "pid=,comm=,command="],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except Exception:
return []
out: list[ProcInfo] = []
my_pid = os.getpid()
for line in (proc.stdout or "").splitlines():
line = line.strip()
if not line:
continue
try:
pid_s, rest = line.split(None, 1)
pid = int(pid_s)
except ValueError:
continue
if pid == my_pid:
continue
# comm is first token of rest sometimes; keep full command
tokens = rest.split(None, 1)
comm = tokens[0]
cmd = tokens[1] if len(tokens) > 1 else ""
if _matches_wormhole(comm, cmd) or _matches_wormhole(os.path.basename(comm), cmd):
out.append(ProcInfo(pid=pid, name=os.path.basename(comm) or comm, path=cmd[:240]))
return _dedupe(out)
def _dedupe(items: list[ProcInfo]) -> list[ProcInfo]:
seen: set[int] = set()
out: list[ProcInfo] = []
for p in items:
if p.pid in seen:
continue
seen.add(p.pid)
out.append(p)
return out
def kill_wormhole_processes(*, force: bool = True) -> dict[str, Any]:
"""Terminate official Wormhole processes. Returns status dict."""
before = list_wormhole_processes()
killed: list[dict[str, Any]] = []
errors: list[str] = []
for info in before:
try:
_kill_one(info.pid, force=force)
killed.append(info.to_dict())
except Exception as exc: # noqa: BLE001
errors.append(f"{info.name}({info.pid}): {exc}")
# brief settle so USB handles are released
if killed:
time.sleep(0.8)
after = list_wormhole_processes()
# second pass for stubborn ones
if after and force:
for info in after:
try:
_kill_one(info.pid, force=True)
if info.to_dict() not in killed:
killed.append(info.to_dict())
except Exception as exc: # noqa: BLE001
errors.append(f"retry {info.name}({info.pid}): {exc}")
time.sleep(0.5)
after = list_wormhole_processes()
return {
"ok": len(after) == 0,
"found": [p.to_dict() for p in before],
"killed": killed,
"remaining": [p.to_dict() for p in after],
"errors": errors,
"message": _summary(before, killed, after),
}
def _summary(before: list[ProcInfo], killed: list[dict], after: list[ProcInfo]) -> str:
if not before:
return "실행 중인 Wormhole 관련 프로세스가 없습니다."
if not after:
names = ", ".join(sorted({k.get("name", "?") for k in killed})) or "프로세스"
return f"Wormhole 종료 완료: {names}"
rem = ", ".join(f"{p.name}({p.pid})" for p in after)
return f"일부 종료되지 않음: {rem} (관리자 권한으로 다시 시도하거나 작업 관리자에서 종료)"
def list_other_juc500_processes() -> list[ProcInfo]:
"""Other juc500 GUI/CLI instances (excludes this PID). They hold IF5."""
my_pid = os.getpid()
out: list[ProcInfo] = []
if _IS_WIN:
try:
ps = (
"Get-CimInstance Win32_Process | "
"Where-Object { $_.CommandLine -match 'juc500_app\\.py|juc500-portable|juc500_portable|-m juc500_xfer' } | "
"Select-Object ProcessId, Name, CommandLine | ConvertTo-Json -Compress"
)
proc = subprocess.run(
["powershell", "-NoProfile", "-Command", ps],
capture_output=True,
text=True,
timeout=15,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
raw = (proc.stdout or "").strip()
if not raw:
return []
import json
data = json.loads(raw)
if isinstance(data, dict):
data = [data]
for d in data:
pid = int(d.get("ProcessId") or 0)
if not pid or pid == my_pid:
continue
cmd = str(d.get("CommandLine") or "")
name = str(d.get("Name") or "python.exe")
if any(m in cmd.lower() for m in SELF_PROCESS_MARKERS):
out.append(ProcInfo(pid=pid, name=name, path=cmd[:240]))
except Exception:
return []
return _dedupe(out)
# POSIX: scan ps
try:
proc = subprocess.run(
["ps", "-ax", "-o", "pid=,command="],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except Exception:
return []
for line in (proc.stdout or "").splitlines():
line = line.strip()
if not line:
continue
try:
pid_s, cmd = line.split(None, 1)
pid = int(pid_s)
except ValueError:
continue
if pid == my_pid:
continue
low = cmd.lower()
if any(m in low for m in SELF_PROCESS_MARKERS):
out.append(ProcInfo(pid=pid, name=os.path.basename(cmd.split()[0]), path=cmd[:240]))
return _dedupe(out)
def kill_other_juc500_processes(*, force: bool = True) -> dict[str, Any]:
"""Terminate other juc500 instances so Interface 5 can be claimed."""
before = list_other_juc500_processes()
killed: list[dict[str, Any]] = []
errors: list[str] = []
for info in before:
try:
_kill_one(info.pid, force=force)
killed.append(info.to_dict())
except Exception as exc: # noqa: BLE001
errors.append(f"{info.name}({info.pid}): {exc}")
if killed:
time.sleep(0.6)
after = list_other_juc500_processes()
return {
"ok": len(after) == 0,
"found": [p.to_dict() for p in before],
"killed": killed,
"remaining": [p.to_dict() for p in after],
"errors": errors,
"message": (
"다른 JUC500 인스턴스가 없습니다."
if not before
else (
f"다른 JUC500 종료: {', '.join(str(k.get('pid')) for k in killed)}"
if not after
else f"일부 JUC500이 남음: {[p.pid for p in after]}"
)
),
}
def _kill_one(pid: int, *, force: bool) -> None:
if _IS_WIN:
args = ["taskkill", "/PID", str(pid)]
if force:
args.append("/F")
proc = subprocess.run(
args,
capture_output=True,
text=True,
timeout=15,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "taskkill failed").strip()
# 128 = not found — treat as OK
if proc.returncode == 128 or "not found" in err.lower():
return
raise RuntimeError(err)
return
sig = signal.SIGKILL if force else signal.SIGTERM
try:
os.kill(pid, sig)
except ProcessLookupError:
return
if not force:
time.sleep(0.4)
try:
os.kill(pid, 0)
except ProcessLookupError:
return
os.kill(pid, signal.SIGKILL)
def wormhole_status() -> dict[str, Any]:
procs = list_wormhole_processes()
return {
"running": len(procs) > 0,
"count": len(procs),
"processes": [p.to_dict() for p in procs],
"message": (
f"Wormhole 관련 프로세스 {len(procs)}개 실행 중 (Interface 5 점유 → 종료 권장)"
if procs
else "Wormhole 프로세스 없음"
),
}