Files
juc500/dist-offline/juc500-offline-update-20260720-174241/juc500_xfer/static/app.js
T
2026-07-21 21:11:19 +09:00

1832 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<svg class="item-icon folder" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const ICON_FILE = `<svg class="item-icon file" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm1 7V3.5L18.5 9H15z"/></svg>`;
const ICON_DRIVE = `<svg class="item-icon folder" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 6h16a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3.5l-1 2h-5l-1-2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2zm0 2v7h16V8H4z"/></svg>`;
function normPath(path) {
return String(path || "").replace(/\\/g, "/");
}
function isRootsPath(path) {
return normPath(path) === ROOTS_PATH;
}
function isAbsPath(path) {
const p = normPath(path);
return p.startsWith("/") || /^[A-Za-z]:/i.test(p);
}
function joinPath(base, name) {
const b = normPath(base).replace(/\/+$/, "");
if (!b || isRootsPath(b)) return name;
if (/^[A-Za-z]:$/i.test(b)) return `${b}/${name}`;
return `${b}/${name}`;
}
function entryNavPath(base, entry) {
if (entry && entry.nav_path) return normPath(entry.nav_path);
return joinPath(base, entry.name);
}
/** Parent directory; drive roots and / go up to computer (__roots__). */
function parentPath(path) {
const 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 = `
<td class="col-name"><div class="name-cell">${ICON_FOLDER}<span>..</span></div></td>
<td class="col-date muted">—</td>
<td class="col-size muted">—</td>
<td class="col-kind kind">상위 폴더</td>
`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = `
<div class="port-meta">
<span class="port-label">${escapeHtml(p.label)}</span>
<span class="port-desc">${escapeHtml(p.description || "")}</span>
<span class="port-proto">${escapeHtml(p.protocol)} · 기본 ${p.default}</span>
</div>
<input class="port-input" type="number" min="1" max="65535" step="1"
data-port-key="${escapeHtml(p.key)}" value="${Number(p.value)}" />
`;
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 = `<td colspan="4" style="padding:24px;color:${isError ? "var(--danger)" : "var(--muted)"};white-space:pre-wrap">${escapeHtml(msg)}</td>`;
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 = `
<td class="col-name"><div class="name-cell">${icon}<span>${escapeHtml(e.name)}</span></div></td>
<td class="col-date muted">${formatMtime(e.mtime)}</td>
<td class="col-size muted">${e.is_dir ? "—" : human(e.size)}</td>
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir, e.kind))}</td>
`;
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 = `
<td class="col-name"><div class="name-cell">${icon}<span>${escapeHtml(e.name)}</span></div></td>
<td class="col-date muted">${formatMtime(e.mtime)}</td>
<td class="col-size muted">${e.is_dir ? "—" : human(e.size)}</td>
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir, e.kind))}</td>
`;
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);
})();