1764 lines
60 KiB
JavaScript
1764 lines
60 KiB
JavaScript
const state = {
|
||
connected: false,
|
||
connecting: false,
|
||
currentPath: "",
|
||
entries: [],
|
||
localPath: "",
|
||
localEntries: [],
|
||
localSelected: new Set(),
|
||
remoteSelected: new Set(),
|
||
remoteFocus: "",
|
||
localFocus: "",
|
||
localSort: { key: "name", dir: 1 },
|
||
remoteSort: { key: "name", dir: 1 },
|
||
remoteStartPath: "",
|
||
remoteReceivePath: "",
|
||
localLoaded: false,
|
||
receiveRevision: null,
|
||
};
|
||
|
||
const ROOTS_PATH = "__roots__";
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
function cableModelLabel(device) {
|
||
if (!device) return "JUC500";
|
||
const m = device.model;
|
||
if (m === "JUC500") return m;
|
||
const pid = device.pid ? parseInt(device.pid, 16) : 0;
|
||
if (pid === 0x7500) return "JUC500";
|
||
return "JUC500";
|
||
}
|
||
|
||
function activeCableFromStatus(s) {
|
||
if (s.active_cable?.model || s.active_cable?.pid) return s.active_cable;
|
||
if (s.device?.model || s.device?.pid) return s.device;
|
||
if (s.devices?.length === 1) return s.devices[0];
|
||
return null;
|
||
}
|
||
|
||
function brandCableLabel(s) {
|
||
if (s.connected && s.cable_model) return s.cable_model;
|
||
const active = activeCableFromStatus(s);
|
||
if (active) return cableModelLabel(active);
|
||
if (s.devices?.length) return cableModelLabel(s.devices[0]);
|
||
return "JUC500";
|
||
}
|
||
|
||
function hasCableDetected(s) {
|
||
return !!(
|
||
s.connected ||
|
||
s.listening ||
|
||
s.cable_model ||
|
||
s.device?.model ||
|
||
s.device?.pid ||
|
||
s.device?.serial ||
|
||
s.cable_ip ||
|
||
s.devices?.length
|
||
);
|
||
}
|
||
|
||
function cableCaption(s) {
|
||
const active = activeCableFromStatus(s);
|
||
const model = s.connected && s.cable_model
|
||
? s.cable_model
|
||
: cableModelLabel(active);
|
||
const serial = active?.serial || s.device?.serial || s.cable_ip;
|
||
return serial ? `${model} · ${serial}` : model;
|
||
}
|
||
|
||
const ICON_FOLDER = `<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, "&")
|
||
.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 = `
|
||
<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.platform === "macos" && info.input_monitoring === false) {
|
||
badge.textContent = "키보드 권한 필요 (입력 모니터링)";
|
||
badge.className = "auto-badge wait";
|
||
badge.title =
|
||
"마우스는 손쉬운 사용만으로 동작하지만 키 입력은 '입력 모니터링' 권한이 필요합니다.\n" +
|
||
"시스템 설정 → 개인정보 보호 및 보안 → 입력 모니터링에서 Terminal/Python(이 앱)을 켠 뒤 " +
|
||
"앱을 재시작하세요.";
|
||
btn.textContent = "입력공유 끄기";
|
||
} else if (info.being_controlled) {
|
||
badge.textContent = "상대가 조종 중 — [이 PC]/[상대 PC]로 회수";
|
||
badge.className = "auto-badge wait";
|
||
badge.title = "이 PC: 조종 해제 / 상대 PC: 제어권 가져오기";
|
||
btn.textContent = "입력공유 끄기";
|
||
} else if (info.enabled && info.peer && info.controlling_remote && !info.remote_acked) {
|
||
badge.textContent = "상대 응답 대기…";
|
||
badge.className = "auto-badge wait";
|
||
badge.title = "상대 PC 응답 대기 중 (입력공유는 자동 동기화됨)";
|
||
btn.textContent = "입력공유 끄기";
|
||
} else if (info.enabled && info.peer) {
|
||
badge.textContent = info.controlling_remote ? "제어: 상대 PC" : "제어: 이 PC";
|
||
badge.className = "auto-badge ok";
|
||
badge.title = "가장자리 전환: 오른쪽→상대, 왼쪽→이 PC · Ctrl+R 제어권 토글 · Esc 종료";
|
||
btn.textContent = "입력공유 끄기";
|
||
} else if (info.enabled) {
|
||
badge.textContent = info.status || "입력공유 대기";
|
||
badge.className = "auto-badge wait";
|
||
btn.textContent = "입력공유 끄기";
|
||
} else {
|
||
badge.textContent = "입력공유 꺼짐";
|
||
badge.className = "auto-badge wait";
|
||
btn.textContent = "입력공유 켜기";
|
||
}
|
||
}
|
||
|
||
async function switchInputControl(target) {
|
||
try {
|
||
const res = await fetch("/api/inputshare/control", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ target }),
|
||
});
|
||
const info = await res.json().catch(() => ({}));
|
||
updateInputShareUI(info);
|
||
if (!res.ok || info.ok === false) {
|
||
setStatus(info.error || "제어권 전환 실패", true);
|
||
return;
|
||
}
|
||
if (info.controlling_remote && !info.remote_acked) {
|
||
setStatus("상대 응답 대기 중…");
|
||
} else if (info.controlling_remote) {
|
||
setStatus("키보드·마우스 제어: 상대 PC (왼쪽 끝 또는 [이 PC]로 복귀)");
|
||
} else if (info.being_controlled) {
|
||
setStatus("상대가 이 PC를 조종 중 — [이 PC]로 회수");
|
||
} else {
|
||
setStatus("키보드·마우스 제어: 이 PC (오른쪽 끝 또는 [상대 PC]로 전환)");
|
||
}
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
}
|
||
}
|
||
|
||
function updateTransferButtons() {
|
||
const canUp = state.connected && state.localSelected.size > 0;
|
||
const canDown = state.connected && state.remoteSelected.size > 0;
|
||
if ($("uploadBtn")) $("uploadBtn").disabled = !canUp;
|
||
if ($("downloadBtn")) $("downloadBtn").disabled = !canDown;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const res = await fetch(path, options);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const err = new Error(data.error || res.statusText || "요청 실패");
|
||
err.code = data.code || "";
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function updateBadge(connected, text) {
|
||
const badge = $("autoBadge");
|
||
badge.textContent = text;
|
||
badge.className = connected ? "auto-badge ok" : "auto-badge wait";
|
||
}
|
||
|
||
function updateWormholeUI(info) {
|
||
const badge = $("wormholeBadge");
|
||
const btn = $("killWormholeBtn");
|
||
if (!badge) return;
|
||
if (!info) {
|
||
badge.textContent = "Wormhole —";
|
||
badge.className = "auto-badge wait";
|
||
return;
|
||
}
|
||
if (info.running) {
|
||
badge.textContent = `Wormhole ${info.count}개 실행중`;
|
||
badge.className = "auto-badge wait";
|
||
badge.title = (info.processes || [])
|
||
.map((p) => `${p.name} (${p.pid})`)
|
||
.join("\n") || info.message || "";
|
||
if (btn) btn.disabled = false;
|
||
} else {
|
||
badge.textContent = "Wormhole 없음";
|
||
badge.className = "auto-badge ok";
|
||
badge.title = info.message || "공식 Wormhole 프로세스 없음";
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function killWormhole() {
|
||
setStatus("공식 Wormhole 종료 중…");
|
||
try {
|
||
const data = await api("/api/wormhole/kill", { method: "POST" });
|
||
updateWormholeUI({
|
||
running: (data.remaining || []).length > 0,
|
||
count: (data.remaining || []).length,
|
||
processes: data.remaining || [],
|
||
message: data.message,
|
||
});
|
||
setStatus(data.message || "Wormhole 종료 완료");
|
||
await refreshStatus();
|
||
} catch (e) {
|
||
setStatus(e.message || String(e), true);
|
||
}
|
||
}
|
||
|
||
function sortEntries(entries, sort) {
|
||
const dir = sort.dir;
|
||
const key = sort.key;
|
||
return [...entries].sort((a, b) => {
|
||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||
let av;
|
||
let bv;
|
||
if (key === "size") {
|
||
av = a.is_dir ? -1 : Number(a.size) || 0;
|
||
bv = b.is_dir ? -1 : Number(b.size) || 0;
|
||
} else if (key === "mtime") {
|
||
av = Number(a.mtime) || 0;
|
||
bv = Number(b.mtime) || 0;
|
||
} else if (key === "kind") {
|
||
av = fileKind(a.name, a.is_dir);
|
||
bv = fileKind(b.name, b.is_dir);
|
||
} else {
|
||
av = String(a.name || "").toLowerCase();
|
||
bv = String(b.name || "").toLowerCase();
|
||
}
|
||
if (av < bv) return -1 * dir;
|
||
if (av > bv) return 1 * dir;
|
||
return String(a.name).localeCompare(String(b.name), "ko");
|
||
});
|
||
}
|
||
|
||
function updateSortButtons(prefix, sort) {
|
||
document.querySelectorAll(`[data-sort-${prefix}]`).forEach((btn) => {
|
||
const k = btn.getAttribute(`data-sort-${prefix}`);
|
||
if (!btn.dataset.label) btn.dataset.label = btn.textContent.replace(/ [▲▼]$/, "");
|
||
btn.classList.toggle("active", k === sort.key);
|
||
btn.textContent =
|
||
k === sort.key ? `${btn.dataset.label} ${sort.dir > 0 ? "▲" : "▼"}` : btn.dataset.label;
|
||
});
|
||
}
|
||
|
||
function formatBuildTag(build) {
|
||
const raw = String(build || "").trim();
|
||
if (!raw || raw === "unknown") return "?";
|
||
return raw.replace(/^20\d{2}-\d{2}-\d{2}-/, "");
|
||
}
|
||
|
||
function formatAppVersion(status) {
|
||
const ver = String(status?.version || "0.1.0").trim() || "0.1.0";
|
||
const xfer = formatBuildTag(status?.xfer_build);
|
||
return `v${ver} · ${xfer}`;
|
||
}
|
||
|
||
function updateAppVersionUI(s) {
|
||
const el = $("appVersion");
|
||
if (!el) return;
|
||
const label = formatAppVersion(s);
|
||
el.textContent = label;
|
||
const inputshare = formatBuildTag(s?.inputshare_build);
|
||
const xfer = formatBuildTag(s?.xfer_build);
|
||
el.title = `앱 v${s?.version || "?"} · xfer ${xfer} · inputshare ${inputshare}`;
|
||
}
|
||
|
||
async function refreshStatus() {
|
||
const s = await api("/api/status");
|
||
state.connected = !!s.connected;
|
||
state.listening = !!s.listening;
|
||
if (s.connecting) state.connecting = true;
|
||
else if (s.connected || s.connect_error) state.connecting = false;
|
||
$("disconnectBtn").disabled = !state.connected;
|
||
const canConnect = hasCableDetected(s) || !!s.listening;
|
||
$("connectBtn").disabled =
|
||
state.connected || state.connecting || !!s.connecting || !canConnect;
|
||
if ($("connectBtn")) {
|
||
$("connectBtn").title = canConnect
|
||
? "상대 PC와 USB 연결"
|
||
: "JUC500(0711:7500) 케이블을 Mac USB에 연결한 뒤 사용";
|
||
}
|
||
updateTransferButtons();
|
||
$("mkdirBtn").disabled = !state.connected;
|
||
|
||
const active = activeCableFromStatus(s);
|
||
const model = s.connected && s.cable_model
|
||
? s.cable_model
|
||
: cableModelLabel(active);
|
||
const hasCable = hasCableDetected(s);
|
||
const brand = brandCableLabel(s);
|
||
if ($("brandModel")) {
|
||
$("brandModel").textContent = brand;
|
||
const vid = active?.vid || s.device?.vid || "?";
|
||
const pid = active?.pid || s.device?.pid || "?";
|
||
$("brandModel").title = hasCable
|
||
? `${brand} (${vid}:${pid})${s.connected ? " — 연결됨" : " — 자동 감지"}`
|
||
: `${brand} — USB 미감지 (JUC500 0711:7500 IF5 확인)`;
|
||
}
|
||
if (document.title !== brand) {
|
||
document.title = brand;
|
||
}
|
||
updateAppVersionUI(s);
|
||
if (s.this_host) $("thisHost").textContent = `${s.this_host} · ${cableCaption(s)}`;
|
||
if (s.local_root) $("localRootHint").textContent = s.local_root;
|
||
|
||
updateInputShareUI(s.inputshare);
|
||
updatePasteBadge(s.clipboard);
|
||
updateWormholeUI(s.wormhole);
|
||
|
||
if (state.connected) {
|
||
updateBadge(true, "연결됨");
|
||
$("peerInfo").textContent = s.hostname || s.host || "상대 PC";
|
||
if (s.devices?.length > 1) {
|
||
const activeTag = s.cable_model ? `${s.cable_model} 연결` : model;
|
||
const list = s.devices.map((d) => `${d.model}·IF${d.if_num ?? "?"}`).join(" / ");
|
||
$("ifaceInfo").textContent = `${activeTag} — ${list}`;
|
||
} else if (active?.serial) {
|
||
$("ifaceInfo").textContent = `${model} · IF${active.if_num ?? s.device?.if_num ?? "?"} · ${active.serial}`;
|
||
} else if (active?.model || active?.pid) {
|
||
$("ifaceInfo").textContent = `${model} · IF${active.if_num ?? s.device?.if_num ?? "?"}`;
|
||
} else {
|
||
$("ifaceInfo").textContent = `${model} bulk bridge`;
|
||
}
|
||
$("remoteRootHint").textContent = s.root || s.host || "연결됨";
|
||
if (s.remote_start != null) state.remoteStartPath = s.remote_start || "";
|
||
if (s.remote_receive != null) state.remoteReceivePath = s.remote_receive || state.remoteStartPath;
|
||
} else if (s.connecting || state.connecting) {
|
||
updateBadge(false, "연결 중");
|
||
const role = s.usb_role === "connector" ? "Mac → Windows" : "연결";
|
||
$("peerInfo").textContent =
|
||
s.auto_status || `${role} HELLO 송신 중…`;
|
||
$("ifaceInfo").textContent = hasCable
|
||
? `${model} · IF${active?.if_num ?? s.device?.if_num ?? "?"} · connector`
|
||
: "";
|
||
$("remoteRootHint").textContent = "연결 시도 중";
|
||
} else if (s.handshake) {
|
||
updateBadge(false, "동기화 중");
|
||
$("peerInfo").textContent =
|
||
s.auto_status || "상대 연결 감지 — 자동 동기화 중…";
|
||
} else if (s.listening) {
|
||
updateBadge(false, "대기 중");
|
||
$("peerInfo").textContent =
|
||
s.auto_status || "케이블 대기 — Mac이 곧 Windows로 연결 시도";
|
||
} else {
|
||
updateBadge(false, hasCable ? "케이블 감지됨" : "케이블 대기");
|
||
$("peerInfo").textContent =
|
||
s.auto_status ||
|
||
(hasCable
|
||
? "상대가 실행되면 Mac이 자동으로 연결 시도"
|
||
: `${model}(Smart Data Link) 케이블을 연결하세요`);
|
||
}
|
||
if (!state.connected && !(s.connecting || state.connecting)) {
|
||
$("ifaceInfo").textContent = hasCable
|
||
? (s.devices?.length > 1
|
||
? s.devices.map((d) => `${d.model}·IF${d.if_num ?? "?"}`).join(" / ")
|
||
: `${model} · IF${active?.if_num ?? s.device?.if_num ?? "?"}${active?.serial ? ` · ${active.serial}` : ""}`)
|
||
: "";
|
||
if (!s.listening && !s.handshake) {
|
||
$("remoteRootHint").textContent = "연결 필요";
|
||
}
|
||
}
|
||
|
||
if (s.progress?.busy) {
|
||
const pct = s.progress.total ? Math.round((100 * s.progress.done) / s.progress.total) : 0;
|
||
$("progressBar").style.width = `${pct}%`;
|
||
setStatus(`전송 중 ${pct}% — ${s.progress.label || ""}`);
|
||
}
|
||
const receiveRevision = Number(s.receive_revision || 0);
|
||
if (state.receiveRevision === null) {
|
||
state.receiveRevision = receiveRevision;
|
||
} else if (receiveRevision !== state.receiveRevision) {
|
||
state.receiveRevision = receiveRevision;
|
||
if (state.localLoaded) {
|
||
await loadLocal(state.localPath);
|
||
const name = String(s.last_received_path || "").split(/[\\/]/).pop();
|
||
setStatus(name ? `수신 완료 — ${name}` : "수신 완료");
|
||
}
|
||
}
|
||
return s;
|
||
}
|
||
|
||
async function connectOneClick() {
|
||
if (state.connecting || state.connected) return;
|
||
state.connecting = true;
|
||
$("connectBtn").disabled = true;
|
||
updateBadge(false, "연결 중…");
|
||
setStatus("공식 Wormhole 종료 후 상대 프로그램 대기 중…");
|
||
$("peerInfo").textContent = "Wormhole 정리 · USB 핸드셰이크 중…";
|
||
|
||
try {
|
||
const data = await api("/api/connect/auto", { method: "POST" });
|
||
if (data.ok === false || data.code === "PEER_NOT_FOUND") {
|
||
const err = new Error(data.error || "상대 PC를 찾지 못했습니다.");
|
||
err.code = data.code || "PEER_NOT_FOUND";
|
||
throw err;
|
||
}
|
||
|
||
// Server connects in background — poll until done (avoids browser aborting 45s POST).
|
||
const deadline = Date.now() + 70000;
|
||
let s = await refreshStatus();
|
||
while (Date.now() < deadline) {
|
||
if (state.connected || (s && s.connected)) break;
|
||
if (s && s.connect_error) {
|
||
const err = new Error(s.connect_error);
|
||
err.code = /연결되지 않았습니다|PEER|상대/.test(s.connect_error)
|
||
? "PEER_NOT_FOUND"
|
||
: "CONNECT_FAILED";
|
||
throw err;
|
||
}
|
||
if (s && !s.connecting && !s.connected) {
|
||
// connect thread finished without success and without error yet — brief settle
|
||
await new Promise((r) => setTimeout(r, 300));
|
||
s = await refreshStatus();
|
||
if (!s.connected && (s.connect_error || !s.connecting)) {
|
||
const err = new Error(
|
||
s.connect_error ||
|
||
s.auto_status ||
|
||
"연결 실패 — 상대 Windows에서 JUC500 GUI 「연결」을 누르세요"
|
||
);
|
||
err.code = "PEER_NOT_FOUND";
|
||
throw err;
|
||
}
|
||
}
|
||
if (s && s.auto_status) setStatus(s.auto_status);
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
s = await refreshStatus();
|
||
}
|
||
|
||
if (!state.connected && !(s && s.connected)) {
|
||
const err = new Error(
|
||
"상대 응답 시간 초과 — Windows에서 같은 JUC500 앱을 실행하고 「연결」을 누르세요"
|
||
);
|
||
err.code = "PEER_NOT_FOUND";
|
||
throw err;
|
||
}
|
||
|
||
await api("/api/auto/start", { method: "POST" }).catch(() => {});
|
||
state.connected = true;
|
||
state.connecting = false;
|
||
updateBadge(true, "연결됨");
|
||
setStatus("연결됨 — 상대 폴더를 불러오는 중…");
|
||
$("peerInfo").textContent =
|
||
(s && (s.hostname || s.host)) || data.hostname || data.host || "상대 PC";
|
||
$("disconnectBtn").disabled = false;
|
||
$("connectBtn").disabled = true;
|
||
|
||
state.currentPath = "";
|
||
state.remoteStartPath = (s && s.remote_start) || data.start_path || "";
|
||
state.remoteReceivePath =
|
||
(s && s.remote_receive) || data.receive_path || data.start_path || "";
|
||
updatePathHints();
|
||
await new Promise((r) => setTimeout(r, 1200));
|
||
try {
|
||
await loadDir(state.remoteStartPath || "");
|
||
setStatus("연결 완료 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
|
||
} catch (listErr) {
|
||
const msg = (listErr && listErr.message) || "상대 폴더 목록을 받지 못했습니다";
|
||
setStatus(msg + " — 새로고침을 눌러 다시 시도하세요", true);
|
||
showRemoteMessage(msg + "\n\n상단 새로고침(↻)으로 다시 불러올 수 있습니다.", true);
|
||
updateTransferButtons();
|
||
}
|
||
} catch (e) {
|
||
state.connecting = false;
|
||
state.connected = false;
|
||
updateBadge(false, "미연결");
|
||
$("connectBtn").disabled = false;
|
||
$("disconnectBtn").disabled = true;
|
||
const msg = (e && e.message) || "연결 실패";
|
||
setStatus(msg, true);
|
||
$("peerInfo").textContent =
|
||
e.code === "PEER_NOT_FOUND"
|
||
? "상대 없음 — 양쪽 JUC500 GUI 실행·케이블·WinUSB(Zadig)를 확인하세요"
|
||
: "연결 실패 — 다시 연결을 누르세요";
|
||
showRemoteMessage(msg, true);
|
||
}
|
||
}
|
||
|
||
function showRemoteMessage(msg, isError = false) {
|
||
const tbody = $("dirList");
|
||
tbody.innerHTML = "";
|
||
$("remoteEmpty").classList.add("hidden");
|
||
$("remoteCount").textContent = "—";
|
||
const tr = document.createElement("tr");
|
||
tr.innerHTML = `<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.localLoaded = true;
|
||
state.localSelected.clear();
|
||
state.localFocus = "";
|
||
renderPathBar($("localPathBar"), state.localPath, (p) => {
|
||
loadLocal(p).catch((err) => setStatus(err.message, true));
|
||
});
|
||
$("localUpBtn").disabled = !canGoParent(state.localPath);
|
||
updatePathHints();
|
||
updateTransferButtons();
|
||
renderLocal();
|
||
}
|
||
|
||
function renderLocal() {
|
||
const tbody = $("localDirList");
|
||
tbody.innerHTML = "";
|
||
updateSortButtons("local", state.localSort);
|
||
const entries = sortEntries(state.localEntries, state.localSort);
|
||
$("localCount").textContent = `${entries.length}개 항목`;
|
||
|
||
if (!entries.length) {
|
||
$("localEmpty").classList.remove("hidden");
|
||
appendParentRow(tbody, {
|
||
canGo: canGoParent(state.localPath),
|
||
onGo: () => localGoUp().catch((err) => setStatus(err.message, true)),
|
||
});
|
||
if (state.localPath) $("localEmpty").classList.add("hidden");
|
||
return;
|
||
}
|
||
$("localEmpty").classList.add("hidden");
|
||
|
||
entries.forEach((e) => {
|
||
const full = entryNavPath(state.localPath, e);
|
||
const tr = document.createElement("tr");
|
||
tr.dataset.path = full;
|
||
tr.dataset.isdir = e.is_dir ? "1" : "0";
|
||
tr.draggable = !isRootsPath(state.localPath);
|
||
if (e.is_dir) tr.dataset.dropPath = full;
|
||
if (state.localSelected.has(full) || state.localFocus === full) {
|
||
tr.classList.add(e.is_dir ? "selected" : "checked");
|
||
}
|
||
const icon =
|
||
e.kind === "drive" || e.kind === "volume" || e.kind === "home"
|
||
? ICON_DRIVE
|
||
: e.is_dir
|
||
? ICON_FOLDER
|
||
: ICON_FILE;
|
||
tr.innerHTML = `
|
||
<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() {
|
||
$("connectBtn").onclick = () => connectOneClick();
|
||
$("disconnectBtn").onclick = () => disconnect().catch((e) => setStatus(e.message, true));
|
||
if ($("killWormholeBtn")) {
|
||
$("killWormholeBtn").onclick = () => killWormhole();
|
||
}
|
||
$("upBtn").onclick = () => goUp().catch((e) => setStatus(e.message, true));
|
||
$("refreshBtn").onclick = () => {
|
||
if (!state.connected) {
|
||
setStatus("먼저 연결 버튼을 누르세요", true);
|
||
return;
|
||
}
|
||
loadDir(state.currentPath)
|
||
.then(() => setStatus("상대 폴더 새로고침"))
|
||
.catch((e) => setStatus(e.message, true));
|
||
};
|
||
$("mkdirBtn").onclick = () => mkdir();
|
||
$("localUpBtn").onclick = () => localGoUp().catch((e) => setStatus(e.message, true));
|
||
$("localRefreshBtn").onclick = () =>
|
||
loadLocal(state.localPath)
|
||
.then(() => setStatus("이 PC 폴더 새로고침"))
|
||
.catch((e) => setStatus(e.message, true));
|
||
|
||
// FTP 스타일: 선택 → 상대/로컬 현재 폴더로 전송
|
||
$("uploadBtn").onclick = () => sendLocalSelected(remoteUploadDest());
|
||
$("downloadBtn").onclick = () => fetchRemoteSelected(state.localPath || "");
|
||
$("inputShareBtn").onclick = async () => {
|
||
try {
|
||
let cur = await api("/api/inputshare");
|
||
if (!cur.trusted) {
|
||
let prompted;
|
||
try {
|
||
prompted = await api("/api/inputshare/prompt-trust", { method: "POST" });
|
||
} catch (e) {
|
||
prompted = await api("/api/inputshare/prompt-trust").catch(() => null);
|
||
if (!prompted) {
|
||
setStatus("권한 API를 찾을 수 없습니다. 앱을 재시작하세요", true);
|
||
return;
|
||
}
|
||
}
|
||
updateInputShareUI(prompted);
|
||
if (!prompted.trusted) {
|
||
const lines = [];
|
||
if (prompted.message) lines.push(prompted.message);
|
||
else if (prompted.howto) lines.push(prompted.howto);
|
||
setStatus(
|
||
lines.join("\n") || "시스템 설정에서 손쉬운 사용/입력 권한을 허용하세요",
|
||
true
|
||
);
|
||
// macOS: wait until user grants Accessibility
|
||
for (let i = 0; i < 30; i++) {
|
||
await new Promise((r) => setTimeout(r, 1000));
|
||
const again = await api("/api/inputshare/prompt-trust", { method: "POST" }).catch(
|
||
() => null
|
||
);
|
||
if (again && again.trusted) {
|
||
cur = again;
|
||
updateInputShareUI(again);
|
||
setStatus("권한이 허용되었습니다 — 입력공유를 켭니다");
|
||
break;
|
||
}
|
||
}
|
||
if (!cur.trusted) return;
|
||
} else {
|
||
cur = prompted;
|
||
}
|
||
}
|
||
const next = !cur.enabled;
|
||
const info = await api("/api/inputshare/enable", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ enabled: next }),
|
||
});
|
||
updateInputShareUI(info);
|
||
if (info.ok === false) {
|
||
setStatus(info.error || "입력공유 전환 실패", true);
|
||
return;
|
||
}
|
||
if (!next) {
|
||
// Unstick local/peer modifiers & cursor after KM handoff
|
||
await api("/api/inputshare/recover", { method: "POST" }).catch(() => null);
|
||
setStatus("입력공유 꺼짐 — 마우스·키보드 상태 복구함");
|
||
} else {
|
||
setStatus("입력공유 켜짐 — 오른쪽 끝→상대 / 왼쪽 끝·Ctrl+Shift+←→이 PC");
|
||
}
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
}
|
||
};
|
||
|
||
if ($("controlLocalBtn")) {
|
||
$("controlLocalBtn").onclick = () => switchInputControl("local");
|
||
}
|
||
if ($("controlRemoteBtn")) {
|
||
$("controlRemoteBtn").onclick = () => switchInputControl("remote");
|
||
}
|
||
|
||
bindSettingsUi();
|
||
|
||
// UI에서도 동일 단축키 (이 PC 제어 중일 때)
|
||
window.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Escape") {
|
||
const modal = $("settingsModal");
|
||
if (modal && !modal.classList.contains("hidden")) {
|
||
ev.preventDefault();
|
||
closeSettingsModal();
|
||
return;
|
||
}
|
||
ev.preventDefault();
|
||
quitApp();
|
||
return;
|
||
}
|
||
// Ctrl+Shift+V → clipboard files to remote current folder
|
||
if ((ev.ctrlKey || ev.metaKey) && ev.shiftKey && (ev.key === "v" || ev.key === "V")) {
|
||
if (state.connected) {
|
||
ev.preventDefault();
|
||
sendClipboardToRemote().catch((e) => setStatus(e.message, true));
|
||
}
|
||
return;
|
||
}
|
||
// Ctrl+R → local/remote input-control toggle (also prevent browser reload).
|
||
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && (ev.key === "r" || ev.key === "R")) {
|
||
// The process-wide input listener performs the toggle. The page only
|
||
// suppresses the browser's refresh action while the GUI has focus.
|
||
ev.preventDefault();
|
||
return;
|
||
}
|
||
if (!(ev.ctrlKey && ev.shiftKey)) return;
|
||
const localBtn = $("controlLocalBtn");
|
||
if (!localBtn || localBtn.disabled) return;
|
||
if (ev.key === "u" || ev.key === "U") {
|
||
ev.preventDefault();
|
||
const remote = $("controlRemoteBtn")?.classList.contains("active");
|
||
switchInputControl(remote ? "local" : "remote");
|
||
} else if (ev.key === "ArrowLeft") {
|
||
ev.preventDefault();
|
||
switchInputControl("local");
|
||
} else if (ev.key === "ArrowRight") {
|
||
ev.preventDefault();
|
||
switchInputControl("remote");
|
||
}
|
||
});
|
||
|
||
bindSort(
|
||
"local",
|
||
() => state.localSort,
|
||
(s) => {
|
||
state.localSort = s;
|
||
},
|
||
() => renderLocal()
|
||
);
|
||
bindSort(
|
||
"remote",
|
||
() => state.remoteSort,
|
||
(s) => {
|
||
state.remoteSort = s;
|
||
},
|
||
() => renderRemote()
|
||
);
|
||
bindDrop();
|
||
}
|
||
|
||
(async function init() {
|
||
bindUi();
|
||
renderPathBar($("localPathBar"), "", (p) => loadLocal(p));
|
||
renderPathBar($("pathBar"), "", () => {});
|
||
try {
|
||
const s = await refreshStatus();
|
||
await loadLocal(s.local_start || "");
|
||
if (state.connected) {
|
||
try {
|
||
await loadDir(state.remoteStartPath || "");
|
||
setStatus("자동 연결됨 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
|
||
} catch (listErr) {
|
||
setStatus(
|
||
((listErr && listErr.message) || "상대 폴더 로드 실패") +
|
||
" — 새로고침을 눌러 다시 시도하세요",
|
||
true
|
||
);
|
||
}
|
||
} else {
|
||
setStatus(s.auto_status || "JUC500 케이블 연결 시 양쪽이 자동 연결됩니다");
|
||
}
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
}
|
||
|
||
// 백그라운드 자동 연결을 UI에 반영
|
||
let wasConnected = state.connected;
|
||
setInterval(async () => {
|
||
if (state.connecting) return;
|
||
try {
|
||
const s = await refreshStatus();
|
||
if (!wasConnected && state.connected) {
|
||
try {
|
||
await new Promise((r) => setTimeout(r, 1200));
|
||
await loadDir(state.remoteStartPath || "");
|
||
const s2 = await refreshStatus();
|
||
if (s2.inputshare?.enabled) {
|
||
setStatus("자동 연결됨 — 입력공유 켜짐");
|
||
} else {
|
||
setStatus("자동 연결됨 — 입력공유 권한 확인 후 「입력공유」를 눌러 주세요");
|
||
}
|
||
} catch (listErr) {
|
||
setStatus(
|
||
((listErr && listErr.message) || "상대 폴더 로드 실패") +
|
||
" — 새로고침을 눌러 다시 시도하세요",
|
||
true
|
||
);
|
||
}
|
||
} else if (!state.connected && s.auto_status) {
|
||
setStatus(s.auto_status);
|
||
}
|
||
wasConnected = state.connected;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, 700);
|
||
})();
|