const state = { connected: false, connecting: false, currentPath: "", entries: [], localPath: "", localEntries: [], localSelected: new Set(), remoteSelected: new Set(), remoteFocus: "", localFocus: "", localSort: { key: "name", dir: 1 }, remoteSort: { key: "name", dir: 1 }, remoteStartPath: "", remoteReceivePath: "", localLoaded: false, receiveRevision: null, }; const ROOTS_PATH = "__roots__"; const $ = (id) => document.getElementById(id); 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 = ``; const ICON_FILE = ``; const ICON_DRIVE = ``; 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 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 }) { const tr = document.createElement("tr"); tr.className = canGo ? "parent-row" : "parent-row parent-row-root"; tr.dataset.isdir = "1"; tr.dataset.parent = "1"; tr.innerHTML = `
${ICON_FOLDER}..
— — 상위 폴더 `; tr.title = canGo ? "상위 폴더로 이동" : "최상위 폴더입니다"; const go = (ev) => { ev.preventDefault(); ev.stopPropagation(); if (!canGo) { setStatus("이미 최상위 폴더입니다"); return; } onGo(); }; tr.onclick = go; tr.ondblclick = go; // 맨 위 고정 tbody.insertBefore(tr, tbody.firstChild); } function displayPath(p) { const s = normPath(p); if (!s) return "/"; if (isRootsPath(s)) return "이 PC"; return s; } function breadcrumbParts(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}`; crumbs.push({ label: part, path: acc }); } return crumbs; } function renderPathBar(el, path, onNavigate) { el.innerHTML = ""; breadcrumbParts(path).forEach((c, idx) => { if (idx > 0) { const sep = document.createElement("span"); sep.className = "path-sep"; sep.textContent = "›"; el.appendChild(sep); } const btn = document.createElement("button"); btn.type = "button"; btn.className = "path-crumb"; btn.textContent = c.label; btn.title = displayPath(c.path); btn.onclick = () => onNavigate(c.path); el.appendChild(btn); }); } function human(n) { const units = ["B", "KB", "MB", "GB", "TB"]; let f = Number(n) || 0; for (const u of units) { if (f < 1024 || u === "TB") { return u === "B" ? `${Math.round(f)}B` : `${f.toFixed(1)} ${u}`; } f /= 1024; } return `${n}B`; } function formatMtime(ts) { if (!ts) return "—"; const d = new Date(Number(ts) * (Number(ts) < 1e12 ? 1000 : 1)); if (Number.isNaN(d.getTime())) return "—"; const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${y}. ${m}. ${day}. ${hh}:${mm}`; } 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 = { png: "PNG 이미지", jpg: "JPEG 이미지", jpeg: "JPEG 이미지", gif: "GIF 이미지", webp: "이미지", pdf: "PDF 문서", txt: "텍스트", md: "Markdown", json: "JSON", js: "JavaScript", ts: "TypeScript", py: "Python", zip: "ZIP 아카이브", dmg: "디스크 이미지", app: "응용 프로그램", }; if (!ext) return "문서"; return map[ext] || `${ext.toUpperCase()} 파일`; } function setStatus(text, isError = false) { const el = $("statusText"); el.textContent = text; el.classList.toggle("error", !!isError); } let settingsPortsCache = []; function openSettingsModal() { const modal = $("settingsModal"); if (!modal) return; modal.classList.remove("hidden"); loadSettingsPorts(); } function closeSettingsModal() { const modal = $("settingsModal"); if (!modal) return; modal.classList.add("hidden"); const msg = $("settingsMsg"); if (msg) { msg.hidden = true; msg.textContent = ""; } } function escapeHtml(s) { return String(s || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function renderPortsForm(ports) { const form = $("portsForm"); if (!form) return; form.innerHTML = ""; settingsPortsCache = ports || []; for (const p of settingsPortsCache) { const row = document.createElement("div"); row.className = "port-row"; row.innerHTML = `
${escapeHtml(p.label)} ${escapeHtml(p.description || "")} ${escapeHtml(p.protocol)} · 기본 ${p.default}
`; form.appendChild(row); } } function collectPortsFromForm() { const out = {}; document.querySelectorAll("[data-port-key]").forEach((input) => { out[input.getAttribute("data-port-key")] = Number(input.value); }); return out; } async function loadSettingsPorts() { const msg = $("settingsMsg"); try { const data = await api("/api/settings/ports"); renderPortsForm(data.ports || []); const pathEl = $("portsConfigPath"); if (pathEl) { pathEl.textContent = data.config_path ? `설정 파일: ${data.config_path}` : ""; } if (msg) { msg.hidden = true; msg.textContent = ""; } } catch (e) { if (msg) { msg.hidden = false; msg.className = "settings-msg error"; msg.textContent = e.message || String(e); } } } async function waitForGui(url, tries = 40) { const base = url.replace(/\/?$/, "/"); for (let i = 0; i < tries; i++) { try { const res = await fetch(base + "api/status", { cache: "no-store" }); if (res.ok) return true; } catch { /* waiting */ } await new Promise((r) => setTimeout(r, 400)); } return false; } async function applyPortsAndRestart() { const msg = $("settingsMsg"); const btn = $("portsApplyBtn"); const ports = collectPortsFromForm(); if (btn) btn.disabled = true; if (msg) { msg.hidden = false; msg.className = "settings-msg"; msg.textContent = "적용 중… 서비스를 재시작합니다"; } try { const data = await api("/api/settings/ports", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ports, restart: true }), }); const redirect = data.redirect || `http://127.0.0.1:${data.gui_port || ports.gui}/`; if (msg) msg.textContent = data.message || "재시작 완료 대기 중…"; const ok = await waitForGui(redirect); if (ok) { window.location.replace(redirect); return; } if (msg) { msg.className = "settings-msg error"; msg.textContent = "재시작 대기가 지연됩니다. 잠시 후 " + redirect + " 로 접속하세요."; } } catch (e) { if (msg) { msg.hidden = false; msg.className = "settings-msg error"; msg.textContent = e.message || String(e); } } finally { if (btn) btn.disabled = false; } } function bindSettingsUi() { const openBtn = $("settingsBtn"); if (openBtn) openBtn.onclick = () => openSettingsModal(); document.querySelectorAll("[data-close-settings]").forEach((el) => { el.addEventListener("click", () => closeSettingsModal()); }); const resetBtn = $("portsResetBtn"); if (resetBtn) { resetBtn.onclick = () => { for (const p of settingsPortsCache) { const input = document.querySelector(`[data-port-key="${p.key}"]`); if (input) input.value = p.default; } }; } const applyBtn = $("portsApplyBtn"); if (applyBtn) applyBtn.onclick = () => applyPortsAndRestart(); } 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() { const local = displayPath(state.localPath); const remote = displayPath(state.currentPath); if ($("localPathHint")) $("localPathHint").textContent = local; if ($("remotePathHint")) $("remotePathHint").textContent = remote; if ($("railLocalPath")) $("railLocalPath").textContent = local; 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; const cur = state.currentPath || state.remoteReceivePath || state.remoteStartPath || ""; return isRootsPath(cur) ? "" : cur; } function updateInputShareUI(info) { const badge = $("inputBadge"); const btn = $("inputShareBtn"); const localBtn = $("controlLocalBtn"); const remoteBtn = $("controlRemoteBtn"); if (!badge || !btn) return; btn.hidden = false; btn.style.display = ""; // 수신 측(being_controlled)도 버튼 활성 — 제어권 회수/탈취 const linkOk = !!( state.connected || (info && (info.peer || info.being_controlled || info.primary_ready)) ); const shareActive = !!( info && info.enabled && linkOk && (info.trusted || info.being_controlled) ); const controlReady = shareActive; if (localBtn && remoteBtn) { localBtn.disabled = !controlReady; remoteBtn.disabled = !controlReady; 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 && !pending); remoteBtn.classList.toggle("active", controlReady && (remote || pending)); localBtn.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 && !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 = info.howto || (pick ? `선택할 항목: ${pickName}\n전체 경로: ${pick}\n(Finder는 상위 폴더를 열고 ${pickName} 을 선택합니다)` : win ? "입력공유를 다시 켜 보세요" : "시스템 설정 → 손쉬운 사용"); btn.textContent = "입력공유 켜기"; return; } 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+R 제어권 토글 · Esc 종료"; btn.textContent = "입력공유 끄기"; } else if (info.enabled) { badge.textContent = info.status || "입력공유 대기"; badge.className = "auto-badge wait"; btn.textContent = "입력공유 끄기"; } else { badge.textContent = "입력공유 꺼짐"; badge.className = "auto-badge wait"; btn.textContent = "입력공유 켜기"; } } async function switchInputControl(target) { try { const res = await fetch("/api/inputshare/control", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target }), }); const info = await res.json().catch(() => ({})); updateInputShareUI(info); if (!res.ok || info.ok === false) { setStatus(info.error || "제어권 전환 실패", true); return; } 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); } } function updateTransferButtons() { const canUp = state.connected && state.localSelected.size > 0; const canDown = state.connected && state.remoteSelected.size > 0; if ($("uploadBtn")) $("uploadBtn").disabled = !canUp; if ($("downloadBtn")) $("downloadBtn").disabled = !canDown; } function escapeHtml(s) { return String(s) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } async function api(path, options = {}) { const res = await fetch(path, options); const data = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(data.error || res.statusText || "요청 실패"); err.code = data.code || ""; err.status = res.status; throw err; } return data; } function updateBadge(connected, text) { const badge = $("autoBadge"); badge.textContent = text; badge.className = connected ? "auto-badge ok" : "auto-badge wait"; } function updateWormholeUI(info) { const badge = $("wormholeBadge"); const btn = $("killWormholeBtn"); if (!badge) return; if (!info) { badge.textContent = "Wormhole —"; badge.className = "auto-badge wait"; return; } if (info.running) { badge.textContent = `Wormhole ${info.count}개 실행중`; badge.className = "auto-badge wait"; badge.title = (info.processes || []) .map((p) => `${p.name} (${p.pid})`) .join("\n") || info.message || ""; if (btn) btn.disabled = false; } else { badge.textContent = "Wormhole 없음"; badge.className = "auto-badge ok"; badge.title = info.message || "공식 Wormhole 프로세스 없음"; if (btn) btn.disabled = false; } } async function killWormhole() { setStatus("공식 Wormhole 종료 중…"); try { const data = await api("/api/wormhole/kill", { method: "POST" }); updateWormholeUI({ running: (data.remaining || []).length > 0, count: (data.remaining || []).length, processes: data.remaining || [], message: data.message, }); setStatus(data.message || "Wormhole 종료 완료"); await refreshStatus(); } catch (e) { setStatus(e.message || String(e), true); } } function sortEntries(entries, sort) { const dir = sort.dir; const key = sort.key; return [...entries].sort((a, b) => { if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; let av; let bv; if (key === "size") { av = a.is_dir ? -1 : Number(a.size) || 0; bv = b.is_dir ? -1 : Number(b.size) || 0; } else if (key === "mtime") { av = Number(a.mtime) || 0; bv = Number(b.mtime) || 0; } else if (key === "kind") { av = fileKind(a.name, a.is_dir); bv = fileKind(b.name, b.is_dir); } else { av = String(a.name || "").toLowerCase(); bv = String(b.name || "").toLowerCase(); } if (av < bv) return -1 * dir; if (av > bv) return 1 * dir; return String(a.name).localeCompare(String(b.name), "ko"); }); } function updateSortButtons(prefix, sort) { document.querySelectorAll(`[data-sort-${prefix}]`).forEach((btn) => { const k = btn.getAttribute(`data-sort-${prefix}`); if (!btn.dataset.label) btn.dataset.label = btn.textContent.replace(/ [▲▼]$/, ""); btn.classList.toggle("active", k === sort.key); btn.textContent = k === sort.key ? `${btn.dataset.label} ${sort.dir > 0 ? "▲" : "▼"}` : btn.dataset.label; }); } 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; 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 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"; 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 (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 ? "상대가 실행되면 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) { const pct = s.progress.total ? Math.round((100 * s.progress.done) / s.progress.total) : 0; $("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; } async function connectOneClick() { if (state.connecting || state.connected) return; state.connecting = true; $("connectBtn").disabled = true; updateBadge(false, "연결 중…"); setStatus("공식 Wormhole 종료 후 상대 프로그램 대기 중…"); $("peerInfo").textContent = "Wormhole 정리 · USB 핸드셰이크 중…"; try { const data = await api("/api/connect/auto", { method: "POST" }); if (data.ok === false || data.code === "PEER_NOT_FOUND") { const err = new Error(data.error || "상대 PC를 찾지 못했습니다."); err.code = data.code || "PEER_NOT_FOUND"; 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 = (s && (s.hostname || s.host)) || data.hostname || data.host || "상대 PC"; $("disconnectBtn").disabled = false; $("connectBtn").disabled = true; state.currentPath = ""; state.remoteStartPath = (s && s.remote_start) || data.start_path || ""; state.remoteReceivePath = (s && s.remote_receive) || data.receive_path || data.start_path || ""; updatePathHints(); 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; updateBadge(false, "미연결"); $("connectBtn").disabled = false; $("disconnectBtn").disabled = true; const msg = (e && e.message) || "연결 실패"; setStatus(msg, true); $("peerInfo").textContent = e.code === "PEER_NOT_FOUND" ? "상대 없음 — 양쪽 JUC500 GUI 실행·케이블·WinUSB(Zadig)를 확인하세요" : "연결 실패 — 다시 연결을 누르세요"; showRemoteMessage(msg, true); } } function showRemoteMessage(msg, isError = false) { const tbody = $("dirList"); tbody.innerHTML = ""; $("remoteEmpty").classList.add("hidden"); $("remoteCount").textContent = "—"; const tr = document.createElement("tr"); tr.innerHTML = `${escapeHtml(msg)}`; tbody.appendChild(tr); } async function disconnect() { await api("/api/disconnect", { method: "POST" }); state.connected = false; state.connecting = false; state.entries = []; state.remoteFocus = ""; showRemoteMessage("연결 후 여기에 상대 폴더가 표시됩니다"); $("remoteEmpty").textContent = "연결 후 여기에 상대 폴더가 표시됩니다"; $("remoteEmpty").classList.remove("hidden"); $("dirList").innerHTML = ""; renderPathBar($("pathBar"), "", () => {}); $("upBtn").disabled = true; $("mkdirBtn").disabled = true; updatePathHints(); await refreshStatus(); setStatus("연결 해제됨 — 케이블 연결 시 자동 재연결됩니다"); } async function loadDir(path) { if (!state.connected) { setStatus("먼저 연결 버튼을 누르세요", true); return; } const rel = normPath(path); const data = await api(`/api/listdir?path=${encodeURIComponent(rel)}`); 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 = !canGoParent(state.currentPath); } function renderRemote() { const tbody = $("dirList"); tbody.innerHTML = ""; updateSortButtons("remote", state.remoteSort); const entries = sortEntries(state.entries, state.remoteSort); $("remoteCount").textContent = state.remoteSelected.size > 0 ? `${state.remoteSelected.size}개 선택됨 / ${entries.length}개 항목` : `${entries.length}개 항목`; if (!entries.length) { $("remoteEmpty").textContent = state.connected ? "폴더가 비어 있습니다" : "연결 후 여기에 상대 폴더가 표시됩니다"; $("remoteEmpty").classList.toggle("hidden", !!state.connected && !!state.currentPath); if (!state.connected) { $("remoteEmpty").classList.remove("hidden"); return; } appendParentRow(tbody, { canGo: canGoParent(state.currentPath), onGo: () => goUp().catch((err) => setStatus(err.message, true)), }); if (state.currentPath) $("remoteEmpty").classList.add("hidden"); else $("remoteEmpty").classList.remove("hidden"); return; } $("remoteEmpty").classList.add("hidden"); entries.forEach((e) => { 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 = !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 = `
${icon}${escapeHtml(e.name)}
${formatMtime(e.mtime)} ${e.is_dir ? "—" : human(e.size)} ${escapeHtml(fileKind(e.name, e.is_dir, e.kind))} `; tr.onclick = (ev) => { state.remoteFocus = full; if (ev.metaKey || ev.ctrlKey) { if (state.remoteSelected.has(full)) state.remoteSelected.delete(full); else state.remoteSelected.add(full); } else { state.remoteSelected.clear(); state.remoteSelected.add(full); } tbody.querySelectorAll("tr").forEach((x) => { x.classList.toggle("selected", state.remoteSelected.has(x.dataset.path)); }); $("remoteCount").textContent = `${state.remoteSelected.size}개 선택됨 / ${entries.length}개 항목`; updateTransferButtons(); }; tr.ondblclick = () => { if (e.is_dir) loadDir(full).catch((err) => setStatus(err.message, true)); }; tr.addEventListener("dragstart", (ev) => { if (!state.remoteSelected.has(full)) { state.remoteSelected.clear(); state.remoteSelected.add(full); tbody.querySelectorAll("tr").forEach((x) => { x.classList.toggle("selected", state.remoteSelected.has(x.dataset.path)); }); } ev.dataTransfer.setData( "application/x-fileshare-remote", JSON.stringify([...state.remoteSelected]) ); ev.dataTransfer.effectAllowed = "copy"; tr.classList.add("dragging"); }); tr.addEventListener("dragend", () => tr.classList.remove("dragging")); tbody.appendChild(tr); }); appendParentRow(tbody, { canGo: canGoParent(state.currentPath), onGo: () => goUp().catch((err) => setStatus(err.message, true)), }); } async function loadLocal(path) { const rel = normPath(path); const data = await api(`/api/local/listdir?path=${encodeURIComponent(rel)}`); 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 = !canGoParent(state.localPath); updatePathHints(); updateTransferButtons(); renderLocal(); } function renderLocal() { const tbody = $("localDirList"); tbody.innerHTML = ""; updateSortButtons("local", state.localSort); const entries = sortEntries(state.localEntries, state.localSort); $("localCount").textContent = `${entries.length}개 항목`; if (!entries.length) { $("localEmpty").classList.remove("hidden"); appendParentRow(tbody, { canGo: canGoParent(state.localPath), onGo: () => localGoUp().catch((err) => setStatus(err.message, true)), }); if (state.localPath) $("localEmpty").classList.add("hidden"); return; } $("localEmpty").classList.add("hidden"); entries.forEach((e) => { 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 = !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 = `
${icon}${escapeHtml(e.name)}
${formatMtime(e.mtime)} ${e.is_dir ? "—" : human(e.size)} ${escapeHtml(fileKind(e.name, e.is_dir, e.kind))} `; tr.onclick = (ev) => { state.localFocus = full; if (e.is_dir) { if (ev.metaKey || ev.ctrlKey) { if (state.localSelected.has(full)) state.localSelected.delete(full); else state.localSelected.add(full); } else { state.localSelected.clear(); state.localSelected.add(full); } tbody.querySelectorAll("tr").forEach((x) => { x.classList.toggle("checked", state.localSelected.has(x.dataset.path)); x.classList.toggle("selected", state.localSelected.has(x.dataset.path) && x.dataset.isdir === "1"); }); $("localCount").textContent = state.localSelected.size > 0 ? `${state.localSelected.size}개 선택됨 / ${entries.length}개 항목` : `${entries.length}개 항목`; updateTransferButtons(); return; } if (ev.metaKey || ev.ctrlKey) { if (state.localSelected.has(full)) state.localSelected.delete(full); else state.localSelected.add(full); } else if (ev.shiftKey && state.localSelected.size) { state.localSelected.add(full); } else { state.localSelected.clear(); state.localSelected.add(full); } tbody.querySelectorAll("tr").forEach((x) => { x.classList.toggle("checked", state.localSelected.has(x.dataset.path)); x.classList.remove("selected"); }); $("localCount").textContent = state.localSelected.size > 0 ? `${state.localSelected.size}개 선택됨 / ${entries.length}개 항목` : `${entries.length}개 항목`; updateTransferButtons(); }; tr.ondblclick = () => { if (e.is_dir) loadLocal(full).catch((err) => setStatus(err.message, true)); }; tr.addEventListener("dragstart", (ev) => { if (!state.localSelected.has(full)) { state.localSelected.clear(); state.localSelected.add(full); tbody.querySelectorAll("tr").forEach((x) => { x.classList.toggle("checked", state.localSelected.has(x.dataset.path)); }); } ev.dataTransfer.setData( "application/x-fileshare-local", JSON.stringify([...state.localSelected]) ); ev.dataTransfer.effectAllowed = "copy"; tr.classList.add("dragging"); }); tr.addEventListener("dragend", () => tr.classList.remove("dragging")); tbody.appendChild(tr); }); appendParentRow(tbody, { canGo: canGoParent(state.localPath), onGo: () => localGoUp().catch((err) => setStatus(err.message, true)), }); } async function goUp() { if (!state.connected) { setStatus("먼저 연결 버튼을 누르세요", true); return; } const parent = parentPath(state.currentPath); if (parent === null) { updatePathHints(); setStatus("이미 최상위 폴더입니다"); return; } await loadDir(parent); } async function localGoUp() { const parent = parentPath(state.localPath); if (parent === null) { setStatus("이미 최상위 폴더입니다"); return; } await loadLocal(parent); } 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()); try { await api("/api/mkdir", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); await loadDir(state.currentPath); updatePathHints(); setStatus(`폴더 생성: ${displayPath(path)}`); } catch (e) { setStatus(e.message, true); } } async function watchProgress(run) { $("progressBar").style.width = "2%"; const poll = setInterval(async () => { try { const p = await api("/api/progress"); if (p.total) { const pct = Math.max(2, Math.round((100 * p.done) / p.total)); $("progressBar").style.width = `${pct}%`; setStatus(`전송 중 ${pct}% — ${p.label || ""}`); } if (p.error) setStatus(p.error, true); } catch (_) {} }, 250); try { await run(); $("progressBar").style.width = "100%"; } finally { clearInterval(poll); setTimeout(() => { $("progressBar").style.width = "0%"; }, 1200); } } async function uploadFiles(fileList, destDir) { if (!state.connected) { setStatus("먼저 연결 버튼을 누르세요", true); return; } const files = [...fileList].filter((f) => f && f.size >= 0 && f.name); if (!files.length) { setStatus("폴더는 OS에서 바로 드롭할 수 없습니다. 파일을 드롭하거나 패널에서 드래그하세요", true); return; } const dest = remoteUploadDest(destDir); const fd = new FormData(); fd.append("dest_dir", dest); files.forEach((f) => fd.append("file", f, f.name)); setStatus(`${files.length}개 파일 → 상대 ${displayPath(dest)} 전송…`); await watchProgress(async () => { const res = await fetch("/api/upload", { method: "POST", body: fd }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "전송 실패"); setStatus(`${data.count}개 전송 완료 → ${displayPath(data.dest_dir)}`); await loadDir(state.currentPath); }).catch((e) => setStatus(e.message, true)); } async function writeLocalFiles(fileList, destDir) { const files = [...fileList].filter((f) => f && f.size >= 0 && f.name); if (!files.length) { setStatus("폴더는 OS에서 바로 드롭할 수 없습니다. 파일을 선택해 드롭하세요", true); return; } const dest = destDir != null ? destDir : state.localPath || ""; const fd = new FormData(); fd.append("dest_dir", dest); files.forEach((f) => fd.append("file", f, f.name)); setStatus(`${files.length}개 파일 → 이 PC ${displayPath(dest)} 저장…`); try { const res = await fetch("/api/local/write", { method: "POST", body: fd }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "저장 실패"); setStatus(`${data.count}개 저장 완료 → ${displayPath(data.dest_dir)}`); await loadLocal(state.localPath); } catch (e) { setStatus(e.message, true); } } async function sendLocalSelected(destDir) { if (!state.connected) { setStatus("먼저 연결 버튼을 누르세요", true); return; } const paths = [...state.localSelected]; if (!paths.length) { setStatus("로컬 파일/폴더를 선택하세요", true); return; } const dest = remoteUploadDest(destDir); setStatus(`${paths.length}개 → 상대 ${displayPath(dest)} 전송…`); await watchProgress(async () => { const data = await api("/api/local/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dest_dir: dest, paths }), }); setStatus(`${data.count}개 전송 완료 → ${displayPath(data.dest_dir)}`); await loadDir(state.currentPath); }).catch((e) => setStatus(e.message, true)); } async function fetchRemoteSelected(destDir) { if (!state.connected) { setStatus("먼저 연결 버튼을 누르세요", true); return; } const paths = [...state.remoteSelected]; if (!paths.length) { setStatus("상대 파일/폴더를 선택하세요", true); return; } const dest = destDir != null ? destDir : state.localPath || ""; setStatus(`${paths.length}개 → 이 PC ${displayPath(dest)} 가져오는 중…`); await watchProgress(async () => { const data = await api("/api/remote/fetch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dest_dir: dest, paths }), }); setStatus(`${data.count}개 가져오기 완료 → ${displayPath(data.dest_dir)}`); await loadLocal(state.localPath); }).catch((e) => setStatus(e.message, true)); } function dropDestFromEvent(e, fallbackPath) { const row = e.target.closest?.("tr[data-drop-path]"); if (row?.dataset?.dropPath) return row.dataset.dropPath; return fallbackPath || ""; } function clearDropHighlights() { document.querySelectorAll(".finder.drop-target, tr.drop-target").forEach((el) => { el.classList.remove("drop-target"); }); } function bindPaneDrop(pane, side) { const prevent = (e) => { e.preventDefault(); e.stopPropagation(); }; const onDragOver = (e) => { prevent(e); e.dataTransfer.dropEffect = "copy"; pane.classList.add("drop-target"); const row = e.target.closest?.("tr[data-drop-path]"); document.querySelectorAll("tr.drop-target").forEach((r) => r.classList.remove("drop-target")); if (row) row.classList.add("drop-target"); }; const onDragLeave = (e) => { if (!pane.contains(e.relatedTarget)) { pane.classList.remove("drop-target"); pane.querySelectorAll("tr.drop-target").forEach((r) => r.classList.remove("drop-target")); } }; const onDrop = async (e) => { prevent(e); clearDropHighlights(); const dest = dropDestFromEvent( e, side === "remote" ? remoteUploadDest() : state.localPath || "" ); const localPayload = e.dataTransfer.getData("application/x-fileshare-local"); const remotePayload = e.dataTransfer.getData("application/x-fileshare-remote"); const osFiles = e.dataTransfer?.files; try { if (side === "remote") { if (localPayload) { state.localSelected = new Set(JSON.parse(localPayload)); await sendLocalSelected(dest); return; } if (remotePayload) { setStatus("같은 상대 패널 안에서는 이동할 수 없습니다", true); return; } if (osFiles?.length) { await uploadFiles(osFiles, dest); return; } } else { // local pane if (remotePayload) { state.remoteSelected = new Set(JSON.parse(remotePayload)); await fetchRemoteSelected(dest); return; } if (localPayload) { setStatus("같은 이 PC 패널 안에서는 이동할 수 없습니다", true); return; } if (osFiles?.length) { await writeLocalFiles(osFiles, dest); return; } } setStatus("드롭할 파일이 없습니다", true); } catch (err) { setStatus(err.message || String(err), true); } }; ["dragenter", "dragover", "dragleave", "drop"].forEach((ev) => { pane.addEventListener(ev, prevent); }); pane.addEventListener("dragover", onDragOver); pane.addEventListener("dragleave", onDragLeave); pane.addEventListener("drop", onDrop); } function bindDrop() { bindPaneDrop(document.querySelector(".remote-browser"), "remote"); bindPaneDrop(document.querySelector(".local-browser"), "local"); } function bindSort(prefix, getSort, setSort, rerender) { document.querySelectorAll(`[data-sort-${prefix}]`).forEach((btn) => { btn.onclick = () => { const key = btn.getAttribute(`data-sort-${prefix}`); const cur = getSort(); if (cur.key === key) setSort({ key, dir: cur.dir * -1 }); else setSort({ key, dir: 1 }); rerender(); }; }); } function bindUi() { $("connectBtn").onclick = () => connectOneClick(); $("disconnectBtn").onclick = () => disconnect().catch((e) => setStatus(e.message, true)); if ($("killWormholeBtn")) { $("killWormholeBtn").onclick = () => killWormhole(); } $("upBtn").onclick = () => goUp().catch((e) => setStatus(e.message, true)); $("refreshBtn").onclick = () => { if (!state.connected) { setStatus("먼저 연결 버튼을 누르세요", true); return; } loadDir(state.currentPath) .then(() => setStatus("상대 폴더 새로고침")) .catch((e) => setStatus(e.message, true)); }; $("mkdirBtn").onclick = () => mkdir(); $("localUpBtn").onclick = () => localGoUp().catch((e) => setStatus(e.message, true)); $("localRefreshBtn").onclick = () => loadLocal(state.localPath) .then(() => setStatus("이 PC 폴더 새로고침")) .catch((e) => setStatus(e.message, true)); // FTP 스타일: 선택 → 상대/로컬 현재 폴더로 전송 $("uploadBtn").onclick = () => sendLocalSelected(remoteUploadDest()); $("downloadBtn").onclick = () => fetchRemoteSelected(state.localPath || ""); $("inputShareBtn").onclick = async () => { try { let cur = await api("/api/inputshare"); if (!cur.trusted) { let prompted; try { prompted = await api("/api/inputshare/prompt-trust", { method: "POST" }); } catch (e) { prompted = await api("/api/inputshare/prompt-trust").catch(() => null); if (!prompted) { setStatus("권한 API를 찾을 수 없습니다. 앱을 재시작하세요", true); return; } } updateInputShareUI(prompted); 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; } } const next = !cur.enabled; const info = await api("/api/inputshare/enable", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: next }), }); updateInputShareUI(info); 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); } }; if ($("controlLocalBtn")) { $("controlLocalBtn").onclick = () => switchInputControl("local"); } if ($("controlRemoteBtn")) { $("controlRemoteBtn").onclick = () => switchInputControl("remote"); } bindSettingsUi(); // 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; if (ev.key === "u" || ev.key === "U") { ev.preventDefault(); const remote = $("controlRemoteBtn")?.classList.contains("active"); switchInputControl(remote ? "local" : "remote"); } else if (ev.key === "ArrowLeft") { ev.preventDefault(); switchInputControl("local"); } else if (ev.key === "ArrowRight") { ev.preventDefault(); switchInputControl("remote"); } }); bindSort( "local", () => state.localSort, (s) => { state.localSort = s; }, () => renderLocal() ); bindSort( "remote", () => state.remoteSort, (s) => { state.remoteSort = s; }, () => renderRemote() ); bindDrop(); } (async function init() { bindUi(); renderPathBar($("localPathBar"), "", (p) => loadLocal(p)); renderPathBar($("pathBar"), "", () => {}); try { const s = await refreshStatus(); await loadLocal(s.local_start || ""); if (state.connected) { try { await loadDir(state.remoteStartPath || ""); setStatus("자동 연결됨 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다"); } catch (listErr) { setStatus( ((listErr && listErr.message) || "상대 폴더 로드 실패") + " — 새로고침을 눌러 다시 시도하세요", true ); } } else { setStatus(s.auto_status || "JUC500 케이블 연결 시 양쪽이 자동 연결됩니다"); } } catch (e) { setStatus(e.message, true); } // 백그라운드 자동 연결을 UI에 반영 let wasConnected = state.connected; setInterval(async () => { if (state.connecting) return; try { const s = await refreshStatus(); if (!wasConnected && state.connected) { 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); } wasConnected = state.connected; } catch { /* ignore */ } }, 700); })();