juc500 정상동작
This commit is contained in:
@@ -62,6 +62,10 @@ def cmd_doctor(_args: argparse.Namespace) -> int:
|
||||
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 []:
|
||||
|
||||
@@ -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
|
||||
+1673
-125
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,
|
||||
}
|
||||
+1135
-153
File diff suppressed because it is too large
Load Diff
+580
-116
@@ -13,26 +13,111 @@ const state = {
|
||||
remoteSort: { key: "name", dir: 1 },
|
||||
remoteStartPath: "",
|
||||
remoteReceivePath: "",
|
||||
localLoaded: false,
|
||||
receiveRevision: null,
|
||||
};
|
||||
|
||||
const ROOTS_PATH = "__roots__";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const ICON_FOLDER = `<svg class="item-icon folder" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||
const ICON_FILE = `<svg class="item-icon file" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm1 7V3.5L18.5 9H15z"/></svg>`;
|
||||
|
||||
function joinPath(base, name) {
|
||||
if (!base) return name;
|
||||
return `${base.replace(/\/+$/, "")}/${name}`;
|
||||
function cableModelLabel(device) {
|
||||
if (!device) return "JUC500";
|
||||
const m = device.model;
|
||||
if (m === "JUC500") return m;
|
||||
const pid = device.pid ? parseInt(device.pid, 16) : 0;
|
||||
if (pid === 0x7500) return "JUC500";
|
||||
return "JUC500";
|
||||
}
|
||||
|
||||
function activeCableFromStatus(s) {
|
||||
if (s.active_cable?.model || s.active_cable?.pid) return s.active_cable;
|
||||
if (s.device?.model || s.device?.pid) return s.device;
|
||||
if (s.devices?.length === 1) return s.devices[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
function brandCableLabel(s) {
|
||||
if (s.connected && s.cable_model) return s.cable_model;
|
||||
const active = activeCableFromStatus(s);
|
||||
if (active) return cableModelLabel(active);
|
||||
if (s.devices?.length) return cableModelLabel(s.devices[0]);
|
||||
return "JUC500";
|
||||
}
|
||||
|
||||
function hasCableDetected(s) {
|
||||
return !!(
|
||||
s.connected ||
|
||||
s.listening ||
|
||||
s.cable_model ||
|
||||
s.device?.model ||
|
||||
s.device?.pid ||
|
||||
s.device?.serial ||
|
||||
s.cable_ip ||
|
||||
s.devices?.length
|
||||
);
|
||||
}
|
||||
|
||||
function cableCaption(s) {
|
||||
const active = activeCableFromStatus(s);
|
||||
const model = s.connected && s.cable_model
|
||||
? s.cable_model
|
||||
: cableModelLabel(active);
|
||||
const serial = active?.serial || s.device?.serial || s.cable_ip;
|
||||
return serial ? `${model} · ${serial}` : model;
|
||||
}
|
||||
|
||||
const ICON_FOLDER = `<svg class="item-icon folder" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||
const ICON_FILE = `<svg class="item-icon file" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm1 7V3.5L18.5 9H15z"/></svg>`;
|
||||
const ICON_DRIVE = `<svg class="item-icon folder" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 6h16a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3.5l-1 2h-5l-1-2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2zm0 2v7h16V8H4z"/></svg>`;
|
||||
|
||||
function normPath(path) {
|
||||
return String(path || "").replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isRootsPath(path) {
|
||||
return normPath(path) === ROOTS_PATH;
|
||||
}
|
||||
|
||||
function isAbsPath(path) {
|
||||
const p = normPath(path);
|
||||
return p.startsWith("/") || /^[A-Za-z]:/i.test(p);
|
||||
}
|
||||
|
||||
function joinPath(base, name) {
|
||||
const b = normPath(base).replace(/\/+$/, "");
|
||||
if (!b || isRootsPath(b)) return name;
|
||||
if (/^[A-Za-z]:$/i.test(b)) return `${b}/${name}`;
|
||||
return `${b}/${name}`;
|
||||
}
|
||||
|
||||
function entryNavPath(base, entry) {
|
||||
if (entry && entry.nav_path) return normPath(entry.nav_path);
|
||||
return joinPath(base, entry.name);
|
||||
}
|
||||
|
||||
/** Parent directory; drive roots and / go up to computer (__roots__). */
|
||||
function parentPath(path) {
|
||||
const parts = String(path || "")
|
||||
.replace(/^\/+/, "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return null;
|
||||
parts.pop();
|
||||
return parts.join("/");
|
||||
const raw = normPath(path);
|
||||
if (!raw || isRootsPath(raw)) return null;
|
||||
// Windows drive root → computer
|
||||
if (/^[A-Za-z]:\/?$/i.test(raw) || /^[A-Za-z]:$/i.test(raw.replace(/\/+$/, ""))) {
|
||||
return ROOTS_PATH;
|
||||
}
|
||||
let p = raw.replace(/\/+$/, "");
|
||||
// Unix filesystem root
|
||||
if (raw === "/" || p === "") return ROOTS_PATH;
|
||||
const idx = p.lastIndexOf("/");
|
||||
if (idx < 0) return ROOTS_PATH;
|
||||
// "/Users" → "/"
|
||||
if (idx === 0) return "/";
|
||||
// "C:/Users" → "C:/"
|
||||
if (idx === 2 && p[1] === ":") return p.slice(0, 3);
|
||||
return p.slice(0, idx);
|
||||
}
|
||||
|
||||
function canGoParent(path) {
|
||||
return parentPath(path) !== null;
|
||||
}
|
||||
|
||||
function appendParentRow(tbody, { canGo, onGo }) {
|
||||
@@ -63,18 +148,33 @@ function appendParentRow(tbody, { canGo, onGo }) {
|
||||
}
|
||||
|
||||
function displayPath(p) {
|
||||
return "/" + (p || "");
|
||||
const s = normPath(p);
|
||||
if (!s) return "/";
|
||||
if (isRootsPath(s)) return "이 PC";
|
||||
return s;
|
||||
}
|
||||
|
||||
function breadcrumbParts(path) {
|
||||
const parts = String(path || "")
|
||||
.replace(/^\/+/, "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
const crumbs = [{ label: "루트", path: "" }];
|
||||
const p = normPath(path);
|
||||
if (isRootsPath(p)) return [{ label: "이 PC", path: ROOTS_PATH }];
|
||||
if (!p) return [{ label: "홈", path: "" }];
|
||||
const crumbs = [{ label: "이 PC", path: ROOTS_PATH }];
|
||||
if (/^[A-Za-z]:/i.test(p)) {
|
||||
const drive = p.slice(0, 2).toUpperCase() + "/";
|
||||
crumbs.push({ label: p.slice(0, 2).toUpperCase(), path: drive });
|
||||
const rest = p.replace(/^[A-Za-z]:\/?/i, "").split("/").filter(Boolean);
|
||||
let acc = drive.replace(/\/$/, "");
|
||||
for (const part of rest) {
|
||||
acc = `${acc}/${part}`;
|
||||
crumbs.push({ label: part, path: acc });
|
||||
}
|
||||
return crumbs;
|
||||
}
|
||||
crumbs.push({ label: "/", path: "/" });
|
||||
const parts = p.replace(/^\/+/, "").split("/").filter(Boolean);
|
||||
let acc = "";
|
||||
for (const part of parts) {
|
||||
acc = acc ? `${acc}/${part}` : part;
|
||||
acc = acc ? `${acc}/${part}` : `/${part}`;
|
||||
crumbs.push({ label: part, path: acc });
|
||||
}
|
||||
return crumbs;
|
||||
@@ -123,7 +223,9 @@ function formatMtime(ts) {
|
||||
return `${y}. ${m}. ${day}. ${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function fileKind(name, isDir) {
|
||||
function fileKind(name, isDir, kind) {
|
||||
if (kind === "drive" || kind === "volume") return "드라이브";
|
||||
if (kind === "home") return "홈";
|
||||
if (isDir) return "폴더";
|
||||
const ext = String(name).includes(".") ? String(name).split(".").pop().toLowerCase() : "";
|
||||
const map = {
|
||||
@@ -301,12 +403,19 @@ function bindSettingsUi() {
|
||||
}
|
||||
const applyBtn = $("portsApplyBtn");
|
||||
if (applyBtn) applyBtn.onclick = () => applyPortsAndRestart();
|
||||
window.addEventListener("keydown", (ev) => {
|
||||
const modal = $("settingsModal");
|
||||
if (ev.key === "Escape" && modal && !modal.classList.contains("hidden")) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function quitApp() {
|
||||
setStatus("입력공유 끄고 종료 중…");
|
||||
try {
|
||||
await api("/api/inputshare/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
}).catch(() => {});
|
||||
} catch (_) {}
|
||||
try {
|
||||
await api("/api/quit", { method: "POST" });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function updatePathHints() {
|
||||
@@ -318,9 +427,104 @@ function updatePathHints() {
|
||||
if ($("railRemotePath")) $("railRemotePath").textContent = remote;
|
||||
}
|
||||
|
||||
function updatePasteBadge(info) {
|
||||
const badge = $("pasteBadge");
|
||||
if (!badge) return;
|
||||
if (!state.connected) {
|
||||
badge.textContent = "붙여넣기 —";
|
||||
badge.className = "auto-badge wait";
|
||||
badge.title = "연결 후 Finder/Explorer에서 복사 → 상대 폴더에서 Ctrl+Shift+V";
|
||||
return;
|
||||
}
|
||||
const count = info && typeof info.count === "number" ? info.count : 0;
|
||||
const dest = (info && info.paste_dest) || state.currentPath || "";
|
||||
const destLabel = dest
|
||||
? dest.split("/").filter(Boolean).slice(-2).join("/") || dest
|
||||
: "(폴더 미선택)";
|
||||
if (info && info.sending) {
|
||||
badge.textContent = "붙여넣기 전송 중…";
|
||||
badge.className = "auto-badge wait";
|
||||
} else if (count > 0) {
|
||||
badge.textContent = `붙여넣기 ${count}개 → ${destLabel}`;
|
||||
badge.className = "auto-badge ok";
|
||||
} else {
|
||||
badge.textContent = `붙여넣기 → ${destLabel}`;
|
||||
badge.className = "auto-badge wait";
|
||||
}
|
||||
const names = (info && info.names) || [];
|
||||
badge.title = [
|
||||
dest ? `대상: ${dest}` : "상대 폴더를 UI에서 연 뒤 붙여넣으세요",
|
||||
count ? `클립보드: ${names.join(", ")}${info.truncated ? "…" : ""}` : "클립보드에 파일 없음",
|
||||
"Ctrl+Shift+V (파일 → 상대 폴더)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function syncPasteDest(path) {
|
||||
const dest = normPath(path || "");
|
||||
if (!state.connected || !dest || isRootsPath(dest)) return;
|
||||
try {
|
||||
await api("/api/paste-dest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: dest }),
|
||||
});
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function sendClipboardToRemote() {
|
||||
if (!state.connected) {
|
||||
setStatus("먼저 연결하세요", true);
|
||||
return;
|
||||
}
|
||||
const dest = remoteUploadDest();
|
||||
if (!dest) {
|
||||
setStatus("상대 폴더를 연 뒤 붙여넣으세요 (이 PC → 상대)", true);
|
||||
return;
|
||||
}
|
||||
await syncPasteDest(dest);
|
||||
setStatus(`클립보드 → ${displayPath(dest)} 전송 중…`);
|
||||
try {
|
||||
const info = await api("/api/clipboard/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (info.ok === false) {
|
||||
setStatus(info.error || "클립보드 전송 실패", true);
|
||||
updatePasteBadge(info);
|
||||
return;
|
||||
}
|
||||
setStatus(
|
||||
`클립보드 ${info.count || "?"}개 → ${displayPath(info.dest_dir || dest)} 전송 시작`
|
||||
);
|
||||
// Poll progress briefly
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const s = await refreshStatus();
|
||||
const clip = s.clipboard || {};
|
||||
updatePasteBadge(clip);
|
||||
if (!clip.sending) {
|
||||
if (clip.error) setStatus(`클립보드 전송 실패: ${clip.error}`, true);
|
||||
else {
|
||||
setStatus("클립보드 파일 전송 완료");
|
||||
try {
|
||||
await loadDir(state.currentPath);
|
||||
} catch (_) {}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(e.message || "클립보드 전송 실패", true);
|
||||
}
|
||||
}
|
||||
|
||||
function remoteUploadDest(explicit) {
|
||||
if (explicit != null && explicit !== undefined) return explicit;
|
||||
return state.currentPath || state.remoteReceivePath || state.remoteStartPath || "";
|
||||
const cur = state.currentPath || state.remoteReceivePath || state.remoteStartPath || "";
|
||||
return isRootsPath(cur) ? "" : cur;
|
||||
}
|
||||
|
||||
function updateInputShareUI(info) {
|
||||
@@ -329,6 +533,8 @@ function updateInputShareUI(info) {
|
||||
const localBtn = $("controlLocalBtn");
|
||||
const remoteBtn = $("controlRemoteBtn");
|
||||
if (!badge || !btn) return;
|
||||
btn.hidden = false;
|
||||
btn.style.display = "";
|
||||
// 수신 측(being_controlled)도 버튼 활성 — 제어권 회수/탈취
|
||||
const linkOk = !!(
|
||||
state.connected ||
|
||||
@@ -341,47 +547,83 @@ function updateInputShareUI(info) {
|
||||
(info.trusted || info.being_controlled)
|
||||
);
|
||||
const controlReady = shareActive;
|
||||
if (localBtn && remoteBtn) {
|
||||
if (localBtn && remoteBtn) {
|
||||
localBtn.disabled = !controlReady;
|
||||
remoteBtn.disabled = !controlReady;
|
||||
const remote = !!(info && info.controlling_remote);
|
||||
const remote = !!(info && info.controlling_remote && info.remote_acked);
|
||||
const pending = !!(info && info.controlling_remote && !info.remote_acked);
|
||||
const controlled = !!(info && info.being_controlled);
|
||||
localBtn.classList.toggle("active", controlReady && !remote && !controlled);
|
||||
remoteBtn.classList.toggle("active", controlReady && remote);
|
||||
localBtn.classList.toggle("active", controlReady && !remote && !controlled && !pending);
|
||||
remoteBtn.classList.toggle("active", controlReady && (remote || pending));
|
||||
localBtn.title = controlled
|
||||
? "이 PC 제어 회수 (상대 조종 해제)"
|
||||
: "이 PC 제어 — 상대 제어 중 왼쪽 끝으로 복귀";
|
||||
remoteBtn.title = controlled
|
||||
? "상대 PC 제어 가져오기 (제어권 탈취)"
|
||||
: "상대 PC 제어 — 이후 화면 오른쪽 끝으로도 전환";
|
||||
remoteBtn.title = pending
|
||||
? "상대 응답 대기 중…"
|
||||
: controlled
|
||||
? "상대 PC 제어 가져오기 (제어권 탈취)"
|
||||
: "상대 PC 제어 — 이후 화면 오른쪽 끝으로도 전환";
|
||||
const sw = localBtn.closest(".control-switch");
|
||||
if (sw) sw.title = controlReady ? "제어권 전환" : "입력공유를 먼저 켜세요";
|
||||
}
|
||||
if (!info || !info.supported) {
|
||||
badge.textContent = "입력공유 미지원";
|
||||
badge.className = "auto-badge wait";
|
||||
btn.disabled = true;
|
||||
btn.textContent = "입력공유";
|
||||
return;
|
||||
}
|
||||
|
||||
// OFF first — never replace the enable button with accessibility helper text
|
||||
if (!info.enabled && !info.being_controlled) {
|
||||
badge.textContent = linkOk ? "입력공유 꺼짐" : "입력공유 꺼짐 (연결 후 켜기)";
|
||||
badge.className = "auto-badge wait";
|
||||
badge.title =
|
||||
"USB 연결 시 입력공유가 기본 켜짐 — 한쪽에서 끄면 상대도 자동으로 꺼집니다";
|
||||
btn.disabled = !linkOk;
|
||||
btn.textContent = "입력공유 켜기";
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = !linkOk;
|
||||
if (!info.trusted) {
|
||||
badge.textContent = "손쉬운 사용 권한 필요";
|
||||
if (!info.trusted && !info.being_controlled) {
|
||||
const win = info.platform === "windows";
|
||||
badge.textContent = win ? "입력 권한 확인 필요" : "손쉬운 사용 권한 필요";
|
||||
badge.className = "auto-badge wait";
|
||||
const pick = info.pick_path || info.python_app || info.executable || "";
|
||||
const pickName = pick.split("/").pop() || "앱";
|
||||
badge.title = pick
|
||||
? `선택할 항목: ${pickName}\n전체 경로: ${pick}\n(Finder는 상위 폴더를 열고 ${pickName} 을 선택합니다)`
|
||||
: "시스템 설정 → 손쉬운 사용";
|
||||
btn.textContent = pickName.endsWith(".app") ? `${pickName} 열기` : "앱 경로 열기";
|
||||
badge.title =
|
||||
info.howto ||
|
||||
(pick
|
||||
? `선택할 항목: ${pickName}\n전체 경로: ${pick}\n(Finder는 상위 폴더를 열고 ${pickName} 을 선택합니다)`
|
||||
: win
|
||||
? "입력공유를 다시 켜 보세요"
|
||||
: "시스템 설정 → 손쉬운 사용");
|
||||
btn.textContent = "입력공유 켜기";
|
||||
return;
|
||||
}
|
||||
if (info.being_controlled) {
|
||||
if (info.platform === "macos" && info.input_monitoring === false) {
|
||||
badge.textContent = "키보드 권한 필요 (입력 모니터링)";
|
||||
badge.className = "auto-badge wait";
|
||||
badge.title =
|
||||
"마우스는 손쉬운 사용만으로 동작하지만 키 입력은 '입력 모니터링' 권한이 필요합니다.\n" +
|
||||
"시스템 설정 → 개인정보 보호 및 보안 → 입력 모니터링에서 Terminal/Python(이 앱)을 켠 뒤 " +
|
||||
"앱을 재시작하세요.";
|
||||
btn.textContent = "입력공유 끄기";
|
||||
} else if (info.being_controlled) {
|
||||
badge.textContent = "상대가 조종 중 — [이 PC]/[상대 PC]로 회수";
|
||||
badge.className = "auto-badge wait";
|
||||
badge.title = "이 PC: 조종 해제 / 상대 PC: 제어권 가져오기";
|
||||
btn.textContent = "입력공유 끄기";
|
||||
} else if (info.enabled && info.peer && info.controlling_remote && !info.remote_acked) {
|
||||
badge.textContent = "상대 응답 대기…";
|
||||
badge.className = "auto-badge wait";
|
||||
badge.title = "상대 PC 응답 대기 중 (입력공유는 자동 동기화됨)";
|
||||
btn.textContent = "입력공유 끄기";
|
||||
} else if (info.enabled && info.peer) {
|
||||
badge.textContent = info.controlling_remote ? "제어: 상대 PC" : "제어: 이 PC";
|
||||
badge.className = "auto-badge ok";
|
||||
badge.title = "가장자리 전환: 오른쪽→상대, 왼쪽→이 PC · 단축키 Ctrl+Shift+U/←/→";
|
||||
badge.title = "가장자리 전환: 오른쪽→상대, 왼쪽→이 PC · Ctrl+R 제어권 토글 · Esc 종료";
|
||||
btn.textContent = "입력공유 끄기";
|
||||
} else if (info.enabled) {
|
||||
badge.textContent = info.status || "입력공유 대기";
|
||||
@@ -407,11 +649,15 @@ async function switchInputControl(target) {
|
||||
setStatus(info.error || "제어권 전환 실패", true);
|
||||
return;
|
||||
}
|
||||
setStatus(
|
||||
target === "remote"
|
||||
? "키보드·마우스 제어: 상대 PC (Ctrl+Shift+← 또는 [이 PC]로 복귀)"
|
||||
: "키보드·마우스 제어: 이 PC (Ctrl+Shift+→ 또는 [상대 PC]로 전환)"
|
||||
);
|
||||
if (info.controlling_remote && !info.remote_acked) {
|
||||
setStatus("상대 응답 대기 중…");
|
||||
} else if (info.controlling_remote) {
|
||||
setStatus("키보드·마우스 제어: 상대 PC (왼쪽 끝 또는 [이 PC]로 복귀)");
|
||||
} else if (info.being_controlled) {
|
||||
setStatus("상대가 이 PC를 조종 중 — [이 PC]로 회수");
|
||||
} else {
|
||||
setStatus("키보드·마우스 제어: 이 PC (오른쪽 끝 또는 [상대 PC]로 전환)");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
}
|
||||
@@ -527,42 +773,122 @@ function updateSortButtons(prefix, sort) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatBuildTag(build) {
|
||||
const raw = String(build || "").trim();
|
||||
if (!raw || raw === "unknown") return "?";
|
||||
return raw.replace(/^20\d{2}-\d{2}-\d{2}-/, "");
|
||||
}
|
||||
|
||||
function formatAppVersion(status) {
|
||||
const ver = String(status?.version || "0.1.0").trim() || "0.1.0";
|
||||
const xfer = formatBuildTag(status?.xfer_build);
|
||||
return `v${ver} · ${xfer}`;
|
||||
}
|
||||
|
||||
function updateAppVersionUI(s) {
|
||||
const el = $("appVersion");
|
||||
if (!el) return;
|
||||
const label = formatAppVersion(s);
|
||||
el.textContent = label;
|
||||
const inputshare = formatBuildTag(s?.inputshare_build);
|
||||
const xfer = formatBuildTag(s?.xfer_build);
|
||||
el.title = `앱 v${s?.version || "?"} · xfer ${xfer} · inputshare ${inputshare}`;
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const s = await api("/api/status");
|
||||
state.connected = !!s.connected;
|
||||
state.listening = !!s.listening;
|
||||
if (s.connecting) state.connecting = true;
|
||||
else if (s.connected || s.connect_error) state.connecting = false;
|
||||
$("disconnectBtn").disabled = !state.connected;
|
||||
$("connectBtn").disabled = state.connected || state.connecting;
|
||||
const canConnect = hasCableDetected(s) || !!s.listening;
|
||||
$("connectBtn").disabled =
|
||||
state.connected || state.connecting || !!s.connecting || !canConnect;
|
||||
if ($("connectBtn")) {
|
||||
$("connectBtn").title = canConnect
|
||||
? "상대 PC와 USB 연결"
|
||||
: "JUC500(0711:7500) 케이블을 Mac USB에 연결한 뒤 사용";
|
||||
}
|
||||
updateTransferButtons();
|
||||
$("mkdirBtn").disabled = !state.connected;
|
||||
|
||||
const cable = s.device?.serial
|
||||
? `JUC500 ${s.device.serial}`
|
||||
: s.cable_ip
|
||||
? `JUC500 ${s.cable_ip}`
|
||||
: "JUC500 케이블";
|
||||
if (s.this_host) $("thisHost").textContent = `${s.this_host} · ${cable}`;
|
||||
const active = activeCableFromStatus(s);
|
||||
const model = s.connected && s.cable_model
|
||||
? s.cable_model
|
||||
: cableModelLabel(active);
|
||||
const hasCable = hasCableDetected(s);
|
||||
const brand = brandCableLabel(s);
|
||||
if ($("brandModel")) {
|
||||
$("brandModel").textContent = brand;
|
||||
const vid = active?.vid || s.device?.vid || "?";
|
||||
const pid = active?.pid || s.device?.pid || "?";
|
||||
$("brandModel").title = hasCable
|
||||
? `${brand} (${vid}:${pid})${s.connected ? " — 연결됨" : " — 자동 감지"}`
|
||||
: `${brand} — USB 미감지 (JUC500 0711:7500 IF5 확인)`;
|
||||
}
|
||||
if (document.title !== brand) {
|
||||
document.title = brand;
|
||||
}
|
||||
updateAppVersionUI(s);
|
||||
if (s.this_host) $("thisHost").textContent = `${s.this_host} · ${cableCaption(s)}`;
|
||||
if (s.local_root) $("localRootHint").textContent = s.local_root;
|
||||
|
||||
updateInputShareUI(s.inputshare);
|
||||
updatePasteBadge(s.clipboard);
|
||||
updateWormholeUI(s.wormhole);
|
||||
|
||||
if (state.connected) {
|
||||
updateBadge(true, "연결됨");
|
||||
$("peerInfo").textContent = s.hostname || s.host || "상대 PC";
|
||||
$("ifaceInfo").textContent = s.device?.serial ? `IF5 Vendor · ${s.device.serial}` : "JUC500 bulk bridge";
|
||||
if (s.devices?.length > 1) {
|
||||
const activeTag = s.cable_model ? `${s.cable_model} 연결` : model;
|
||||
const list = s.devices.map((d) => `${d.model}·IF${d.if_num ?? "?"}`).join(" / ");
|
||||
$("ifaceInfo").textContent = `${activeTag} — ${list}`;
|
||||
} else if (active?.serial) {
|
||||
$("ifaceInfo").textContent = `${model} · IF${active.if_num ?? s.device?.if_num ?? "?"} · ${active.serial}`;
|
||||
} else if (active?.model || active?.pid) {
|
||||
$("ifaceInfo").textContent = `${model} · IF${active.if_num ?? s.device?.if_num ?? "?"}`;
|
||||
} else {
|
||||
$("ifaceInfo").textContent = `${model} bulk bridge`;
|
||||
}
|
||||
$("remoteRootHint").textContent = s.root || s.host || "연결됨";
|
||||
if (s.remote_start != null) state.remoteStartPath = s.remote_start || "";
|
||||
if (s.remote_receive != null) state.remoteReceivePath = s.remote_receive || state.remoteStartPath;
|
||||
} else if (!state.connecting) {
|
||||
const hasCable = !!(s.device?.serial || s.cable_ip);
|
||||
} else if (s.connecting || state.connecting) {
|
||||
updateBadge(false, "연결 중");
|
||||
const role = s.usb_role === "connector" ? "Mac → Windows" : "연결";
|
||||
$("peerInfo").textContent =
|
||||
s.auto_status || `${role} HELLO 송신 중…`;
|
||||
$("ifaceInfo").textContent = hasCable
|
||||
? `${model} · IF${active?.if_num ?? s.device?.if_num ?? "?"} · connector`
|
||||
: "";
|
||||
$("remoteRootHint").textContent = "연결 시도 중";
|
||||
} else if (s.handshake) {
|
||||
updateBadge(false, "동기화 중");
|
||||
$("peerInfo").textContent =
|
||||
s.auto_status || "상대 연결 감지 — 자동 동기화 중…";
|
||||
} else if (s.listening) {
|
||||
updateBadge(false, "대기 중");
|
||||
$("peerInfo").textContent =
|
||||
s.auto_status || "케이블 대기 — Mac이 곧 Windows로 연결 시도";
|
||||
} else {
|
||||
updateBadge(false, hasCable ? "케이블 감지됨" : "케이블 대기");
|
||||
$("peerInfo").textContent =
|
||||
s.auto_status ||
|
||||
(hasCable
|
||||
? "연결을 누르면 상대 JUC500 프로그램과 핸드셰이크합니다"
|
||||
: "JUC500(Smart Data Link) 케이블을 연결하세요");
|
||||
$("ifaceInfo").textContent = hasCable ? `시리얼 ${s.device?.serial || s.cable_ip}` : "";
|
||||
$("remoteRootHint").textContent = "연결 필요";
|
||||
? "상대가 실행되면 Mac이 자동으로 연결 시도"
|
||||
: `${model}(Smart Data Link) 케이블을 연결하세요`);
|
||||
}
|
||||
if (!state.connected && !(s.connecting || state.connecting)) {
|
||||
$("ifaceInfo").textContent = hasCable
|
||||
? (s.devices?.length > 1
|
||||
? s.devices.map((d) => `${d.model}·IF${d.if_num ?? "?"}`).join(" / ")
|
||||
: `${model} · IF${active?.if_num ?? s.device?.if_num ?? "?"}${active?.serial ? ` · ${active.serial}` : ""}`)
|
||||
: "";
|
||||
if (!s.listening && !s.handshake) {
|
||||
$("remoteRootHint").textContent = "연결 필요";
|
||||
}
|
||||
}
|
||||
|
||||
if (s.progress?.busy) {
|
||||
@@ -570,6 +896,17 @@ async function refreshStatus() {
|
||||
$("progressBar").style.width = `${pct}%`;
|
||||
setStatus(`전송 중 ${pct}% — ${s.progress.label || ""}`);
|
||||
}
|
||||
const receiveRevision = Number(s.receive_revision || 0);
|
||||
if (state.receiveRevision === null) {
|
||||
state.receiveRevision = receiveRevision;
|
||||
} else if (receiveRevision !== state.receiveRevision) {
|
||||
state.receiveRevision = receiveRevision;
|
||||
if (state.localLoaded) {
|
||||
await loadLocal(state.localPath);
|
||||
const name = String(s.last_received_path || "").split(/[\\/]/).pop();
|
||||
setStatus(name ? `수신 완료 — ${name}` : "수신 완료");
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -589,21 +926,70 @@ async function connectOneClick() {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Server connects in background — poll until done (avoids browser aborting 45s POST).
|
||||
const deadline = Date.now() + 70000;
|
||||
let s = await refreshStatus();
|
||||
while (Date.now() < deadline) {
|
||||
if (state.connected || (s && s.connected)) break;
|
||||
if (s && s.connect_error) {
|
||||
const err = new Error(s.connect_error);
|
||||
err.code = /연결되지 않았습니다|PEER|상대/.test(s.connect_error)
|
||||
? "PEER_NOT_FOUND"
|
||||
: "CONNECT_FAILED";
|
||||
throw err;
|
||||
}
|
||||
if (s && !s.connecting && !s.connected) {
|
||||
// connect thread finished without success and without error yet — brief settle
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
s = await refreshStatus();
|
||||
if (!s.connected && (s.connect_error || !s.connecting)) {
|
||||
const err = new Error(
|
||||
s.connect_error ||
|
||||
s.auto_status ||
|
||||
"연결 실패 — 상대 Windows에서 JUC500 GUI 「연결」을 누르세요"
|
||||
);
|
||||
err.code = "PEER_NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (s && s.auto_status) setStatus(s.auto_status);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
s = await refreshStatus();
|
||||
}
|
||||
|
||||
if (!state.connected && !(s && s.connected)) {
|
||||
const err = new Error(
|
||||
"상대 응답 시간 초과 — Windows에서 같은 JUC500 앱을 실행하고 「연결」을 누르세요"
|
||||
);
|
||||
err.code = "PEER_NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
|
||||
await api("/api/auto/start", { method: "POST" }).catch(() => {});
|
||||
state.connected = true;
|
||||
state.connecting = false;
|
||||
updateBadge(true, "연결됨");
|
||||
setStatus("연결됨 — 상대 폴더를 불러오는 중…");
|
||||
$("peerInfo").textContent = data.hostname || data.host || "상대 PC";
|
||||
$("peerInfo").textContent =
|
||||
(s && (s.hostname || s.host)) || data.hostname || data.host || "상대 PC";
|
||||
$("disconnectBtn").disabled = false;
|
||||
$("connectBtn").disabled = true;
|
||||
|
||||
state.currentPath = "";
|
||||
state.remoteStartPath = data.start_path || "";
|
||||
state.remoteReceivePath = data.receive_path || data.start_path || "";
|
||||
state.remoteStartPath = (s && s.remote_start) || data.start_path || "";
|
||||
state.remoteReceivePath =
|
||||
(s && s.remote_receive) || data.receive_path || data.start_path || "";
|
||||
updatePathHints();
|
||||
await loadDir(state.remoteStartPath || "");
|
||||
setStatus("연결 완료 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
try {
|
||||
await loadDir(state.remoteStartPath || "");
|
||||
setStatus("연결 완료 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
|
||||
} catch (listErr) {
|
||||
const msg = (listErr && listErr.message) || "상대 폴더 목록을 받지 못했습니다";
|
||||
setStatus(msg + " — 새로고침을 눌러 다시 시도하세요", true);
|
||||
showRemoteMessage(msg + "\n\n상단 새로고침(↻)으로 다시 불러올 수 있습니다.", true);
|
||||
updateTransferButtons();
|
||||
}
|
||||
} catch (e) {
|
||||
state.connecting = false;
|
||||
state.connected = false;
|
||||
@@ -645,7 +1031,7 @@ async function disconnect() {
|
||||
$("mkdirBtn").disabled = true;
|
||||
updatePathHints();
|
||||
await refreshStatus();
|
||||
setStatus("연결 해제됨 — 연결 버튼을 누르세요");
|
||||
setStatus("연결 해제됨 — 케이블 연결 시 자동 재연결됩니다");
|
||||
}
|
||||
|
||||
async function loadDir(path) {
|
||||
@@ -653,19 +1039,20 @@ async function loadDir(path) {
|
||||
setStatus("먼저 연결 버튼을 누르세요", true);
|
||||
return;
|
||||
}
|
||||
const rel = String(path || "").replace(/^\/+/, "");
|
||||
const rel = normPath(path);
|
||||
const data = await api(`/api/listdir?path=${encodeURIComponent(rel)}`);
|
||||
state.currentPath = String(data.path || rel || "").replace(/^\/+/, "");
|
||||
state.currentPath = normPath(data.path || rel || "");
|
||||
state.entries = data.entries || [];
|
||||
state.remoteFocus = "";
|
||||
state.remoteSelected.clear();
|
||||
await syncPasteDest(state.currentPath);
|
||||
renderPathBar($("pathBar"), state.currentPath, (p) => {
|
||||
loadDir(p).catch((err) => setStatus(err.message, true));
|
||||
});
|
||||
renderRemote();
|
||||
updatePathHints();
|
||||
updateTransferButtons();
|
||||
$("upBtn").disabled = !state.currentPath;
|
||||
$("upBtn").disabled = !canGoParent(state.currentPath);
|
||||
}
|
||||
|
||||
function renderRemote() {
|
||||
@@ -688,7 +1075,7 @@ function renderRemote() {
|
||||
return;
|
||||
}
|
||||
appendParentRow(tbody, {
|
||||
canGo: !!state.currentPath,
|
||||
canGo: canGoParent(state.currentPath),
|
||||
onGo: () => goUp().catch((err) => setStatus(err.message, true)),
|
||||
});
|
||||
if (state.currentPath) $("remoteEmpty").classList.add("hidden");
|
||||
@@ -698,20 +1085,26 @@ function renderRemote() {
|
||||
$("remoteEmpty").classList.add("hidden");
|
||||
|
||||
entries.forEach((e) => {
|
||||
const full = joinPath(state.currentPath, e.name);
|
||||
const full = entryNavPath(state.currentPath, e);
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.path = full;
|
||||
tr.dataset.isdir = e.is_dir ? "1" : "0";
|
||||
tr.draggable = true;
|
||||
tr.draggable = !isRootsPath(state.currentPath);
|
||||
if (e.is_dir) tr.dataset.dropPath = full;
|
||||
if (state.remoteSelected.has(full) || state.remoteFocus === full) {
|
||||
tr.classList.add("selected");
|
||||
}
|
||||
const icon =
|
||||
e.kind === "drive" || e.kind === "volume" || e.kind === "home"
|
||||
? ICON_DRIVE
|
||||
: e.is_dir
|
||||
? ICON_FOLDER
|
||||
: ICON_FILE;
|
||||
tr.innerHTML = `
|
||||
<td class="col-name"><div class="name-cell">${e.is_dir ? ICON_FOLDER : ICON_FILE}<span>${escapeHtml(e.name)}</span></div></td>
|
||||
<td class="col-name"><div class="name-cell">${icon}<span>${escapeHtml(e.name)}</span></div></td>
|
||||
<td class="col-date muted">${formatMtime(e.mtime)}</td>
|
||||
<td class="col-size muted">${e.is_dir ? "—" : human(e.size)}</td>
|
||||
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir))}</td>
|
||||
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir, e.kind))}</td>
|
||||
`;
|
||||
tr.onclick = (ev) => {
|
||||
state.remoteFocus = full;
|
||||
@@ -750,22 +1143,23 @@ function renderRemote() {
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
appendParentRow(tbody, {
|
||||
canGo: !!state.currentPath,
|
||||
canGo: canGoParent(state.currentPath),
|
||||
onGo: () => goUp().catch((err) => setStatus(err.message, true)),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLocal(path) {
|
||||
const rel = String(path || "").replace(/^\/+/, "");
|
||||
const rel = normPath(path);
|
||||
const data = await api(`/api/local/listdir?path=${encodeURIComponent(rel)}`);
|
||||
state.localPath = String(data.path || rel || "").replace(/^\/+/, "");
|
||||
state.localPath = normPath(data.path || rel || "");
|
||||
state.localEntries = data.entries || [];
|
||||
state.localLoaded = true;
|
||||
state.localSelected.clear();
|
||||
state.localFocus = "";
|
||||
renderPathBar($("localPathBar"), state.localPath, (p) => {
|
||||
loadLocal(p).catch((err) => setStatus(err.message, true));
|
||||
});
|
||||
$("localUpBtn").disabled = !state.localPath;
|
||||
$("localUpBtn").disabled = !canGoParent(state.localPath);
|
||||
updatePathHints();
|
||||
updateTransferButtons();
|
||||
renderLocal();
|
||||
@@ -781,7 +1175,7 @@ function renderLocal() {
|
||||
if (!entries.length) {
|
||||
$("localEmpty").classList.remove("hidden");
|
||||
appendParentRow(tbody, {
|
||||
canGo: !!state.localPath,
|
||||
canGo: canGoParent(state.localPath),
|
||||
onGo: () => localGoUp().catch((err) => setStatus(err.message, true)),
|
||||
});
|
||||
if (state.localPath) $("localEmpty").classList.add("hidden");
|
||||
@@ -790,20 +1184,26 @@ function renderLocal() {
|
||||
$("localEmpty").classList.add("hidden");
|
||||
|
||||
entries.forEach((e) => {
|
||||
const full = joinPath(state.localPath, e.name);
|
||||
const full = entryNavPath(state.localPath, e);
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.path = full;
|
||||
tr.dataset.isdir = e.is_dir ? "1" : "0";
|
||||
tr.draggable = true;
|
||||
tr.draggable = !isRootsPath(state.localPath);
|
||||
if (e.is_dir) tr.dataset.dropPath = full;
|
||||
if (state.localSelected.has(full) || state.localFocus === full) {
|
||||
tr.classList.add(e.is_dir ? "selected" : "checked");
|
||||
}
|
||||
const icon =
|
||||
e.kind === "drive" || e.kind === "volume" || e.kind === "home"
|
||||
? ICON_DRIVE
|
||||
: e.is_dir
|
||||
? ICON_FOLDER
|
||||
: ICON_FILE;
|
||||
tr.innerHTML = `
|
||||
<td class="col-name"><div class="name-cell">${e.is_dir ? ICON_FOLDER : ICON_FILE}<span>${escapeHtml(e.name)}</span></div></td>
|
||||
<td class="col-name"><div class="name-cell">${icon}<span>${escapeHtml(e.name)}</span></div></td>
|
||||
<td class="col-date muted">${formatMtime(e.mtime)}</td>
|
||||
<td class="col-size muted">${e.is_dir ? "—" : human(e.size)}</td>
|
||||
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir))}</td>
|
||||
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir, e.kind))}</td>
|
||||
`;
|
||||
tr.onclick = (ev) => {
|
||||
state.localFocus = full;
|
||||
@@ -867,7 +1267,7 @@ function renderLocal() {
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
appendParentRow(tbody, {
|
||||
canGo: !!state.localPath,
|
||||
canGo: canGoParent(state.localPath),
|
||||
onGo: () => localGoUp().catch((err) => setStatus(err.message, true)),
|
||||
});
|
||||
}
|
||||
@@ -897,6 +1297,10 @@ async function localGoUp() {
|
||||
|
||||
async function mkdir() {
|
||||
if (!state.connected) return;
|
||||
if (isRootsPath(state.currentPath)) {
|
||||
setStatus("드라이브 목록에서는 폴더를 만들 수 없습니다. 드라이브를 먼저 여세요", true);
|
||||
return;
|
||||
}
|
||||
const name = prompt("새 폴더 이름");
|
||||
if (!name) return;
|
||||
const path = joinPath(state.currentPath, name.trim());
|
||||
@@ -1166,7 +1570,7 @@ function bindUi() {
|
||||
$("downloadBtn").onclick = () => fetchRemoteSelected(state.localPath || "");
|
||||
$("inputShareBtn").onclick = async () => {
|
||||
try {
|
||||
const cur = await api("/api/inputshare");
|
||||
let cur = await api("/api/inputshare");
|
||||
if (!cur.trusted) {
|
||||
let prompted;
|
||||
try {
|
||||
@@ -1174,30 +1578,36 @@ function bindUi() {
|
||||
} catch (e) {
|
||||
prompted = await api("/api/inputshare/prompt-trust").catch(() => null);
|
||||
if (!prompted) {
|
||||
setStatus("권한 API를 찾을 수 없습니다. fileShare 앱을 재시작하세요", true);
|
||||
setStatus("권한 API를 찾을 수 없습니다. 앱을 재시작하세요", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateInputShareUI(prompted);
|
||||
const lines = [];
|
||||
if (prompted.message) lines.push(prompted.message);
|
||||
if (Array.isArray(prompted.howto)) lines.push(...prompted.howto.map((x) => `• ${x}`));
|
||||
setStatus(
|
||||
lines.join("\n") ||
|
||||
"시스템 창에서 「시스템 설정 열기」 → fileShare ON (앱이 권한을 스스로 켤 수는 없습니다)",
|
||||
!prompted.trusted
|
||||
);
|
||||
// 사용자가 설정에서 ON 한 뒤 감지
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const again = await api("/api/inputshare").catch(() => null);
|
||||
if (again && again.trusted) {
|
||||
updateInputShareUI(again);
|
||||
setStatus("손쉬운 사용 권한이 허용되었습니다. 앱을 재시작하면 마우스 전환이 됩니다.");
|
||||
return;
|
||||
if (!prompted.trusted) {
|
||||
const lines = [];
|
||||
if (prompted.message) lines.push(prompted.message);
|
||||
else if (prompted.howto) lines.push(prompted.howto);
|
||||
setStatus(
|
||||
lines.join("\n") || "시스템 설정에서 손쉬운 사용/입력 권한을 허용하세요",
|
||||
true
|
||||
);
|
||||
// macOS: wait until user grants Accessibility
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const again = await api("/api/inputshare/prompt-trust", { method: "POST" }).catch(
|
||||
() => null
|
||||
);
|
||||
if (again && again.trusted) {
|
||||
cur = again;
|
||||
updateInputShareUI(again);
|
||||
setStatus("권한이 허용되었습니다 — 입력공유를 켭니다");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cur.trusted) return;
|
||||
} else {
|
||||
cur = prompted;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const next = !cur.enabled;
|
||||
const info = await api("/api/inputshare/enable", {
|
||||
@@ -1206,11 +1616,17 @@ function bindUi() {
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
updateInputShareUI(info);
|
||||
setStatus(
|
||||
next
|
||||
? "입력공유 켜짐 — [이 PC]/[상대 PC] 버튼 또는 Ctrl+Shift+U/←/→"
|
||||
: "입력공유 꺼짐"
|
||||
);
|
||||
if (info.ok === false) {
|
||||
setStatus(info.error || "입력공유 전환 실패", true);
|
||||
return;
|
||||
}
|
||||
if (!next) {
|
||||
// Unstick local/peer modifiers & cursor after KM handoff
|
||||
await api("/api/inputshare/recover", { method: "POST" }).catch(() => null);
|
||||
setStatus("입력공유 꺼짐 — 마우스·키보드 상태 복구함");
|
||||
} else {
|
||||
setStatus("입력공유 켜짐 — 오른쪽 끝→상대 / 왼쪽 끝·Ctrl+Shift+←→이 PC");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
}
|
||||
@@ -1227,6 +1643,32 @@ function bindUi() {
|
||||
|
||||
// UI에서도 동일 단축키 (이 PC 제어 중일 때)
|
||||
window.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape") {
|
||||
const modal = $("settingsModal");
|
||||
if (modal && !modal.classList.contains("hidden")) {
|
||||
ev.preventDefault();
|
||||
closeSettingsModal();
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
quitApp();
|
||||
return;
|
||||
}
|
||||
// Ctrl+Shift+V → clipboard files to remote current folder
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.shiftKey && (ev.key === "v" || ev.key === "V")) {
|
||||
if (state.connected) {
|
||||
ev.preventDefault();
|
||||
sendClipboardToRemote().catch((e) => setStatus(e.message, true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+R → local/remote input-control toggle (also prevent browser reload).
|
||||
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && (ev.key === "r" || ev.key === "R")) {
|
||||
// The process-wide input listener performs the toggle. The page only
|
||||
// suppresses the browser's refresh action while the GUI has focus.
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!(ev.ctrlKey && ev.shiftKey)) return;
|
||||
const localBtn = $("controlLocalBtn");
|
||||
if (!localBtn || localBtn.disabled) return;
|
||||
@@ -1270,10 +1712,18 @@ function bindUi() {
|
||||
const s = await refreshStatus();
|
||||
await loadLocal(s.local_start || "");
|
||||
if (state.connected) {
|
||||
await loadDir(state.remoteStartPath || "");
|
||||
setStatus("자동 연결됨 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
|
||||
try {
|
||||
await loadDir(state.remoteStartPath || "");
|
||||
setStatus("자동 연결됨 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
|
||||
} catch (listErr) {
|
||||
setStatus(
|
||||
((listErr && listErr.message) || "상대 폴더 로드 실패") +
|
||||
" — 새로고침을 눌러 다시 시도하세요",
|
||||
true
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setStatus(s.auto_status || "JUC500 케이블을 연결한 뒤 「연결」을 누르세요");
|
||||
setStatus(s.auto_status || "JUC500 케이블 연결 시 양쪽이 자동 연결됩니다");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
@@ -1286,8 +1736,22 @@ function bindUi() {
|
||||
try {
|
||||
const s = await refreshStatus();
|
||||
if (!wasConnected && state.connected) {
|
||||
await loadDir(state.remoteStartPath || "");
|
||||
setStatus("자동 연결됨 — 상대 폴더를 불러왔습니다");
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
await loadDir(state.remoteStartPath || "");
|
||||
const s2 = await refreshStatus();
|
||||
if (s2.inputshare?.enabled) {
|
||||
setStatus("자동 연결됨 — 입력공유 켜짐");
|
||||
} else {
|
||||
setStatus("자동 연결됨 — 입력공유 권한 확인 후 「입력공유」를 눌러 주세요");
|
||||
}
|
||||
} catch (listErr) {
|
||||
setStatus(
|
||||
((listErr && listErr.message) || "상대 폴더 로드 실패") +
|
||||
" — 새로고침을 눌러 다시 시도하세요",
|
||||
true
|
||||
);
|
||||
}
|
||||
} else if (!state.connected && s.auto_status) {
|
||||
setStatus(s.auto_status);
|
||||
}
|
||||
@@ -1295,5 +1759,5 @@ function bindUi() {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 1500);
|
||||
}, 700);
|
||||
})();
|
||||
|
||||
@@ -10,16 +10,20 @@
|
||||
<div class="app">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">JUC500</span>
|
||||
<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>
|
||||
<button id="inputShareBtn" type="button" class="ghost" disabled>입력공유</button>
|
||||
<div class="control-switch" title="입력공유 미지원">
|
||||
<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>
|
||||
@@ -52,7 +56,7 @@
|
||||
</div>
|
||||
|
||||
<section id="peerBar" class="peer-bar">
|
||||
<span id="peerInfo">연결을 누르면 상대 PC 폴더가 표시됩니다</span>
|
||||
<span id="peerInfo">한쪽에서 「연결」을 누르면 양쪽이 자동 연결됩니다</span>
|
||||
<span id="ifaceInfo"></span>
|
||||
</section>
|
||||
|
||||
@@ -110,7 +114,7 @@
|
||||
<span class="xfer-arrow">←</span>
|
||||
<span class="xfer-text">가져오기</span>
|
||||
</button>
|
||||
<p class="rail-hint">선택 후 버튼<br/>또는 패널로 드래그</p>
|
||||
<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>
|
||||
|
||||
@@ -38,8 +38,10 @@ body {
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
/* 좌·우 여백 = 하단과 동일 (14px) */
|
||||
padding: 12px 14px 14px;
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
@@ -64,6 +66,19 @@ body {
|
||||
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 {
|
||||
@@ -132,6 +147,18 @@ button.ghost:hover:not(:disabled) { background: #e8ebef; }
|
||||
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);
|
||||
|
||||
+743
-131
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,15 @@ NAME_HINTS = (
|
||||
# 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:
|
||||
@@ -225,6 +234,105 @@ def _summary(before: list[ProcInfo], killed: list[dict], after: list[ProcInfo])
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user