Device Simulator
Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays.
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import { getToolWebviewStyles } from './shared';
const store: {
panel: vscode.WebviewPanel | null;
previewServer: http.Server | null;
previewPort: number;
previewContent: string;
previewDir: string;
} = { panel: null, previewServer: null, previewPort: 0, previewContent: '', previewDir: '' };
interface Device {
id: string;
name: string;
sw: number; sh: number;
scr: number;
notch: 'none' | 'notch' | 'island' | 'punch';
nw?: number; nh?: number; nr?: number; ps?: number;
sat: number; sab: number;
}
const DEVICES: Device[] = [
// ── iPhone 17 ──────────────────────────────────────────────────────────────
{ id: 'iphone-17-pro-max', name: 'iPhone 17 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-17-pro', name: 'iPhone 17 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-air', name: 'iPhone Air', sw: 420, sh: 912, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-17', name: 'iPhone 17', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
// ── iPhone 16 ──────────────────────────────────────────────────────────────
{ id: 'iphone-16-pro-max', name: 'iPhone 16 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-16-pro', name: 'iPhone 16 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-16-plus', name: 'iPhone 16 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-16', name: 'iPhone 16', sw: 390, sh: 844, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
// ── iPhone 15 ──────────────────────────────────────────────────────────────
{ id: 'iphone-15-pro-max', name: 'iPhone 15 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15-pro', name: 'iPhone 15 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15-plus', name: 'iPhone 15 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15', name: 'iPhone 15', sw: 390, sh: 844, scr: 38, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
// ── iPhone 14 ──────────────────────────────────────────────────────────────
{ id: 'iphone-14-pro-max', name: 'iPhone 14 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-14-pro', name: 'iPhone 14 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-14-plus', name: 'iPhone 14 Plus', sw: 428, sh: 926, scr: 40, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
{ id: 'iphone-14', name: 'iPhone 14', sw: 390, sh: 844, scr: 38, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
// ── Other ──────────────────────────────────────────────────────────────────
{ id: 'iphone-se3', name: 'iPhone SE (3rd gen)', sw: 375, sh: 667, scr: 4, notch: 'none', sat: 20, sab: 0 },
{ id: 'pixel-8', name: 'Google Pixel 8', sw: 411, sh: 914, scr: 22, notch: 'punch', ps: 14, sat: 28, sab: 24 },
{ id: 'galaxy-s24', name: 'Samsung Galaxy S24', sw: 360, sh: 780, scr: 20, notch: 'punch', ps: 12, sat: 28, sab: 24 },
];
export function openDeviceSimulator(_context: vscode.ExtensionContext) {
if (store.panel) {
store.panel.reveal();
return;
}
const panel = vscode.window.createWebviewPanel(
'hplToolbox.deviceSimulator',
'Device Simulator',
vscode.ViewColumn.Active,
{ enableScripts: true, retainContextWhenHidden: true }
);
store.panel = panel;
panel.onDidDispose(() => {
store.panel = null;
store.previewContent = '';
store.previewDir = '';
if (store.previewServer) {
store.previewServer.close();
store.previewServer = null;
store.previewPort = 0;
}
});
panel.webview.html = getHtml(DEVICES);
let lastFilePath = '';
panel.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'pickFile') {
const picked = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
openLabel: 'Select File',
filters: { 'HTML': ['html', 'htm'] },
});
if (picked?.[0]) {
lastFilePath = picked[0].fsPath;
sendFile(panel, lastFilePath, true);
}
} else if (msg.type === 'reload') {
if (lastFilePath) sendFile(panel, lastFilePath, false);
} else if (msg.type === 'exportDevices') {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file('hpl-device-simulator-devices.json'),
filters: { 'JSON': ['json'] },
saveLabel: 'Export Devices',
});
if (target) {
await fs.promises.writeFile(target.fsPath, String(msg.content || ''), 'utf8');
}
}
});
}
function sendFile(panel: vscode.WebviewPanel, filePath: string, resetMute: boolean) {
try {
const content = fs.readFileSync(filePath, 'utf8');
store.previewContent = content;
store.previewDir = path.dirname(filePath);
ensurePreviewServer(() => {
const sourceUrl = store.previewPort ? `http://127.0.0.1:${store.previewPort}/preview/index.html` : '';
panel.webview.postMessage({ type: 'fileLoaded', content, name: path.basename(filePath), sourceUrl, resetMute });
});
} catch {
vscode.window.showErrorMessage('Device Simulator: could not read ' + path.basename(filePath));
}
}
function ensurePreviewServer(callback: () => void) {
if (store.previewServer && store.previewPort) {
callback();
return;
}
const server = http.createServer((req, res) => {
if (!req.url || !req.url.startsWith('/preview')) {
res.writeHead(404);
res.end('Not found');
return;
}
const requestUrl = new URL(req.url, 'http://127.0.0.1');
const assetPath = decodeURIComponent(requestUrl.pathname.replace(/^\/preview\/?/, ''));
if (!assetPath || assetPath === 'index.html') {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(injectServerMuteBridge(store.previewContent));
return;
}
const resolved = path.resolve(store.previewDir, assetPath);
if (!store.previewDir || !resolved.startsWith(path.resolve(store.previewDir) + path.sep)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
fs.readFile(resolved, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Cache-Control': 'no-store' });
res.end(data);
});
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
store.previewServer = server;
store.previewPort = typeof address === 'object' && address ? address.port : 0;
callback();
});
server.on('error', () => {
store.previewServer = null;
store.previewPort = 0;
callback();
});
}
function injectServerMuteBridge(html: string): string {
const bridge =
'
Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays.