로그인
KO
apps/jumphp-ide-remote / ui / js / app.js · commit b81e652
// IDE Remote shell: pairing, connection (transport.js), the hello / welcome / invoke / event protocol (docs/ide-remote.md)// and the global API the views use: window.IdeRemote.import { Channel } from './channel.js';import { connect } from './transport.js';const $ = (id) => document.getElementById(id);const store = {  get(k) { try { return localStorage.getItem('ide-remote:' + k); } catch { return null; } },  set(k, v) { try { v == null ? localStorage.removeItem('ide-remote:' + k) : localStorage.setItem('ide-remote:' + k, v); } catch { /* private window */ } },};// ---- texts (English built in; the 14 languages come from config/lang.yaml through PHP) -----------------------------const EN = {  connecting: 'Connecting…', waiting: 'Waiting for the PC to allow this device…', connected: 'Connected',  reconnecting: 'Connection lost, reconnecting…', denied_refused: 'The PC refused this device.', denied_revoked: 'Access was revoked on the PC.',  denied_expired: 'Sharing expired. Share again from the IDE.', denied_version: 'This app and the IDE versions do not match. Update the IDE.',  pair_title: 'Connect to your IDE', pair_hint: 'In the IDE, click the screen icon of the Claude panel and open the link or scan the QR code, or paste the link here.',  pair_button: 'Connect', device_name: 'Name of this device', retry: 'Retry', disconnect: 'Disconnect', panel_claude: 'Claude', panel_terminal: 'Terminal',  panel_files: 'Files', panel_run: 'Run', via_direct: 'direct (same network)', via_relay: 'via relay (encrypted)', pair_invalid: 'This link is not a valid pairing link.',  err_denied: 'The IDE refused this action.', err_timeout: 'The IDE did not answer in time.', err_closed: 'The connection was closed.', err_not_connected: 'Not connected to the IDE.',  err_file_too_large: 'This file is too large to be sent.', err_bad_data: 'Invalid data.', err_empty_or_too_long: 'The message is empty or too long.', err_failed: 'The action failed.',};let table = EN;const langCode = (new URLSearchParams(location.search).get('lang') || navigator.language || 'en').slice(0, 2).toLowerCase();const t = (k, vars, fallback) => String(table[k] || EN[k] || fallback || k).replace(/\{(\w+)\}/g, (m, name) => (vars && name in vars ? vars[name] : m));// An error of a call (the codes of the host and of this page) in the page's language; any other message is shown as it is.const ERRORS = { denied: 'err_denied', timeout: 'err_timeout', closed: 'err_closed', 'channel closed': 'err_closed', 'not connected': 'err_not_connected',  'file too large': 'err_file_too_large', 'bad data': 'err_bad_data', 'empty or too long': 'err_empty_or_too_long', failed: 'err_failed' };const errorText = (e) => { const m = (e && e.message) || String(e); return ERRORS[m] ? t(ERRORS[m]) : m; };function applyTexts() {  const set = (id, k) => { $(id).textContent = t(k); };  set('pairTitle', 'pair_title'); set('pairHint', 'pair_hint'); set('pairButton', 'pair_button'); set('deviceLabel', 'device_name');  set('retry', 'retry'); set('disconnect', 'disconnect');  document.documentElement.lang = langCode;  document.querySelectorAll('#tabs button').forEach((b) => { b.textContent = t('panel_' + b.dataset.panel); });  render();}// ---- the API for the views ---------------------------------------------------------------------------------------const listeners = new Map();const on = (type, fn) => {  if (!listeners.has(type)) listeners.set(type, new Set());  listeners.get(type).add(fn);  return () => listeners.get(type).delete(fn);};const emit = (type, ...args) => { for (const fn of [...(listeners.get(type) || [])]) { try { fn(...args); } catch (e) { console.error(`[IdeRemote] listener of ${type}`, e); } } };let channel = null; let nextId = 1; const calls = new Map();const API = window.IdeRemote = {  /** 'idle' | 'connecting' | 'waiting' | 'connected' | 'lost' | 'denied' */  state: 'idle', scope: null, panels: [], project: null, via: null, active: null, t, errorText,  /** Sends one message object to the IDE (queued until the channel is open). */  send(obj) { return channel ? channel.send(obj) : Promise.reject(new Error('not connected')); },  /** Runs an allowlisted command of the IDE: Promise<result>, rejects with Error(message) ('denied', 'timeout', 'closed', ...). */  invoke(name, payload = {}, timeoutMs = 30000) {    if (!channel || API.state !== 'connected') return Promise.reject(new Error('not connected'));    const id = nextId++;    return new Promise((resolve, reject) => {      const timer = setTimeout(() => { calls.delete(id); reject(new Error('timeout')); }, timeoutMs);      calls.set(id, { resolve, reject, timer });      channel.send({ t: 'invoke', id, name, payload }).catch((e) => { clearTimeout(timer); calls.delete(id); reject(e); });    });  },  /**   * Subscribes. type: 'state' (state, info), 'welcome' (msg), 'snap' (msg) / 'snap:<panel>' (msg), 'event' (name, payload),   * any IDE event name such as 'claude.event' / 'terminal.output' (payload), 'panel' (id: the panel shown), 'denied' (reason),   * 'ready' (connected and welcomed: use it to ask a snapshot). Returns the unsubscribe function.   */  on,  /** Shows a panel (its <section id="panel-<id>">). */  show(id) { showPanel(id); },  /** Asks the host for the late-joiner state of a panel: the answer arrives as a 'snap' message. */  snap(panel) { return API.send({ t: 'invoke', id: nextId++, name: 'snap', payload: { panel } }); },};// ---- state / rendering -------------------------------------------------------------------------------------------let pairing = null; let deviceName = ''; let info = '';function setState(s, text = '') {  API.state = s; info = text;  render();  emit('state', s, text);}function render() {  const s = API.state;  $('dot').dataset.state = s;  $('status').textContent = s === 'connected' ? `${t('connected')} · ${t(API.via === 'direct' ? 'via_direct' : 'via_relay')}`    : s === 'connecting' ? t('connecting') : s === 'waiting' ? t('waiting') : s === 'lost' ? t('reconnecting') : info;  $('project').textContent = API.project?.name || 'JumPHP IDE';  $('device').textContent = deviceName;  $('disconnect').hidden = s === 'idle';  const paired = s !== 'idle';  $('pairForm').hidden = paired;  $('wait').hidden = !paired || s === 'connected';  $('waitText').textContent = s === 'denied' ? info : s === 'waiting' ? t('waiting') : s === 'lost' ? t('reconnecting') : t('connecting');  $('retry').hidden = s !== 'denied';  document.querySelector('.spinner').hidden = s === 'denied';  $('gate').hidden = s === 'connected' && API.panels.length > 0;  $('panels').hidden = !(s === 'connected' || (s === 'lost' && API.panels.length > 0));  if (s === 'lost' && API.panels.length > 0) $('gate').hidden = true;}function buildTabs() {  const tabs = $('tabs'); tabs.replaceChildren();  const known = ['claude', 'terminal', 'files'];  for (const p of API.panels) {    if (!known.includes(p.id) && !$('panel-' + p.id)) continue;    const b = document.createElement('button');    b.type = 'button'; b.role = 'tab'; b.dataset.panel = p.id; b.textContent = t('panel_' + p.id, null, p.title);    b.addEventListener('click', () => showPanel(p.id));    tabs.append(b);  }}function showPanel(id) {  if (!API.panels.some((p) => p.id === id)) return;  API.active = id; store.set('panel', id);  document.querySelectorAll('.panel').forEach((el) => { el.hidden = el.id !== 'panel-' + id; });  document.querySelectorAll('#tabs button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.panel === id)));  emit('panel', id);}// ---- protocol ----------------------------------------------------------------------------------------------------let attempt = 0; let stopped = false; let lastSeen = 0; let timers = [];const clearTimers = () => { timers.forEach(clearInterval); timers = []; };function failCalls(reason) { for (const [id, c] of calls) { clearTimeout(c.timer); c.reject(new Error(reason)); calls.delete(id); } }async function start() {  if (!pairing) return;  stopped = false;  setState('connecting');  let settings = { relay_url: 'wss://jumphp.net/ide-relay/ws', net_mode: 'auto', native: false, direct_head_start: 1.5 };  try { settings = { ...settings, ...(await window.jumphp.invoke('settings', {})) }; } catch { /* PHP not up: defaults */ }  if (window.IDE_REMOTE_RELAY) settings.relay_url = window.IDE_REMOTE_RELAY;  try {    const { channel: ch, via } = await connect({ pairing, settings });    if (stopped) return ch.close();    channel = ch; API.via = via; lastSeen = Date.now(); attempt = 0;    ch.on('message', onMessage);    ch.on('close', () => onClosed('closed'));    ch.on('error', (e) => console.warn('[IdeRemote] channel error', e));    setState('waiting');    ch.send({ t: 'hello', v: 1, name: deviceName, want: 'all' });    timers.push(setInterval(() => {      if (Date.now() - lastSeen > 60000) return onClosed('silent');      ch.send({ t: 'ping' }).catch(() => {});    }, 20000));  } catch (e) {    console.warn('[IdeRemote] connect failed', e);    onClosed('failed');  }}function onClosed() {  clearTimers(); failCalls('closed');  if (channel) { try { channel.close(); } catch { /* already closed */ } channel = null; }  if (stopped || API.state === 'denied' || API.state === 'idle') return;  setState('lost');  const delay = Math.min(15000, 1000 * 2 ** attempt++);  setTimeout(() => { if (API.state === 'lost' && !stopped) start(); }, delay);}function onMessage(m) {  lastSeen = Date.now();  switch (m && m.t) {    case 'welcome': {      API.scope = m.scope; API.project = m.project || null;      API.panels = (m.panels || []).filter((p) => p && p.id);      if (m.scope === 'all' && !API.panels.some((p) => p.id === 'files')) API.panels.push({ id: 'files', title: t('panel_files') });      buildTabs(); setState('connected');      showPanel(API.panels.some((p) => p.id === store.get('panel')) ? store.get('panel') : API.panels[0]?.id);      channel.send({ t: 'sub', panels: API.panels.map((p) => p.id).filter((id) => id !== 'files') });      emit('welcome', m); emit('ready');      break;    }    case 'denied':      stopped = true; clearTimers(); store.set('pairing', null);      setState('denied', t('denied_' + (m.reason || 'refused')));      emit('denied', m.reason);      channel?.close(); channel = null;      break;    case 'result': {      const c = calls.get(m.id);      if (!c) break;      calls.delete(m.id); clearTimeout(c.timer);      m.ok ? c.resolve(m.result) : c.reject(new Error(m.error || 'failed'));      break;    }    case 'event': emit('event', m.name, m.payload); emit(m.name, m.payload); break;    case 'snap': emit('snap', m); emit('snap:' + m.panel, m); break;    case 'ping': channel?.send({ t: 'pong' }).catch(() => {}); break;    default: break; // pong and unknown types: only lastSeen counts  }}function disconnect() {  stopped = true; clearTimers();  try { channel?.send({ t: 'bye' }); } catch { /* closing */ }  setTimeout(() => { channel?.close(); channel = null; }, 100);  failCalls('closed');  store.set('pairing', null); pairing = null; API.panels = []; API.project = null; API.scope = null; API.via = null;  $('pairLink').value = '';  setState('idle');}// ---- pairing form / boot -----------------------------------------------------------------------------------------function guessDeviceName() {  const ua = navigator.userAgent || '';  const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad/.test(ua) ? 'iPhone' : /Windows/.test(ua) ? 'Windows' : /Mac/.test(ua) ? 'Mac' : /Linux/.test(ua) ? 'Linux' : 'Device';  return `${os} browser`;}function pair(text) {  const p = Channel.parsePairing(text);  if (!p) { $('pairError').textContent = t('pair_invalid'); $('pairError').hidden = false; return; }  $('pairError').hidden = true;  deviceName = $('deviceName').value.trim().slice(0, 40) || guessDeviceName();  store.set('device', deviceName); store.set('pairing', text);  pairing = p; attempt = 0;  if (location.hash) history.replaceState(null, '', location.pathname + location.search); // the key leaves the address bar  try { if (window.parent !== window && window.parent.location.hash) window.parent.history.replaceState(null, '', window.parent.location.pathname + window.parent.location.search); } catch { /* not ours to touch */ }  start();}$('pairForm').addEventListener('submit', (e) => { e.preventDefault(); pair($('pairLink').value.trim()); });$('disconnect').addEventListener('click', disconnect);$('retry').addEventListener('click', () => { const saved = $('pairLink').value; pairing = null; setState('idle'); $('pairLink').value = saved; });document.addEventListener('visibilitychange', () => { if (!document.hidden && API.state === 'lost') start(); });addEventListener('online', () => { if (API.state === 'lost') start(); });(async function boot() {  $('deviceName').value = store.get('device') || guessDeviceName();  applyTexts();  render();  // texts in the user's language (PHP reads config/lang.yaml)  try {    const all = await window.jumphp.invoke('strings', {});    if (all && all[langCode]) { table = { ...EN, ...all[langCode] }; applyTexts(); }  } catch { /* English */ }  // the app runs in a frame of the engine's page (same origin): the link's fragment is on the page around it  let outer = '';  try { outer = window.parent !== window ? window.parent.location.hash : ''; } catch { /* another origin: the pasted link then */ }  const text = location.hash || outer || store.get('pairing') || '';  if (Channel.parsePairing(text)) { $('pairLink').value = text; pair(text); }})();