Compare commits
7 Commits
7094e6c3b0
...
v0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e5d56ab1d | |||
| 68d15aa208 | |||
| b254daf06a | |||
| 16de3cc116 | |||
| 5fa1ce949c | |||
| e50b4c2133 | |||
| cebb65fb35 |
26
README.md
26
README.md
@@ -11,6 +11,7 @@ Bundled VS Code extension and standalone tools for playable ad workflows.
|
|||||||
- **MRAID Checker** - Check HTML files against MRAID requirements and best practices.
|
- **MRAID Checker** - Check HTML files against MRAID requirements and best practices.
|
||||||
- **Mintegral Checker** - Validate Mintegral playable ZIPs against PlayTurbo requirements.
|
- **Mintegral Checker** - Validate Mintegral playable ZIPs against PlayTurbo requirements.
|
||||||
- **Playworks Converter** - Convert Playworks HTML into per-network variants. Beta.
|
- **Playworks Converter** - Convert Playworks HTML into per-network variants. Beta.
|
||||||
|
- **Initialize Project** - Download team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared Google Drive manifest.
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
@@ -105,3 +106,28 @@ Fixed
|
|||||||
- Fixed Device Simulator Add Device and Remove Device interactions inside embedded webviews.
|
- 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.
|
- 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.2.4**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added Initialize Project tool to the VS Code extension and standalone app.
|
||||||
|
- Downloads team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared manifest.
|
||||||
|
- Manifest is a JSON file hosted on Google Drive or any direct URL, listing files with name, URL, and description.
|
||||||
|
- Manifest URL is configurable per-user; defaults to the shared team Google Drive manifest.
|
||||||
|
- Google Drive share links are automatically converted to direct download URLs.
|
||||||
|
- Files panel shows a checklist loaded from the manifest with per-file Open button and Refresh/Configure controls.
|
||||||
|
- Destination folder is selectable via folder picker, defaulting to workspace root in the extension.
|
||||||
|
- Standalone stores the manifest URL in config.json and exposes an inline URL editor via the settings (⚙) button.
|
||||||
|
```
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
17
manifest.json
Normal file
17
manifest.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"filename": "AGENTS.md",
|
||||||
|
"description": "AI coding guidelines for Phaser 3 playable ads. Covers stack (Phaser 3.90, TypeScript, Vite), supported ad networks, project structure, responsibility split between game modules and GameScene, performance rules, responsive coordinate system (sx/sy/sd), and CTA fallback chain.",
|
||||||
|
"url": "https://drive.google.com/file/d/1O3Rp9HIfVA1Urr3APFyvIM0KDBxhEOOS/view?usp=sharing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "LOCALIZATION.md",
|
||||||
|
"description": "Runtime localization system for playable ads. Documents the two-layer pattern (reusable runtime + project adapter), 10 supported locales (EN/DE/FR/ES/PT/RU/ZH/KO/JA), locale detection order (URL override → browser language → English fallback), and a drop-in AI prompt for implementing localization in a new or existing project.",
|
||||||
|
"url": "https://drive.google.com/file/d/1bDldnMJX6Vwngkakeve9cxonG7NU_KZo/view?usp=drive_link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": ".gitignore",
|
||||||
|
"description": "Standard .gitignore for Phaser 3 / Vite playable ad projects.",
|
||||||
|
"url": "https://drive.google.com/file/d/1wh3Yo_HZDxLqe4EDM6KsvWeG8eFzp5pG/view?usp=drive_link"
|
||||||
|
}
|
||||||
|
]
|
||||||
20
package.json
20
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "hpl-toolbox",
|
"name": "hpl-toolbox",
|
||||||
"displayName": "HPL Toolbox",
|
"displayName": "HPL Toolbox",
|
||||||
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"publisher": "hesukastro",
|
"publisher": "hesukastro",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -51,6 +51,14 @@
|
|||||||
{
|
{
|
||||||
"command": "hplToolbox.openDeviceSimulator",
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
"title": "HPL Toolbox: Open Device Simulator"
|
"title": "HPL Toolbox: Open Device Simulator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.checkForUpdates",
|
||||||
|
"title": "HPL Toolbox: Check for Updates"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openProjectInit",
|
||||||
|
"title": "HPL Toolbox: Initialize Project"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"viewsContainers": {
|
"viewsContainers": {
|
||||||
@@ -127,6 +135,16 @@
|
|||||||
"description": "Port for the temporary LAN HTTP server. 0 (default) = OS-assigned ephemeral port."
|
"description": "Port for the temporary LAN HTTP server. 0 (default) = OS-assigned ephemeral port."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "HPL Toolbox — Initialize Project",
|
||||||
|
"properties": {
|
||||||
|
"hplToolbox.projectInit.manifestUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "https://drive.google.com/file/d/1lRmHIy_0nXEy_LTwn0NzIqoJ4s9_JmxO/view?usp=drive_link",
|
||||||
|
"description": "URL to a JSON manifest file listing the files to download. The manifest must be an array of { \"filename\": string, \"url\": string } objects. Can be a raw GitHub/Gitea file URL or any direct JSON URL."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { openPlayworksConverter } from './tools/playworksConverter';
|
|||||||
import { openMintegralChecker } from './tools/mintegralChecker';
|
import { openMintegralChecker } from './tools/mintegralChecker';
|
||||||
import { openChangelog } from './changelogView';
|
import { openChangelog } from './changelogView';
|
||||||
import { openDeviceSimulator } from './tools/deviceSimulator';
|
import { openDeviceSimulator } from './tools/deviceSimulator';
|
||||||
|
import { openProjectInit } from './tools/projectInit';
|
||||||
|
import { checkForUpdates } from './updateChecker';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
@@ -20,9 +22,13 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openMintegralChecker', () => openMintegralChecker(context)),
|
vscode.commands.registerCommand('hplToolbox.openMintegralChecker', () => openMintegralChecker(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openDeviceSimulator', () => openDeviceSimulator(context)),
|
vscode.commands.registerCommand('hplToolbox.openDeviceSimulator', () => openDeviceSimulator(context)),
|
||||||
|
vscode.commands.registerCommand('hplToolbox.openProjectInit', () => openProjectInit(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
||||||
|
vscode.commands.registerCommand('hplToolbox.checkForUpdates', () => checkForUpdates(context)),
|
||||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void checkForUpdates(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate() {}
|
export function deactivate() {}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ interface ToolDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_TOOLS: ToolDefinition[] = [
|
const FALLBACK_TOOLS: ToolDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'project-init',
|
||||||
|
command: 'hplToolbox.openProjectInit',
|
||||||
|
title: 'Initialize Project',
|
||||||
|
description: 'Download AGENTS.md and .gitignore templates from Google Drive',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
command: 'hplToolbox.openPlecUpload',
|
command: 'hplToolbox.openPlecUpload',
|
||||||
title: 'PLEC Upload',
|
title: 'PLEC Upload',
|
||||||
@@ -62,6 +68,8 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
|||||||
vscode.commands.executeCommand(msg.command);
|
vscode.commands.executeCommand(msg.command);
|
||||||
} else if (msg?.type === 'openChangelog') {
|
} else if (msg?.type === 'openChangelog') {
|
||||||
vscode.commands.executeCommand('hplToolbox.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') {
|
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
||||||
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
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, []));
|
view.webview.html = getHtml(version, msg.enabled, tools, this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []));
|
||||||
@@ -204,6 +212,7 @@ function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefiniti
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
justify-self: center;
|
||||||
}
|
}
|
||||||
.changelog-link {
|
.changelog-link {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
@@ -217,7 +226,7 @@ ${toolButtons}
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
|
<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>
|
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
@@ -269,6 +278,9 @@ ${toolButtons}
|
|||||||
document.getElementById('changelogLink').addEventListener('click', () => {
|
document.getElementById('changelogLink').addEventListener('click', () => {
|
||||||
vscode.postMessage({ type: 'openChangelog' });
|
vscode.postMessage({ type: 'openChangelog' });
|
||||||
});
|
});
|
||||||
|
document.getElementById('updateCheck').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'checkForUpdates' });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as http from 'http';
|
||||||
import { getToolWebviewStyles } from './shared';
|
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 {
|
interface Device {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,7 +63,16 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
store.panel = panel;
|
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);
|
panel.webview.html = getHtml(DEVICES);
|
||||||
|
|
||||||
let lastFilePath = '';
|
let lastFilePath = '';
|
||||||
@@ -72,10 +88,10 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
|||||||
});
|
});
|
||||||
if (picked?.[0]) {
|
if (picked?.[0]) {
|
||||||
lastFilePath = picked[0].fsPath;
|
lastFilePath = picked[0].fsPath;
|
||||||
sendFile(panel, lastFilePath);
|
sendFile(panel, lastFilePath, true);
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'reload') {
|
} else if (msg.type === 'reload') {
|
||||||
if (lastFilePath) sendFile(panel, lastFilePath);
|
if (lastFilePath) sendFile(panel, lastFilePath, false);
|
||||||
} else if (msg.type === 'exportDevices') {
|
} else if (msg.type === 'exportDevices') {
|
||||||
const target = await vscode.window.showSaveDialog({
|
const target = await vscode.window.showSaveDialog({
|
||||||
defaultUri: vscode.Uri.file('hpl-device-simulator-devices.json'),
|
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 {
|
try {
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
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 {
|
} catch {
|
||||||
vscode.window.showErrorMessage('Device Simulator: could not read ' + path.basename(filePath));
|
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 {
|
function getHtml(devices: Device[]): string {
|
||||||
const devicesJson = JSON.stringify(devices);
|
const devicesJson = JSON.stringify(devices);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
@@ -187,8 +284,13 @@ html, body { min-height: 100%; }
|
|||||||
}
|
}
|
||||||
.title-orient {
|
.title-orient {
|
||||||
min-height: 28px;
|
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 {
|
.icon-btn {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -413,7 +515,8 @@ html, body { min-height: 100%; }
|
|||||||
</div>
|
</div>
|
||||||
<div class="title-actions">
|
<div class="title-actions">
|
||||||
<select id="device-select" class="title-select" title="Device"></select>
|
<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="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>
|
<button id="reload-btn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -430,12 +533,27 @@ html, body { min-height: 100%; }
|
|||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
const DEFAULT_DEVICES = ${devicesJson};
|
const DEFAULT_DEVICES = ${devicesJson};
|
||||||
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
|
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 devices = loadDevices();
|
||||||
let device = devices[0] || DEFAULT_DEVICES[0];
|
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||||
|
let language = 'en';
|
||||||
let landscape = false;
|
let landscape = false;
|
||||||
let currentContent = null;
|
let currentContent = null;
|
||||||
|
let currentSourceUrl = '';
|
||||||
let currentBlobUrl = null;
|
let currentBlobUrl = null;
|
||||||
|
let loadVersion = 0;
|
||||||
let cutoutColor = '#000000';
|
let cutoutColor = '#000000';
|
||||||
let muted = false;
|
let muted = false;
|
||||||
const mediaVolumes = new WeakMap();
|
const mediaVolumes = new WeakMap();
|
||||||
@@ -444,12 +562,18 @@ let muteDocument = null;
|
|||||||
|
|
||||||
/* ── device selector ─────────────────────────────────────────────────── */
|
/* ── device selector ─────────────────────────────────────────────────── */
|
||||||
const sel = document.getElementById('device-select');
|
const sel = document.getElementById('device-select');
|
||||||
|
const languageSelect = document.getElementById('language-select');
|
||||||
populateDeviceSelect(device.id);
|
populateDeviceSelect(device.id);
|
||||||
|
populateLanguageSelect();
|
||||||
sel.addEventListener('change', function() {
|
sel.addEventListener('change', function() {
|
||||||
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
||||||
renderScreen();
|
renderScreen();
|
||||||
if (currentContent) loadContent(currentContent);
|
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('add-device-btn').addEventListener('click', addDevice);
|
||||||
document.getElementById('remove-device-btn').addEventListener('click', removeCurrentDevice);
|
document.getElementById('remove-device-btn').addEventListener('click', removeCurrentDevice);
|
||||||
document.getElementById('import-devices-btn').addEventListener('click', function() {
|
document.getElementById('import-devices-btn').addEventListener('click', function() {
|
||||||
@@ -554,6 +678,17 @@ function populateDeviceSelect(selectedId) {
|
|||||||
renderDeviceList(getCheckedDeviceIds());
|
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) {
|
function renderDeviceList(checkedIds) {
|
||||||
const list = document.getElementById('device-list');
|
const list = document.getElementById('device-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@@ -767,11 +902,18 @@ function importDevices(event) {
|
|||||||
/* ── orientation ────────────────────────────────────────────────────── */
|
/* ── orientation ────────────────────────────────────────────────────── */
|
||||||
document.getElementById('orient-btn').addEventListener('click', function() {
|
document.getElementById('orient-btn').addEventListener('click', function() {
|
||||||
landscape = !landscape;
|
landscape = !landscape;
|
||||||
this.textContent = landscape ? 'Portrait' : 'Landscape';
|
renderOrientationButton();
|
||||||
renderScreen();
|
renderScreen();
|
||||||
if (currentContent) loadContent(currentContent);
|
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) {
|
function triggerPick(btn) {
|
||||||
const status = document.getElementById('setup-status');
|
const status = document.getElementById('setup-status');
|
||||||
if (status) {
|
if (status) {
|
||||||
@@ -859,12 +1001,17 @@ function loadContent(html) {
|
|||||||
const frame = document.getElementById('preview-frame');
|
const frame = document.getElementById('preview-frame');
|
||||||
if (!frame) return;
|
if (!frame) return;
|
||||||
|
|
||||||
// Revoke previous blob URL to free memory
|
|
||||||
if (currentBlobUrl) {
|
if (currentBlobUrl) {
|
||||||
URL.revokeObjectURL(currentBlobUrl);
|
URL.revokeObjectURL(currentBlobUrl);
|
||||||
currentBlobUrl = null;
|
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
|
// 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.
|
// 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' });
|
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||||
@@ -872,8 +1019,22 @@ function loadContent(html) {
|
|||||||
frame.src = currentBlobUrl;
|
frame.src = currentBlobUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withPreviewParams(url) {
|
||||||
|
const separator = url.indexOf('?') >= 0 ? '&' : '?';
|
||||||
|
loadVersion += 1;
|
||||||
|
return url + separator + 'lang=' + encodeURIComponent(language) + '&v=' + loadVersion;
|
||||||
|
}
|
||||||
|
|
||||||
function injectMuteBridge(html) {
|
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>' +
|
'<scr' + 'ipt>' +
|
||||||
'(function(){' +
|
'(function(){' +
|
||||||
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
||||||
@@ -909,10 +1070,10 @@ function renderMuteButton() {
|
|||||||
function applyMute() {
|
function applyMute() {
|
||||||
const frame = document.getElementById('preview-frame');
|
const frame = document.getElementById('preview-frame');
|
||||||
if (!frame || !frame.contentWindow) return;
|
if (!frame || !frame.contentWindow) return;
|
||||||
|
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||||
try {
|
try {
|
||||||
frame.contentWindow.__hplMuted = muted;
|
frame.contentWindow.__hplMuted = muted;
|
||||||
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
||||||
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
|
||||||
const doc = frame.contentWindow.document;
|
const doc = frame.contentWindow.document;
|
||||||
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
if (muteDocument !== doc) {
|
if (muteDocument !== doc) {
|
||||||
@@ -962,8 +1123,11 @@ window.addEventListener('message', function(event) {
|
|||||||
if (!msg || !msg.type) return;
|
if (!msg || !msg.type) return;
|
||||||
if (msg.type === 'fileLoaded') {
|
if (msg.type === 'fileLoaded') {
|
||||||
currentContent = msg.content;
|
currentContent = msg.content;
|
||||||
muted = false;
|
currentSourceUrl = msg.sourceUrl || '';
|
||||||
renderMuteButton();
|
if (msg.resetMute) {
|
||||||
|
muted = false;
|
||||||
|
renderMuteButton();
|
||||||
|
}
|
||||||
document.getElementById('file-label').textContent = msg.name;
|
document.getElementById('file-label').textContent = msg.name;
|
||||||
document.getElementById('setup-file-name').textContent = msg.name;
|
document.getElementById('setup-file-name').textContent = msg.name;
|
||||||
var setupStatus = document.getElementById('setup-status');
|
var setupStatus = document.getElementById('setup-status');
|
||||||
|
|||||||
266
src/tools/projectInit.ts
Normal file
266
src/tools/projectInit.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { singletonPanel, getToolWebviewStyles, handleClipboardAndOpen } from './shared';
|
||||||
|
|
||||||
|
interface FileEntry { filename: string; url: string; description?: string; }
|
||||||
|
|
||||||
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
|
function gdriveToDirectUrl(url: string): string {
|
||||||
|
const fileMatch = url.match(/\/file\/d\/([a-zA-Z0-9_-]+)/);
|
||||||
|
if (fileMatch) { return `https://drive.google.com/uc?export=download&id=${fileMatch[1]}`; }
|
||||||
|
const idMatch = url.match(/[?&]id=([a-zA-Z0-9_-]+)/);
|
||||||
|
if (idMatch) { return `https://drive.google.com/uc?export=download&id=${idMatch[1]}`; }
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchManifest(url: string): Promise<FileEntry[]> {
|
||||||
|
const res = await fetch(gdriveToDirectUrl(url), { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||||
|
if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); }
|
||||||
|
const json = await res.json() as unknown;
|
||||||
|
if (!Array.isArray(json)) { throw new Error('Manifest must be a JSON array of { filename, url } objects.'); }
|
||||||
|
return (json as FileEntry[]).filter(e => e.filename && e.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(url: string, destPath: string): Promise<void> {
|
||||||
|
const directUrl = gdriveToDirectUrl(url);
|
||||||
|
const res = await fetch(directUrl, { headers: { 'User-Agent': 'Mozilla/5.0' }, redirect: 'follow' });
|
||||||
|
if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); }
|
||||||
|
const contentType = res.headers.get('content-type') ?? '';
|
||||||
|
const text = await res.text();
|
||||||
|
const ext = path.extname(destPath).toLowerCase();
|
||||||
|
if (contentType.includes('text/html') && ext !== '.html' && ext !== '.htm') {
|
||||||
|
throw new Error(
|
||||||
|
'Got an HTML page instead of a file — the Google Drive link may require sign-in or a download confirmation. Make sure the file is set to "Anyone with the link can view".'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fs.promises.writeFile(destPath, text, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
${getToolWebviewStyles()}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="tool-page">
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Initialize Project</h2>
|
||||||
|
<p class="tool-description">Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Files</h3>
|
||||||
|
<div style="display:flex;gap:var(--tool-gap-xs);align-items:center;">
|
||||||
|
<span id="manifestSource" class="muted" style="font-size:11px;margin-right:var(--tool-gap-xs);"></span>
|
||||||
|
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Refresh manifest" onclick="refresh()">↻</button>
|
||||||
|
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="openManifestSettings()">⚙</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div id="fileList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<p class="control-label">Download folder</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<input type="text" id="destFolder" value="" placeholder="(workspace root)" readonly />
|
||||||
|
<button class="secondary" onclick="pickFolder()">Browse…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="initBtn" onclick="initProject()" disabled>Initialize Project</button>
|
||||||
|
<button id="selectAllBtn" class="secondary" onclick="toggleSelectAll()" style="display:none;">Deselect all</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
const savedState = vscode.getState() || {};
|
||||||
|
document.getElementById('destFolder').value = savedState.destFolder || '';
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
let allSelected = true;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
setStatus('', '');
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest…</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
vscode.postMessage({ type: 'fetchManifest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManifestSettings() { vscode.postMessage({ type: 'openSettings' }); }
|
||||||
|
function pickFolder() { vscode.postMessage({ type: 'pickFolder' }); }
|
||||||
|
|
||||||
|
function initProject() {
|
||||||
|
const checked = files.filter((_, i) => document.getElementById('cb_' + i)?.checked);
|
||||||
|
if (!checked.length) { setStatus('No files selected.', 'err'); return; }
|
||||||
|
const destFolder = document.getElementById('destFolder').value.trim();
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = 'Downloading ' + checked.length + ' file(s)…';
|
||||||
|
el.className = 'status-panel is-busy';
|
||||||
|
vscode.postMessage({ type: 'init', files: checked, destFolder });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
allSelected = !allSelected;
|
||||||
|
files.forEach((_, i) => { const cb = document.getElementById('cb_' + i); if (cb) cb.checked = allSelected; });
|
||||||
|
document.getElementById('selectAllBtn').textContent = allSelected ? 'Deselect all' : 'Select all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(list, manifestUrl) {
|
||||||
|
files = list;
|
||||||
|
allSelected = true;
|
||||||
|
document.getElementById('manifestSource').textContent =
|
||||||
|
manifestUrl ? '(' + manifestUrl.replace(/^https?:\\/\\//, '').split('/')[0] + ')' : '';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Manifest loaded but contains no files.</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = list.map((f, i) =>
|
||||||
|
'<tr>' +
|
||||||
|
'<td style="width:20px;text-align:center;"><input type="checkbox" id="cb_' + i + '" checked /></td>' +
|
||||||
|
'<td class="mono wrap" style="width:20%;">' + escapeHtml(f.filename) + '</td>' +
|
||||||
|
'<td class="muted wrap" style="width:70%;">' + escapeHtml(f.description || '') + '</td>' +
|
||||||
|
'<td style="width:10%;text-align:right;"><button style="min-height:22px;padding:2px 8px;font-size:11px;" data-url="' + escapeHtml(f.url) + '" onclick="openUrl(this)">Open</button></td>' +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
document.getElementById('fileList').innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;overflow-x:hidden;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th style="width:20px;"></th>' +
|
||||||
|
'<th style="width:20%;">Filename</th>' +
|
||||||
|
'<th style="width:70%;">Description</th>' +
|
||||||
|
'<th style="width:10%;"></th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('initBtn').disabled = false;
|
||||||
|
document.getElementById('selectAllBtn').style.display = '';
|
||||||
|
document.getElementById('selectAllBtn').textContent = 'Deselect all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUrl(btn) {
|
||||||
|
vscode.postMessage({ type: 'open', text: btn.dataset.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', e => {
|
||||||
|
const msg = e.data;
|
||||||
|
if (msg.type === 'manifest') { renderFiles(msg.files, msg.manifestUrl); setStatus('', ''); }
|
||||||
|
if (msg.type === 'manifestError') {
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="err" style="margin:0;">' + escapeHtml(msg.text) + '</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
}
|
||||||
|
if (msg.type === 'folder') {
|
||||||
|
document.getElementById('destFolder').value = msg.path;
|
||||||
|
const s = vscode.getState() || {};
|
||||||
|
s.destFolder = msg.path;
|
||||||
|
vscode.setState(s);
|
||||||
|
}
|
||||||
|
if (msg.type === 'result') { setStatus(msg.text, msg.ok ? 'ok' : 'err'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = 'status-panel' + (cls ? ' ' + cls : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openProjectInit(context: vscode.ExtensionContext) {
|
||||||
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.projectInit', 'Initialize Project');
|
||||||
|
if (!isNew) { return; }
|
||||||
|
|
||||||
|
panel.webview.html = getHtml();
|
||||||
|
|
||||||
|
async function sendManifest() {
|
||||||
|
const manifestUrl = vscode.workspace.getConfiguration('hplToolbox.projectInit').get<string>('manifestUrl', '');
|
||||||
|
if (!manifestUrl) {
|
||||||
|
panel.webview.postMessage({
|
||||||
|
type: 'manifestError',
|
||||||
|
text: 'No manifest URL configured. Click ⚙ to set it in Settings.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const files = await fetchManifest(manifestUrl);
|
||||||
|
panel.webview.postMessage({ type: 'manifest', files, manifestUrl });
|
||||||
|
} catch (err: any) {
|
||||||
|
panel.webview.postMessage({ type: 'manifestError', text: `Failed to load manifest: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
if (handleClipboardAndOpen(msg)) { return; }
|
||||||
|
|
||||||
|
if (msg.type === 'fetchManifest') {
|
||||||
|
await sendManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'openSettings') {
|
||||||
|
vscode.commands.executeCommand('workbench.action.openSettings', 'hplToolbox.projectInit.manifestUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'pickFolder') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Destination Folder',
|
||||||
|
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
|
||||||
|
});
|
||||||
|
if (picked?.[0]) {
|
||||||
|
panel.webview.postMessage({ type: 'folder', path: picked[0].fsPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'init') {
|
||||||
|
const files: FileEntry[] = msg.files;
|
||||||
|
const dest: string = msg.destFolder || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
|
if (!dest) {
|
||||||
|
panel.webview.postMessage({ type: 'result', ok: false, text: 'No destination folder selected and no workspace is open.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
await downloadFile(f.url, path.join(dest, f.filename));
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`${f.filename}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length) {
|
||||||
|
panel.webview.postMessage({ type: 'result', ok: false, text: `${files.length - errors.length}/${files.length} downloaded.\n\nErrors:\n${errors.join('\n')}` });
|
||||||
|
} else {
|
||||||
|
panel.webview.postMessage({ type: 'result', ok: true, text: `Done! ${files.length} file(s) downloaded to: ${dest}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, undefined, context.subscriptions);
|
||||||
|
}
|
||||||
80
src/updateChecker.ts
Normal file
80
src/updateChecker.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type deviceSimulatorDevice struct {
|
type deviceSimulatorDevice struct {
|
||||||
@@ -132,7 +133,8 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="title-actions">
|
<div class="title-actions">
|
||||||
<select id="deviceSelect" class="title-select" title="Device"></select>
|
<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="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>
|
<button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +168,9 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
#dimLabel { white-space: nowrap; flex-shrink: 0; }
|
#dimLabel { white-space: nowrap; flex-shrink: 0; }
|
||||||
.title-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
.title-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||||
.title-select { width:180px; min-width:120px; }
|
.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 {
|
.icon-btn {
|
||||||
width:28px;
|
width:28px;
|
||||||
height:28px;
|
height:28px;
|
||||||
@@ -314,11 +318,26 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
<script>
|
<script>
|
||||||
const DEFAULT_DEVICES = ` + string(devicesJSON) + `;
|
const DEFAULT_DEVICES = ` + string(devicesJSON) + `;
|
||||||
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
|
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 devices = loadDevices();
|
||||||
let device = devices[0] || DEFAULT_DEVICES[0];
|
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||||
|
let language = 'en';
|
||||||
let landscape = false;
|
let landscape = false;
|
||||||
let currentPath = '';
|
let currentPath = '';
|
||||||
let currentContent = '';
|
let currentContent = '';
|
||||||
|
let currentBlobUrl = '';
|
||||||
|
let loadVersion = 0;
|
||||||
let muted = false;
|
let muted = false;
|
||||||
const mediaVolumes = new WeakMap();
|
const mediaVolumes = new WeakMap();
|
||||||
let muteObserver = null;
|
let muteObserver = null;
|
||||||
@@ -330,6 +349,7 @@ const fileLabel = document.getElementById('fileLabel');
|
|||||||
const dimLabel = document.getElementById('dimLabel');
|
const dimLabel = document.getElementById('dimLabel');
|
||||||
const simPanel = document.getElementById('simPanel');
|
const simPanel = document.getElementById('simPanel');
|
||||||
const sel = document.getElementById('deviceSelect');
|
const sel = document.getElementById('deviceSelect');
|
||||||
|
const languageSelect = document.getElementById('languageSelect');
|
||||||
|
|
||||||
function basename(p) {
|
function basename(p) {
|
||||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||||
@@ -340,11 +360,16 @@ function setBusy(text) {
|
|||||||
statusEl.classList.toggle('is-busy', !!text);
|
statusEl.classList.toggle('is-busy', !!text);
|
||||||
}
|
}
|
||||||
populateDeviceSelect(device.id);
|
populateDeviceSelect(device.id);
|
||||||
|
populateLanguageSelect();
|
||||||
sel.addEventListener('change', () => {
|
sel.addEventListener('change', () => {
|
||||||
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
||||||
renderScreen();
|
renderScreen();
|
||||||
if (currentContent) loadContent(currentContent);
|
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('addDeviceBtn').addEventListener('click', addDevice);
|
||||||
document.getElementById('removeDeviceBtn').addEventListener('click', removeCurrentDevice);
|
document.getElementById('removeDeviceBtn').addEventListener('click', removeCurrentDevice);
|
||||||
document.getElementById('importDevicesBtn').addEventListener('click', () => {
|
document.getElementById('importDevicesBtn').addEventListener('click', () => {
|
||||||
@@ -449,6 +474,17 @@ function populateDeviceSelect(selectedId) {
|
|||||||
renderDeviceList(getCheckedDeviceIds());
|
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) {
|
function renderDeviceList(checkedIds) {
|
||||||
const list = document.getElementById('deviceList');
|
const list = document.getElementById('deviceList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@@ -659,16 +695,22 @@ function importDevices(event) {
|
|||||||
}
|
}
|
||||||
document.getElementById('orientBtn').addEventListener('click', (event) => {
|
document.getElementById('orientBtn').addEventListener('click', (event) => {
|
||||||
landscape = !landscape;
|
landscape = !landscape;
|
||||||
event.currentTarget.textContent = landscape ? 'Portrait' : 'Landscape';
|
renderOrientationButton();
|
||||||
renderScreen();
|
renderScreen();
|
||||||
if (currentContent) loadContent(currentContent);
|
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 () => {
|
document.getElementById('pickBtn').addEventListener('click', async () => {
|
||||||
setBusy('Opening file picker...');
|
setBusy('Opening file picker...');
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/device-simulator/pick', { method:'POST' });
|
const r = await fetch('/api/device-simulator/pick', { method:'POST' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.path) await loadPath(j.path);
|
if (j.path) await loadPath(j.path, true);
|
||||||
else setBusy('');
|
else setBusy('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.classList.remove('is-busy');
|
statusEl.classList.remove('is-busy');
|
||||||
@@ -676,7 +718,7 @@ document.getElementById('pickBtn').addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('reloadBtn').addEventListener('click', () => {
|
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||||
if (currentPath) loadPath(currentPath);
|
if (currentPath) loadPath(currentPath, false);
|
||||||
});
|
});
|
||||||
document.getElementById('muteBtn').addEventListener('click', () => {
|
document.getElementById('muteBtn').addEventListener('click', () => {
|
||||||
muted = !muted;
|
muted = !muted;
|
||||||
@@ -684,11 +726,11 @@ document.getElementById('muteBtn').addEventListener('click', () => {
|
|||||||
applyMute();
|
applyMute();
|
||||||
});
|
});
|
||||||
setupDropZone('dropZone', statusEl, (j) => {
|
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.';
|
else if (j.skipped) statusEl.textContent = 'Drop an HTML file.';
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadPath(path) {
|
async function loadPath(path, resetMute) {
|
||||||
currentPath = path;
|
currentPath = path;
|
||||||
setBusy('Reading ' + basename(path) + '...');
|
setBusy('Reading ' + basename(path) + '...');
|
||||||
const r = await fetch('/api/device-simulator/load', {
|
const r = await fetch('/api/device-simulator/load', {
|
||||||
@@ -706,8 +748,10 @@ async function loadPath(path) {
|
|||||||
fileNameEl.textContent = j.name;
|
fileNameEl.textContent = j.name;
|
||||||
fileLabel.textContent = j.name;
|
fileLabel.textContent = j.name;
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
muted = false;
|
if (resetMute) {
|
||||||
renderMuteButton();
|
muted = false;
|
||||||
|
renderMuteButton();
|
||||||
|
}
|
||||||
simPanel.classList.add('active');
|
simPanel.classList.add('active');
|
||||||
renderScreen();
|
renderScreen();
|
||||||
loadContent(currentContent);
|
loadContent(currentContent);
|
||||||
@@ -753,10 +797,34 @@ function renderScreen() {
|
|||||||
function loadContent(html) {
|
function loadContent(html) {
|
||||||
const frame = document.getElementById('previewFrame');
|
const frame = document.getElementById('previewFrame');
|
||||||
if (!frame) return;
|
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) {
|
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>' +
|
'<scr' + 'ipt>' +
|
||||||
'(function(){' +
|
'(function(){' +
|
||||||
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
||||||
@@ -790,10 +858,10 @@ function renderMuteButton() {
|
|||||||
function applyMute() {
|
function applyMute() {
|
||||||
const frame = document.getElementById('previewFrame');
|
const frame = document.getElementById('previewFrame');
|
||||||
if (!frame || !frame.contentWindow) return;
|
if (!frame || !frame.contentWindow) return;
|
||||||
|
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||||
try {
|
try {
|
||||||
frame.contentWindow.__hplMuted = muted;
|
frame.contentWindow.__hplMuted = muted;
|
||||||
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
||||||
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
|
||||||
const doc = frame.contentWindow.document;
|
const doc = frame.contentWindow.document;
|
||||||
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
if (muteDocument !== doc) {
|
if (muteDocument !== doc) {
|
||||||
@@ -882,3 +950,68 @@ func DeviceSimulatorLoad(w http.ResponseWriter, r *http.Request) {
|
|||||||
"content": string(data),
|
"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
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -159,6 +159,55 @@ const SharedCSS = `
|
|||||||
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
|
.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 { 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-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 { align-items: stretch; }
|
||||||
body.sidebar-collapsed .sidebar-title,
|
body.sidebar-collapsed .sidebar-title,
|
||||||
body.sidebar-collapsed .nav-text,
|
body.sidebar-collapsed .nav-text,
|
||||||
@@ -427,6 +476,7 @@ type navItem struct {
|
|||||||
|
|
||||||
var navItems = []navItem{
|
var navItems = []navItem{
|
||||||
{Path: "/", Label: "Home"},
|
{Path: "/", Label: "Home"},
|
||||||
|
{Path: "/project-init", Label: "Initialize Project", Description: "Download AGENTS.md and .gitignore templates from Google Drive"},
|
||||||
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
|
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
|
||||||
{Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
|
{Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
|
||||||
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
|
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
|
||||||
@@ -472,6 +522,8 @@ func betaToolCount() int {
|
|||||||
|
|
||||||
func navInitials(label string) string {
|
func navInitials(label string) string {
|
||||||
switch label {
|
switch label {
|
||||||
|
case "Initialize Project":
|
||||||
|
return "IP"
|
||||||
case "Base64 Scanner":
|
case "Base64 Scanner":
|
||||||
return "B64"
|
return "B64"
|
||||||
case "AppLovin Playable Preview":
|
case "AppLovin Playable Preview":
|
||||||
@@ -539,10 +591,15 @@ func Page(activePath, title, body string) string {
|
|||||||
<div class="content">` + body + `</div>
|
<div class="content">` + body + `</div>
|
||||||
<div class="app-footer">
|
<div class="app-footer">
|
||||||
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
|
<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>
|
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
if (localStorage.getItem('hplSidebarCollapsed') === 'true') {
|
if (localStorage.getItem('hplSidebarCollapsed') === 'true') {
|
||||||
document.body.classList.add('sidebar-collapsed');
|
document.body.classList.add('sidebar-collapsed');
|
||||||
@@ -628,6 +685,51 @@ document.getElementById('betaToolsToggle').addEventListener('click', async (even
|
|||||||
});
|
});
|
||||||
window.location.reload();
|
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>
|
</script>
|
||||||
</body></html>`
|
</body></html>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ func buildMux() *http.ServeMux {
|
|||||||
}
|
}
|
||||||
HomePage(w, r)
|
HomePage(w, r)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("GET /project-init", ProjectInitPage)
|
||||||
mux.HandleFunc("GET /plec", PlecPage)
|
mux.HandleFunc("GET /plec", PlecPage)
|
||||||
mux.HandleFunc("GET /applovin", ApplovinPage)
|
mux.HandleFunc("GET /applovin", ApplovinPage)
|
||||||
mux.HandleFunc("GET /base64", Base64Page)
|
mux.HandleFunc("GET /base64", Base64Page)
|
||||||
@@ -118,11 +119,18 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET /changelog", ChangelogPage)
|
mux.HandleFunc("GET /changelog", ChangelogPage)
|
||||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||||
|
|
||||||
|
// Initialize Project
|
||||||
|
mux.HandleFunc("GET /api/project-init/manifest", ProjectInitManifestEndpoint)
|
||||||
|
mux.HandleFunc("POST /api/project-init/pickFolder", ProjectInitPickFolderEndpoint)
|
||||||
|
mux.HandleFunc("POST /api/project-init/setManifestUrl", ProjectInitSetManifestURLEndpoint)
|
||||||
|
mux.HandleFunc("POST /api/project-init/download", ProjectInitDownloadEndpoint)
|
||||||
|
|
||||||
// Shared API
|
// Shared API
|
||||||
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
|
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
|
||||||
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
||||||
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
||||||
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
|
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
|
||||||
|
mux.HandleFunc("GET /api/update/check", UpdateCheckEndpoint)
|
||||||
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
|
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
|
||||||
|
|
||||||
// PLEC
|
// PLEC
|
||||||
@@ -169,6 +177,7 @@ func buildMux() *http.ServeMux {
|
|||||||
// Device Simulator
|
// Device Simulator
|
||||||
mux.HandleFunc("POST /api/device-simulator/pick", DeviceSimulatorPick)
|
mux.HandleFunc("POST /api/device-simulator/pick", DeviceSimulatorPick)
|
||||||
mux.HandleFunc("POST /api/device-simulator/load", DeviceSimulatorLoad)
|
mux.HandleFunc("POST /api/device-simulator/load", DeviceSimulatorLoad)
|
||||||
|
mux.HandleFunc("GET /api/device-simulator/preview/", DeviceSimulatorPreview)
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ type SendToMobileConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
Plec PlecConfig `json:"plec"`
|
Plec PlecConfig `json:"plec"`
|
||||||
Applovin ApplovinConfig `json:"applovin"`
|
Applovin ApplovinConfig `json:"applovin"`
|
||||||
SendToMobile SendToMobileConfig `json:"sendToMobile"`
|
SendToMobile SendToMobileConfig `json:"sendToMobile"`
|
||||||
BetaToolsEnabled bool `json:"betaToolsEnabled"`
|
BetaToolsEnabled bool `json:"betaToolsEnabled"`
|
||||||
LastPickDir string `json:"lastPickDir,omitempty"`
|
LastPickDir string `json:"lastPickDir,omitempty"`
|
||||||
|
ProjectInitManifestURL string `json:"projectInitManifestUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configMu sync.Mutex
|
var configMu sync.Mutex
|
||||||
|
|||||||
354
standalone/project_init.go
Normal file
354
standalone/project_init.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultManifestURL = "https://drive.google.com/file/d/1lRmHIy_0nXEy_LTwn0NzIqoJ4s9_JmxO/view?usp=drive_link"
|
||||||
|
|
||||||
|
type ManifestEntry struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reGDriveFileID = regexp.MustCompile(`/file/d/([a-zA-Z0-9_-]+)`)
|
||||||
|
var reGDriveIDParam = regexp.MustCompile(`[?&]id=([a-zA-Z0-9_-]+)`)
|
||||||
|
|
||||||
|
func gdriveToDirectURL(rawURL string) string {
|
||||||
|
if m := reGDriveFileID.FindStringSubmatch(rawURL); m != nil {
|
||||||
|
return "https://drive.google.com/uc?export=download&id=" + m[1]
|
||||||
|
}
|
||||||
|
if m := reGDriveIDParam.FindStringSubmatch(rawURL); m != nil {
|
||||||
|
return "https://drive.google.com/uc?export=download&id=" + m[1]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchManifest(manifestURL string) ([]ManifestEntry, error) {
|
||||||
|
req, err := http.NewRequest("GET", gdriveToDirectURL(manifestURL), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
var entries []ManifestEntry
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
||||||
|
}
|
||||||
|
filtered := entries[:0]
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Filename != "" && e.URL != "" {
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadManifestFile(rawURL, destPath string) error {
|
||||||
|
req, err := http.NewRequest("GET", gdriveToDirectURL(rawURL), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
ext := strings.ToLower(filepath.Ext(destPath))
|
||||||
|
if strings.Contains(ct, "text/html") && ext != ".html" && ext != ".htm" {
|
||||||
|
return fmt.Errorf("got an HTML page instead of a file — make sure the Google Drive file is set to \"Anyone with the link can view\"")
|
||||||
|
}
|
||||||
|
return os.WriteFile(destPath, body, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getManifestURL() string {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
if cfg.ProjectInitManifestURL != "" {
|
||||||
|
return cfg.ProjectInitManifestURL
|
||||||
|
}
|
||||||
|
return defaultManifestURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
manifestURL := getManifestURL()
|
||||||
|
body := `
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Initialize Project</h2>
|
||||||
|
<p class="tool-description">Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<h3 class="panel-title">Files</h3>
|
||||||
|
<div style="display:flex;gap:4px;align-items:center;">
|
||||||
|
<span id="manifestSource" class="muted" style="font-size:11px;margin-right:4px;"></span>
|
||||||
|
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Refresh manifest" onclick="refresh()">↻</button>
|
||||||
|
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="toggleSettings()">⚙</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="settingsGroup" class="control-group" style="display:none;">
|
||||||
|
<p class="control-label">Manifest URL</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<input type="text" id="manifestUrlInput" value="` + htmlEscape(manifestURL) + `" style="flex:1;" placeholder="https://…" />
|
||||||
|
<button onclick="saveManifestUrl()">Save & Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<div id="fileList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<p class="control-label">Download folder</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<input type="text" id="destFolder" value="" placeholder="(no folder selected)" readonly style="flex:1;" />
|
||||||
|
<button class="secondary" onclick="pickFolder()">Browse…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="initBtn" onclick="initProject()" disabled>Initialize Project</button>
|
||||||
|
<button id="selectAllBtn" class="secondary" onclick="toggleSelectAll()" style="display:none;">Deselect all</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let files = [];
|
||||||
|
let allSelected = true;
|
||||||
|
let settingsVisible = false;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSettings() {
|
||||||
|
settingsVisible = !settingsVisible;
|
||||||
|
document.getElementById('settingsGroup').style.display = settingsVisible ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveManifestUrl() {
|
||||||
|
const url = document.getElementById('manifestUrlInput').value.trim();
|
||||||
|
await fetch('/api/project-init/setManifestUrl', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
settingsVisible = false;
|
||||||
|
document.getElementById('settingsGroup').style.display = 'none';
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setStatus('', '');
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest…</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/project-init/manifest');
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.error) { renderError(j.error); return; }
|
||||||
|
renderFiles(j.files || [], j.manifestUrl || '');
|
||||||
|
} catch (e) {
|
||||||
|
renderError('Failed to load manifest: ' + (e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(msg) {
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="err" style="margin:0;">' + escapeHtml(msg) + '</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
document.getElementById('manifestSource').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(list, manifestUrl) {
|
||||||
|
files = list;
|
||||||
|
allSelected = true;
|
||||||
|
document.getElementById('manifestSource').textContent =
|
||||||
|
manifestUrl ? '(' + manifestUrl.replace(/^https?:\/\//, '').split('/')[0] + ')' : '';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Manifest loaded but contains no files.</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = list.map((f, i) =>
|
||||||
|
'<tr>' +
|
||||||
|
'<td style="width:20px;text-align:center;"><input type="checkbox" id="cb_' + i + '" checked /></td>' +
|
||||||
|
'<td class="mono wrap" style="width:20%;">' + escapeHtml(f.filename) + '</td>' +
|
||||||
|
'<td class="muted wrap" style="width:70%;">' + escapeHtml(f.description || '') + '</td>' +
|
||||||
|
'<td style="width:10%;text-align:right;"><button style="min-height:22px;padding:2px 8px;font-size:11px;" onclick="openUrl(' + JSON.stringify(f.url) + ')">Open</button></td>' +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
document.getElementById('fileList').innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;overflow-x:hidden;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th style="width:20px;"></th><th style="width:20%;">Filename</th><th style="width:70%;">Description</th><th style="width:10%;"></th></tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('initBtn').disabled = false;
|
||||||
|
document.getElementById('selectAllBtn').style.display = '';
|
||||||
|
document.getElementById('selectAllBtn').textContent = 'Deselect all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
allSelected = !allSelected;
|
||||||
|
files.forEach((_, i) => { const cb = document.getElementById('cb_' + i); if (cb) cb.checked = allSelected; });
|
||||||
|
document.getElementById('selectAllBtn').textContent = allSelected ? 'Deselect all' : 'Select all';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFolder() {
|
||||||
|
const r = await fetch('/api/project-init/pickFolder', { method: 'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) document.getElementById('destFolder').value = j.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openUrl(url) {
|
||||||
|
await fetch('/api/open', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initProject() {
|
||||||
|
const checked = files.filter((_, i) => document.getElementById('cb_' + i)?.checked);
|
||||||
|
if (!checked.length) { setStatus('No files selected.', 'err'); return; }
|
||||||
|
const destFolder = document.getElementById('destFolder').value.trim();
|
||||||
|
if (!destFolder) { setStatus('Please select a destination folder first.', 'err'); return; }
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = 'Downloading ' + checked.length + ' file(s)…';
|
||||||
|
el.className = 'status-panel is-busy';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/project-init/download', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ files: checked, destFolder }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
setStatus(j.message, j.ok ? 'ok' : 'err');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Error: ' + (e.message || e), 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = 'status-panel' + (cls ? ' ' + cls : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/project-init", "Initialize Project", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func htmlEscape(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitManifestEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
manifestURL := getManifestURL()
|
||||||
|
entries, err := fetchManifest(manifestURL)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"files": entries, "manifestUrl": manifestURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitPickFolderEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
folder := PickFolder("Select Destination Folder", cfg.LastPickDir)
|
||||||
|
if folder == "" {
|
||||||
|
writeJSON(w, map[string]any{"path": ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.LastPickDir = folder
|
||||||
|
_ = SaveConfig(cfg)
|
||||||
|
writeJSON(w, map[string]any{"path": folder})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitSetManifestURLEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := LoadConfig()
|
||||||
|
cfg.ProjectInitManifestURL = strings.TrimSpace(req.URL)
|
||||||
|
if err := SaveConfig(cfg); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitDownloadEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Files []ManifestEntry `json:"files"`
|
||||||
|
DestFolder string `json:"destFolder"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "Invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DestFolder == "" {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "No destination folder specified."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
for _, f := range req.Files {
|
||||||
|
if err := downloadManifestFile(f.URL, filepath.Join(req.DestFolder, f.Filename)); err != nil {
|
||||||
|
errs = append(errs, f.Filename+": "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := len(req.Files)
|
||||||
|
if len(errs) > 0 {
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"message": fmt.Sprintf("%d/%d downloaded.\n\nErrors:\n%s", total-len(errs), total, strings.Join(errs, "\n")),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"message": fmt.Sprintf("Done! %d file%s downloaded to: %s", total, pluralS(total), req.DestFolder),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
127
standalone/update_checker.go
Normal file
127
standalone/update_checker.go
Normal 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
|
||||||
|
}
|
||||||
10
tools.json
10
tools.json
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"id": "project-init",
|
||||||
|
"title": "Initialize Project",
|
||||||
|
"description": "Download AGENTS.md and .gitignore templates from Google Drive",
|
||||||
|
"command": "hplToolbox.openProjectInit",
|
||||||
|
"path": "/project-init",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "plec",
|
"id": "plec",
|
||||||
"title": "PLEC Upload",
|
"title": "PLEC Upload",
|
||||||
@@ -62,5 +70,5 @@
|
|||||||
"command": "hplToolbox.openDeviceSimulator",
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
"path": "/device-simulator",
|
"path": "/device-simulator",
|
||||||
"beta": false
|
"beta": false
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user