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
JavaScript
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;
}
}