Compare commits
11 Commits
986993a027
...
v0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e5d56ab1d | |||
| 68d15aa208 | |||
| b254daf06a | |||
| 16de3cc116 | |||
| 5fa1ce949c | |||
| e50b4c2133 | |||
| cebb65fb35 | |||
| 7094e6c3b0 | |||
| 6c09beea43 | |||
| ad6403f3e4 | |||
| 700bb135ad |
46
README.md
46
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
|
||||||
|
|
||||||
@@ -85,3 +86,48 @@ Changed
|
|||||||
- Standalone navigation now uses a collapsible sidebar with compact tool initials.
|
- Standalone navigation now uses a collapsible sidebar with compact tool initials.
|
||||||
- Mintegral Checker UI now embeds the playable preview, checklist, runtime events, mute, and reload controls in one view.
|
- Mintegral Checker UI now embeds the playable preview, checklist, runtime events, mute, and reload controls in one view.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**v0.2.1**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added Device Simulator to the VS Code extension and standalone app.
|
||||||
|
- Added editable Device Simulator device lists with add, delete, restore defaults, import, and export.
|
||||||
|
- Added drag-and-drop reordering for Device Simulator devices.
|
||||||
|
- Added iPhone 17, iPhone Air, iPhone 17 Pro, and iPhone 17 Pro Max defaults.
|
||||||
|
- Added drag-and-drop tool reordering with persisted order in the VS Code launcher and standalone Home/sidebar.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Device Simulator settings now use a modal list manager with multi-select, add/delete, and move up/down controls.
|
||||||
|
- Standalone Device Simulator sidebar abbreviation is now DS.
|
||||||
|
- Send To Mobile browser preview now opens playables in a delayed full-screen preview wrapper while Download still serves the original HTML.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed Device Simulator mute for HTML media and WebAudio playables.
|
||||||
|
- Fixed Device Simulator Add Device and Remove Device interactions inside embedded webviews.
|
||||||
|
- Fixed Send To Mobile browser preview spending the View button's initial tap as a premature playable click-through while preserving real user-initiated redirects.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.2.2**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added automatic update checking to the VS Code extension and standalone app.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.2.3**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added Localization dropdown in Device Simulator.
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
File diff suppressed because one or more lines are too long
BIN
dist/hpl-toolbox-0.2.0.vsix
vendored
BIN
dist/hpl-toolbox-0.2.0.vsix
vendored
Binary file not shown.
Binary file not shown.
BIN
dist/hpl-toolbox-0.2.4.vsix
vendored
Normal file
BIN
dist/hpl-toolbox-0.2.4.vsix
vendored
Normal file
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
24
package.json
24
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.0",
|
"version": "0.2.4",
|
||||||
"publisher": "hesukastro",
|
"publisher": "hesukastro",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -47,6 +47,18 @@
|
|||||||
{
|
{
|
||||||
"command": "hplToolbox.openMintegralChecker",
|
"command": "hplToolbox.openMintegralChecker",
|
||||||
"title": "HPL Toolbox: Open Mintegral Checker"
|
"title": "HPL Toolbox: Open Mintegral Checker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
|
"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": {
|
||||||
@@ -123,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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { openSendToMobile } from './tools/sendToMobile';
|
|||||||
import { openPlayworksConverter } from './tools/playworksConverter';
|
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 { 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(
|
||||||
@@ -18,9 +21,14 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||||
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.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() {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
||||||
|
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
|
||||||
|
|
||||||
interface ToolDefinition {
|
interface ToolDefinition {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -13,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',
|
||||||
@@ -54,15 +61,20 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
|||||||
const version = this.context.extension.packageJSON.version as string;
|
const version = this.context.extension.packageJSON.version as string;
|
||||||
const tools = loadToolDefinitions(this.context.extensionPath);
|
const tools = loadToolDefinitions(this.context.extensionPath);
|
||||||
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
||||||
view.webview.html = getHtml(version, betaToolsEnabled, tools);
|
const toolOrder = this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []);
|
||||||
|
view.webview.html = getHtml(version, betaToolsEnabled, tools, toolOrder);
|
||||||
view.webview.onDidReceiveMessage(async (msg) => {
|
view.webview.onDidReceiveMessage(async (msg) => {
|
||||||
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
||||||
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);
|
view.webview.html = getHtml(version, msg.enabled, tools, this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []));
|
||||||
|
} else if (msg?.type === 'setToolOrder' && Array.isArray(msg.order)) {
|
||||||
|
await this.context.globalState.update(TOOL_ORDER_KEY, msg.order.filter((id: unknown) => typeof id === 'string'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -88,8 +100,8 @@ function loadToolDefinitions(extensionPath: string): ToolDefinition[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[]): string {
|
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[], toolOrder: string[]): string {
|
||||||
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta));
|
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta), toolOrder);
|
||||||
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -127,6 +139,9 @@ function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefiniti
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
.tool-btn.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
.tool-btn:hover {
|
.tool-btn:hover {
|
||||||
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
||||||
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
||||||
@@ -197,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;
|
||||||
@@ -210,16 +226,51 @@ ${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>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.dragging === 'true') {
|
||||||
|
btn.dataset.dragging = 'false';
|
||||||
|
return;
|
||||||
|
}
|
||||||
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const toolsEl = document.querySelector('.tools');
|
||||||
|
let dragged = null;
|
||||||
|
toolsEl.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||||
|
btn.draggable = true;
|
||||||
|
btn.addEventListener('dragstart', (event) => {
|
||||||
|
dragged = btn;
|
||||||
|
btn.classList.add('dragging');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', btn.dataset.id || '');
|
||||||
|
});
|
||||||
|
btn.addEventListener('dragend', () => {
|
||||||
|
btn.classList.remove('dragging');
|
||||||
|
btn.dataset.dragging = 'true';
|
||||||
|
dragged = null;
|
||||||
|
persistToolOrder();
|
||||||
|
setTimeout(() => { btn.dataset.dragging = 'false'; }, 0);
|
||||||
|
});
|
||||||
|
btn.addEventListener('dragover', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dragged || dragged === btn) return;
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
const after = event.clientY > rect.top + rect.height / 2;
|
||||||
|
toolsEl.insertBefore(dragged, after ? btn.nextSibling : btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function persistToolOrder() {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'setToolOrder',
|
||||||
|
order: Array.from(toolsEl.querySelectorAll('.tool-btn')).map(btn => btn.dataset.id).filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
document.getElementById('betaToggle').addEventListener('click', (event) => {
|
document.getElementById('betaToggle').addEventListener('click', (event) => {
|
||||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||||
@@ -227,22 +278,37 @@ ${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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] {
|
function sortToolsForDisplay(tools: ToolDefinition[], toolOrder: string[]): ToolDefinition[] {
|
||||||
return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta)));
|
const order = new Map(toolOrder.map((id, index) => [id, index]));
|
||||||
|
return [...tools].sort((a, b) => {
|
||||||
|
const aId = toolId(a);
|
||||||
|
const bId = toolId(b);
|
||||||
|
const ai = order.has(aId) ? order.get(aId)! : Number.MAX_SAFE_INTEGER;
|
||||||
|
const bi = order.has(bId) ? order.get(bId)! : Number.MAX_SAFE_INTEGER;
|
||||||
|
if (ai !== bi) return ai - bi;
|
||||||
|
return Number(Boolean(a.beta)) - Number(Boolean(b.beta));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToolButton(tool: ToolDefinition): string {
|
function renderToolButton(tool: ToolDefinition): string {
|
||||||
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}">
|
return ` <button class="tool-btn" data-id="${escapeHtml(toolId(tool))}" data-cmd="${escapeHtml(tool.command)}">
|
||||||
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
||||||
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolId(tool: ToolDefinition): string {
|
||||||
|
return tool.id || tool.command;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value.replace(/[&<>"']/g, ch => ({
|
return value.replace(/[&<>"']/g, ch => ({
|
||||||
'&': '&',
|
'&': '&',
|
||||||
|
|||||||
1150
src/tools/deviceSimulator.ts
Normal file
1150
src/tools/deviceSimulator.ts
Normal file
File diff suppressed because it is too large
Load Diff
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);
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
|||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
});
|
});
|
||||||
res.end(file.buf);
|
res.end(mobilePreviewPage(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +296,35 @@ function chooserPage(files: SharedFile[]): string {
|
|||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mobilePreviewPage(file: SharedFile): string {
|
||||||
|
const playableJson = JSON.stringify(file.buf.toString('utf8')).replace(/<\/script/gi, '<\\/script');
|
||||||
|
const title = file.filename.replace(/[&<>"]/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)
|
||||||
|
);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
|
||||||
|
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
|
||||||
|
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<div id="boot" class="boot">Loading preview...</div>
|
||||||
|
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
|
||||||
|
<script>
|
||||||
|
const playableHtml = ${playableJson};
|
||||||
|
setTimeout(() => {
|
||||||
|
const frame = document.getElementById('playable');
|
||||||
|
document.getElementById('boot').style.display = 'none';
|
||||||
|
frame.srcdoc = playableHtml;
|
||||||
|
}, 750);
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
interface LanIp { address: string; iface: string; }
|
interface LanIp { address: string; iface: string; }
|
||||||
|
|
||||||
function getLanIps(): LanIp[] {
|
function getLanIps(): LanIp[] {
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
1017
standalone/device_simulator.go
Normal file
1017
standalone/device_simulator.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if n.Beta {
|
if n.Beta {
|
||||||
badge = ` <span class="beta-pill">Beta</span>`
|
badge = ` <span class="beta-pill">Beta</span>`
|
||||||
}
|
}
|
||||||
items.WriteString(`<button type="button" class="home-tool" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
|
items.WriteString(`<button type="button" class="home-tool" draggable="true" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := `
|
body := `
|
||||||
@@ -43,6 +43,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
font:inherit;
|
font:inherit;
|
||||||
}
|
}
|
||||||
.home-tool:hover { background:#33373a; border-color:#007fd4; }
|
.home-tool:hover { background:#33373a; border-color:#007fd4; }
|
||||||
|
.home-tool.dragging { opacity:0.55; }
|
||||||
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
|
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
|
||||||
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 19 KiB |
Binary file not shown.
@@ -104,6 +104,7 @@ const SharedCSS = `
|
|||||||
}
|
}
|
||||||
.sidebar .nav-link:hover { background: #2a2d2e; }
|
.sidebar .nav-link:hover { background: #2a2d2e; }
|
||||||
.sidebar .nav-link.active { background: #094771; color: #fff; }
|
.sidebar .nav-link.active { background: #094771; color: #fff; }
|
||||||
|
.sidebar .nav-link.dragging { opacity: 0.55; }
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@@ -158,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,
|
||||||
@@ -426,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"},
|
||||||
@@ -433,6 +484,7 @@ var navItems = []navItem{
|
|||||||
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
|
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
|
||||||
{Path: "/mintegral", Label: "Mintegral Checker", Description: "Validate Mintegral playable ZIPs against PlayTurbo checks"},
|
{Path: "/mintegral", Label: "Mintegral Checker", Description: "Validate Mintegral playable ZIPs against PlayTurbo checks"},
|
||||||
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
|
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
|
||||||
|
{Path: "/device-simulator", Label: "Device Simulator", Description: "Preview HTML playables inside mobile device frames"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
||||||
@@ -470,12 +522,16 @@ 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":
|
||||||
return "APP"
|
return "APP"
|
||||||
case "Mintegral Checker":
|
case "Mintegral Checker":
|
||||||
return "MiC"
|
return "MiC"
|
||||||
|
case "Device Simulator":
|
||||||
|
return "DS"
|
||||||
}
|
}
|
||||||
words := strings.Fields(label)
|
words := strings.Fields(label)
|
||||||
if len(words) == 0 {
|
if len(words) == 0 {
|
||||||
@@ -509,7 +565,7 @@ func Page(activePath, title, body string) string {
|
|||||||
if n.Beta {
|
if n.Beta {
|
||||||
label += `<span class="beta-pill">Beta</span>`
|
label += `<span class="beta-pill">Beta</span>`
|
||||||
}
|
}
|
||||||
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `" title="` + n.Label + `"><span class="nav-icon">` + navInitials(n.Label) + `</span><span class="nav-text">` + label + `</span></button>`)
|
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `" draggable="` + boolAttr(n.Path != "/") + `" title="` + n.Label + `"><span class="nav-icon">` + navInitials(n.Label) + `</span><span class="nav-text">` + label + `</span></button>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
betaButtonLabel := "Show beta"
|
betaButtonLabel := "Show beta"
|
||||||
@@ -535,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');
|
||||||
@@ -549,10 +610,72 @@ document.getElementById('sidebarToggle').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
document.querySelectorAll('[data-path]').forEach((element) => {
|
document.querySelectorAll('[data-path]').forEach((element) => {
|
||||||
element.addEventListener('click', () => {
|
element.addEventListener('click', () => {
|
||||||
|
if (element.dataset.dragging === 'true') {
|
||||||
|
element.dataset.dragging = 'false';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const path = element.dataset.path;
|
const path = element.dataset.path;
|
||||||
if (path) window.location.href = path;
|
if (path) window.location.href = path;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
|
||||||
|
function getToolOrder() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(TOOL_ORDER_KEY) || '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(path => typeof path === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setToolOrder(paths) {
|
||||||
|
localStorage.setItem(TOOL_ORDER_KEY, JSON.stringify(paths));
|
||||||
|
}
|
||||||
|
function applyStoredToolOrder(container, selector) {
|
||||||
|
if (!container) return;
|
||||||
|
const order = getToolOrder();
|
||||||
|
if (!order.length) return;
|
||||||
|
const elements = Array.from(container.querySelectorAll(selector));
|
||||||
|
const byPath = new Map(elements.map(el => [el.dataset.path, el]));
|
||||||
|
const home = byPath.get('/');
|
||||||
|
const ordered = order.map(path => byPath.get(path)).filter(Boolean);
|
||||||
|
const orderedSet = new Set(ordered);
|
||||||
|
const remaining = elements.filter(el => el !== home && !orderedSet.has(el));
|
||||||
|
if (home) container.appendChild(home);
|
||||||
|
ordered.concat(remaining).forEach(el => container.appendChild(el));
|
||||||
|
}
|
||||||
|
function setupToolReorder(container, selector) {
|
||||||
|
if (!container) return;
|
||||||
|
applyStoredToolOrder(container, selector);
|
||||||
|
let dragged = null;
|
||||||
|
container.querySelectorAll(selector).forEach(element => {
|
||||||
|
if (element.dataset.path === '/') return;
|
||||||
|
element.addEventListener('dragstart', event => {
|
||||||
|
dragged = element;
|
||||||
|
element.classList.add('dragging');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', element.dataset.path || '');
|
||||||
|
});
|
||||||
|
element.addEventListener('dragend', () => {
|
||||||
|
element.classList.remove('dragging');
|
||||||
|
element.dataset.dragging = 'true';
|
||||||
|
dragged = null;
|
||||||
|
const paths = Array.from(container.querySelectorAll(selector))
|
||||||
|
.map(el => el.dataset.path)
|
||||||
|
.filter(path => path && path !== '/');
|
||||||
|
setToolOrder(paths);
|
||||||
|
setTimeout(() => { element.dataset.dragging = 'false'; }, 0);
|
||||||
|
});
|
||||||
|
element.addEventListener('dragover', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dragged || dragged === element || element.dataset.path === '/') return;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const after = event.clientY > rect.top + rect.height / 2;
|
||||||
|
container.insertBefore(dragged, after ? element.nextSibling : element);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupToolReorder(document.querySelector('.sidebar-nav'), '.nav-link');
|
||||||
|
setupToolReorder(document.querySelector('.home-tools'), '.home-tool');
|
||||||
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
||||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
await fetch('/api/betaTools', {
|
await fetch('/api/betaTools', {
|
||||||
@@ -562,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)
|
||||||
@@ -114,14 +115,22 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET /mintegral", MintegralPage)
|
mux.HandleFunc("GET /mintegral", MintegralPage)
|
||||||
mux.HandleFunc("GET /mobile", MobilePage)
|
mux.HandleFunc("GET /mobile", MobilePage)
|
||||||
mux.HandleFunc("GET /playworks", PlayworksPage)
|
mux.HandleFunc("GET /playworks", PlayworksPage)
|
||||||
|
mux.HandleFunc("GET /device-simulator", DeviceSimulatorPage)
|
||||||
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
|
||||||
@@ -165,6 +174,11 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
||||||
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
||||||
|
|
||||||
|
// Device Simulator
|
||||||
|
mux.HandleFunc("POST /api/device-simulator/pick", DeviceSimulatorPick)
|
||||||
|
mux.HandleFunc("POST /api/device-simulator/load", DeviceSimulatorLoad)
|
||||||
|
mux.HandleFunc("GET /api/device-simulator/preview/", DeviceSimulatorPreview)
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(s.files[0].buf)
|
_, _ = w.Write([]byte(mobilePreviewPage(s.files[0])))
|
||||||
case "/file":
|
case "/file":
|
||||||
fallthrough
|
fallthrough
|
||||||
case "/file/0":
|
case "/file/0":
|
||||||
@@ -481,7 +481,7 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
if parts[0] == "view" {
|
if parts[0] == "view" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(file.buf)
|
_, _ = w.Write([]byte(mobilePreviewPage(file)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
safeName := safeFilename(file.filename)
|
safeName := safeFilename(file.filename)
|
||||||
@@ -499,6 +499,35 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mobilePreviewPage(file sharedFile) string {
|
||||||
|
playableJSON, _ := json.Marshal(string(file.buf))
|
||||||
|
playableScriptValue := strings.ReplaceAll(string(playableJSON), "</script", "<\\/script")
|
||||||
|
playableScriptValue = strings.ReplaceAll(playableScriptValue, "</SCRIPT", "<\\/SCRIPT")
|
||||||
|
title := html.EscapeString(file.filename)
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>` + title + `</title>
|
||||||
|
<style>
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
|
||||||
|
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
|
||||||
|
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<div id="boot" class="boot">Loading preview...</div>
|
||||||
|
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
|
||||||
|
<script>
|
||||||
|
const playableHtml = ` + playableScriptValue + `;
|
||||||
|
setTimeout(() => {
|
||||||
|
const frame = document.getElementById('playable');
|
||||||
|
document.getElementById('boot').style.display = 'none';
|
||||||
|
frame.srcdoc = playableHtml;
|
||||||
|
}, 750);
|
||||||
|
</script>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||||
|
|
||||||
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type AppConfig struct {
|
|||||||
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
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
|
||||||
|
}
|
||||||
18
tools.json
18
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",
|
||||||
@@ -54,5 +62,13 @@
|
|||||||
"command": "hplToolbox.openMintegralChecker",
|
"command": "hplToolbox.openMintegralChecker",
|
||||||
"path": "/mintegral",
|
"path": "/mintegral",
|
||||||
"beta": false
|
"beta": false
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"id": "device-simulator",
|
||||||
|
"title": "Device Simulator",
|
||||||
|
"description": "Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays",
|
||||||
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
|
"path": "/device-simulator",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user