Added localization in Device Simulator
This commit is contained in:
@@ -111,3 +111,9 @@ Fixed
|
||||
Added
|
||||
- Added automatic update checking to the VS Code extension and standalone app.
|
||||
```
|
||||
|
||||
**v0.2.3**
|
||||
```
|
||||
Added
|
||||
- Added Localization dropdown in Device Simulator.
|
||||
```
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
"name": "hpl-toolbox",
|
||||
"displayName": "HPL Toolbox",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"publisher": "hesukastro",
|
||||
"license": "UNLICENSED",
|
||||
"repository": {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
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 } = { panel: null };
|
||||
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;
|
||||
@@ -56,7 +63,16 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
||||
);
|
||||
|
||||
store.panel = panel;
|
||||
panel.onDidDispose(() => { store.panel = null; });
|
||||
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 = '';
|
||||
@@ -72,10 +88,10 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
||||
});
|
||||
if (picked?.[0]) {
|
||||
lastFilePath = picked[0].fsPath;
|
||||
sendFile(panel, lastFilePath);
|
||||
sendFile(panel, lastFilePath, true);
|
||||
}
|
||||
} else if (msg.type === 'reload') {
|
||||
if (lastFilePath) sendFile(panel, lastFilePath);
|
||||
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'),
|
||||
@@ -89,15 +105,96 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
||||
});
|
||||
}
|
||||
|
||||
function sendFile(panel: vscode.WebviewPanel, filePath: string) {
|
||||
function sendFile(panel: vscode.WebviewPanel, filePath: string, resetMute: boolean) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
panel.webview.postMessage({ type: 'fileLoaded', content, name: path.basename(filePath) });
|
||||
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 =
|
||||
'<scr' + 'ipt>' +
|
||||
'(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<media.length;i++)applyMedia(media[i]);for(var j=0;j<gains.length;j++){try{gains[j].gain.value=muted?0:1;}catch(e){}}}' +
|
||||
'window.__hplSetMuted=function(v){muted=!!v;window.__hplMuted=muted;apply();};' +
|
||||
'window.addEventListener("message",function(e){if(e.data&&e.data.type==="hplDeviceSimulatorMute")window.__hplSetMuted(e.data.muted);});' +
|
||||
'function patchAudioContext(name){var Orig=window[name];if(!Orig||Orig.__hplPatched)return;function Wrapped(){var ctx=arguments.length?new Orig(arguments[0]):new Orig();try{var gain=ctx.createGain();gain.gain.value=muted?0:1;gain.connect(ctx.destination);contexts.push(ctx);gains.push(gain);destinations.push(ctx.destination);}catch(e){}return ctx;}Wrapped.prototype=Orig.prototype;try{Object.setPrototypeOf(Wrapped,Orig);}catch(e){}Wrapped.__hplPatched=true;window[name]=Wrapped;}' +
|
||||
'try{var origConnect=window.AudioNode&&window.AudioNode.prototype&&window.AudioNode.prototype.connect;if(origConnect&&!origConnect.__hplPatched){var patched=function(target){var args=Array.prototype.slice.call(arguments);var idx=destinations.indexOf(args[0]);if(idx>=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){}' +
|
||||
'})();' +
|
||||
'</scr' + 'ipt>';
|
||||
if (/<head\b[^>]*>/i.test(html)) {
|
||||
return html.replace(/<head\b[^>]*>/i, (match) => match + bridge);
|
||||
}
|
||||
return bridge + html;
|
||||
}
|
||||
|
||||
function getHtml(devices: Device[]): string {
|
||||
const devicesJson = JSON.stringify(devices);
|
||||
return `<!DOCTYPE html>
|
||||
@@ -187,8 +284,13 @@ html, body { min-height: 100%; }
|
||||
}
|
||||
.title-orient {
|
||||
min-height: 28px;
|
||||
padding: 2px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.orient-icon {
|
||||
display: block;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.title-orient.landscape .orient-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.icon-btn {
|
||||
width: 28px;
|
||||
@@ -413,7 +515,8 @@ html, body { min-height: 100%; }
|
||||
</div>
|
||||
<div class="title-actions">
|
||||
<select id="device-select" class="title-select" title="Device"></select>
|
||||
<button id="orient-btn" class="secondary title-orient">Landscape</button>
|
||||
<select id="language-select" class="title-select" title="Language" aria-label="Language"></select>
|
||||
<button id="orient-btn" class="icon-btn title-orient" title="Switch to Landscape" aria-label="Switch to Landscape"><span class="orient-icon">📱</span></button>
|
||||
<button id="mute-btn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button>
|
||||
<button id="reload-btn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||
</div>
|
||||
@@ -430,12 +533,27 @@ html, body { min-height: 100%; }
|
||||
const vscode = acquireVsCodeApi();
|
||||
const DEFAULT_DEVICES = ${devicesJson};
|
||||
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
|
||||
const LANGUAGES = [
|
||||
{ name: 'English', code: 'en' },
|
||||
{ name: 'Chinese (Simplified)', code: 'zh-hans' },
|
||||
{ name: 'Chinese (Traditional)', code: 'zh-hant' },
|
||||
{ name: 'French', code: 'fr' },
|
||||
{ name: 'German', code: 'de' },
|
||||
{ name: 'Japanese', code: 'ja' },
|
||||
{ name: 'Korean', code: 'ko' },
|
||||
{ name: 'Portuguese', code: 'pt' },
|
||||
{ name: 'Russian', code: 'ru' },
|
||||
{ name: 'Spanish', code: 'es' }
|
||||
];
|
||||
|
||||
let devices = loadDevices();
|
||||
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||
let language = 'en';
|
||||
let landscape = false;
|
||||
let currentContent = null;
|
||||
let currentSourceUrl = '';
|
||||
let currentBlobUrl = null;
|
||||
let loadVersion = 0;
|
||||
let cutoutColor = '#000000';
|
||||
let muted = false;
|
||||
const mediaVolumes = new WeakMap();
|
||||
@@ -444,12 +562,18 @@ let muteDocument = null;
|
||||
|
||||
/* ── device selector ─────────────────────────────────────────────────── */
|
||||
const sel = document.getElementById('device-select');
|
||||
const languageSelect = document.getElementById('language-select');
|
||||
populateDeviceSelect(device.id);
|
||||
populateLanguageSelect();
|
||||
sel.addEventListener('change', function() {
|
||||
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
languageSelect.addEventListener('change', function() {
|
||||
language = languageSelect.value || LANGUAGES[0].code;
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
document.getElementById('add-device-btn').addEventListener('click', addDevice);
|
||||
document.getElementById('remove-device-btn').addEventListener('click', removeCurrentDevice);
|
||||
document.getElementById('import-devices-btn').addEventListener('click', function() {
|
||||
@@ -554,6 +678,17 @@ function populateDeviceSelect(selectedId) {
|
||||
renderDeviceList(getCheckedDeviceIds());
|
||||
}
|
||||
|
||||
function populateLanguageSelect() {
|
||||
languageSelect.innerHTML = '';
|
||||
LANGUAGES.forEach(function(lang) {
|
||||
const o = document.createElement('option');
|
||||
o.value = lang.code;
|
||||
o.textContent = lang.name;
|
||||
languageSelect.appendChild(o);
|
||||
});
|
||||
languageSelect.value = language;
|
||||
}
|
||||
|
||||
function renderDeviceList(checkedIds) {
|
||||
const list = document.getElementById('device-list');
|
||||
if (!list) return;
|
||||
@@ -767,11 +902,18 @@ function importDevices(event) {
|
||||
/* ── orientation ────────────────────────────────────────────────────── */
|
||||
document.getElementById('orient-btn').addEventListener('click', function() {
|
||||
landscape = !landscape;
|
||||
this.textContent = landscape ? 'Portrait' : 'Landscape';
|
||||
renderOrientationButton();
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
|
||||
function renderOrientationButton() {
|
||||
const btn = document.getElementById('orient-btn');
|
||||
btn.classList.toggle('landscape', landscape);
|
||||
btn.title = landscape ? 'Switch to Portrait' : 'Switch to Landscape';
|
||||
btn.setAttribute('aria-label', landscape ? 'Switch to Portrait' : 'Switch to Landscape');
|
||||
}
|
||||
|
||||
function triggerPick(btn) {
|
||||
const status = document.getElementById('setup-status');
|
||||
if (status) {
|
||||
@@ -859,12 +1001,17 @@ function loadContent(html) {
|
||||
const frame = document.getElementById('preview-frame');
|
||||
if (!frame) return;
|
||||
|
||||
// Revoke previous blob URL to free memory
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
currentBlobUrl = null;
|
||||
}
|
||||
|
||||
if (currentSourceUrl) {
|
||||
frame.src = withPreviewParams(currentSourceUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Revoke previous blob URL to free memory
|
||||
// Blob URL iframe: the blob document has its own security context — VS Code
|
||||
// does not inject its nonce-based CSP into it, so inline scripts run freely.
|
||||
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||
@@ -872,8 +1019,22 @@ function loadContent(html) {
|
||||
frame.src = currentBlobUrl;
|
||||
}
|
||||
|
||||
function withPreviewParams(url) {
|
||||
const separator = url.indexOf('?') >= 0 ? '&' : '?';
|
||||
loadVersion += 1;
|
||||
return url + separator + 'lang=' + encodeURIComponent(language) + '&v=' + loadVersion;
|
||||
}
|
||||
|
||||
function injectMuteBridge(html) {
|
||||
const bridge =
|
||||
const languageBridge =
|
||||
'<scr' + 'ipt>' +
|
||||
'(function(){try{' +
|
||||
'var url=new URL(window.location.href);' +
|
||||
'url.searchParams.set("lang","' + language + '");' +
|
||||
'history.replaceState(null,"",url.href);' +
|
||||
'}catch(e){}})();' +
|
||||
'</scr' + 'ipt>';
|
||||
const bridge = languageBridge +
|
||||
'<scr' + 'ipt>' +
|
||||
'(function(){' +
|
||||
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
||||
@@ -909,10 +1070,10 @@ function renderMuteButton() {
|
||||
function applyMute() {
|
||||
const frame = document.getElementById('preview-frame');
|
||||
if (!frame || !frame.contentWindow) return;
|
||||
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||
try {
|
||||
frame.contentWindow.__hplMuted = muted;
|
||||
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
||||
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||
const doc = frame.contentWindow.document;
|
||||
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||
if (muteDocument !== doc) {
|
||||
@@ -962,8 +1123,11 @@ window.addEventListener('message', function(event) {
|
||||
if (!msg || !msg.type) return;
|
||||
if (msg.type === 'fileLoaded') {
|
||||
currentContent = msg.content;
|
||||
muted = false;
|
||||
renderMuteButton();
|
||||
currentSourceUrl = msg.sourceUrl || '';
|
||||
if (msg.resetMute) {
|
||||
muted = false;
|
||||
renderMuteButton();
|
||||
}
|
||||
document.getElementById('file-label').textContent = msg.name;
|
||||
document.getElementById('setup-file-name').textContent = msg.name;
|
||||
var setupStatus = document.getElementById('setup-status');
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type deviceSimulatorDevice struct {
|
||||
@@ -132,7 +133,8 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
||||
</div>
|
||||
<div class="title-actions">
|
||||
<select id="deviceSelect" class="title-select" title="Device"></select>
|
||||
<button id="orientBtn" class="secondary title-orient">Landscape</button>
|
||||
<select id="languageSelect" class="title-select" title="Language" aria-label="Language"></select>
|
||||
<button id="orientBtn" class="icon-btn title-orient" title="Switch to Landscape" aria-label="Switch to Landscape"><span class="orient-icon">📱</span></button>
|
||||
<button id="muteBtn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button>
|
||||
<button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||
</div>
|
||||
@@ -166,7 +168,9 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
||||
#dimLabel { white-space: nowrap; flex-shrink: 0; }
|
||||
.title-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||
.title-select { width:180px; min-width:120px; }
|
||||
.title-orient { min-height:28px; padding:2px 10px; white-space:nowrap; }
|
||||
.title-orient { min-height:28px; }
|
||||
.orient-icon { display:block; transition:transform 120ms ease; }
|
||||
.title-orient.landscape .orient-icon { transform:rotate(90deg); }
|
||||
.icon-btn {
|
||||
width:28px;
|
||||
height:28px;
|
||||
@@ -314,11 +318,26 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
||||
<script>
|
||||
const DEFAULT_DEVICES = ` + string(devicesJSON) + `;
|
||||
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
|
||||
const LANGUAGES = [
|
||||
{ name: 'English', code: 'en' },
|
||||
{ name: 'Chinese (Simplified)', code: 'zh-hans' },
|
||||
{ name: 'Chinese (Traditional)', code: 'zh-hant' },
|
||||
{ name: 'French', code: 'fr' },
|
||||
{ name: 'German', code: 'de' },
|
||||
{ name: 'Japanese', code: 'ja' },
|
||||
{ name: 'Korean', code: 'ko' },
|
||||
{ name: 'Portuguese', code: 'pt' },
|
||||
{ name: 'Russian', code: 'ru' },
|
||||
{ name: 'Spanish', code: 'es' }
|
||||
];
|
||||
let devices = loadDevices();
|
||||
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||
let language = 'en';
|
||||
let landscape = false;
|
||||
let currentPath = '';
|
||||
let currentContent = '';
|
||||
let currentBlobUrl = '';
|
||||
let loadVersion = 0;
|
||||
let muted = false;
|
||||
const mediaVolumes = new WeakMap();
|
||||
let muteObserver = null;
|
||||
@@ -330,6 +349,7 @@ const fileLabel = document.getElementById('fileLabel');
|
||||
const dimLabel = document.getElementById('dimLabel');
|
||||
const simPanel = document.getElementById('simPanel');
|
||||
const sel = document.getElementById('deviceSelect');
|
||||
const languageSelect = document.getElementById('languageSelect');
|
||||
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||
@@ -340,11 +360,16 @@ function setBusy(text) {
|
||||
statusEl.classList.toggle('is-busy', !!text);
|
||||
}
|
||||
populateDeviceSelect(device.id);
|
||||
populateLanguageSelect();
|
||||
sel.addEventListener('change', () => {
|
||||
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
languageSelect.addEventListener('change', () => {
|
||||
language = languageSelect.value || LANGUAGES[0].code;
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
document.getElementById('addDeviceBtn').addEventListener('click', addDevice);
|
||||
document.getElementById('removeDeviceBtn').addEventListener('click', removeCurrentDevice);
|
||||
document.getElementById('importDevicesBtn').addEventListener('click', () => {
|
||||
@@ -449,6 +474,17 @@ function populateDeviceSelect(selectedId) {
|
||||
renderDeviceList(getCheckedDeviceIds());
|
||||
}
|
||||
|
||||
function populateLanguageSelect() {
|
||||
languageSelect.innerHTML = '';
|
||||
LANGUAGES.forEach((lang) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = lang.code;
|
||||
o.textContent = lang.name;
|
||||
languageSelect.appendChild(o);
|
||||
});
|
||||
languageSelect.value = language;
|
||||
}
|
||||
|
||||
function renderDeviceList(checkedIds) {
|
||||
const list = document.getElementById('deviceList');
|
||||
if (!list) return;
|
||||
@@ -659,16 +695,22 @@ function importDevices(event) {
|
||||
}
|
||||
document.getElementById('orientBtn').addEventListener('click', (event) => {
|
||||
landscape = !landscape;
|
||||
event.currentTarget.textContent = landscape ? 'Portrait' : 'Landscape';
|
||||
renderOrientationButton();
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
function renderOrientationButton() {
|
||||
const btn = document.getElementById('orientBtn');
|
||||
btn.classList.toggle('landscape', landscape);
|
||||
btn.title = landscape ? 'Switch to Portrait' : 'Switch to Landscape';
|
||||
btn.setAttribute('aria-label', landscape ? 'Switch to Portrait' : 'Switch to Landscape');
|
||||
}
|
||||
document.getElementById('pickBtn').addEventListener('click', async () => {
|
||||
setBusy('Opening file picker...');
|
||||
try {
|
||||
const r = await fetch('/api/device-simulator/pick', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) await loadPath(j.path);
|
||||
if (j.path) await loadPath(j.path, true);
|
||||
else setBusy('');
|
||||
} catch (e) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
@@ -676,7 +718,7 @@ document.getElementById('pickBtn').addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||
if (currentPath) loadPath(currentPath);
|
||||
if (currentPath) loadPath(currentPath, false);
|
||||
});
|
||||
document.getElementById('muteBtn').addEventListener('click', () => {
|
||||
muted = !muted;
|
||||
@@ -684,11 +726,11 @@ document.getElementById('muteBtn').addEventListener('click', () => {
|
||||
applyMute();
|
||||
});
|
||||
setupDropZone('dropZone', statusEl, (j) => {
|
||||
if (j.paths && j.paths.length) loadPath(j.paths[0]);
|
||||
if (j.paths && j.paths.length) loadPath(j.paths[0], true);
|
||||
else if (j.skipped) statusEl.textContent = 'Drop an HTML file.';
|
||||
});
|
||||
|
||||
async function loadPath(path) {
|
||||
async function loadPath(path, resetMute) {
|
||||
currentPath = path;
|
||||
setBusy('Reading ' + basename(path) + '...');
|
||||
const r = await fetch('/api/device-simulator/load', {
|
||||
@@ -706,8 +748,10 @@ async function loadPath(path) {
|
||||
fileNameEl.textContent = j.name;
|
||||
fileLabel.textContent = j.name;
|
||||
statusEl.textContent = '';
|
||||
muted = false;
|
||||
renderMuteButton();
|
||||
if (resetMute) {
|
||||
muted = false;
|
||||
renderMuteButton();
|
||||
}
|
||||
simPanel.classList.add('active');
|
||||
renderScreen();
|
||||
loadContent(currentContent);
|
||||
@@ -753,10 +797,34 @@ function renderScreen() {
|
||||
function loadContent(html) {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
if (!frame) return;
|
||||
frame.srcdoc = injectMuteBridge(html);
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
currentBlobUrl = '';
|
||||
}
|
||||
if (currentPath) {
|
||||
frame.src = previewUrl();
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||
currentBlobUrl = URL.createObjectURL(blob);
|
||||
frame.src = currentBlobUrl;
|
||||
}
|
||||
function previewUrl() {
|
||||
loadVersion += 1;
|
||||
return '/api/device-simulator/preview/index.html?path=' + encodeURIComponent(currentPath) +
|
||||
'&lang=' + encodeURIComponent(language) +
|
||||
'&v=' + loadVersion;
|
||||
}
|
||||
function injectMuteBridge(html) {
|
||||
const bridge =
|
||||
const languageBridge =
|
||||
'<scr' + 'ipt>' +
|
||||
'(function(){try{' +
|
||||
'var url=new URL(window.location.href);' +
|
||||
'url.searchParams.set("lang","' + language + '");' +
|
||||
'history.replaceState(null,"",url.href);' +
|
||||
'}catch(e){}})();' +
|
||||
'</scr' + 'ipt>';
|
||||
const bridge = languageBridge +
|
||||
'<scr' + 'ipt>' +
|
||||
'(function(){' +
|
||||
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
||||
@@ -790,10 +858,10 @@ function renderMuteButton() {
|
||||
function applyMute() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
if (!frame || !frame.contentWindow) return;
|
||||
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||
try {
|
||||
frame.contentWindow.__hplMuted = muted;
|
||||
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
||||
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||
const doc = frame.contentWindow.document;
|
||||
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||
if (muteDocument !== doc) {
|
||||
@@ -882,3 +950,68 @@ func DeviceSimulatorLoad(w http.ResponseWriter, r *http.Request) {
|
||||
"content": string(data),
|
||||
})
|
||||
}
|
||||
|
||||
func DeviceSimulatorPreview(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Query().Get("path")
|
||||
if path == "" || !isHTMLPath(path) {
|
||||
http.Error(w, "Pick an HTML file.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
http.Error(w, "Expected an HTML file, got a folder.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(path)
|
||||
assetName := filepath.ToSlash(r.URL.Path[len("/api/device-simulator/preview/"):])
|
||||
targetPath := path
|
||||
if assetName != "" && assetName != "index.html" {
|
||||
cleanName := filepath.Clean(filepath.FromSlash(assetName))
|
||||
targetPath = filepath.Join(baseDir, cleanName)
|
||||
resolvedBase, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resolvedTarget, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if resolvedTarget != resolvedBase && len(resolvedTarget) > len(resolvedBase) && resolvedTarget[:len(resolvedBase)+1] != resolvedBase+string(os.PathSeparator) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if targetPath == path {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data = []byte(injectDeviceSimulatorMuteBridge(string(data)))
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func injectDeviceSimulatorMuteBridge(html string) string {
|
||||
bridge := `<script>(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<media.length;i++)applyMedia(media[i]);for(var j=0;j<gains.length;j++){try{gains[j].gain.value=muted?0:1;}catch(e){}}}window.__hplSetMuted=function(v){muted=!!v;window.__hplMuted=muted;apply();};window.addEventListener("message",function(e){if(e.data&&e.data.type==="hplDeviceSimulatorMute")window.__hplSetMuted(e.data.muted);});function patchAudioContext(name){var Orig=window[name];if(!Orig||Orig.__hplPatched)return;function Wrapped(){var ctx=arguments.length?new Orig(arguments[0]):new Orig();try{var gain=ctx.createGain();gain.gain.value=muted?0:1;gain.connect(ctx.destination);contexts.push(ctx);gains.push(gain);destinations.push(ctx.destination);}catch(e){}return ctx;}Wrapped.prototype=Orig.prototype;try{Object.setPrototypeOf(Wrapped,Orig);}catch(e){}Wrapped.__hplPatched=true;window[name]=Wrapped;}try{var origConnect=window.AudioNode&&window.AudioNode.prototype&&window.AudioNode.prototype.connect;if(origConnect&&!origConnect.__hplPatched){var patched=function(target){var args=Array.prototype.slice.call(arguments);var idx=destinations.indexOf(args[0]);if(idx>=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){}})();</script>`
|
||||
lower := strings.ToLower(html)
|
||||
headIndex := strings.Index(lower, "<head")
|
||||
if headIndex >= 0 {
|
||||
closeIndex := strings.Index(lower[headIndex:], ">")
|
||||
if closeIndex >= 0 {
|
||||
insertAt := headIndex + closeIndex + 1
|
||||
return html[:insertAt] + bridge + html[insertAt:]
|
||||
}
|
||||
}
|
||||
return bridge + html
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ func buildMux() *http.ServeMux {
|
||||
// Device Simulator
|
||||
mux.HandleFunc("POST /api/device-simulator/pick", DeviceSimulatorPick)
|
||||
mux.HandleFunc("POST /api/device-simulator/load", DeviceSimulatorLoad)
|
||||
mux.HandleFunc("GET /api/device-simulator/preview/", DeviceSimulatorPreview)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user