Files
juc500/juc500_xfer/static/app.js
T
joyharam 8774a4843e Add Windows installer packaging (Setup.exe + portable exe)
PyInstaller onedir/onefile specs, Inno Setup script, and GitHub Actions
workflow to produce JUC500-Setup-win64.exe and portable builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 10:26:10 +09:00

1255 lines
42 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 $ = (id) => document.getElementById(id);
const ICON_FOLDER = `<svg class="item-icon folder" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const ICON_FILE = `<svg class="item-icon file" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm1 7V3.5L18.5 9H15z"/></svg>`;
function joinPath(base, name) {
if (!base) return name;
return `${base.replace(/\/+$/, "")}/${name}`;
}
function parentPath(path) {
const parts = String(path || "")
.replace(/^\/+/, "")
.split("/")
.filter(Boolean);
if (!parts.length) return null;
parts.pop();
return parts.join("/");
}
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) {
return "/" + (p || "");
}
function breadcrumbParts(path) {
const parts = String(path || "")
.replace(/^\/+/, "")
.split("/")
.filter(Boolean);
const crumbs = [{ label: "루트", path: "" }];
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) {
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();
window.addEventListener("keydown", (ev) => {
const modal = $("settingsModal");
if (ev.key === "Escape" && modal && !modal.classList.contains("hidden")) {
closeSettingsModal();
}
});
}
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 remoteUploadDest(explicit) {
if (explicit != null && explicit !== undefined) return explicit;
return state.currentPath || state.remoteReceivePath || state.remoteStartPath || "";
}
function updateInputShareUI(info) {
const badge = $("inputBadge");
const btn = $("inputShareBtn");
const localBtn = $("controlLocalBtn");
const remoteBtn = $("controlRemoteBtn");
if (!badge || !btn) return;
// 수신 측(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);
const controlled = !!(info && info.being_controlled);
localBtn.classList.toggle("active", controlReady && !remote && !controlled);
remoteBtn.classList.toggle("active", controlReady && remote);
localBtn.title = controlled
? "이 PC 제어 회수 (상대 조종 해제)"
: "이 PC 제어 — 상대 제어 중 왼쪽 끝으로 복귀";
remoteBtn.title = controlled
? "상대 PC 제어 가져오기 (제어권 탈취)"
: "상대 PC 제어 — 이후 화면 오른쪽 끝으로도 전환";
}
if (!info || !info.supported) {
badge.textContent = "입력공유 미지원";
badge.className = "auto-badge wait";
btn.disabled = true;
return;
}
btn.disabled = !linkOk;
if (!info.trusted) {
badge.textContent = "손쉬운 사용 권한 필요";
badge.className = "auto-badge wait";
const pick = info.pick_path || info.python_app || info.executable || "";
const pickName = pick.split("/").pop() || "앱";
badge.title = pick
? `선택할 항목: ${pickName}\n전체 경로: ${pick}\n(Finder는 상위 폴더를 열고 ${pickName} 을 선택합니다)`
: "시스템 설정 → 손쉬운 사용";
btn.textContent = pickName.endsWith(".app") ? `${pickName} 열기` : "앱 경로 열기";
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) {
badge.textContent = info.controlling_remote ? "제어: 상대 PC" : "제어: 이 PC";
badge.className = "auto-badge ok";
badge.title = "가장자리 전환: 오른쪽→상대, 왼쪽→이 PC · 단축키 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;
}
setStatus(
target === "remote"
? "키보드·마우스 제어: 상대 PC (Ctrl+Shift+← 또는 [이 PC]로 복귀)"
: "키보드·마우스 제어: 이 PC (Ctrl+Shift+→ 또는 [상대 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 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;
});
}
async function refreshStatus() {
const s = await api("/api/status");
state.connected = !!s.connected;
$("disconnectBtn").disabled = !state.connected;
$("connectBtn").disabled = state.connected || state.connecting;
updateTransferButtons();
$("mkdirBtn").disabled = !state.connected;
const cable = s.device?.serial
? `JUC500 ${s.device.serial}`
: s.cable_ip
? `JUC500 ${s.cable_ip}`
: "JUC500 케이블";
if (s.this_host) $("thisHost").textContent = `${s.this_host} · ${cable}`;
if (s.local_root) $("localRootHint").textContent = s.local_root;
updateInputShareUI(s.inputshare);
if (state.connected) {
updateBadge(true, "연결됨");
$("peerInfo").textContent = s.hostname || s.host || "상대 PC";
$("ifaceInfo").textContent = s.device?.serial ? `IF5 Vendor · ${s.device.serial}` : "JUC500 bulk bridge";
$("remoteRootHint").textContent = s.root || s.host || "연결됨";
if (s.remote_start != null) state.remoteStartPath = s.remote_start || "";
if (s.remote_receive != null) state.remoteReceivePath = s.remote_receive || state.remoteStartPath;
} else if (!state.connecting) {
const hasCable = !!(s.device?.serial || s.cable_ip);
updateBadge(false, hasCable ? "케이블 감지됨" : "케이블 대기");
$("peerInfo").textContent =
s.auto_status ||
(hasCable
? "연결을 누르면 상대 JUC500 프로그램과 핸드셰이크합니다"
: "JUC500(Smart Data Link) 케이블을 연결하세요");
$("ifaceInfo").textContent = hasCable ? `시리얼 ${s.device?.serial || s.cable_ip}` : "";
$("remoteRootHint").textContent = "연결 필요";
}
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("상대 JUC500 프로그램 대기 중…");
$("peerInfo").textContent = "USB 핸드셰이크 중… 상대에서도 GUI를 실행하세요";
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;
}
await api("/api/auto/start", { method: "POST" }).catch(() => {});
state.connected = true;
state.connecting = false;
updateBadge(true, "연결됨");
setStatus("연결됨 — 상대 폴더를 불러오는 중…");
$("peerInfo").textContent = data.hostname || data.host || "상대 PC";
$("disconnectBtn").disabled = false;
$("connectBtn").disabled = true;
state.currentPath = "";
state.remoteStartPath = data.start_path || "";
state.remoteReceivePath = data.receive_path || data.start_path || "";
updatePathHints();
await loadDir(state.remoteStartPath || "");
setStatus("연결 완료 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
} 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 = String(path || "").replace(/^\/+/, "");
const data = await api(`/api/listdir?path=${encodeURIComponent(rel)}`);
state.currentPath = String(data.path || rel || "").replace(/^\/+/, "");
state.entries = data.entries || [];
state.remoteFocus = "";
state.remoteSelected.clear();
renderPathBar($("pathBar"), state.currentPath, (p) => {
loadDir(p).catch((err) => setStatus(err.message, true));
});
renderRemote();
updatePathHints();
updateTransferButtons();
$("upBtn").disabled = !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: !!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 = joinPath(state.currentPath, e.name);
const tr = document.createElement("tr");
tr.dataset.path = full;
tr.dataset.isdir = e.is_dir ? "1" : "0";
tr.draggable = true;
if (e.is_dir) tr.dataset.dropPath = full;
if (state.remoteSelected.has(full) || state.remoteFocus === full) {
tr.classList.add("selected");
}
tr.innerHTML = `
<td class="col-name"><div class="name-cell">${e.is_dir ? ICON_FOLDER : ICON_FILE}<span>${escapeHtml(e.name)}</span></div></td>
<td class="col-date muted">${formatMtime(e.mtime)}</td>
<td class="col-size muted">${e.is_dir ? "—" : human(e.size)}</td>
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir))}</td>
`;
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: !!state.currentPath,
onGo: () => goUp().catch((err) => setStatus(err.message, true)),
});
}
async function loadLocal(path) {
const rel = String(path || "").replace(/^\/+/, "");
const data = await api(`/api/local/listdir?path=${encodeURIComponent(rel)}`);
state.localPath = String(data.path || rel || "").replace(/^\/+/, "");
state.localEntries = data.entries || [];
state.localSelected.clear();
state.localFocus = "";
renderPathBar($("localPathBar"), state.localPath, (p) => {
loadLocal(p).catch((err) => setStatus(err.message, true));
});
$("localUpBtn").disabled = !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: !!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 = joinPath(state.localPath, e.name);
const tr = document.createElement("tr");
tr.dataset.path = full;
tr.dataset.isdir = e.is_dir ? "1" : "0";
tr.draggable = true;
if (e.is_dir) tr.dataset.dropPath = full;
if (state.localSelected.has(full) || state.localFocus === full) {
tr.classList.add(e.is_dir ? "selected" : "checked");
}
tr.innerHTML = `
<td class="col-name"><div class="name-cell">${e.is_dir ? ICON_FOLDER : ICON_FILE}<span>${escapeHtml(e.name)}</span></div></td>
<td class="col-date muted">${formatMtime(e.mtime)}</td>
<td class="col-size muted">${e.is_dir ? "—" : human(e.size)}</td>
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir))}</td>
`;
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: !!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;
const name = prompt("새 폴더 이름");
if (!name) return;
const path = joinPath(state.currentPath, name.trim());
try {
await api("/api/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
await loadDir(state.currentPath);
updatePathHints();
setStatus(`폴더 생성: ${displayPath(path)}`);
} catch (e) {
setStatus(e.message, true);
}
}
async function watchProgress(run) {
$("progressBar").style.width = "2%";
const poll = setInterval(async () => {
try {
const p = await api("/api/progress");
if (p.total) {
const pct = Math.max(2, Math.round((100 * p.done) / p.total));
$("progressBar").style.width = `${pct}%`;
setStatus(`전송 중 ${pct}% — ${p.label || ""}`);
}
if (p.error) setStatus(p.error, true);
} catch (_) {}
}, 250);
try {
await run();
$("progressBar").style.width = "100%";
} finally {
clearInterval(poll);
setTimeout(() => {
$("progressBar").style.width = "0%";
}, 1200);
}
}
async function uploadFiles(fileList, destDir) {
if (!state.connected) {
setStatus("먼저 연결 버튼을 누르세요", true);
return;
}
const files = [...fileList].filter((f) => f && f.size >= 0 && f.name);
if (!files.length) {
setStatus("폴더는 OS에서 바로 드롭할 수 없습니다. 파일을 드롭하거나 패널에서 드래그하세요", true);
return;
}
const dest = remoteUploadDest(destDir);
const fd = new FormData();
fd.append("dest_dir", dest);
files.forEach((f) => fd.append("file", f, f.name));
setStatus(`${files.length}개 파일 → 상대 ${displayPath(dest)} 전송…`);
await watchProgress(async () => {
const res = await fetch("/api/upload", { method: "POST", body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "전송 실패");
setStatus(`${data.count}개 전송 완료 → ${displayPath(data.dest_dir)}`);
await loadDir(state.currentPath);
}).catch((e) => setStatus(e.message, true));
}
async function writeLocalFiles(fileList, destDir) {
const files = [...fileList].filter((f) => f && f.size >= 0 && f.name);
if (!files.length) {
setStatus("폴더는 OS에서 바로 드롭할 수 없습니다. 파일을 선택해 드롭하세요", true);
return;
}
const dest = destDir != null ? destDir : state.localPath || "";
const fd = new FormData();
fd.append("dest_dir", dest);
files.forEach((f) => fd.append("file", f, f.name));
setStatus(`${files.length}개 파일 → 이 PC ${displayPath(dest)} 저장…`);
try {
const res = await fetch("/api/local/write", { method: "POST", body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "저장 실패");
setStatus(`${data.count}개 저장 완료 → ${displayPath(data.dest_dir)}`);
await loadLocal(state.localPath);
} catch (e) {
setStatus(e.message, true);
}
}
async function sendLocalSelected(destDir) {
if (!state.connected) {
setStatus("먼저 연결 버튼을 누르세요", true);
return;
}
const paths = [...state.localSelected];
if (!paths.length) {
setStatus("로컬 파일/폴더를 선택하세요", true);
return;
}
const dest = remoteUploadDest(destDir);
setStatus(`${paths.length}개 → 상대 ${displayPath(dest)} 전송…`);
await watchProgress(async () => {
const data = await api("/api/local/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dest_dir: dest, paths }),
});
setStatus(`${data.count}개 전송 완료 → ${displayPath(data.dest_dir)}`);
await loadDir(state.currentPath);
}).catch((e) => setStatus(e.message, true));
}
async function fetchRemoteSelected(destDir) {
if (!state.connected) {
setStatus("먼저 연결 버튼을 누르세요", true);
return;
}
const paths = [...state.remoteSelected];
if (!paths.length) {
setStatus("상대 파일/폴더를 선택하세요", true);
return;
}
const dest = destDir != null ? destDir : state.localPath || "";
setStatus(`${paths.length}개 → 이 PC ${displayPath(dest)} 가져오는 중…`);
await watchProgress(async () => {
const data = await api("/api/remote/fetch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dest_dir: dest, paths }),
});
setStatus(`${data.count}개 가져오기 완료 → ${displayPath(data.dest_dir)}`);
await loadLocal(state.localPath);
}).catch((e) => setStatus(e.message, true));
}
function dropDestFromEvent(e, fallbackPath) {
const row = e.target.closest?.("tr[data-drop-path]");
if (row?.dataset?.dropPath) return row.dataset.dropPath;
return fallbackPath || "";
}
function clearDropHighlights() {
document.querySelectorAll(".finder.drop-target, tr.drop-target").forEach((el) => {
el.classList.remove("drop-target");
});
}
function bindPaneDrop(pane, side) {
const prevent = (e) => {
e.preventDefault();
e.stopPropagation();
};
const onDragOver = (e) => {
prevent(e);
e.dataTransfer.dropEffect = "copy";
pane.classList.add("drop-target");
const row = e.target.closest?.("tr[data-drop-path]");
document.querySelectorAll("tr.drop-target").forEach((r) => r.classList.remove("drop-target"));
if (row) row.classList.add("drop-target");
};
const onDragLeave = (e) => {
if (!pane.contains(e.relatedTarget)) {
pane.classList.remove("drop-target");
pane.querySelectorAll("tr.drop-target").forEach((r) => r.classList.remove("drop-target"));
}
};
const onDrop = async (e) => {
prevent(e);
clearDropHighlights();
const dest = dropDestFromEvent(
e,
side === "remote" ? remoteUploadDest() : state.localPath || ""
);
const localPayload = e.dataTransfer.getData("application/x-fileshare-local");
const remotePayload = e.dataTransfer.getData("application/x-fileshare-remote");
const osFiles = e.dataTransfer?.files;
try {
if (side === "remote") {
if (localPayload) {
state.localSelected = new Set(JSON.parse(localPayload));
await sendLocalSelected(dest);
return;
}
if (remotePayload) {
setStatus("같은 상대 패널 안에서는 이동할 수 없습니다", true);
return;
}
if (osFiles?.length) {
await uploadFiles(osFiles, dest);
return;
}
} else {
// local pane
if (remotePayload) {
state.remoteSelected = new Set(JSON.parse(remotePayload));
await fetchRemoteSelected(dest);
return;
}
if (localPayload) {
setStatus("같은 이 PC 패널 안에서는 이동할 수 없습니다", true);
return;
}
if (osFiles?.length) {
await writeLocalFiles(osFiles, dest);
return;
}
}
setStatus("드롭할 파일이 없습니다", true);
} catch (err) {
setStatus(err.message || String(err), true);
}
};
["dragenter", "dragover", "dragleave", "drop"].forEach((ev) => {
pane.addEventListener(ev, prevent);
});
pane.addEventListener("dragover", onDragOver);
pane.addEventListener("dragleave", onDragLeave);
pane.addEventListener("drop", onDrop);
}
function bindDrop() {
bindPaneDrop(document.querySelector(".remote-browser"), "remote");
bindPaneDrop(document.querySelector(".local-browser"), "local");
}
function bindSort(prefix, getSort, setSort, rerender) {
document.querySelectorAll(`[data-sort-${prefix}]`).forEach((btn) => {
btn.onclick = () => {
const key = btn.getAttribute(`data-sort-${prefix}`);
const cur = getSort();
if (cur.key === key) setSort({ key, dir: cur.dir * -1 });
else setSort({ key, dir: 1 });
rerender();
};
});
}
function bindUi() {
$("connectBtn").onclick = () => connectOneClick();
$("disconnectBtn").onclick = () => disconnect().catch((e) => setStatus(e.message, true));
$("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 {
const 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를 찾을 수 없습니다. fileShare 앱을 재시작하세요", true);
return;
}
}
updateInputShareUI(prompted);
const lines = [];
if (prompted.message) lines.push(prompted.message);
if (Array.isArray(prompted.howto)) lines.push(...prompted.howto.map((x) => `• ${x}`));
setStatus(
lines.join("\n") ||
"시스템 창에서 「시스템 설정 열기」 → fileShare ON (앱이 권한을 스스로 켤 수는 없습니다)",
!prompted.trusted
);
// 사용자가 설정에서 ON 한 뒤 감지
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
const again = await api("/api/inputshare").catch(() => null);
if (again && again.trusted) {
updateInputShareUI(again);
setStatus("손쉬운 사용 권한이 허용되었습니다. 앱을 재시작하면 마우스 전환이 됩니다.");
return;
}
}
return;
}
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);
setStatus(
next
? "입력공유 켜짐 — [이 PC]/[상대 PC] 버튼 또는 Ctrl+Shift+U/←/→"
: "입력공유 꺼짐"
);
} 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.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) {
await loadDir(state.remoteStartPath || "");
setStatus("자동 연결됨 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
} 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) {
await loadDir(state.remoteStartPath || "");
setStatus("자동 연결됨 — 상대 폴더를 불러왔습니다");
} else if (!state.connected && s.auto_status) {
setStatus(s.auto_status);
}
wasConnected = state.connected;
} catch {
/* ignore */
}
}, 1500);
})();