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 = '' + '(function(){' + 'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' + 'var muted=false,media=[],contexts=[],gains=[],destinations=[];' + 'function rememberMedia(el){if(!el||media.indexOf(el)>=0)return;media.push(el);applyMedia(el);}' + 'function applyMedia(el){try{el.muted=muted;if(muted){if(el.__hplVolume===undefined)el.__hplVolume=el.volume;el.volume=0;}else{if(el.__hplVolume!==undefined)el.volume=el.__hplVolume;}}catch(e){}}' + 'function apply(){for(var i=0;i=0&&gains[idx])args[0]=gains[idx];return origConnect.apply(this,args);};patched.__hplPatched=true;window.AudioNode.prototype.connect=patched;}}catch(e){}' + 'try{patchAudioContext("AudioContext");patchAudioContext("webkitAudioContext");}catch(e){}' + 'try{var play=window.HTMLMediaElement&&window.HTMLMediaElement.prototype&&window.HTMLMediaElement.prototype.play;if(play&&!play.__hplPatched){var p=function(){rememberMedia(this);return play.apply(this,arguments);};p.__hplPatched=true;window.HTMLMediaElement.prototype.play=p;}}catch(e){}' + 'function scan(root){try{(root||document).querySelectorAll("audio,video").forEach(rememberMedia);}catch(e){}}' + 'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){scan(document);});else scan(document);' + 'try{new MutationObserver(function(ms){ms.forEach(function(m){Array.prototype.forEach.call(m.addedNodes,function(n){if(n.nodeType!==1)return;if(n.matches&&n.matches("audio,video"))rememberMedia(n);scan(n);});});}).observe(document.documentElement,{childList:true,subtree:true});}catch(e){}' + '})();' + ''; if (/]*>/i.test(html)) { return html.replace(/]*>/i, (match) => match + bridge); } return bridge + html; } function getHtml(devices: Device[]): string { const devicesJson = JSON.stringify(devices); return `

Device Simulator

Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays.

Input

Playable HTML
(no file selected)

Playable

`; }