Skip to content

Instantly share code, notes, and snippets.

@ericboehs
Last active December 11, 2025 16:52
Show Gist options
  • Select an option

  • Save ericboehs/1528f16eb493173c7b62bcbd7fdd3283 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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();
@ericboehs
Copy link
Author

ericboehs commented Dec 10, 2025

Note: This code and README were generated with AI assistance (Claude).

iOS Safari Remote Control

Remote control Safari on iOS devices (iPhone, iPad) from the command line.

Requirements

  • ios_webkit_debug_proxy - brew install ios-webkit-debug-proxy
  • Node.js with ws package - npm install ws
  • iOS device with Web Inspector enabled (Settings > Safari > Advanced > Web Inspector)
  • Device connected via USB (or "Connect via Network" enabled)

Setup

# Start the proxy
ios_webkit_debug_proxy

# Download the script
curl -O https://gist.githubusercontent.com/ericboehs/1528f16eb493173c7b62bcbd7fdd3283/raw/ios-safari-remote.js

# Install dependency
npm install ws

Usage

# List connected devices and open Safari pages
node ios-safari-remote.js list

# Get page title (device selection required)
node ios-safari-remote.js -d "Eric's M4 iPad" 0 title

# Get current URL
node ios-safari-remote.js -d "Eric's iPhone 16" 0 url

# List clickable links (using device index)
node ios-safari-remote.js -d 0 0 links 10

# Click a link by index
node ios-safari-remote.js -d 0 0 click 5

# Navigate to a URL
node ios-safari-remote.js -d "Eric's M4 iPad" 0 nav "https://example.com"

# Get page text content
node ios-safari-remote.js -d 0 0 text

# Get page HTML
node ios-safari-remote.js -d 0 0 html

# Run JavaScript
node ios-safari-remote.js -d 0 0 eval "document.querySelectorAll('button').length"

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

  • The -d flag is required for all commands except list to prevent accidentally controlling the wrong device
  • Device names with curly quotes (') are normalized to straight quotes (') for matching
  • Page index 0 refers to the first non-extension page listed by list
  • Requires ios_webkit_debug_proxy to be running

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment