Last active
December 11, 2025 16:52
-
-
Save ericboehs/1528f16eb493173c7b62bcbd7fdd3283 to your computer and use it in GitHub Desktop.
iOS Safari Remote Control - CLI tool to control Safari on iOS devices via USB. Requires ios_webkit_debug_proxy. Features: device selection by name (-d), list pages, navigate, get HTML/text, click links, eval JS.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| const WebSocket = require('ws'); | |
| const http = require('http'); | |
| // Default ports to try for device discovery | |
| const DEVICE_LIST_PORTS = [9221, 9100]; | |
| // Parse CLI arguments | |
| const args = process.argv.slice(2); | |
| let port = null; // null means auto-discover | |
| let deviceSelector = null; // null means auto-discover, can be index (0,1) or name match ("iPhone", "iPad") | |
| let command = null; | |
| let commandArgs = []; | |
| for (let i = 0; i < args.length; i++) { | |
| if (args[i] === '--port' || args[i] === '-p') { | |
| port = parseInt(args[++i], 10); | |
| } else if (args[i] === '--device' || args[i] === '-d') { | |
| deviceSelector = args[++i]; | |
| } else if (!command) { | |
| command = args[i]; | |
| } else { | |
| commandArgs.push(args[i]); | |
| } | |
| } | |
| // Fetch JSON from HTTP endpoint | |
| async function fetchJson(url, timeout = 20000) { | |
| return new Promise((resolve, reject) => { | |
| const req = http.get(url, (res) => { | |
| let data = ''; | |
| res.on('data', chunk => data += chunk); | |
| res.on('end', () => { | |
| try { | |
| resolve(JSON.parse(data)); | |
| } catch (e) { | |
| reject(new Error(`Failed to parse JSON: ${data}`)); | |
| } | |
| }); | |
| }); | |
| req.on('error', reject); | |
| req.setTimeout(timeout, () => { | |
| req.destroy(); | |
| reject(new Error('timeout')); | |
| }); | |
| }); | |
| } | |
| // Normalize quotes (curly → straight) for comparison | |
| function normalizeQuotes(str) { | |
| return str.replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"'); | |
| } | |
| // Find device by selector (index or exact name match) | |
| function findDevice(devices, selector) { | |
| // Try as numeric index first | |
| const index = parseInt(selector, 10); | |
| if (!isNaN(index)) { | |
| if (index >= devices.length) { | |
| console.error(`Device index ${index} not found. Only ${devices.length} device(s) connected.`); | |
| console.error('Available devices:'); | |
| devices.forEach((d, i) => console.error(` [${i}] ${d.deviceName}`)); | |
| process.exit(1); | |
| } | |
| return devices[index]; | |
| } | |
| // Try as exact name match (normalize quotes for comparison) | |
| const normalizedSelector = normalizeQuotes(selector); | |
| const match = devices.find(d => normalizeQuotes(d.deviceName) === normalizedSelector); | |
| if (!match) { | |
| console.error(`No device named "${selector}" found.`); | |
| console.error('Available devices:'); | |
| devices.forEach((d, i) => console.error(` [${i}] ${d.deviceName}`)); | |
| process.exit(1); | |
| } | |
| return match; | |
| } | |
| // Auto-discover the device port by checking known device list ports | |
| async function discoverDevicePort() { | |
| for (const listPort of DEVICE_LIST_PORTS) { | |
| try { | |
| const devices = await fetchJson(`http://localhost:${listPort}/json`); | |
| // Device list response has deviceId/deviceName, page list has url/title | |
| if (devices.length > 0 && devices[0].deviceId) { | |
| // If deviceSelector is specified, find that device | |
| if (deviceSelector !== null) { | |
| const device = findDevice(devices, deviceSelector); | |
| const devicePort = parseInt(device.url.split(':')[1], 10); | |
| return { devicePort, devices, listPort, selectedDevice: device }; | |
| } | |
| // Find a device that has pages open | |
| for (const device of devices) { | |
| const devicePort = parseInt(device.url.split(':')[1], 10); | |
| try { | |
| const pages = await fetchJson(`http://localhost:${devicePort}/json`); | |
| const realPages = pages.filter(p => p.url && !p.url.startsWith('safari-web-extension://')); | |
| if (realPages.length > 0) { | |
| return { devicePort, devices, listPort }; | |
| } | |
| } catch (e) { | |
| // Device not responding, try next | |
| } | |
| } | |
| // No device with pages, return first device | |
| const deviceUrl = devices[0].url; | |
| const devicePort = parseInt(deviceUrl.split(':')[1], 10); | |
| return { devicePort, devices, listPort }; | |
| } | |
| } catch (e) { | |
| // Try next port | |
| } | |
| } | |
| return null; | |
| } | |
| // Get device port - either from CLI arg or auto-discover | |
| async function getDevicePort() { | |
| if (port) { | |
| return port; | |
| } | |
| const discovery = await discoverDevicePort(); | |
| if (discovery) { | |
| return discovery.devicePort; | |
| } | |
| // Last resort: try common device ports directly | |
| for (const p of [9222, 9101, 9102]) { | |
| try { | |
| await fetchJson(`http://localhost:${p}/json`); | |
| return p; | |
| } catch (e) {} | |
| } | |
| return null; | |
| } | |
| // List available devices and pages | |
| async function listDevicesAndPages() { | |
| const discovery = await discoverDevicePort(); | |
| if (discovery) { | |
| console.log('Connected devices:\n'); | |
| discovery.devices.forEach((device, i) => { | |
| console.log(` [${i}] ${device.deviceName} (${device.deviceOSVersion})`); | |
| console.log(` Port: ${device.url}\n`); | |
| }); | |
| } | |
| const devicePort = port || discovery?.devicePort; | |
| if (!devicePort) { | |
| console.error('Could not find ios_webkit_debug_proxy.'); | |
| console.error('Make sure it is running: ios_webkit_debug_proxy'); | |
| process.exit(1); | |
| } | |
| try { | |
| const pages = await fetchJson(`http://localhost:${devicePort}/json`); | |
| console.log('Available pages:\n'); | |
| pages.forEach((page, i) => { | |
| if (page.url && !page.url.startsWith('safari-web-extension://')) { | |
| console.log(` [${i}] ${page.title || '(no title)'}`); | |
| console.log(` ${page.url}\n`); | |
| } | |
| }); | |
| console.log(`Use: ios-safari-remote.js <page-index> <command> [args]`); | |
| } catch (e) { | |
| console.error(`Error connecting to device on port ${devicePort}`); | |
| process.exit(1); | |
| } | |
| } | |
| // Connect to a page and run commands | |
| async function connectToPage(pageIndex, devicePort) { | |
| const pages = await fetchJson(`http://localhost:${devicePort}/json`); | |
| const page = pages[pageIndex]; | |
| if (!page) { | |
| console.error(`Page ${pageIndex} not found. Use 'list' to see available pages.`); | |
| process.exit(1); | |
| } | |
| // Extract the WebSocket URL | |
| const wsUrl = page.webSocketDebuggerUrl; | |
| if (!wsUrl) { | |
| console.error('No WebSocket URL found for this page.'); | |
| process.exit(1); | |
| } | |
| return new Promise((resolve, reject) => { | |
| const ws = new WebSocket(wsUrl); | |
| let pageTargetId = null; | |
| let msgId = 1; | |
| const callbacks = {}; | |
| function sendToTarget(method, params = {}) { | |
| return new Promise((res) => { | |
| const id = msgId++; | |
| callbacks[id] = res; | |
| ws.send(JSON.stringify({ | |
| id: 1000 + id, | |
| method: 'Target.sendMessageToTarget', | |
| params: { | |
| targetId: pageTargetId, | |
| message: JSON.stringify({ id, method, params }) | |
| } | |
| })); | |
| setTimeout(() => { | |
| if (callbacks[id]) { | |
| delete callbacks[id]; | |
| res({ error: 'timeout' }); | |
| } | |
| }, 15000); | |
| }); | |
| } | |
| const device = { | |
| async getTitle() { | |
| const r = await sendToTarget('Runtime.evaluate', { expression: 'document.title' }); | |
| return r.result?.result?.value; | |
| }, | |
| async getUrl() { | |
| const r = await sendToTarget('Runtime.evaluate', { expression: 'window.location.href' }); | |
| return r.result?.result?.value; | |
| }, | |
| async navigate(url) { | |
| await sendToTarget('Runtime.evaluate', { expression: `window.location.href = "${url}"` }); | |
| await new Promise(r => setTimeout(r, 2000)); | |
| return await device.getUrl(); | |
| }, | |
| async getLinks(limit = 30) { | |
| const r = await sendToTarget('Runtime.evaluate', { | |
| expression: ` | |
| JSON.stringify( | |
| Array.from(document.querySelectorAll('a[href]')) | |
| .filter(a => a.innerText.trim().length > 0 && a.href.startsWith('http')) | |
| .slice(0, ${limit}) | |
| .map((a, i) => ({ index: i, text: a.innerText.trim().slice(0, 60), href: a.href })) | |
| ) | |
| ` | |
| }); | |
| return JSON.parse(r.result?.result?.value || '[]'); | |
| }, | |
| async click(index) { | |
| const r = await sendToTarget('Runtime.evaluate', { | |
| expression: ` | |
| (function() { | |
| const links = Array.from(document.querySelectorAll('a[href]')) | |
| .filter(a => a.innerText.trim().length > 0 && a.href.startsWith('http')); | |
| const link = links[${index}]; | |
| if (link) { link.click(); return { clicked: true, text: link.innerText.trim(), href: link.href }; } | |
| return { clicked: false }; | |
| })() | |
| `, | |
| returnByValue: true | |
| }); | |
| return r.result?.result?.value; | |
| }, | |
| async getText() { | |
| const r = await sendToTarget('Runtime.evaluate', { expression: 'document.body.innerText' }); | |
| return r.result?.result?.value; | |
| }, | |
| async getHtml() { | |
| const r = await sendToTarget('Runtime.evaluate', { expression: 'document.documentElement.outerHTML' }); | |
| return r.result?.result?.value; | |
| }, | |
| async eval(expr) { | |
| const r = await sendToTarget('Runtime.evaluate', { expression: expr, returnByValue: true }); | |
| return r.result?.result?.value ?? r.result?.result?.description ?? JSON.stringify(r); | |
| } | |
| }; | |
| ws.on('open', () => {}); | |
| ws.on('message', (data) => { | |
| const msg = JSON.parse(data); | |
| if (msg.method === 'Target.targetCreated' && msg.params?.targetInfo?.type === 'page') { | |
| pageTargetId = msg.params.targetInfo.targetId; | |
| // Enable domains then resolve | |
| (async () => { | |
| await sendToTarget('Runtime.enable'); | |
| await sendToTarget('DOM.enable'); | |
| await sendToTarget('Page.enable'); | |
| resolve({ ws, device }); | |
| })(); | |
| } | |
| if (msg.method === 'Target.dispatchMessageFromTarget') { | |
| try { | |
| const targetMsg = JSON.parse(msg.params.message); | |
| if (targetMsg.id && callbacks[targetMsg.id]) { | |
| callbacks[targetMsg.id](targetMsg); | |
| delete callbacks[targetMsg.id]; | |
| } | |
| } catch (e) {} | |
| } | |
| }); | |
| ws.on('error', (e) => { | |
| console.error('WebSocket error:', e.message); | |
| reject(e); | |
| }); | |
| setTimeout(() => reject(new Error('Connection timeout')), 30000); | |
| }); | |
| } | |
| // Main | |
| async function main() { | |
| if (!command || command === 'help' || command === '--help' || command === '-h') { | |
| console.log(` | |
| iOS Safari Remote Control | |
| Usage: ios-safari-remote.js [options] <command> [args] | |
| Options: | |
| -d, --device <name> Device to use (REQUIRED for non-list commands). | |
| Can be index (0, 1) or exact device name. | |
| -p, --port <port> Port for ios_webkit_debug_proxy (overrides device selection) | |
| Commands: | |
| list List available devices and pages | |
| <page> url Get current URL | |
| <page> title Get page title | |
| <page> links [n] List first n links (default: 30) | |
| <page> click <n> Click link at index n | |
| <page> nav <url> Navigate to URL | |
| <page> text Get page text content | |
| <page> html Get page HTML | |
| <page> eval <expr> Evaluate JavaScript expression | |
| Examples: | |
| ios-safari-remote.js list | |
| ios-safari-remote.js -d "Eric's M4 iPad" 0 title | |
| ios-safari-remote.js -d "Eric's iPhone 16" 0 nav "https://google.com" | |
| ios-safari-remote.js -d 1 0 links 10 # Use index instead of name | |
| ios-safari-remote.js -p 9223 0 html # Directly specify port (skips device check) | |
| `); | |
| process.exit(0); | |
| } | |
| if (command === 'list') { | |
| await listDevicesAndPages(); | |
| process.exit(0); | |
| } | |
| // Require device selection for all non-list commands (unless port is directly specified) | |
| if (deviceSelector === null && port === null) { | |
| console.error('Error: Device selection required. Use -d <device> to specify which device.'); | |
| console.error('You can use an index (0, 1) or exact device name.'); | |
| console.error('Run "ios-safari-remote.js list" to see available devices.'); | |
| console.error('Examples:'); | |
| console.error(' ios-safari-remote.js -d "Eric\'s M4 iPad" 0 title'); | |
| console.error(' ios-safari-remote.js -d 1 0 nav "https://google.com"'); | |
| process.exit(1); | |
| } | |
| // Get device port | |
| const devicePort = await getDevicePort(); | |
| if (!devicePort) { | |
| console.error('Could not find ios_webkit_debug_proxy.'); | |
| console.error('Make sure it is running: ios_webkit_debug_proxy'); | |
| process.exit(1); | |
| } | |
| // command is page index, first commandArg is the actual command | |
| const pageIndex = parseInt(command, 10); | |
| if (isNaN(pageIndex)) { | |
| console.error(`Unknown command: ${command}. Use 'help' for usage.`); | |
| process.exit(1); | |
| } | |
| const subCommand = commandArgs[0]; | |
| const subArg = commandArgs.slice(1).join(' '); | |
| if (!subCommand) { | |
| console.error('No command specified. Use "help" for usage.'); | |
| process.exit(1); | |
| } | |
| try { | |
| const { ws, device } = await connectToPage(pageIndex, devicePort); | |
| switch (subCommand) { | |
| case 'url': | |
| console.log(await device.getUrl()); | |
| break; | |
| case 'title': | |
| console.log(await device.getTitle()); | |
| break; | |
| case 'links': | |
| const links = await device.getLinks(parseInt(subArg, 10) || 30); | |
| links.forEach(l => console.log(`[${l.index}] ${l.text}\n ${l.href}\n`)); | |
| break; | |
| case 'click': | |
| const clickResult = await device.click(parseInt(subArg, 10)); | |
| if (clickResult?.clicked) { | |
| console.log(`Clicked: ${clickResult.text}`); | |
| await new Promise(r => setTimeout(r, 2000)); | |
| console.log(`New URL: ${await device.getUrl()}`); | |
| } else { | |
| console.log('Link not found'); | |
| } | |
| break; | |
| case 'nav': | |
| case 'navigate': | |
| console.log(`Navigating to: ${subArg}`); | |
| console.log(`Result: ${await device.navigate(subArg)}`); | |
| break; | |
| case 'text': | |
| console.log(await device.getText()); | |
| break; | |
| case 'html': | |
| console.log(await device.getHtml()); | |
| break; | |
| case 'eval': | |
| console.log(await device.eval(subArg)); | |
| break; | |
| default: | |
| console.error(`Unknown command: ${subCommand}. Use 'help' for usage.`); | |
| } | |
| ws.close(); | |
| process.exit(0); | |
| } catch (e) { | |
| console.error('Error:', e.message); | |
| process.exit(1); | |
| } | |
| } | |
| main(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
iOS Safari Remote Control
Remote control Safari on iOS devices (iPhone, iPad) from the command line.
Requirements
brew install ios-webkit-debug-proxywspackage -npm install wsSetup
Usage
Options
-d, --device <name|index>- Required for non-list commands. Specify device by exact name or index (0, 1, etc.)-p, --port <port>- Directly specify device port (bypasses device selection)Notes
-dflag is required for all commands exceptlistto prevent accidentally controlling the wrong device') are normalized to straight quotes (') for matching0refers to the first non-extension page listed bylistios_webkit_debug_proxyto be running