UNPKG

shell-mirror

Version:

Access your Mac shell from any device securely. Perfect for mobile coding with Claude Code CLI, Gemini CLI, and any shell tool.

570 lines (500 loc) โ€ข 23.5 kB
const term = new Terminal({ cursorBlink: true, macOptionIsMeta: true, scrollback: 1000, // Mac Terminal.app appearance settings theme: { background: '#000000', // Pure black like Mac Terminal foreground: '#ffffff', // White text cursor: '#ffffff', // White cursor cursorAccent: '#000000', // Black cursor accent selection: '#5c5c5c', // Mac selection color // Mac Terminal color palette black: '#000000', red: '#c23621', green: '#25bc24', yellow: '#adad27', blue: '#492ee1', magenta: '#d338d3', cyan: '#33bbc8', white: '#cbcccd', brightBlack: '#818383', brightRed: '#fc391f', brightGreen: '#31e722', brightYellow: '#eaec23', brightBlue: '#5833ff', brightMagenta: '#f935f8', brightCyan: '#14f0f0', brightWhite: '#e9ebeb' }, fontFamily: '"SF Mono", Monaco, Menlo, "Ubuntu Mono", monospace', // Mac system fonts fontSize: 11, // Mac Terminal default size lineHeight: 1.2, // Mac Terminal line spacing letterSpacing: 0, // Tight character spacing like Mac allowTransparency: false, // Solid background convertEol: true, // Convert line endings properly cols: 120, // Match agent terminal width rows: 30 // Match agent terminal height }); const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); const connectContainer = document.getElementById('connect-container'); const terminalContainer = document.getElementById('terminal-container'); const agentIdInput = document.getElementById('agent-id-input'); const connectBtn = document.getElementById('connect-btn'); const showManualBtn = document.getElementById('show-manual'); const manualConnect = document.getElementById('manual-connect'); const agentDiscovery = document.getElementById('agent-discovery'); const agentList = document.getElementById('agent-list'); let ws; let peerConnection; let dataChannel; let user; let AGENT_ID; let CLIENT_ID; // Auto-discover agents on page load window.addEventListener('load', () => { discoverAgents(); }); showManualBtn.onclick = () => { agentDiscovery.style.display = 'none'; manualConnect.style.display = 'block'; showManualBtn.style.display = 'none'; }; connectBtn.onclick = () => { AGENT_ID = agentIdInput.value.trim(); if (!AGENT_ID) { alert('Please enter a valid Agent ID.'); return; } startConnection(); }; function connectToAgent(agentId) { AGENT_ID = agentId; startConnection(); } function startConnection() { connectContainer.style.display = 'none'; terminalContainer.style.display = 'block'; term.open(document.getElementById('terminal')); // Delay fit to ensure proper dimensions after CSS transitions setTimeout(() => { fitAddon.fit(); }, 100); initialize(); } async function discoverAgents() { console.log('[DISCOVERY] ๐Ÿ” Starting agent discovery...'); agentList.innerHTML = '<p style="color: #ccc;">Searching for Mac agents...</p>'; const signalingUrl = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host; const discoveryWs = new WebSocket(`${signalingUrl}?role=discovery`); discoveryWs.onopen = () => { console.log('[DISCOVERY] โœ… Connected to signaling server for agent discovery'); discoveryWs.send(JSON.stringify({ type: 'list-agents' })); }; discoveryWs.onmessage = (message) => { const data = JSON.parse(message.data); console.log('[DISCOVERY] ๐Ÿ“จ Received:', data); if (data.type === 'agent-list') { displayAvailableAgents(data.agents); } }; discoveryWs.onclose = () => { console.log('[DISCOVERY] ๐Ÿ”Œ Discovery connection closed'); }; discoveryWs.onerror = (error) => { console.error('[DISCOVERY] โŒ WebSocket error:', error); agentList.innerHTML = '<p style="color: #f44336;">Discovery failed. Check server connection.</p>'; showManualBtn.style.display = 'block'; }; // Timeout after 8 seconds (increased from 5) setTimeout(() => { if (discoveryWs.readyState === WebSocket.OPEN) { discoveryWs.close(); } if (agentList.children.length === 0 || agentList.textContent.includes('Searching')) { console.log('[DISCOVERY] โฐ Discovery timeout - no agents found'); agentList.innerHTML = '<p style="color: #ff9800;">โš ๏ธ No Mac agents found.<br><small>Make sure your Mac agent is running with: <code>cd mac-agent && npm start</code></small></p>'; showManualBtn.style.display = 'block'; } }, 8000); } function displayAvailableAgents(agents) { console.log('[DISCOVERY] ๐Ÿ–ฅ๏ธ Displaying agents:', agents); agentList.innerHTML = ''; if (agents.length === 0) { agentList.innerHTML = '<p style="color: #ff9800;">โŒ No Mac agents currently running.</p>'; showManualBtn.style.display = 'block'; return; } console.log(`[DISCOVERY] โœ… Found ${agents.length} agent(s)`); agents.forEach(agent => { const agentDiv = document.createElement('div'); agentDiv.style.cssText = 'margin: 10px 0; padding: 15px; background: #333; border-radius: 8px; cursor: pointer; border: 2px solid #555; transition: all 0.3s ease;'; agentDiv.innerHTML = ` <div style="display: flex; align-items: center; gap: 10px;"> <div style="width: 12px; height: 12px; background: #4CAF50; border-radius: 50%; animation: pulse 2s infinite;"></div> <div> <strong style="color: #fff;">${agent.id}</strong><br> <small style="color: #aaa;">๐Ÿ–ฑ๏ธ Click to connect to Mac terminal</small> </div> </div> `; agentDiv.onmouseover = () => { agentDiv.style.borderColor = '#4CAF50'; agentDiv.style.background = '#444'; }; agentDiv.onmouseout = () => { agentDiv.style.borderColor = '#555'; agentDiv.style.background = '#333'; }; agentDiv.onclick = () => { console.log(`[DISCOVERY] ๐Ÿ–ฑ๏ธ User clicked on agent: ${agent.id}`); connectToAgent(agent.id); }; agentList.appendChild(agentDiv); }); showManualBtn.style.display = 'block'; // Add CSS animation for pulse effect const style = document.createElement('style'); style.textContent = ` @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } } `; document.head.appendChild(style); } async function initialize() { console.log('[CLIENT] ๐Ÿš€ Initializing WebRTC connection to agent:', AGENT_ID); const signalingUrl = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host; ws = new WebSocket(`${signalingUrl}?role=client`); ws.onopen = () => { console.log('[CLIENT] โœ… WebSocket connection to signaling server opened.'); }; ws.onmessage = async (message) => { const data = JSON.parse(message.data); console.log(`[CLIENT] Received message of type: ${data.type}`); switch (data.type) { case 'server-hello': CLIENT_ID = data.id; console.log(`[CLIENT] Assigned Client ID: ${CLIENT_ID}`); // First send a test message to verify communication console.log(`[CLIENT] ๐Ÿงช Sending test ping message first...`); const testSent = sendMessage({ type: 'ping', from: CLIENT_ID, to: AGENT_ID, timestamp: Date.now() }); if (!testSent) { console.error(`[CLIENT] โŒ Failed to send test message - WebSocket connection broken`); return; } // Start polling to connect to the agent const intervalId = setInterval(() => { console.log(`[CLIENT] ๐Ÿ“ž Sending client-hello to Agent: ${AGENT_ID}`); const sent = sendMessage({ type: 'client-hello', from: CLIENT_ID, to: AGENT_ID }); if (!sent) { console.error(`[CLIENT] โŒ Failed to send client-hello - stopping attempts`); clearInterval(intervalId); } }, 1000); // This is a bit of a hack for the message handler. // We redefine it to handle the next phase of messages. ws.onmessage = async (nextMessage) => { let messageData = nextMessage.data; // Handle Blob messages by converting to text first if (messageData instanceof Blob) { console.log(`[CLIENT] ๐Ÿ“„ Received Blob message, converting to text...`); messageData = await messageData.text(); } try { const nextData = JSON.parse(messageData); console.log(`[CLIENT] ๐Ÿ“จ Received message of type: ${nextData.type}`); if (nextData.type === 'offer') { console.log('[CLIENT] Received offer from agent. Stopping client-hello retries.'); clearInterval(intervalId); console.log('[CLIENT] Received WebRTC offer from agent.'); await createPeerConnection(); await peerConnection.setRemoteDescription(new RTCSessionDescription(nextData)); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); console.log('[CLIENT] Sending WebRTC answer to agent.'); sendMessage({ type: 'answer', sdp: answer.sdp, to: AGENT_ID, from: CLIENT_ID }); // Force ICE gathering if it hasn't started within 2 seconds console.log('[CLIENT] ๐Ÿ”ง Setting up ICE gathering fallback timer...'); setTimeout(() => { if (peerConnection.iceGatheringState === 'new') { console.log('[CLIENT] โš ๏ธ ICE gathering hasn\'t started - triggering restart'); try { peerConnection.restartIce(); } catch (error) { console.error('[CLIENT] โŒ Failed to restart ICE:', error); } } else { console.log('[CLIENT] โœ… ICE gathering is active:', peerConnection.iceGatheringState); } }, 2000); } else if (nextData.type === 'candidate') { console.log('[CLIENT] ๐ŸงŠ Received ICE candidate from agent:', { candidate: nextData.candidate.candidate, sdpMid: nextData.candidate.sdpMid, sdpMLineIndex: nextData.candidate.sdpMLineIndex }); if (peerConnection) { try { await peerConnection.addIceCandidate(new RTCIceCandidate(nextData.candidate)); console.log('[CLIENT] โœ… ICE candidate added successfully'); } catch (error) { console.error('[CLIENT] โŒ Error adding ICE candidate:', error); } } else { console.error('[CLIENT] โŒ Cannot add ICE candidate - no peer connection'); } } } catch (error) { console.error(`[CLIENT] โŒ Error processing WebRTC message:`, error); } }; break; } }; ws.onclose = (event) => { console.log(`[CLIENT] ๐Ÿ”Œ Disconnected from signaling server. Code: ${event.code}, Reason: ${event.reason}`); term.write('\r\n\r\nConnection to server lost. Please refresh.\r\n'); }; ws.onerror = (error) => { console.error('[CLIENT] โŒ WebSocket error:', error); }; } async function testSTUNConnectivity() { console.log('[CLIENT] ๐Ÿงช Testing STUN server connectivity...'); try { // Create a test peer connection to check STUN server access const testPC = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); let candidateReceived = false; return new Promise((resolve) => { const timeout = setTimeout(() => { console.log('[CLIENT] โš ๏ธ STUN connectivity test timed out - may indicate network restrictions'); testPC.close(); resolve(false); }, 5000); testPC.onicecandidate = (event) => { if (event.candidate && !candidateReceived) { candidateReceived = true; console.log('[CLIENT] โœ… STUN server connectivity confirmed'); clearTimeout(timeout); testPC.close(); resolve(true); } }; // Create a dummy data channel to trigger ICE gathering testPC.createDataChannel('test'); testPC.createOffer().then(offer => testPC.setLocalDescription(offer)); }); } catch (error) { console.error('[CLIENT] โŒ STUN connectivity test failed:', error); return false; } } async function createPeerConnection() { console.log('[CLIENT] Creating PeerConnection.'); // Test STUN connectivity first const stunWorking = await testSTUNConnectivity(); if (!stunWorking) { console.log('[CLIENT] โš ๏ธ STUN servers may be blocked - using TURN servers for connectivity'); } // Test STUN server connectivity with multiple backup servers console.log('[CLIENT] ๐ŸŒ Configuring ICE servers with multiple STUN/TURN options...'); const iceServers = [ // Google STUN servers (primary) { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, // Cloudflare STUN servers (backup) { urls: 'stun:stun.cloudflare.com:3478' }, // Mozilla STUN servers (backup) { urls: 'stun:stun.services.mozilla.com:3478' }, // OpenRelay free TURN server (for NAT traversal) { urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' }, // Alternative TURN server { urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' } ]; console.log('[CLIENT] ๐Ÿ“‹ Configured ICE servers:', iceServers.map(server => server.urls)); // Enhanced WebRTC configuration for better ICE candidate generation const rtcConfig = { iceServers: iceServers, iceCandidatePoolSize: 10, // Generate more ICE candidates iceTransportPolicy: 'all', // Use both STUN and TURN bundlePolicy: 'balanced' // Optimize for connection establishment }; console.log('[CLIENT] โš™๏ธ WebRTC config:', rtcConfig); peerConnection = new RTCPeerConnection(rtcConfig); // Debug: Verify event handler is being attached console.log('[CLIENT] ๐Ÿ”ง Attaching ICE candidate event handler...'); peerConnection.onicecandidate = (event) => { console.log('[CLIENT] ๐ŸงŠ ICE candidate event fired:', event.candidate ? 'candidate found' : 'gathering complete'); if (event.candidate) { console.log('[CLIENT] ๐Ÿ“ค ICE candidate details:', { candidate: event.candidate.candidate, sdpMid: event.candidate.sdpMid, sdpMLineIndex: event.candidate.sdpMLineIndex }); console.log('[CLIENT] ๐Ÿ“ค Sending ICE candidate to agent...'); const sent = sendMessage({ type: 'candidate', candidate: event.candidate, to: AGENT_ID, from: CLIENT_ID }); if (sent) { console.log('[CLIENT] โœ… ICE candidate sent successfully'); } else { console.log('[CLIENT] โŒ Failed to send ICE candidate'); } } else { console.log('[CLIENT] ๐Ÿ ICE candidate gathering complete.'); } }; peerConnection.oniceconnectionstatechange = () => { console.log(`[CLIENT] ๐Ÿ“Š ICE connection state changed: ${peerConnection.iceConnectionState}`); console.log(`[CLIENT] ๐Ÿ“Š ICE gathering state: ${peerConnection.iceGatheringState}`); switch (peerConnection.iceConnectionState) { case 'new': console.log('[CLIENT] ๐Ÿ†• ICE connection starting...'); break; case 'checking': console.log('[CLIENT] ๐Ÿ” ICE connection checking candidates...'); break; case 'connected': console.log('[CLIENT] โœ… WebRTC connection established!'); break; case 'completed': console.log('[CLIENT] โœ… ICE connection completed successfully!'); break; case 'failed': console.log('[CLIENT] โŒ ICE connection failed - no viable candidates'); console.log('[CLIENT] ๐Ÿ’ก Troubleshooting: This may be due to firewall/NAT issues or blocked STUN servers'); term.write('\r\n\r\nโŒ Connection failed: Network connectivity issues\r\n'); term.write('๐Ÿ’ก This may be due to:\r\n'); term.write(' โ€ข Firewall blocking WebRTC traffic\r\n'); term.write(' โ€ข Corporate network restrictions\r\n'); term.write(' โ€ข STUN/TURN servers unreachable\r\n'); term.write('\r\n๐Ÿ”„ Please refresh the page to retry...\r\n'); break; case 'disconnected': console.log('[CLIENT] โš ๏ธ ICE connection disconnected'); break; case 'closed': console.log('[CLIENT] ๐Ÿ” ICE connection closed'); break; } }; peerConnection.onconnectionstatechange = () => { console.log(`[CLIENT] ๐Ÿ“ก Connection state changed: ${peerConnection.connectionState}`); switch (peerConnection.connectionState) { case 'new': console.log('[CLIENT] ๐Ÿ†• Connection starting...'); break; case 'connecting': console.log('[CLIENT] ๐Ÿ”„ Connection in progress...'); break; case 'connected': console.log('[CLIENT] โœ… Peer connection fully established!'); break; case 'disconnected': console.log('[CLIENT] โš ๏ธ Peer connection disconnected'); break; case 'failed': console.log('[CLIENT] โŒ Peer connection failed completely'); break; case 'closed': console.log('[CLIENT] ๐Ÿ” Peer connection closed'); break; } }; peerConnection.onicegatheringstatechange = () => { console.log(`[CLIENT] ๐Ÿ” ICE gathering state changed: ${peerConnection.iceGatheringState}`); switch (peerConnection.iceGatheringState) { case 'new': console.log('[CLIENT] ๐Ÿ†• ICE gathering not started'); break; case 'gathering': console.log('[CLIENT] ๐Ÿ” ICE gathering in progress...'); break; case 'complete': console.log('[CLIENT] โœ… ICE gathering completed'); break; } }; // Client waits for data channel from agent peerConnection.ondatachannel = (event) => { console.log('[CLIENT] ๐Ÿ“จ Data channel received from agent!'); dataChannel = event.channel; setupDataChannel(); }; } function setupDataChannel() { dataChannel.onopen = () => { console.log('[CLIENT] โœ… Data channel is open!'); term.focus(); fitAddon.fit(); // Mac-style connection message with proper colors term.write('\r\n\x1b[32mConnected to Mac Terminal\x1b[0m\r\n'); }; dataChannel.onmessage = (event) => { try { const message = JSON.parse(event.data); if (message.type === 'output') { term.write(message.data); } } catch (err) { console.error('[CLIENT] Error parsing data channel message:', err); } }; dataChannel.onclose = () => { console.log('[CLIENT] Data channel closed.'); term.write('\r\n\r\n\x1b[31mโŒ Terminal session ended.\x1b[0m\r\n'); }; dataChannel.onerror = (error) => { console.error('[CLIENT] Data channel error:', error); term.write('\r\n\r\n\x1b[31mโŒ Data channel error occurred.\x1b[0m\r\n'); }; term.onData((data) => { if (dataChannel && dataChannel.readyState === 'open') { dataChannel.send(JSON.stringify({ type: 'input', data })); } }); window.addEventListener('resize', () => { fitAddon.fit(); }); term.onResize(({ cols, rows }) => { if (dataChannel && dataChannel.readyState === 'open') { dataChannel.send(JSON.stringify({ type: 'resize', cols, rows })); } }); } function sendMessage(message) { console.log(`[CLIENT] ๐Ÿ“ค Attempting to send message:`, message); console.log(`[CLIENT] ๐Ÿ” WebSocket state: ${ws ? ws.readyState : 'null'} (OPEN=1)`); if (!ws) { console.error('[CLIENT] โŒ WebSocket is null - cannot send message'); return false; } if (ws.readyState !== 1) { // WebSocket.OPEN = 1 const states = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; console.error(`[CLIENT] โŒ WebSocket not open (state: ${ws.readyState} = ${states[ws.readyState] || 'UNKNOWN'}) - cannot send message`); return false; } try { const messageStr = JSON.stringify(message); console.log(`[CLIENT] ๐Ÿ“จ Sending message: ${messageStr}`); ws.send(messageStr); console.log(`[CLIENT] โœ… Message sent successfully`); return true; } catch (error) { console.error(`[CLIENT] โŒ Error sending message:`, error); return false; } }