3 Commits

Author SHA1 Message Date
5fa1ce949c v0.2.3 2026-06-16 10:21:41 +08:00
e50b4c2133 Added localization in Device Simulator 2026-06-16 10:20:49 +08:00
cebb65fb35 v0.2.2 2026-06-15 13:55:05 +08:00
13 changed files with 7042 additions and 30 deletions

View File

@@ -105,3 +105,15 @@ Fixed
- Fixed Device Simulator Add Device and Remove Device interactions inside embedded webviews.
- Fixed Send To Mobile browser preview spending the View button's initial tap as a premature playable click-through while preserving real user-initiated redirects.
```
**v0.2.2**
```
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

View File

@@ -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.1",
"version": "0.2.3",
"publisher": "hesukastro",
"license": "UNLICENSED",
"repository": {
@@ -51,6 +51,10 @@
{
"command": "hplToolbox.openDeviceSimulator",
"title": "HPL Toolbox: Open Device Simulator"
},
{
"command": "hplToolbox.checkForUpdates",
"title": "HPL Toolbox: Check for Updates"
}
],
"viewsContainers": {

View File

@@ -9,6 +9,7 @@ import { openPlayworksConverter } from './tools/playworksConverter';
import { openMintegralChecker } from './tools/mintegralChecker';
import { openChangelog } from './changelogView';
import { openDeviceSimulator } from './tools/deviceSimulator';
import { checkForUpdates } from './updateChecker';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
@@ -21,8 +22,11 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('hplToolbox.openMintegralChecker', () => openMintegralChecker(context)),
vscode.commands.registerCommand('hplToolbox.openDeviceSimulator', () => openDeviceSimulator(context)),
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
vscode.commands.registerCommand('hplToolbox.checkForUpdates', () => checkForUpdates(context)),
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
);
void checkForUpdates(context);
}
export function deactivate() {}

View File

@@ -62,6 +62,8 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
vscode.commands.executeCommand(msg.command);
} else if (msg?.type === 'openChangelog') {
vscode.commands.executeCommand('hplToolbox.openChangelog');
} else if (msg?.type === 'checkForUpdates') {
vscode.commands.executeCommand('hplToolbox.checkForUpdates');
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
view.webview.html = getHtml(version, msg.enabled, tools, this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []));
@@ -204,6 +206,7 @@ function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefiniti
opacity: 0.75;
text-align: center;
white-space: nowrap;
justify-self: center;
}
.changelog-link {
justify-self: end;
@@ -217,7 +220,7 @@ ${toolButtons}
</div>
<div class="footer">
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
<span class="footer-version">${version} - JJGC 00784</span>
<button id="updateCheck" class="footer-action footer-version" title="Check for updates">${version} - JJGC 00784</button>
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
</div>
<script>
@@ -269,6 +272,9 @@ ${toolButtons}
document.getElementById('changelogLink').addEventListener('click', () => {
vscode.postMessage({ type: 'openChangelog' });
});
document.getElementById('updateCheck').addEventListener('click', () => {
vscode.postMessage({ type: 'checkForUpdates' });
});
</script>
</body>
</html>`;

View File

@@ -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">&#128241;</span></button>
<button id="mute-btn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">&#128263;</button>
<button id="reload-btn" class="icon-btn" title="Reload" aria-label="Reload">&#8635;</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');

80
src/updateChecker.ts Normal file
View File

@@ -0,0 +1,80 @@
import * as vscode from 'vscode';
const REMOTE_PACKAGE_JSON_URL = 'https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox/raw/branch/main/package.json';
interface RemotePackageJson {
version?: unknown;
}
export async function checkForUpdates(context: vscode.ExtensionContext): Promise<void> {
const currentVersion = String(context.extension.packageJSON.version ?? '').trim();
if (!currentVersion) {
vscode.window.showWarningMessage('HPL Toolbox could not read the installed version.');
return;
}
try {
const remoteVersion = await fetchRemoteVersion();
const comparison = compareVersions(remoteVersion, currentVersion);
if (comparison > 0) {
const action = await vscode.window.showInformationMessage(
`HPL Toolbox update available: ${remoteVersion} (installed: ${currentVersion}).`,
'Open Repository'
);
if (action === 'Open Repository') {
await vscode.env.openExternal(vscode.Uri.parse('https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox'));
}
return;
}
vscode.window.showInformationMessage(`HPL Toolbox is up to date (${currentVersion}).`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
vscode.window.showWarningMessage(`HPL Toolbox update check failed: ${message}`);
}
}
async function fetchRemoteVersion(): Promise<string> {
const response = await fetch(REMOTE_PACKAGE_JSON_URL, {
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`remote package.json returned HTTP ${response.status}`);
}
const packageJson = await response.json() as RemotePackageJson;
if (typeof packageJson.version !== 'string' || !packageJson.version.trim()) {
throw new Error('remote package.json is missing a version');
}
return packageJson.version.trim();
}
function compareVersions(left: string, right: string): number {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
}
function parseVersion(version: string): number[] {
return version
.split(/[.-]/)
.map(part => Number.parseInt(part, 10))
.filter(part => Number.isFinite(part));
}

View File

@@ -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">&#128241;</span></button>
<button id="muteBtn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">&#128263;</button>
<button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">&#8635;</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
}

View File

@@ -159,6 +159,55 @@ const SharedCSS = `
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
.app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; }
.app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; }
.app-footer-version-button { justify-self: center; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.75; text-align: center; white-space: nowrap; }
.app-footer-version-button:hover { background: transparent; opacity: 1; text-decoration: underline; }
.toast-box {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 50;
max-width: min(420px, calc(100vw - 36px));
padding: 10px 12px;
display: none;
align-items: center;
gap: 10px;
color: #ddd;
background: #252526;
border: 1px solid #444;
border-left: 3px solid #007fd4;
border-radius: 5px;
box-shadow: 0 10px 28px rgba(0,0,0,0.35);
font-size: 12px;
line-height: 1.35;
}
.toast-box.is-visible { display: flex; }
.toast-box.is-update { border-left-color: #ffd580; }
.toast-box.is-current { border-left-color: #89d185; }
.toast-box.is-error { border-left-color: #f48771; }
.toast-message { min-width: 0; flex: 1; overflow-wrap: anywhere; }
.toast-action {
flex: 0 0 auto;
padding: 4px 8px;
background: #0e639c;
color: #fff;
border: 0;
border-radius: 2px;
font-size: 12px;
}
.toast-close {
flex: 0 0 auto;
width: 22px;
height: 22px;
padding: 0;
display: grid;
place-items: center;
background: transparent;
color: #a7a7a7;
border: 0;
font-size: 16px;
line-height: 1;
}
.toast-close:hover { background: rgba(255,255,255,0.08); color: #fff; }
body.sidebar-collapsed .sidebar { align-items: stretch; }
body.sidebar-collapsed .sidebar-title,
body.sidebar-collapsed .nav-text,
@@ -539,10 +588,15 @@ func Page(activePath, title, body string) string {
<div class="content">` + body + `</div>
<div class="app-footer">
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span>
<button id="updateCheck" type="button" class="app-footer-version-button" title="Check for updates">` + AppVersion + ` - JJGC 00784</button>
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
</div>
</div>
<div id="toastBox" class="toast-box" role="status" aria-live="polite">
<span id="toastMessage" class="toast-message"></span>
<button id="toastAction" type="button" class="toast-action is-hidden">Open Repository</button>
<button id="toastClose" type="button" class="toast-close" aria-label="Close">x</button>
</div>
<script>
if (localStorage.getItem('hplSidebarCollapsed') === 'true') {
document.body.classList.add('sidebar-collapsed');
@@ -628,6 +682,51 @@ document.getElementById('betaToolsToggle').addEventListener('click', async (even
});
window.location.reload();
});
const toastBox = document.getElementById('toastBox');
const toastMessage = document.getElementById('toastMessage');
const toastAction = document.getElementById('toastAction');
const toastClose = document.getElementById('toastClose');
let toastTimer = null;
let updateRepositoryUrl = '';
function showToast(message, status, repositoryUrl) {
window.clearTimeout(toastTimer);
updateRepositoryUrl = repositoryUrl || '';
toastMessage.textContent = message || '';
toastAction.classList.toggle('is-hidden', !updateRepositoryUrl);
toastBox.className = 'toast-box is-visible is-' + (status || 'current');
toastTimer = window.setTimeout(() => {
toastBox.classList.remove('is-visible');
}, updateRepositoryUrl ? 9000 : 5000);
}
async function checkForUpdates(showCurrent) {
try {
const response = await fetch('/api/update/check');
const result = await response.json();
if (result.status === 'current' && !showCurrent) return;
showToast(result.message || 'HPL Toolbox update check finished.', result.status, result.repositoryUrl);
} catch (error) {
showToast('HPL Toolbox update check failed: ' + (error.message || error), 'error');
}
}
document.getElementById('updateCheck').addEventListener('click', () => {
checkForUpdates(true);
});
toastClose.addEventListener('click', () => {
window.clearTimeout(toastTimer);
toastBox.classList.remove('is-visible');
});
toastAction.addEventListener('click', async () => {
if (!updateRepositoryUrl) return;
await fetch('/api/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: updateRepositoryUrl }),
});
});
if (sessionStorage.getItem('hplToolboxUpdateChecked') !== 'true') {
sessionStorage.setItem('hplToolboxUpdateChecked', 'true');
checkForUpdates(true);
}
</script>
</body></html>`
}

View File

@@ -123,6 +123,7 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /api/open", OpenEndpoint)
mux.HandleFunc("POST /api/focus", FocusEndpoint)
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
mux.HandleFunc("GET /api/update/check", UpdateCheckEndpoint)
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
// PLEC
@@ -169,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
}

View File

@@ -0,0 +1,127 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
const remotePackageJSONURL = "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox/raw/branch/main/package.json"
const updateRepositoryURL = "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox"
type updateCheckResponse struct {
OK bool `json:"ok"`
Status string `json:"status"`
CurrentVersion string `json:"currentVersion"`
RemoteVersion string `json:"remoteVersion,omitempty"`
Message string `json:"message"`
RepositoryURL string `json:"repositoryUrl,omitempty"`
}
func UpdateCheckEndpoint(w http.ResponseWriter, r *http.Request) {
remoteVersion, err := fetchRemoteVersion()
if err != nil {
writeJSON(w, updateCheckResponse{
OK: false,
Status: "error",
CurrentVersion: AppVersion,
Message: "HPL Toolbox update check failed: " + err.Error(),
})
return
}
comparison := compareVersions(remoteVersion, AppVersion)
if comparison > 0 {
writeJSON(w, updateCheckResponse{
OK: true,
Status: "update",
CurrentVersion: AppVersion,
RemoteVersion: remoteVersion,
Message: fmt.Sprintf("HPL Toolbox update available: %s (installed: %s).", remoteVersion, AppVersion),
RepositoryURL: updateRepositoryURL,
})
return
}
writeJSON(w, updateCheckResponse{
OK: true,
Status: "current",
CurrentVersion: AppVersion,
RemoteVersion: remoteVersion,
Message: fmt.Sprintf("HPL Toolbox is up to date (%s).", AppVersion),
})
}
func fetchRemoteVersion() (string, error) {
client := http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, remotePackageJSONURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("remote package.json returned HTTP %d", resp.StatusCode)
}
var packageJSON struct {
Version string `json:"version"`
}
if err := json.NewDecoder(resp.Body).Decode(&packageJSON); err != nil {
return "", err
}
if strings.TrimSpace(packageJSON.Version) == "" {
return "", fmt.Errorf("remote package.json is missing a version")
}
return strings.TrimSpace(packageJSON.Version), nil
}
func compareVersions(left, right string) int {
leftParts := parseVersionParts(left)
rightParts := parseVersionParts(right)
length := len(leftParts)
if len(rightParts) > length {
length = len(rightParts)
}
for i := 0; i < length; i++ {
leftPart := 0
rightPart := 0
if i < len(leftParts) {
leftPart = leftParts[i]
}
if i < len(rightParts) {
rightPart = rightParts[i]
}
if leftPart > rightPart {
return 1
}
if leftPart < rightPart {
return -1
}
}
return 0
}
func parseVersionParts(version string) []int {
fields := strings.FieldsFunc(version, func(r rune) bool {
return r == '.' || r == '-'
})
parts := make([]int, 0, len(fields))
for _, field := range fields {
part, err := strconv.Atoi(field)
if err == nil {
parts = append(parts, part)
}
}
return parts
}