// 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, 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:' (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
). */ 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); } })();