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
+4
View File
@@ -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 []:
+298
View File
@@ -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
+1678 -127
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+189
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+580 -116
View File
@@ -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 -6
View File
@@ -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>
+29 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+108
View File
@@ -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)]