juc500 정상동작

This commit is contained in:
Macbook
2026-07-21 21:04:25 +09:00
parent ff60cee1cc
commit 1388668b56
2917 changed files with 708021 additions and 651 deletions
+580 -116
View File
@@ -13,26 +13,111 @@ const state = {
remoteSort: { key: "name", dir: 1 },
remoteStartPath: "",
remoteReceivePath: "",
localLoaded: false,
receiveRevision: null,
};
const ROOTS_PATH = "__roots__";
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 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 parts = String(path || "")
.replace(/^\/+/, "")
.split("/")
.filter(Boolean);
if (!parts.length) return null;
parts.pop();
return parts.join("/");
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 }) {
@@ -63,18 +148,33 @@ function appendParentRow(tbody, { canGo, onGo }) {
}
function displayPath(p) {
return "/" + (p || "");
const s = normPath(p);
if (!s) return "/";
if (isRootsPath(s)) return "이 PC";
return s;
}
function breadcrumbParts(path) {
const parts = String(path || "")
.replace(/^\/+/, "")
.split("/")
.filter(Boolean);
const crumbs = [{ label: "루트", 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;
acc = acc ? `${acc}/${part}` : `/${part}`;
crumbs.push({ label: part, path: acc });
}
return crumbs;
@@ -123,7 +223,9 @@ function formatMtime(ts) {
return `${y}. ${m}. ${day}. ${hh}:${mm}`;
}
function fileKind(name, isDir) {
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 = {
@@ -301,12 +403,19 @@ function bindSettingsUi() {
}
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();
}
});
}
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() {
@@ -318,9 +427,104 @@ function updatePathHints() {
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;
return state.currentPath || state.remoteReceivePath || state.remoteStartPath || "";
const cur = state.currentPath || state.remoteReceivePath || state.remoteStartPath || "";
return isRootsPath(cur) ? "" : cur;
}
function updateInputShareUI(info) {
@@ -329,6 +533,8 @@ function updateInputShareUI(info) {
const localBtn = $("controlLocalBtn");
const remoteBtn = $("controlRemoteBtn");
if (!badge || !btn) return;
btn.hidden = false;
btn.style.display = "";
// 수신 측(being_controlled)도 버튼 활성 — 제어권 회수/탈취
const linkOk = !!(
state.connected ||
@@ -341,47 +547,83 @@ function updateInputShareUI(info) {
(info.trusted || info.being_controlled)
);
const controlReady = shareActive;
if (localBtn && remoteBtn) {
if (localBtn && remoteBtn) {
localBtn.disabled = !controlReady;
remoteBtn.disabled = !controlReady;
const remote = !!(info && info.controlling_remote);
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);
remoteBtn.classList.toggle("active", controlReady && remote);
localBtn.classList.toggle("active", controlReady && !remote && !controlled && !pending);
remoteBtn.classList.toggle("active", controlReady && (remote || pending));
localBtn.title = controlled
? "이 PC 제어 회수 (상대 조종 해제)"
: "이 PC 제어 — 상대 제어 중 왼쪽 끝으로 복귀";
remoteBtn.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) {
badge.textContent = "손쉬운 사용 권한 필요";
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 = pick
? `선택할 항목: ${pickName}\n전체 경로: ${pick}\n(Finder는 상위 폴더를 열고 ${pickName} 을 선택합니다)`
: "시스템 설정 → 손쉬운 사용";
btn.textContent = pickName.endsWith(".app") ? `${pickName} 열기` : "앱 경로 열기";
badge.title =
info.howto ||
(pick
? `선택할 항목: ${pickName}\n전체 경로: ${pick}\n(Finder는 상위 폴더를 열고 ${pickName} 을 선택합니다)`
: win
? "입력공유를 다시 켜 보세요"
: "시스템 설정 → 손쉬운 사용");
btn.textContent = "입력공유 켜기";
return;
}
if (info.being_controlled) {
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+Shift+U/←/→";
badge.title = "가장자리 전환: 오른쪽→상대, 왼쪽→이 PC · Ctrl+R 제어권 토글 · Esc 종료";
btn.textContent = "입력공유 끄기";
} else if (info.enabled) {
badge.textContent = info.status || "입력공유 대기";
@@ -407,11 +649,15 @@ async function switchInputControl(target) {
setStatus(info.error || "제어권 전환 실패", true);
return;
}
setStatus(
target === "remote"
? "키보드·마우스 제어: 상대 PC (Ctrl+Shift+← 또는 [이 PC]로 복귀)"
: "키보드·마우스 제어: 이 PC (Ctrl+Shift+→ 또는 [상대 PC]로 전환)"
);
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);
}
@@ -527,42 +773,122 @@ function updateSortButtons(prefix, sort) {
});
}
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;
$("connectBtn").disabled = state.connected || state.connecting;
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 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}`;
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";
$("ifaceInfo").textContent = s.device?.serial ? `IF5 Vendor · ${s.device.serial}` : "JUC500 bulk bridge";
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 (!state.connecting) {
const hasCable = !!(s.device?.serial || s.cable_ip);
} 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
? "연결을 누르면 상대 JUC500 프로그램과 핸드셰이크합니다"
: "JUC500(Smart Data Link) 케이블을 연결하세요");
$("ifaceInfo").textContent = hasCable ? `시리얼 ${s.device?.serial || s.cable_ip}` : "";
$("remoteRootHint").textContent = "연결 필요";
? "상대가 실행되면 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) {
@@ -570,6 +896,17 @@ async function refreshStatus() {
$("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;
}
@@ -589,21 +926,70 @@ async function connectOneClick() {
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 = data.hostname || data.host || "상대 PC";
$("peerInfo").textContent =
(s && (s.hostname || s.host)) || 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 || "";
state.remoteStartPath = (s && s.remote_start) || data.start_path || "";
state.remoteReceivePath =
(s && s.remote_receive) || data.receive_path || data.start_path || "";
updatePathHints();
await loadDir(state.remoteStartPath || "");
setStatus("연결 완료 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
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;
@@ -645,7 +1031,7 @@ async function disconnect() {
$("mkdirBtn").disabled = true;
updatePathHints();
await refreshStatus();
setStatus("연결 해제됨 — 연결 버튼을 누르세요");
setStatus("연결 해제됨 — 케이블 연결 시 자동 재연결됩니다");
}
async function loadDir(path) {
@@ -653,19 +1039,20 @@ async function loadDir(path) {
setStatus("먼저 연결 버튼을 누르세요", true);
return;
}
const rel = String(path || "").replace(/^\/+/, "");
const rel = normPath(path);
const data = await api(`/api/listdir?path=${encodeURIComponent(rel)}`);
state.currentPath = String(data.path || rel || "").replace(/^\/+/, "");
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 = !state.currentPath;
$("upBtn").disabled = !canGoParent(state.currentPath);
}
function renderRemote() {
@@ -688,7 +1075,7 @@ function renderRemote() {
return;
}
appendParentRow(tbody, {
canGo: !!state.currentPath,
canGo: canGoParent(state.currentPath),
onGo: () => goUp().catch((err) => setStatus(err.message, true)),
});
if (state.currentPath) $("remoteEmpty").classList.add("hidden");
@@ -698,20 +1085,26 @@ function renderRemote() {
$("remoteEmpty").classList.add("hidden");
entries.forEach((e) => {
const full = joinPath(state.currentPath, e.name);
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 = true;
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">${e.is_dir ? ICON_FOLDER : ICON_FILE}<span>${escapeHtml(e.name)}</span></div></td>
<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))}</td>
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir, e.kind))}</td>
`;
tr.onclick = (ev) => {
state.remoteFocus = full;
@@ -750,22 +1143,23 @@ function renderRemote() {
tbody.appendChild(tr);
});
appendParentRow(tbody, {
canGo: !!state.currentPath,
canGo: canGoParent(state.currentPath),
onGo: () => goUp().catch((err) => setStatus(err.message, true)),
});
}
async function loadLocal(path) {
const rel = String(path || "").replace(/^\/+/, "");
const rel = normPath(path);
const data = await api(`/api/local/listdir?path=${encodeURIComponent(rel)}`);
state.localPath = String(data.path || rel || "").replace(/^\/+/, "");
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 = !state.localPath;
$("localUpBtn").disabled = !canGoParent(state.localPath);
updatePathHints();
updateTransferButtons();
renderLocal();
@@ -781,7 +1175,7 @@ function renderLocal() {
if (!entries.length) {
$("localEmpty").classList.remove("hidden");
appendParentRow(tbody, {
canGo: !!state.localPath,
canGo: canGoParent(state.localPath),
onGo: () => localGoUp().catch((err) => setStatus(err.message, true)),
});
if (state.localPath) $("localEmpty").classList.add("hidden");
@@ -790,20 +1184,26 @@ function renderLocal() {
$("localEmpty").classList.add("hidden");
entries.forEach((e) => {
const full = joinPath(state.localPath, e.name);
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 = true;
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">${e.is_dir ? ICON_FOLDER : ICON_FILE}<span>${escapeHtml(e.name)}</span></div></td>
<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))}</td>
<td class="col-kind kind">${escapeHtml(fileKind(e.name, e.is_dir, e.kind))}</td>
`;
tr.onclick = (ev) => {
state.localFocus = full;
@@ -867,7 +1267,7 @@ function renderLocal() {
tbody.appendChild(tr);
});
appendParentRow(tbody, {
canGo: !!state.localPath,
canGo: canGoParent(state.localPath),
onGo: () => localGoUp().catch((err) => setStatus(err.message, true)),
});
}
@@ -897,6 +1297,10 @@ async function localGoUp() {
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());
@@ -1166,7 +1570,7 @@ function bindUi() {
$("downloadBtn").onclick = () => fetchRemoteSelected(state.localPath || "");
$("inputShareBtn").onclick = async () => {
try {
const cur = await api("/api/inputshare");
let cur = await api("/api/inputshare");
if (!cur.trusted) {
let prompted;
try {
@@ -1174,30 +1578,36 @@ function bindUi() {
} catch (e) {
prompted = await api("/api/inputshare/prompt-trust").catch(() => null);
if (!prompted) {
setStatus("권한 API를 찾을 수 없습니다. fileShare 앱을 재시작하세요", true);
setStatus("권한 API를 찾을 수 없습니다. 앱을 재시작하세요", 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;
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;
}
return;
}
const next = !cur.enabled;
const info = await api("/api/inputshare/enable", {
@@ -1206,11 +1616,17 @@ function bindUi() {
body: JSON.stringify({ enabled: next }),
});
updateInputShareUI(info);
setStatus(
next
? "입력공유 켜짐 — [이 PC]/[상대 PC] 버튼 또는 Ctrl+Shift+U/←/→"
: "입력공유 꺼짐"
);
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);
}
@@ -1227,6 +1643,32 @@ function bindUi() {
// 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;
@@ -1270,10 +1712,18 @@ function bindUi() {
const s = await refreshStatus();
await loadLocal(s.local_start || "");
if (state.connected) {
await loadDir(state.remoteStartPath || "");
setStatus("자동 연결됨 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
try {
await loadDir(state.remoteStartPath || "");
setStatus("자동 연결됨 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다");
} catch (listErr) {
setStatus(
((listErr && listErr.message) || "상대 폴더 로드 실패") +
" — 새로고침을 눌러 다시 시도하세요",
true
);
}
} else {
setStatus(s.auto_status || "JUC500 케이블을 연결한 뒤 「연결」을 누르세요");
setStatus(s.auto_status || "JUC500 케이블 연결 시 양쪽이 자동 연결됩니다");
}
} catch (e) {
setStatus(e.message, true);
@@ -1286,8 +1736,22 @@ function bindUi() {
try {
const s = await refreshStatus();
if (!wasConnected && state.connected) {
await loadDir(state.remoteStartPath || "");
setStatus("자동 연결됨 — 상대 폴더를 불러왔습니다");
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);
}
@@ -1295,5 +1759,5 @@ function bindUi() {
} catch {
/* ignore */
}
}, 1500);
}, 700);
})();