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: "",
};
const ROOTS_PATH = "__roots__";
const $ = (id) => document.getElementById(id);
function cableModelLabel(device) {
if (!device) return "JUC";
const m = device.model;
if (m === "JUC500" || m === "JUC700" || m === "SmartKMLink") return m;
const vid = device.vid ? parseInt(device.vid, 16) : 0;
const pid = device.pid ? parseInt(device.pid, 16) : 0;
if (vid === 0x0ea0 && pid === 0x7301) return "SmartKMLink";
if (pid === 0x7500) return "JUC500";
if (pid >= 0x7000 && pid <= 0x700f) return "JUC700";
return "Smart Data Link";
}
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]);
const avail = (s.available_cable_models || []).map((m) => String(m).toUpperCase());
if (avail.length === 1) return avail[0];
const selected = String(s.selected_cable_model || "").toUpperCase().replace(/\s+/g, "");
if (selected && avail.includes(selected)) return selected;
// No USB device yet — show user preference (JUC700 default on Mac).
if (selected === "JUC500" || selected === "JUC700" || selected === "SMARTKMLINK") {
return selected === "SMARTKMLINK" ? "SmartKMLink" : selected;
}
if (avail.length) return avail[0];
return "JUC";
}
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.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 · Esc 종료 · Ctrl+Shift+U/←/→";
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 updateCableSwitchUI(s) {
const selected = (s.selected_cable_model || "JUC500").toUpperCase().replace(/\s+/g, "");
const b500 = $("cableJuc500Btn");
const b700 = $("cableJuc700Btn");
const bKm = $("cableKmLinkBtn");
const sw = document.querySelector(".cable-switch");
if (!b500 || !b700) return;
const available = (s.available_cable_models || []).map((m) => String(m));
const availU = available.map((m) => m.toUpperCase().replace(/\s+/g, ""));
const detected = availU.length > 0;
// When nothing is plugged in, keep JUC500/JUC700 as preference toggles.
const show500 = detected ? availU.includes("JUC500") : true;
const show700 = detected ? availU.includes("JUC700") : true;
const showKm = detected && availU.includes("SMARTKMLINK");
const visible = [show500, show700, showKm].filter(Boolean).length;
b500.classList.toggle("cable-hidden", !show500);
b700.classList.toggle("cable-hidden", !show700);
if (bKm) bKm.classList.toggle("cable-hidden", !showKm);
if (sw) sw.classList.toggle("cable-single", visible === 1);
const sel = selected === "SMARTKMLINK" || selected === "KMLINK" ? "SMARTKMLINK" : selected;
b500.classList.toggle("active", sel === "JUC500");
b700.classList.toggle("active", sel === "JUC700");
if (bKm) bKm.classList.toggle("active", sel === "SMARTKMLINK");
const busy = !!(s.connecting || state.connecting);
b500.disabled = busy || (detected && !show500);
b700.disabled = busy || (detected && !show700);
if (bKm) bKm.disabled = busy || (detected && !showKm);
state.availableCableModels = available;
}
async function selectCableModel(model) {
if (state.connecting) return;
const available = (state.availableCableModels || []).map((m) =>
String(m).toUpperCase().replace(/\s+/g, "")
);
const modelKey = String(model).toUpperCase().replace(/\s+/g, "");
if (available.length && !available.includes(modelKey)) return;
const prev = (state.selectedCableModel || "").toUpperCase();
if (prev === model && state.connected) {
setStatus(`${model} — 이미 연결됨`);
return;
}
state.connecting = true;
setStatus(`${model} 케이블로 전환 중…`);
try {
await api("/api/cable/select", { method: "POST", body: JSON.stringify({ model }) });
const deadline = Date.now() + 70000;
let s = await refreshStatus();
while (Date.now() < deadline) {
if (s.connected) {
setStatus(`${model} 연결됨`);
return;
}
if (s.connect_error && !s.connecting) {
throw new Error(s.connect_error);
}
if (!s.connecting && !s.connected && (s.listening || s.connect_error)) {
if (s.connect_error) throw new Error(s.connect_error);
break;
}
await new Promise((r) => setTimeout(r, 500));
s = await refreshStatus();
}
if (!s.connected) {
throw new Error(s.connect_error || s.auto_status || "케이블 전환 시간 초과");
}
} catch (e) {
setStatus(e.message, true);
await refreshStatus();
} finally {
state.connecting = false;
}
}
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;
state.selectedCableModel = s.selected_cable_model || "";
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/JUC700 케이블을 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/JUC700 버튼으로 모델 선택)`;
}
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);
updateCableSwitchUI(s);
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 || ""}`);
}
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.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() {
if ($("cableJuc500Btn")) {
$("cableJuc500Btn").onclick = () => selectCableModel("JUC500");
}
if ($("cableJuc700Btn")) {
$("cableJuc700Btn").onclick = () => selectCableModel("JUC700");
}
if ($("cableKmLinkBtn")) {
$("cableKmLinkBtn").onclick = () => selectCableModel("SmartKMLink");
}
$("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;
}
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);
})();