Compare commits
15 Commits
9e24342142
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 331e49ea29 | |||
| c336f176e6 | |||
| 0e30ed0b36 | |||
| 8e5d56ab1d | |||
| 68d15aa208 | |||
| b254daf06a | |||
| 16de3cc116 | |||
| 5fa1ce949c | |||
| e50b4c2133 | |||
| cebb65fb35 | |||
| 7094e6c3b0 | |||
| 6c09beea43 | |||
| ad6403f3e4 | |||
| 700bb135ad | |||
| 986993a027 |
131
PROJECT.md
Normal file
131
PROJECT.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# HPL Toolbox — Project Summary for AI
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
HPL Toolbox is an internal tool for a playable ad team. It ships as **two separate deliverables** from the same repo:
|
||||||
|
|
||||||
|
1. **VS Code Extension** (`src/`) — a `.vsix` that adds tool panels as webview tabs inside VS Code.
|
||||||
|
2. **Standalone Windows App** (`standalone/`) — a Go + WebView2 desktop app that exposes the same tools through a local HTTP server rendered in a native window. It is a **real, complete, production app** — not a prototype or subset.
|
||||||
|
|
||||||
|
Both deliverables implement the same set of tools. New features are typically added to both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
HPLToolbox-Extension/
|
||||||
|
├── src/
|
||||||
|
│ ├── extension.ts # Extension entry point, registers commands
|
||||||
|
│ ├── tools/ # One file per tool (VS Code webview panels)
|
||||||
|
│ │ ├── shared.ts # Shared utilities: singletonPanel, webview styles, clipboard/open handlers
|
||||||
|
│ │ ├── plecUpload.ts # PLEC Upload tool
|
||||||
|
│ │ ├── applovin.ts # AppLovin Playable Preview
|
||||||
|
│ │ ├── base64Scanner.ts # Base64 Scanner
|
||||||
|
│ │ ├── mraidChecker.ts # MRAID Checker
|
||||||
|
│ │ ├── mintegral.ts # Mintegral Checker
|
||||||
|
│ │ ├── sendToMobile.ts # Send To Mobile
|
||||||
|
│ │ ├── playworks.ts # Playworks Converter
|
||||||
|
│ │ ├── deviceSimulator.ts# Device Simulator
|
||||||
|
│ │ └── projectInit.ts # Initialize Project
|
||||||
|
│ └── tools.json # Tool launcher config (name, command, icon, description)
|
||||||
|
├── standalone/ # The Go standalone app — complete, production-grade
|
||||||
|
│ ├── main.go # Entry point: HTTP server + WebView2 window + route registration
|
||||||
|
│ ├── platform.go # AppConfig struct, defaultConfig(), LoadConfig/SaveConfig, OS helpers
|
||||||
|
│ ├── settings.go # /api/settings POST endpoint; settings modal HTML
|
||||||
|
│ ├── layout.go # Shared HTML layout, nav sidebar, CSS
|
||||||
|
│ ├── home.go # Home page / tool launcher
|
||||||
|
│ ├── plec.go # PLEC Upload: page HTML, pick endpoints, upload (legacy + WordPress)
|
||||||
|
│ ├── applovin.go # AppLovin upload
|
||||||
|
│ ├── base64.go # Base64 Scanner
|
||||||
|
│ ├── mraid.go # MRAID Checker
|
||||||
|
│ ├── mintegral.go # Mintegral Checker
|
||||||
|
│ ├── mobile.go # Send To Mobile
|
||||||
|
│ ├── playworks.go # Playworks Converter
|
||||||
|
│ ├── device_simulator.go # Device Simulator
|
||||||
|
│ ├── project_init.go # Initialize Project
|
||||||
|
│ ├── changelog.go # Changelog page
|
||||||
|
│ ├── json.go # newJSONStream helper (NDJSON streaming responses)
|
||||||
|
│ ├── update_checker.go # Auto-update check against Gitea release API
|
||||||
|
│ ├── single_instance.go # Single-instance lock via TCP port file
|
||||||
|
│ └── assets/ # Embedded static assets (qrcode.min.js, mintegral preview util)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── build.ps1 # Builds both .vsix and standalone exe into dist/
|
||||||
|
│ └── run-standalone.ps1 # Runs the standalone app in dev mode (go run .)
|
||||||
|
├── data/
|
||||||
|
│ └── manifest.json # Project Init manifest (team template files list)
|
||||||
|
├── aiAssets/ # Sample HTML files used for testing/reference
|
||||||
|
├── package.json # Extension manifest, VS Code settings contributions, npm scripts
|
||||||
|
├── tsconfig.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| PLEC Upload | Upload HTML playable files to internal PLEC servers. Supports two servers: Default (167.99.227.249, legacy PHP) and Backup (20.255.60.183, WordPress). Backup server uses auto-login via credentials. |
|
||||||
|
| AppLovin Playable Preview | Upload HTML files to p.applov.in and generate QR preview links. |
|
||||||
|
| Base64 Scanner | Scan HTML files for external or non-base64 asset references. |
|
||||||
|
| Send To Mobile | Serve selected HTML files over LAN and display a QR code for mobile preview. |
|
||||||
|
| MRAID Checker | Check HTML files against MRAID requirements and best practices. |
|
||||||
|
| Mintegral Checker | Validate Mintegral playable ZIPs against PlayTurbo requirements with embedded runtime preview. |
|
||||||
|
| Playworks Converter | Convert Playworks HTML into per-network variants. |
|
||||||
|
| Device Simulator | Preview HTML playables inside accurate mobile device frames with notch/Dynamic Island overlays. |
|
||||||
|
| Initialize Project | Download team template files from a shared manifest (Google Drive or direct URL). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
### VS Code Extension
|
||||||
|
- Each tool is a singleton webview panel (`singletonPanel` in `shared.ts`).
|
||||||
|
- The panel HTML is returned as a template string from `getHtml()` inside each tool file.
|
||||||
|
- Communication between extension host and webview uses `panel.webview.postMessage` / `onDidReceiveMessage`.
|
||||||
|
- Settings are read from `vscode.workspace.getConfiguration('hplToolbox.*')` and contributed in `package.json`.
|
||||||
|
- Session-persistent UI state (e.g. last selected server) uses `context.globalState`.
|
||||||
|
|
||||||
|
### Standalone App
|
||||||
|
- Go HTTP server on a random localhost port, served into a WebView2 window.
|
||||||
|
- All pages return full HTML strings (no template files on disk).
|
||||||
|
- Long-running operations (uploads, scans) use `newJSONStream` to stream NDJSON progress events — the browser reads these with `ReadableStream` and updates the UI incrementally.
|
||||||
|
- Config is persisted to `%LOCALAPPDATA%\HPLToolbox\config.json` via `LoadConfig` / `SaveConfig`.
|
||||||
|
- `localStorage` is used for lightweight UI-only state (e.g. last selected server in PLEC Upload).
|
||||||
|
- Single-instance enforcement: on launch, tries to connect to a previous instance's port from a lock file and `POST /api/focus` to bring its window forward, then exits.
|
||||||
|
|
||||||
|
### PLEC Upload — dual server detail
|
||||||
|
- **Default (legacy)**: `POST http://167.99.227.249/src/upload_HTML_Experimental.php` with `fileUpload_N` / `iterationNameUpload_N` fields. Filenames are timestamped.
|
||||||
|
- **Backup (WordPress)**: `POST http://20.255.60.183/wp-admin/admin-ajax.php` with `action=plec_upload_files`, `nonce`, `rows` (JSON array), and `file_N` fields. Login flow: POST `/wp-login.php` → GET `/file-upload/` to extract nonce. Iteration name is used as the uploaded filename. Preview URL is constructed as `/preview?theme=calcite&n1=<original>&m1=<saved>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build # builds both .vsix and standalone .exe into dist/
|
||||||
|
npm run build -- -Extension # .vsix only
|
||||||
|
npm run build -- -Standalone # .exe only
|
||||||
|
npm run run-standalone # dev: go run . in standalone/
|
||||||
|
npm run compile # tsc only
|
||||||
|
npm run watch # tsc watch
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires: Node.js, TypeScript (`npm install`), Go, optionally `vsce` and `rsrc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key config (VS Code settings)
|
||||||
|
|
||||||
|
All under `hplToolbox.*`:
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `plec.uploadUrl` | `http://167.99.227.249/src/upload_HTML_Experimental.php` | Legacy server endpoint |
|
||||||
|
| `plec.originUrl` | `http://167.99.227.249` | Origin/Referer for legacy uploads |
|
||||||
|
| `plec.wpUsername` | `pm` | WordPress username for backup server |
|
||||||
|
| `plec.wpPassword` | _(set)_ | WordPress password for backup server |
|
||||||
|
| `applovin.cookie` | _(empty)_ | `__Host-APPLOVINID` cookie for p.applov.in |
|
||||||
|
| `deviceSimulator.devicesUrl` | Google Drive link | Remote devices.json URL |
|
||||||
|
| `deviceSimulator.languagesUrl` | Google Drive link | Remote languages.json URL |
|
||||||
81
README.md
81
README.md
@@ -10,7 +10,9 @@ Bundled VS Code extension and standalone tools for playable ad workflows.
|
|||||||
- **Send To Mobile** - Share selected HTML files to a phone over LAN with a QR code.
|
- **Send To Mobile** - Share selected HTML files to a phone over LAN with a QR code.
|
||||||
- **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.
|
||||||
|
- **Device Simulator** - Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays.
|
||||||
|
- **Initialize Project** - Download team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared Google Drive manifest.
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
@@ -85,3 +87,80 @@ 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v1.0.0**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added remote devices.json and languages.json support for Device Simulator.
|
||||||
|
- Device and language lists are fetched from a configurable Google Drive URL on open and cached for offline use, with built-in defaults as fallback.
|
||||||
|
- Device Simulator remote JSON URLs are configurable in VS Code extension settings (hplToolbox.deviceSimulator.devicesUrl / languagesUrl).
|
||||||
|
- Settings modal in standalone — accessible from any page via the ⚙ button, with Device Simulator and Initialize Project sections highlighted when opened from those tools.
|
||||||
|
- Device list is now sorted alphabetically in both extension and standalone.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Device Simulator settings gear now opens the VS Code extension settings (extension) or the global settings modal (standalone).
|
||||||
|
- Device management (add/edit/delete/reorder) removed from Device Simulator in both extension and standalone; device list is now managed via remote JSON.
|
||||||
|
- Settings page in standalone replaced by a modal overlay available on every page.
|
||||||
|
- Initialize Project manifest URL is now configured via the settings modal instead of the inline settings panel.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed Device Simulator not appearing in the VS Code extension launcher due to a trailing comma in tools.json causing JSON.parse to fail and fall back to a hardcoded list that excluded it.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v1.0.1**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added backup PLEC server (20.255.60.183) as a selectable upload target in PLEC Upload.
|
||||||
|
- Server dropdown in the PLEC Upload inputs panel — Default (167.99.227.249) or Backup (20.255.60.183).
|
||||||
|
- Backup server uses WordPress authentication (auto-login, nonce fetch) — no manual token management.
|
||||||
|
- Iteration name is used as the uploaded filename on the backup server.
|
||||||
|
- Selected server persists across sessions (VS Code globalState / localStorage in standalone).
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Build scripts moved to scripts/ and added as npm run build and npm run run-standalone.
|
||||||
|
```
|
||||||
243
data/devices.json
Normal file
243
data/devices.json
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"id": "iphone-17-pro-max",
|
||||||
|
"name": "iPhone 17 Pro Max",
|
||||||
|
"sw": 440,
|
||||||
|
"sh": 956,
|
||||||
|
"scr": 44,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 126,
|
||||||
|
"nh": 37,
|
||||||
|
"nr": 20,
|
||||||
|
"sat": 62,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-17-pro",
|
||||||
|
"name": "iPhone 17 Pro",
|
||||||
|
"sw": 402,
|
||||||
|
"sh": 874,
|
||||||
|
"scr": 42,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 126,
|
||||||
|
"nh": 37,
|
||||||
|
"nr": 20,
|
||||||
|
"sat": 62,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-air",
|
||||||
|
"name": "iPhone Air",
|
||||||
|
"sw": 420,
|
||||||
|
"sh": 912,
|
||||||
|
"scr": 42,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 126,
|
||||||
|
"nh": 37,
|
||||||
|
"nr": 20,
|
||||||
|
"sat": 62,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-17",
|
||||||
|
"name": "iPhone 17",
|
||||||
|
"sw": 402,
|
||||||
|
"sh": 874,
|
||||||
|
"scr": 42,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 126,
|
||||||
|
"nh": 37,
|
||||||
|
"nr": 20,
|
||||||
|
"sat": 62,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-16-pro-max",
|
||||||
|
"name": "iPhone 16 Pro Max",
|
||||||
|
"sw": 440,
|
||||||
|
"sh": 956,
|
||||||
|
"scr": 44,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 126,
|
||||||
|
"nh": 37,
|
||||||
|
"nr": 20,
|
||||||
|
"sat": 62,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-16-pro",
|
||||||
|
"name": "iPhone 16 Pro",
|
||||||
|
"sw": 402,
|
||||||
|
"sh": 874,
|
||||||
|
"scr": 42,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 126,
|
||||||
|
"nh": 37,
|
||||||
|
"nr": 20,
|
||||||
|
"sat": 62,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-16-plus",
|
||||||
|
"name": "iPhone 16 Plus",
|
||||||
|
"sw": 430,
|
||||||
|
"sh": 932,
|
||||||
|
"scr": 42,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 120,
|
||||||
|
"nh": 36,
|
||||||
|
"nr": 18,
|
||||||
|
"sat": 59,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-16",
|
||||||
|
"name": "iPhone 16",
|
||||||
|
"sw": 390,
|
||||||
|
"sh": 844,
|
||||||
|
"scr": 40,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 120,
|
||||||
|
"nh": 36,
|
||||||
|
"nr": 18,
|
||||||
|
"sat": 59,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-15-pro-max",
|
||||||
|
"name": "iPhone 15 Pro Max",
|
||||||
|
"sw": 430,
|
||||||
|
"sh": 932,
|
||||||
|
"scr": 42,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 120,
|
||||||
|
"nh": 36,
|
||||||
|
"nr": 18,
|
||||||
|
"sat": 59,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-15-pro",
|
||||||
|
"name": "iPhone 15 Pro",
|
||||||
|
"sw": 393,
|
||||||
|
"sh": 852,
|
||||||
|
"scr": 40,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 120,
|
||||||
|
"nh": 36,
|
||||||
|
"nr": 18,
|
||||||
|
"sat": 59,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-15-plus",
|
||||||
|
"name": "iPhone 15 Plus",
|
||||||
|
"sw": 430,
|
||||||
|
"sh": 932,
|
||||||
|
"scr": 42,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 120,
|
||||||
|
"nh": 36,
|
||||||
|
"nr": 18,
|
||||||
|
"sat": 59,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-15",
|
||||||
|
"name": "iPhone 15",
|
||||||
|
"sw": 390,
|
||||||
|
"sh": 844,
|
||||||
|
"scr": 38,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 120,
|
||||||
|
"nh": 36,
|
||||||
|
"nr": 18,
|
||||||
|
"sat": 59,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-14-pro-max",
|
||||||
|
"name": "iPhone 14 Pro Max",
|
||||||
|
"sw": 430,
|
||||||
|
"sh": 932,
|
||||||
|
"scr": 42,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 120,
|
||||||
|
"nh": 36,
|
||||||
|
"nr": 18,
|
||||||
|
"sat": 59,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-14-pro",
|
||||||
|
"name": "iPhone 14 Pro",
|
||||||
|
"sw": 393,
|
||||||
|
"sh": 852,
|
||||||
|
"scr": 40,
|
||||||
|
"notch": "island",
|
||||||
|
"nw": 120,
|
||||||
|
"nh": 36,
|
||||||
|
"nr": 18,
|
||||||
|
"sat": 59,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-14-plus",
|
||||||
|
"name": "iPhone 14 Plus",
|
||||||
|
"sw": 428,
|
||||||
|
"sh": 926,
|
||||||
|
"scr": 40,
|
||||||
|
"notch": "notch",
|
||||||
|
"nw": 130,
|
||||||
|
"nh": 34,
|
||||||
|
"sat": 47,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-14",
|
||||||
|
"name": "iPhone 14",
|
||||||
|
"sw": 390,
|
||||||
|
"sh": 844,
|
||||||
|
"scr": 38,
|
||||||
|
"notch": "notch",
|
||||||
|
"nw": 130,
|
||||||
|
"nh": 34,
|
||||||
|
"sat": 47,
|
||||||
|
"sab": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iphone-se3",
|
||||||
|
"name": "iPhone SE (3rd gen)",
|
||||||
|
"sw": 375,
|
||||||
|
"sh": 667,
|
||||||
|
"scr": 4,
|
||||||
|
"notch": "none",
|
||||||
|
"sat": 20,
|
||||||
|
"sab": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pixel-8",
|
||||||
|
"name": "Google Pixel 8",
|
||||||
|
"sw": 411,
|
||||||
|
"sh": 914,
|
||||||
|
"scr": 22,
|
||||||
|
"notch": "punch",
|
||||||
|
"ps": 14,
|
||||||
|
"sat": 28,
|
||||||
|
"sab": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "galaxy-s24",
|
||||||
|
"name": "Samsung Galaxy S24",
|
||||||
|
"sw": 360,
|
||||||
|
"sh": 780,
|
||||||
|
"scr": 20,
|
||||||
|
"notch": "punch",
|
||||||
|
"ps": 12,
|
||||||
|
"sat": 28,
|
||||||
|
"sab": 24
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
15
data/languages.json
Normal file
15
data/languages.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
17
data/manifest.json
Normal file
17
data/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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
56
package.json
56
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": "1.0.1",
|
||||||
"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": {
|
||||||
@@ -63,7 +75,8 @@
|
|||||||
{
|
{
|
||||||
"type": "webview",
|
"type": "webview",
|
||||||
"id": "hplToolbox.launcher",
|
"id": "hplToolbox.launcher",
|
||||||
"name": ""
|
"name": "",
|
||||||
|
"icon": "media/hpl.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -90,6 +103,16 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Skip the pre-check that verifies the filename does not already exist on the preview server."
|
"description": "Skip the pre-check that verifies the filename does not already exist on the preview server."
|
||||||
|
},
|
||||||
|
"hplToolbox.plec.wpUsername": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "pm",
|
||||||
|
"description": "WordPress username for the backup PLEC server (20.255.60.183)."
|
||||||
|
},
|
||||||
|
"hplToolbox.plec.wpPassword": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "nRLz#LV@y#)q@CW@z8fGEPyI",
|
||||||
|
"description": "WordPress password for the backup PLEC server (20.255.60.183)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -123,13 +146,40 @@
|
|||||||
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "HPL Toolbox — Device Simulator",
|
||||||
|
"properties": {
|
||||||
|
"hplToolbox.deviceSimulator.devicesUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "https://drive.google.com/file/d/1w04wviCmgNqtDCO1GLyYfwEeWpjBPGJ4/view?usp=drive_link",
|
||||||
|
"description": "URL to a remote devices.json file. When set, the Device Simulator fetches this on open and uses it as the default device list. Leave empty to use built-in defaults only. Supports Google Drive share links (file/d/...) or any direct JSON URL."
|
||||||
|
},
|
||||||
|
"hplToolbox.deviceSimulator.languagesUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "https://drive.google.com/file/d/1I3ZiI8CLU2JxYZEtWCz6Vr0r8Uj2732f/view?usp=drive_link",
|
||||||
|
"description": "URL to a remote languages.json file. When set, the Device Simulator fetches this on open and populates the language dropdown from it. Leave empty to use built-in defaults only."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"compile": "tsc -p ./",
|
"compile": "tsc -p ./",
|
||||||
"watch": "tsc -watch -p ./",
|
"watch": "tsc -watch -p ./",
|
||||||
"vscode:prepublish": "npm run compile"
|
"vscode:prepublish": "npm run compile",
|
||||||
|
"build": "pwsh -File scripts/build.ps1",
|
||||||
|
"run-standalone": "pwsh -File scripts/run-standalone.ps1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
|
|
||||||
# If neither was specified, build both.
|
# If neither was specified, build both.
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { chromium } from 'playwright';
|
|
||||||
import { createServer } from 'node:http';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
const dogCatRoot = process.env.DOGCAT_ROOT || path.join(root, 'aiAssets', 'DogCat3');
|
|
||||||
const waitMs = Number(process.env.RUNTIME_WAIT_MS || 9000);
|
|
||||||
|
|
||||||
const mraidStub = `
|
|
||||||
window.mraid = window.mraid || {
|
|
||||||
getState: function(){ return 'default'; },
|
|
||||||
isViewable: function(){ return true; },
|
|
||||||
addEventListener: function(name, cb){ if (name === 'ready') setTimeout(cb, 0); },
|
|
||||||
removeEventListener: function(){},
|
|
||||||
open: function(url){ console.log('[stub mraid.open]', url); },
|
|
||||||
close: function(){ console.log('[stub mraid.close]'); },
|
|
||||||
supports: function(){ return false; }
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const exitApiStub = `
|
|
||||||
window.ExitApi = window.ExitApi || {
|
|
||||||
exit: function(){ console.log('[stub ExitApi.exit]'); }
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
|
|
||||||
async function collectCases(tempDir) {
|
|
||||||
const cases = [];
|
|
||||||
const networkFolders = [
|
|
||||||
'Applovin',
|
|
||||||
'Facebook',
|
|
||||||
'Ironsource',
|
|
||||||
'Moloco',
|
|
||||||
'Unity',
|
|
||||||
'GoogleAds',
|
|
||||||
'Mintegral',
|
|
||||||
'Vungle',
|
|
||||||
];
|
|
||||||
for (const name of networkFolders) {
|
|
||||||
const dir = path.join(dogCatRoot, name);
|
|
||||||
if (!existsSync(dir)) continue;
|
|
||||||
const entries = await readdir(dir);
|
|
||||||
const htmlName = entries.find((entry) => /\.html?$/i.test(entry));
|
|
||||||
if (htmlName) {
|
|
||||||
cases.push([name, await readFile(path.join(dir, htmlName))]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const zipName = entries.find((entry) => /\.zip$/i.test(entry));
|
|
||||||
if (!zipName) continue;
|
|
||||||
const zip = path.join(dir, zipName);
|
|
||||||
const out = path.join(tempDir, name);
|
|
||||||
execFileSync('powershell', [
|
|
||||||
'-NoProfile',
|
|
||||||
'-Command',
|
|
||||||
`Expand-Archive -LiteralPath '${zip.replaceAll("'", "''")}' -DestinationPath '${out.replaceAll("'", "''")}' -Force`,
|
|
||||||
], { stdio: 'ignore' });
|
|
||||||
const expanded = await readdir(out);
|
|
||||||
const indexName = expanded.find((entry) => entry.toLowerCase() === 'index.html');
|
|
||||||
if (indexName) cases.push([name, await readFile(path.join(out, indexName))]);
|
|
||||||
}
|
|
||||||
return cases;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startServer(cases) {
|
|
||||||
const html = new Map(cases.map(([name, body]) => [`/${name}/index.html`, body]));
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
||||||
if (url.pathname.endsWith('/mraid.js')) {
|
|
||||||
res.writeHead(200, { 'content-type': 'application/javascript' });
|
|
||||||
res.end(mraidStub);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname.endsWith('/exitapi.js')) {
|
|
||||||
res.writeHead(200, { 'content-type': 'application/javascript' });
|
|
||||||
res.end(exitApiStub);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = html.get(url.pathname);
|
|
||||||
if (body) {
|
|
||||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
||||||
res.end(body);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
||||||
res.end('not found');
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
||||||
return {
|
|
||||||
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
|
||||||
close: () => new Promise((resolve) => server.close(resolve)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function simplifyUrl(url) {
|
|
||||||
return url.replace(/^https?:\/\/127\.0\.0\.1:\d+\//, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCase(browser, baseUrl, name) {
|
|
||||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true });
|
|
||||||
const issues = [];
|
|
||||||
|
|
||||||
page.on('pageerror', (error) => {
|
|
||||||
issues.push({ type: 'pageerror', text: error.stack || error.message });
|
|
||||||
});
|
|
||||||
page.on('console', (message) => {
|
|
||||||
if (['error', 'assert'].includes(message.type())) {
|
|
||||||
const location = message.location();
|
|
||||||
const suffix = location.url ? ` @ ${simplifyUrl(location.url)}:${location.lineNumber}:${location.columnNumber}` : '';
|
|
||||||
issues.push({ type: `console.${message.type()}`, text: `${message.text()}${suffix}` });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.on('requestfailed', (request) => {
|
|
||||||
issues.push({
|
|
||||||
type: 'requestfailed',
|
|
||||||
text: `${simplifyUrl(request.url())} :: ${request.failure()?.errorText || 'failed'}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
page.on('response', (response) => {
|
|
||||||
if (response.status() >= 400) {
|
|
||||||
issues.push({
|
|
||||||
type: `http.${response.status()}`,
|
|
||||||
text: simplifyUrl(response.url()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(`${baseUrl}/${name}/index.html`, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
||||||
await page.waitForTimeout(waitMs);
|
|
||||||
|
|
||||||
const bodyText = await page.locator('body').evaluate((body) => body.innerText.slice(0, 2000)).catch(() => '');
|
|
||||||
const canvasCount = await page.locator('canvas').count().catch(() => 0);
|
|
||||||
const title = await page.title().catch(() => '');
|
|
||||||
await page.close();
|
|
||||||
|
|
||||||
return { issues, canvasCount, title, bodyText };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'dogcat3-playwright-'));
|
|
||||||
let server;
|
|
||||||
let browser;
|
|
||||||
try {
|
|
||||||
const cases = await collectCases(tempDir);
|
|
||||||
server = await startServer(cases);
|
|
||||||
browser = await chromium.launch({ channel: 'msedge', headless: true });
|
|
||||||
|
|
||||||
for (const [name] of cases) {
|
|
||||||
const result = await runCase(browser, server.baseUrl, name);
|
|
||||||
console.log(`\n## ${name}`);
|
|
||||||
console.log(`canvas=${result.canvasCount}`);
|
|
||||||
if (result.title) console.log(`title=${result.title}`);
|
|
||||||
if (!result.issues.length) {
|
|
||||||
console.log('No page errors, console errors, failed requests, or HTTP 4xx/5xx responses captured.');
|
|
||||||
} else {
|
|
||||||
for (const issue of result.issues) {
|
|
||||||
console.log(`- ${issue.type}: ${issue.text.replace(/\s+/g, ' ').slice(0, 700)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (result.bodyText && /error|exception|cannot|failed/i.test(result.bodyText)) {
|
|
||||||
console.log(`body_error_text=${result.bodyText.replace(/\s+/g, ' ').slice(0, 700)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (browser) await browser.close();
|
|
||||||
if (server) await server.close();
|
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
$standalone = Join-Path $root 'standalone'
|
$standalone = Join-Path $root 'standalone'
|
||||||
|
|
||||||
Push-Location $standalone
|
Push-Location $standalone
|
||||||
@@ -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',
|
||||||
@@ -42,7 +49,6 @@ const FALLBACK_TOOLS: ToolDefinition[] = [
|
|||||||
command: 'hplToolbox.openPlayworksConverter',
|
command: 'hplToolbox.openPlayworksConverter',
|
||||||
title: 'Playworks Converter',
|
title: 'Playworks Converter',
|
||||||
description: 'Convert Playworks HTML to per-network variants',
|
description: 'Convert Playworks HTML to per-network variants',
|
||||||
beta: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -54,15 +60,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 +99,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 +138,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 +211,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 +225,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 +277,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 => ({
|
||||||
'&': '&',
|
'&': '&',
|
||||||
|
|||||||
749
src/tools/deviceSimulator.ts
Normal file
749
src/tools/deviceSimulator.ts
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as https from 'https';
|
||||||
|
import { getToolWebviewStyles } from './shared';
|
||||||
|
|
||||||
|
const GS_DEVICES_KEY = 'hplDeviceSimulator.remoteDevices.v1';
|
||||||
|
const GS_LANGUAGES_KEY = 'hplDeviceSimulator.remoteLanguages.v1';
|
||||||
|
|
||||||
|
function getSimulatorConfig() {
|
||||||
|
const cfg = vscode.workspace.getConfiguration('hplToolbox.deviceSimulator');
|
||||||
|
return {
|
||||||
|
devicesUrl: (cfg.get<string>('devicesUrl') || '').trim(),
|
||||||
|
languagesUrl: (cfg.get<string>('languagesUrl') || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGDriveUrl(url: string): string {
|
||||||
|
const fileId = url.match(/\/file\/d\/([a-zA-Z0-9_-]+)/)?.[1]
|
||||||
|
?? url.match(/[?&]id=([a-zA-Z0-9_-]+)/)?.[1];
|
||||||
|
return fileId ? `https://drive.google.com/uc?export=download&id=${fileId}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store: {
|
||||||
|
panel: vscode.WebviewPanel | null;
|
||||||
|
previewServer: http.Server | null;
|
||||||
|
previewPort: number;
|
||||||
|
previewContent: string;
|
||||||
|
previewDir: string;
|
||||||
|
} = { panel: null, previewServer: null, previewPort: 0, previewContent: '', previewDir: '' };
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sw: number; sh: number;
|
||||||
|
scr: number;
|
||||||
|
notch: 'none' | 'notch' | 'island' | 'punch';
|
||||||
|
nw?: number; nh?: number; nr?: number; ps?: number;
|
||||||
|
sat: number; sab: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICES: Device[] = [
|
||||||
|
{ id: 'pixel-8', name: 'Google Pixel 8', sw: 411, sh: 914, scr: 22, notch: 'punch', ps: 14, sat: 28, sab: 24 },
|
||||||
|
{ id: 'iphone-14', name: 'iPhone 14', sw: 390, sh: 844, scr: 38, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
|
||||||
|
{ id: 'iphone-14-plus', name: 'iPhone 14 Plus', sw: 428, sh: 926, scr: 40, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
|
||||||
|
{ id: 'iphone-14-pro', name: 'iPhone 14 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||||
|
{ id: 'iphone-14-pro-max', name: 'iPhone 14 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||||
|
{ id: 'iphone-15', name: 'iPhone 15', sw: 390, sh: 844, scr: 38, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||||
|
{ id: 'iphone-15-plus', name: 'iPhone 15 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||||
|
{ id: 'iphone-15-pro', name: 'iPhone 15 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||||
|
{ id: 'iphone-15-pro-max', name: 'iPhone 15 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||||
|
{ id: 'iphone-16', name: 'iPhone 16', sw: 390, sh: 844, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||||
|
{ id: 'iphone-16-plus', name: 'iPhone 16 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||||
|
{ id: 'iphone-16-pro', name: 'iPhone 16 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||||
|
{ id: 'iphone-16-pro-max', name: 'iPhone 16 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||||
|
{ id: 'iphone-17', name: 'iPhone 17', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||||
|
{ id: 'iphone-17-pro', name: 'iPhone 17 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||||
|
{ id: 'iphone-17-pro-max', name: 'iPhone 17 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||||
|
{ id: 'iphone-air', name: 'iPhone Air', sw: 420, sh: 912, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||||
|
{ id: 'iphone-se3', name: 'iPhone SE (3rd gen)', sw: 375, sh: 667, scr: 4, notch: 'none', sat: 20, sab: 0 },
|
||||||
|
{ id: 'galaxy-s24', name: 'Samsung Galaxy S24', sw: 360, sh: 780, scr: 20, notch: 'punch', ps: 12, sat: 28, sab: 24 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fetchJson(url: string, redirectsLeft = 3): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.get(url, { headers: { 'User-Agent': 'HPLToolbox-Extension' } }, (res) => {
|
||||||
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
if (redirectsLeft > 0) {
|
||||||
|
fetchJson(res.headers.location, redirectsLeft - 1).then(resolve).catch(reject);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Too many redirects'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDeviceSimulator(context: vscode.ExtensionContext) {
|
||||||
|
if (store.panel) {
|
||||||
|
store.panel.reveal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = vscode.window.createWebviewPanel(
|
||||||
|
'hplToolbox.deviceSimulator',
|
||||||
|
'Device Simulator',
|
||||||
|
vscode.ViewColumn.Active,
|
||||||
|
{ enableScripts: true, retainContextWhenHidden: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
store.panel = panel;
|
||||||
|
panel.onDidDispose(() => {
|
||||||
|
store.panel = null;
|
||||||
|
store.previewContent = '';
|
||||||
|
store.previewDir = '';
|
||||||
|
if (store.previewServer) {
|
||||||
|
store.previewServer.close();
|
||||||
|
store.previewServer = null;
|
||||||
|
store.previewPort = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
panel.webview.html = getHtml(DEVICES);
|
||||||
|
|
||||||
|
// Send cached remote data immediately so dropdowns update without waiting for network
|
||||||
|
const cachedDevices = context.globalState.get<string>(GS_DEVICES_KEY);
|
||||||
|
const cachedLanguages = context.globalState.get<string>(GS_LANGUAGES_KEY);
|
||||||
|
if (cachedDevices) panel.webview.postMessage({ type: 'updateRemoteDevices', payload: cachedDevices });
|
||||||
|
if (cachedLanguages) panel.webview.postMessage({ type: 'updateRemoteLanguages', payload: cachedLanguages });
|
||||||
|
|
||||||
|
// Background fetch — updates cache and pushes fresh data if panel is still open
|
||||||
|
const simCfg = getSimulatorConfig();
|
||||||
|
if (simCfg.devicesUrl) {
|
||||||
|
fetchJson(resolveGDriveUrl(simCfg.devicesUrl)).then((data) => {
|
||||||
|
const payload = JSON.stringify(data);
|
||||||
|
context.globalState.update(GS_DEVICES_KEY, payload);
|
||||||
|
panel.webview.postMessage({ type: 'updateRemoteDevices', payload });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
if (simCfg.languagesUrl) {
|
||||||
|
fetchJson(resolveGDriveUrl(simCfg.languagesUrl)).then((data) => {
|
||||||
|
const payload = JSON.stringify(data);
|
||||||
|
context.globalState.update(GS_LANGUAGES_KEY, payload);
|
||||||
|
panel.webview.postMessage({ type: 'updateRemoteLanguages', payload });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastFilePath = '';
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
if (msg.type === 'pickFile') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: true,
|
||||||
|
canSelectFolders: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select File',
|
||||||
|
filters: { 'HTML': ['html', 'htm'] },
|
||||||
|
});
|
||||||
|
if (picked?.[0]) {
|
||||||
|
lastFilePath = picked[0].fsPath;
|
||||||
|
sendFile(panel, lastFilePath, true);
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'reload') {
|
||||||
|
if (lastFilePath) sendFile(panel, lastFilePath, false);
|
||||||
|
} else if (msg.type === 'openSettings') {
|
||||||
|
vscode.commands.executeCommand('workbench.action.openSettings', 'hplToolbox.deviceSimulator');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(panel: vscode.WebviewPanel, filePath: string, resetMute: boolean) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
store.previewContent = content;
|
||||||
|
store.previewDir = path.dirname(filePath);
|
||||||
|
ensurePreviewServer(() => {
|
||||||
|
const sourceUrl = store.previewPort ? `http://127.0.0.1:${store.previewPort}/preview/index.html` : '';
|
||||||
|
panel.webview.postMessage({ type: 'fileLoaded', content, name: path.basename(filePath), sourceUrl, resetMute });
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
vscode.window.showErrorMessage('Device Simulator: could not read ' + path.basename(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePreviewServer(callback: () => void) {
|
||||||
|
if (store.previewServer && store.previewPort) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (!req.url || !req.url.startsWith('/preview')) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestUrl = new URL(req.url, 'http://127.0.0.1');
|
||||||
|
const assetPath = decodeURIComponent(requestUrl.pathname.replace(/^\/preview\/?/, ''));
|
||||||
|
if (!assetPath || assetPath === 'index.html') {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(injectServerMuteBridge(store.previewContent));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(store.previewDir, assetPath);
|
||||||
|
if (!store.previewDir || !resolved.startsWith(path.resolve(store.previewDir) + path.sep)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end('Forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.readFile(resolved, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Cache-Control': 'no-store' });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
store.previewServer = server;
|
||||||
|
store.previewPort = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
server.on('error', () => {
|
||||||
|
store.previewServer = null;
|
||||||
|
store.previewPort = 0;
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectServerMuteBridge(html: string): string {
|
||||||
|
const bridge =
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(function(){' +
|
||||||
|
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
||||||
|
'var muted=false,media=[],contexts=[],gains=[],destinations=[];' +
|
||||||
|
'function rememberMedia(el){if(!el||media.indexOf(el)>=0)return;media.push(el);applyMedia(el);}' +
|
||||||
|
'function applyMedia(el){try{el.muted=muted;if(muted){if(el.__hplVolume===undefined)el.__hplVolume=el.volume;el.volume=0;}else{if(el.__hplVolume!==undefined)el.volume=el.__hplVolume;}}catch(e){}}' +
|
||||||
|
'function apply(){for(var i=0;i<media.length;i++)applyMedia(media[i]);for(var j=0;j<gains.length;j++){try{gains[j].gain.value=muted?0:1;}catch(e){}}}' +
|
||||||
|
'window.__hplSetMuted=function(v){muted=!!v;window.__hplMuted=muted;apply();};' +
|
||||||
|
'window.addEventListener("message",function(e){if(e.data&&e.data.type==="hplDeviceSimulatorMute")window.__hplSetMuted(e.data.muted);});' +
|
||||||
|
'function patchAudioContext(name){var Orig=window[name];if(!Orig||Orig.__hplPatched)return;function Wrapped(){var ctx=arguments.length?new Orig(arguments[0]):new Orig();try{var gain=ctx.createGain();gain.gain.value=muted?0:1;gain.connect(ctx.destination);contexts.push(ctx);gains.push(gain);destinations.push(ctx.destination);}catch(e){}return ctx;}Wrapped.prototype=Orig.prototype;try{Object.setPrototypeOf(Wrapped,Orig);}catch(e){}Wrapped.__hplPatched=true;window[name]=Wrapped;}' +
|
||||||
|
'try{var origConnect=window.AudioNode&&window.AudioNode.prototype&&window.AudioNode.prototype.connect;if(origConnect&&!origConnect.__hplPatched){var patched=function(target){var args=Array.prototype.slice.call(arguments);var idx=destinations.indexOf(args[0]);if(idx>=0&&gains[idx])args[0]=gains[idx];return origConnect.apply(this,args);};patched.__hplPatched=true;window.AudioNode.prototype.connect=patched;}}catch(e){}' +
|
||||||
|
'try{patchAudioContext("AudioContext");patchAudioContext("webkitAudioContext");}catch(e){}' +
|
||||||
|
'try{var play=window.HTMLMediaElement&&window.HTMLMediaElement.prototype&&window.HTMLMediaElement.prototype.play;if(play&&!play.__hplPatched){var p=function(){rememberMedia(this);return play.apply(this,arguments);};p.__hplPatched=true;window.HTMLMediaElement.prototype.play=p;}}catch(e){}' +
|
||||||
|
'function scan(root){try{(root||document).querySelectorAll("audio,video").forEach(rememberMedia);}catch(e){}}' +
|
||||||
|
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){scan(document);});else scan(document);' +
|
||||||
|
'try{new MutationObserver(function(ms){ms.forEach(function(m){Array.prototype.forEach.call(m.addedNodes,function(n){if(n.nodeType!==1)return;if(n.matches&&n.matches("audio,video"))rememberMedia(n);scan(n);});});}).observe(document.documentElement,{childList:true,subtree:true});}catch(e){}' +
|
||||||
|
'})();' +
|
||||||
|
'</scr' + 'ipt>';
|
||||||
|
if (/<head\b[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<head\b[^>]*>/i, (match) => match + bridge);
|
||||||
|
}
|
||||||
|
return bridge + html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtml(devices: Device[]): string {
|
||||||
|
const devicesJson = JSON.stringify(devices);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
${getToolWebviewStyles()}
|
||||||
|
|
||||||
|
html, body { min-height: 100%; }
|
||||||
|
#setup-view { min-height: 100%; }
|
||||||
|
#sim-view {
|
||||||
|
display: none;
|
||||||
|
height: min(720px, calc(100vh - 280px));
|
||||||
|
min-height: 520px;
|
||||||
|
margin-top: var(--tool-gap-lg);
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#sim-view.active { display: grid; }
|
||||||
|
.sim-panel {
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: var(--tool-panel-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
#preview-area {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#screen-scaler { transform-origin: top center; flex: 0 0 auto; }
|
||||||
|
#device-screen {
|
||||||
|
--cutout-bg: #000;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255,255,255,0.08),
|
||||||
|
0 0 0 3px #1a1a1a,
|
||||||
|
0 0 0 4px rgba(255,255,255,0.05),
|
||||||
|
0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
#device-screen iframe { display: block; border: none; position: absolute; top: 0; left: 0; }
|
||||||
|
.cutout { position: absolute; pointer-events: none; z-index: 10; background: var(--cutout-bg); }
|
||||||
|
.title-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
||||||
|
.title-select { width: 180px; min-width: 120px; }
|
||||||
|
.title-orient { min-height: 28px; }
|
||||||
|
.orient-icon { display: block; transition: transform 120ms ease; }
|
||||||
|
.title-orient.landscape .orient-icon { transform: rotate(90deg); }
|
||||||
|
.icon-btn {
|
||||||
|
width: 28px; height: 28px; min-height: 28px; padding: 0;
|
||||||
|
display: grid; place-items: center; font-size: 15px; line-height: 1;
|
||||||
|
background: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border-color: var(--tool-border);
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||||
|
.icon-btn[aria-pressed="true"] { color: var(--vscode-testing-iconPassed, #73c991); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
#sim-view { height: auto; min-height: 0; grid-template-rows: minmax(420px, 60vh); overflow: visible; }
|
||||||
|
.title-select { width: min(180px, 36vw); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="setup-view">
|
||||||
|
<main class="tool-page">
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Device Simulator</h2>
|
||||||
|
<p class="tool-description">Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays.</p>
|
||||||
|
</header>
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
||||||
|
<h3 class="panel-title">Input</h3>
|
||||||
|
<button id="settings-btn" class="icon-btn" title="Open Extension Settings" aria-label="Open Extension Settings">⚙</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-label">Playable HTML</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<button id="pick-btn" class="secondary">Select File</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" id="setup-file-name" style="margin-top:8px;">(no file selected)</div>
|
||||||
|
<div id="setup-status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="sim-view">
|
||||||
|
<section class="sim-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Playable</h3>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden;margin-left:auto;">
|
||||||
|
<span id="file-label" style="font-size:11px;color:var(--vscode-descriptionForeground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;"></span>
|
||||||
|
<span id="dim-label" style="font-size:11px;color:var(--vscode-descriptionForeground);white-space:nowrap;flex-shrink:0;"></span>
|
||||||
|
</div>
|
||||||
|
<div class="title-actions">
|
||||||
|
<select id="device-select" class="title-select" title="Device"></select>
|
||||||
|
<select id="language-select" class="title-select" title="Language" aria-label="Language"></select>
|
||||||
|
<button id="orient-btn" class="icon-btn title-orient" title="Switch to Landscape" aria-label="Switch to Landscape"><span class="orient-icon">📱</span></button>
|
||||||
|
<button id="mute-btn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button>
|
||||||
|
<button id="reload-btn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="preview-area">
|
||||||
|
<div id="screen-scaler"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let DEFAULT_DEVICES = ${devicesJson};
|
||||||
|
let 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 = DEFAULT_DEVICES.slice();
|
||||||
|
let device = devices[0];
|
||||||
|
let language = 'en';
|
||||||
|
let landscape = false;
|
||||||
|
let currentContent = null;
|
||||||
|
let currentSourceUrl = '';
|
||||||
|
let currentBlobUrl = null;
|
||||||
|
let loadVersion = 0;
|
||||||
|
let cutoutColor = '#000000';
|
||||||
|
let muted = false;
|
||||||
|
const mediaVolumes = new WeakMap();
|
||||||
|
let muteObserver = null;
|
||||||
|
let muteDocument = null;
|
||||||
|
|
||||||
|
const sel = document.getElementById('device-select');
|
||||||
|
const languageSelect = document.getElementById('language-select');
|
||||||
|
populateDeviceSelect(device.id);
|
||||||
|
populateLanguageSelect();
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
device = devices[parseInt(sel.value, 10)] || devices[0];
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
languageSelect.addEventListener('change', function() {
|
||||||
|
language = languageSelect.value || LANGUAGES[0].code;
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
document.getElementById('settings-btn').addEventListener('click', function() {
|
||||||
|
vscode.postMessage({ type: 'openSettings' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function populateDeviceSelect(selectedId) {
|
||||||
|
sel.innerHTML = '';
|
||||||
|
devices.forEach(function(d, i) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = String(i);
|
||||||
|
o.textContent = d.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
const idx = Math.max(0, devices.findIndex(function(d) { return d.id === selectedId; }));
|
||||||
|
sel.value = String(idx);
|
||||||
|
device = devices[idx] || devices[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 normalizeDeviceList(value) {
|
||||||
|
const list = Array.isArray(value) ? value : (value && Array.isArray(value.devices) ? value.devices : []);
|
||||||
|
return list.map(normalizeDevice).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDevice(raw, index) {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const name = String(raw.name || '').trim();
|
||||||
|
const notch = ['none', 'notch', 'island', 'punch'].includes(raw.notch) ? raw.notch : 'none';
|
||||||
|
const d = {
|
||||||
|
id: String(raw.id || slugify(name) || ('device-' + Date.now() + '-' + index)).trim(),
|
||||||
|
name: name || 'Custom Device',
|
||||||
|
sw: intInRange(raw.sw, 1, 5000),
|
||||||
|
sh: intInRange(raw.sh, 1, 5000),
|
||||||
|
scr: intInRange(raw.scr, 0, 500),
|
||||||
|
notch: notch,
|
||||||
|
sat: intInRange(raw.sat, 0, 500),
|
||||||
|
sab: intInRange(raw.sab, 0, 500)
|
||||||
|
};
|
||||||
|
if (!d.sw || !d.sh) return null;
|
||||||
|
if (raw.nw !== undefined) d.nw = intInRange(raw.nw, 0, 1000);
|
||||||
|
if (raw.nh !== undefined) d.nh = intInRange(raw.nh, 0, 1000);
|
||||||
|
if (raw.nr !== undefined) d.nr = intInRange(raw.nr, 0, 500);
|
||||||
|
if (raw.ps !== undefined) d.ps = intInRange(raw.ps, 0, 500);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intInRange(value, min, max) {
|
||||||
|
const n = Math.round(Number(value));
|
||||||
|
if (!Number.isFinite(n)) return min;
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDevicesByName(arr) {
|
||||||
|
return arr.slice().sort(function(a, b) {
|
||||||
|
const na = a.name.toLowerCase(), nb = b.name.toLowerCase();
|
||||||
|
return na < nb ? -1 : na > nb ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('orient-btn').addEventListener('click', function() {
|
||||||
|
landscape = !landscape;
|
||||||
|
renderOrientationButton();
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderOrientationButton() {
|
||||||
|
const btn = document.getElementById('orient-btn');
|
||||||
|
btn.classList.toggle('landscape', landscape);
|
||||||
|
btn.title = landscape ? 'Switch to Portrait' : 'Switch to Landscape';
|
||||||
|
btn.setAttribute('aria-label', landscape ? 'Switch to Portrait' : 'Switch to Landscape');
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerPick(btn) {
|
||||||
|
const status = document.getElementById('setup-status');
|
||||||
|
if (status) {
|
||||||
|
status.className = 'status-panel is-busy';
|
||||||
|
status.textContent = 'Opening file picker...';
|
||||||
|
}
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Selecting...'; }
|
||||||
|
vscode.postMessage({ type: 'pickFile' });
|
||||||
|
setTimeout(function() {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Select File'; }
|
||||||
|
if (status && status.textContent === 'Opening file picker...') {
|
||||||
|
status.className = 'status-panel';
|
||||||
|
status.textContent = '';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
document.getElementById('pick-btn').addEventListener('click', function() { triggerPick(this); });
|
||||||
|
document.getElementById('reload-btn').addEventListener('click', function() {
|
||||||
|
vscode.postMessage({ type: 'reload' });
|
||||||
|
});
|
||||||
|
document.getElementById('mute-btn').addEventListener('click', function() {
|
||||||
|
muted = !muted;
|
||||||
|
renderMuteButton();
|
||||||
|
applyMute();
|
||||||
|
});
|
||||||
|
|
||||||
|
function sw() { return landscape ? device.sh : device.sw; }
|
||||||
|
function sh() { return landscape ? device.sw : device.sh; }
|
||||||
|
|
||||||
|
function buildCutout() {
|
||||||
|
const d = device;
|
||||||
|
if (d.notch === 'notch') {
|
||||||
|
const r = Math.round((d.nh || 0) * 0.5);
|
||||||
|
if (!landscape) {
|
||||||
|
return '<div class="cutout" style="top:0;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:0 0 ' + r + 'px ' + r + 'px;"></div>';
|
||||||
|
}
|
||||||
|
return '<div class="cutout" style="left:0;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:0 ' + r + 'px ' + r + 'px 0;"></div>';
|
||||||
|
}
|
||||||
|
if (d.notch === 'island') {
|
||||||
|
if (!landscape) {
|
||||||
|
return '<div class="cutout" style="top:12px;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:' + d.nr + 'px;"></div>';
|
||||||
|
}
|
||||||
|
return '<div class="cutout" style="left:12px;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:' + d.nr + 'px;"></div>';
|
||||||
|
}
|
||||||
|
if (d.notch === 'punch') {
|
||||||
|
const ps = d.ps || 12;
|
||||||
|
if (!landscape) {
|
||||||
|
return '<div class="cutout" style="top:10px;left:50%;transform:translateX(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
||||||
|
}
|
||||||
|
return '<div class="cutout" style="left:10px;top:50%;transform:translateY(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHomeBar() {
|
||||||
|
if (!device.sab) return '';
|
||||||
|
const base = 'position:absolute;background:rgba(255,255,255,0.3);border-radius:3px;pointer-events:none;z-index:10;';
|
||||||
|
if (!landscape) {
|
||||||
|
return '<div style="' + base + 'bottom:8px;left:50%;transform:translateX(-50%);width:130px;height:5px;"></div>';
|
||||||
|
}
|
||||||
|
return '<div style="' + base + 'right:8px;top:50%;transform:translateY(-50%);width:5px;height:100px;"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreen() {
|
||||||
|
const W = sw(), H = sh();
|
||||||
|
const scaler = document.getElementById('screen-scaler');
|
||||||
|
scaler.innerHTML =
|
||||||
|
'<div id="device-screen" style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
|
||||||
|
buildCutout() +
|
||||||
|
'<iframe id="preview-frame" width="' + W + '" height="' + H +
|
||||||
|
'" scrolling="no" frameborder="0" allow="autoplay;pointer-lock"></iframe>' +
|
||||||
|
buildHomeBar() +
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('dim-label').textContent = W + ' \xd7 ' + H;
|
||||||
|
document.getElementById('device-screen').style.setProperty('--cutout-bg', cutoutColor);
|
||||||
|
document.getElementById('preview-frame').addEventListener('load', applyMute);
|
||||||
|
updateScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadContent(html) {
|
||||||
|
const frame = document.getElementById('preview-frame');
|
||||||
|
if (!frame) return;
|
||||||
|
if (currentBlobUrl) {
|
||||||
|
URL.revokeObjectURL(currentBlobUrl);
|
||||||
|
currentBlobUrl = null;
|
||||||
|
}
|
||||||
|
if (currentSourceUrl) {
|
||||||
|
frame.src = withPreviewParams(currentSourceUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||||
|
currentBlobUrl = URL.createObjectURL(blob);
|
||||||
|
frame.src = currentBlobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPreviewParams(url) {
|
||||||
|
const separator = url.indexOf('?') >= 0 ? '&' : '?';
|
||||||
|
loadVersion += 1;
|
||||||
|
return url + separator + 'lang=' + encodeURIComponent(language) + '&v=' + loadVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectMuteBridge(html) {
|
||||||
|
const languageBridge =
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(function(){try{' +
|
||||||
|
'var url=new URL(window.location.href);' +
|
||||||
|
'url.searchParams.set("lang","' + language + '");' +
|
||||||
|
'history.replaceState(null,"",url.href);' +
|
||||||
|
'}catch(e){}})();' +
|
||||||
|
'</scr' + 'ipt>';
|
||||||
|
const bridge = languageBridge +
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(function(){' +
|
||||||
|
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
||||||
|
'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, function(match) { return match + bridge; });
|
||||||
|
}
|
||||||
|
return bridge + html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMuteButton() {
|
||||||
|
const btn = document.getElementById('mute-btn');
|
||||||
|
btn.innerHTML = muted ? '🔈' : '🔇';
|
||||||
|
btn.title = muted ? 'Unmute' : 'Mute';
|
||||||
|
btn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
|
||||||
|
btn.setAttribute('aria-pressed', muted ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMute() {
|
||||||
|
const frame = document.getElementById('preview-frame');
|
||||||
|
if (!frame || !frame.contentWindow) return;
|
||||||
|
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||||
|
try {
|
||||||
|
frame.contentWindow.__hplMuted = muted;
|
||||||
|
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
||||||
|
const doc = frame.contentWindow.document;
|
||||||
|
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
|
if (muteDocument !== doc) {
|
||||||
|
if (muteObserver) muteObserver.disconnect();
|
||||||
|
muteDocument = doc;
|
||||||
|
muteObserver = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
mutation.addedNodes.forEach(function(node) {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
if (node.matches && node.matches('audio,video')) syncMediaMute(node);
|
||||||
|
if (node.querySelectorAll) node.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
muteObserver.observe(doc.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMediaMute(media) {
|
||||||
|
if (muted) {
|
||||||
|
if (!mediaVolumes.has(media)) mediaVolumes.set(media, media.volume);
|
||||||
|
media.muted = true;
|
||||||
|
media.volume = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
media.muted = false;
|
||||||
|
if (mediaVolumes.has(media)) media.volume = mediaVolumes.get(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScale() {
|
||||||
|
const area = document.getElementById('preview-area');
|
||||||
|
const screen = document.getElementById('device-screen');
|
||||||
|
const scaler = document.getElementById('screen-scaler');
|
||||||
|
if (!screen || !area || !scaler) return;
|
||||||
|
scaler.style.transform = 'none';
|
||||||
|
const nw = screen.offsetWidth, nh = screen.offsetHeight;
|
||||||
|
const aw = area.clientWidth - 40, ah = area.clientHeight - 40;
|
||||||
|
const scale = Math.min(aw / nw, ah / nh, 1);
|
||||||
|
scaler.style.transform = 'scale(' + scale + ')';
|
||||||
|
scaler.style.marginBottom = Math.round(nh * (scale - 1)) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── messages ────────────────────────────────────────────────────────── */
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
const msg = event.data;
|
||||||
|
if (!msg || !msg.type) return;
|
||||||
|
|
||||||
|
if (msg.type === 'updateRemoteDevices') {
|
||||||
|
try {
|
||||||
|
const newDefaults = normalizeDeviceList(JSON.parse(msg.payload));
|
||||||
|
if (!newDefaults.length) return;
|
||||||
|
DEFAULT_DEVICES = sortDevicesByName(newDefaults);
|
||||||
|
devices = DEFAULT_DEVICES.slice();
|
||||||
|
const currentId = device ? device.id : null;
|
||||||
|
const stillExists = currentId && devices.some(function(d) { return d.id === currentId; });
|
||||||
|
populateDeviceSelect(stillExists ? currentId : devices[0].id);
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'updateRemoteLanguages') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(msg.payload);
|
||||||
|
const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.languages) ? parsed.languages : []);
|
||||||
|
const valid = list.filter(function(l) { return l && l.name && l.code; });
|
||||||
|
if (!valid.length) return;
|
||||||
|
LANGUAGES = valid;
|
||||||
|
const prev = language;
|
||||||
|
populateLanguageSelect();
|
||||||
|
const stillValid = LANGUAGES.some(function(l) { return l.code === prev; });
|
||||||
|
language = stillValid ? prev : LANGUAGES[0].code;
|
||||||
|
languageSelect.value = language;
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'fileLoaded') {
|
||||||
|
currentContent = msg.content;
|
||||||
|
currentSourceUrl = msg.sourceUrl || '';
|
||||||
|
if (msg.resetMute) {
|
||||||
|
muted = false;
|
||||||
|
renderMuteButton();
|
||||||
|
}
|
||||||
|
document.getElementById('file-label').textContent = msg.name;
|
||||||
|
document.getElementById('setup-file-name').textContent = msg.name;
|
||||||
|
var setupStatus = document.getElementById('setup-status');
|
||||||
|
setupStatus.className = 'status-panel';
|
||||||
|
setupStatus.textContent = '';
|
||||||
|
var pb = document.getElementById('pick-btn');
|
||||||
|
pb.disabled = false; pb.textContent = 'Select File';
|
||||||
|
var first = !document.getElementById('sim-view').classList.contains('active');
|
||||||
|
document.getElementById('sim-view').classList.add('active');
|
||||||
|
if (first) renderScreen();
|
||||||
|
loadContent(currentContent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateScale);
|
||||||
|
new ResizeObserver(updateScale).observe(document.getElementById('preview-area'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -6,15 +6,25 @@ import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './
|
|||||||
|
|
||||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
export function openPlecUpload(_context: vscode.ExtensionContext) {
|
export function openPlecUpload(context: vscode.ExtensionContext) {
|
||||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.plecUpload', 'PLEC Upload');
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.plecUpload', 'PLEC Upload');
|
||||||
if (!isNew) return;
|
if (!isNew) return;
|
||||||
|
|
||||||
panel.webview.html = getHtml();
|
panel.webview.html = getHtml();
|
||||||
|
|
||||||
|
// Restore last selected server
|
||||||
|
const lastServer = context.globalState.get<string>('plec.lastServer', 'legacy');
|
||||||
|
panel.webview.postMessage({ type: 'restoreServer', value: lastServer });
|
||||||
|
|
||||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
try {
|
try {
|
||||||
if (handleClipboardAndOpen(msg)) return;
|
if (handleClipboardAndOpen(msg)) return;
|
||||||
|
|
||||||
|
if (msg.type === 'saveServer') {
|
||||||
|
context.globalState.update('plec.lastServer', msg.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'pickFolder') {
|
if (msg.type === 'pickFolder') {
|
||||||
const picked = await vscode.window.showOpenDialog({
|
const picked = await vscode.window.showOpenDialog({
|
||||||
canSelectFolders: true,
|
canSelectFolders: true,
|
||||||
@@ -51,7 +61,7 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'upload') {
|
if (msg.type === 'upload') {
|
||||||
await handleUpload(panel, msg.rows);
|
await handleUpload(panel, msg.rows, msg.server);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -66,11 +76,7 @@ interface UploadRow {
|
|||||||
iteration: string;
|
iteration: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[], server: string) {
|
||||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
|
|
||||||
const uploadUrl = cfg.get<string>('uploadUrl')!;
|
|
||||||
const originUrl = cfg.get<string>('originUrl')!;
|
|
||||||
|
|
||||||
const valid: UploadRow[] = [];
|
const valid: UploadRow[] = [];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!r.path || !fs.existsSync(r.path)) {
|
if (!r.path || !fs.existsSync(r.path)) {
|
||||||
@@ -92,11 +98,21 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (server === 'wordpress') {
|
||||||
|
await handleUploadWordPress(panel, valid);
|
||||||
|
} else {
|
||||||
|
await handleUploadLegacy(panel, valid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadLegacy(panel: vscode.WebviewPanel, valid: UploadRow[]) {
|
||||||
|
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
|
||||||
|
const uploadUrl = cfg.get<string>('uploadUrl')!;
|
||||||
|
const originUrl = cfg.get<string>('originUrl')!;
|
||||||
|
|
||||||
const stamp = timestampSuffix();
|
const stamp = timestampSuffix();
|
||||||
const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp));
|
const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp));
|
||||||
|
|
||||||
panel.webview.postMessage({ type: 'status', message: 'Uploading...' });
|
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (let i = 0; i < valid.length; i++) {
|
for (let i = 0; i < valid.length; i++) {
|
||||||
const r = valid[i];
|
const r = valid[i];
|
||||||
@@ -129,20 +145,14 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
|||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
panel.webview.postMessage({
|
panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` });
|
||||||
type: 'error',
|
|
||||||
message: `HTTP ${res.status}\n${text.slice(0, 800)}`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let json: any;
|
let json: any;
|
||||||
try { json = JSON.parse(text); }
|
try { json = JSON.parse(text); }
|
||||||
catch {
|
catch {
|
||||||
panel.webview.postMessage({
|
panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` });
|
||||||
type: 'error',
|
|
||||||
message: `Server returned non-JSON:\n${text.slice(0, 800)}`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +160,154 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
|||||||
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
|
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function wpAuthenticate(panel: vscode.WebviewPanel): Promise<{ cookie: string; nonce: string }> {
|
||||||
|
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
|
||||||
|
const username = cfg.get<string>('wpUsername') || '';
|
||||||
|
const password = cfg.get<string>('wpPassword') || '';
|
||||||
|
const baseUrl = 'http://20.255.60.183';
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error('WordPress credentials not set. Add hplToolbox.plec.wpUsername and wpPassword in VS Code settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.webview.postMessage({ type: 'status', message: 'Authenticating with backup server...' });
|
||||||
|
|
||||||
|
const loginBody = new URLSearchParams({
|
||||||
|
log: username,
|
||||||
|
pwd: password,
|
||||||
|
'wp-submit': 'Log In',
|
||||||
|
redirect_to: '/wp-admin/',
|
||||||
|
testcookie: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginRes = await fetch(`${baseUrl}/wp-login.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Cookie': 'wordpress_test_cookie=WP Cookie check',
|
||||||
|
'Origin': baseUrl,
|
||||||
|
'Referer': `${baseUrl}/wp-login.php`,
|
||||||
|
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
|
||||||
|
},
|
||||||
|
body: loginBody.toString(),
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
const location = loginRes.headers.get('location') || '';
|
||||||
|
|
||||||
|
// Extract Set-Cookie headers — try getSetCookie() first (Node 18+), fall back to entries()
|
||||||
|
let rawCookies: string[] = [];
|
||||||
|
if (typeof (loginRes.headers as any).getSetCookie === 'function') {
|
||||||
|
rawCookies = (loginRes.headers as any).getSetCookie();
|
||||||
|
} else {
|
||||||
|
for (const [k, v] of (loginRes.headers as any).entries()) {
|
||||||
|
if (k.toLowerCase() === 'set-cookie') rawCookies.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cookie = rawCookies.map((c: string) => c.split(';')[0]).join('; ');
|
||||||
|
|
||||||
|
// WordPress sends a 302 to /wp-admin/ on success, or 200 (login page with error) on failure
|
||||||
|
const loginOk = (loginRes.status === 301 || loginRes.status === 302) && !location.includes('wp-login');
|
||||||
|
if (!loginOk) {
|
||||||
|
const loginHtml = await loginRes.text();
|
||||||
|
const errorMatch = loginHtml.match(/<div id="login_error">([\s\S]*?)<\/div>/);
|
||||||
|
const wpError = errorMatch
|
||||||
|
? errorMatch[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||||
|
: null;
|
||||||
|
const bodyPreview = loginHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
|
||||||
|
throw new Error(
|
||||||
|
`WordPress login failed (HTTP ${loginRes.status}).\n` +
|
||||||
|
(wpError ? `Server: "${wpError}"\n` : '') +
|
||||||
|
`Body: ${bodyPreview}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error(`Login succeeded (redirected to ${location}) but no session cookies were returned. Check server config.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.webview.postMessage({ type: 'status', message: 'Fetching upload token...' });
|
||||||
|
|
||||||
|
const pageRes = await fetch(`${baseUrl}/file-upload/`, {
|
||||||
|
headers: { 'Cookie': cookie, 'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)' },
|
||||||
|
});
|
||||||
|
const pageText = await pageRes.text();
|
||||||
|
const match = pageText.match(/"nonce"\s*:\s*"([a-f0-9]+)"/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Could not extract nonce from backup server page. Are you sure the account has access?');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cookie, nonce: match[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadWordPress(panel: vscode.WebviewPanel, valid: UploadRow[]) {
|
||||||
|
const { cookie, nonce } = await wpAuthenticate(panel);
|
||||||
|
const baseUrl = 'http://20.255.60.183';
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('action', 'plec_upload_files');
|
||||||
|
form.append('nonce', nonce);
|
||||||
|
|
||||||
|
const rowsMeta: { field: string; iterationName: string }[] = [];
|
||||||
|
for (let i = 0; i < valid.length; i++) {
|
||||||
|
const r = valid[i];
|
||||||
|
const idx = i + 1;
|
||||||
|
const fieldName = `file_${idx}`;
|
||||||
|
const sanitized = r.iteration.replace(/\s+/g, '');
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${sanitized}.html` });
|
||||||
|
const buf = fs.readFileSync(r.path);
|
||||||
|
const file = new File([buf], `${sanitized}.html`, { type: 'text/html' });
|
||||||
|
form.append(fieldName, file as any);
|
||||||
|
rowsMeta.push({ field: fieldName, iterationName: sanitized });
|
||||||
|
}
|
||||||
|
form.append('rows', JSON.stringify(rowsMeta));
|
||||||
|
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` });
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${baseUrl}/wp-admin/admin-ajax.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: {
|
||||||
|
'Cookie': cookie,
|
||||||
|
'Origin': baseUrl,
|
||||||
|
'Referer': `${baseUrl}/file-upload/`,
|
||||||
|
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': '*/*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
panel.webview.postMessage({ type: 'error', message: 'Network error: ' + (err?.message || err) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: any;
|
||||||
|
try { json = JSON.parse(text); }
|
||||||
|
catch {
|
||||||
|
panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: { original: string; saved: string }[] = json.data?.files || [];
|
||||||
|
let preview: string | null = json.preview || json.previewURL || null;
|
||||||
|
if (!preview && files.length > 0) {
|
||||||
|
const params = new URLSearchParams({ theme: 'calcite' });
|
||||||
|
files.forEach((f, i) => {
|
||||||
|
params.append(`n${i + 1}`, f.original.replace(/\.html?$/i, ''));
|
||||||
|
params.append(`m${i + 1}`, f.saved);
|
||||||
|
});
|
||||||
|
preview = `${baseUrl}/preview?${params.toString()}`;
|
||||||
|
}
|
||||||
|
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
|
||||||
|
}
|
||||||
|
|
||||||
function timestampSuffix(d: Date = new Date()): string {
|
function timestampSuffix(d: Date = new Date()): string {
|
||||||
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
|
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
|
||||||
return (
|
return (
|
||||||
@@ -225,6 +383,17 @@ ${getToolWebviewStyles()}
|
|||||||
.result a { color: var(--vscode-textLink-foreground); }
|
.result a { color: var(--vscode-textLink-foreground); }
|
||||||
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
||||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||||
|
.panel-header { display: flex; align-items: center; }
|
||||||
|
#serverSelect {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--vscode-dropdown-background);
|
||||||
|
color: var(--vscode-dropdown-foreground);
|
||||||
|
border: 1px solid var(--vscode-dropdown-border);
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -237,6 +406,10 @@ ${getToolWebviewStyles()}
|
|||||||
<section class="tool-panel input-panel">
|
<section class="tool-panel input-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3 class="panel-title">Inputs</h3>
|
<h3 class="panel-title">Inputs</h3>
|
||||||
|
<select id="serverSelect">
|
||||||
|
<option value="legacy">167.99.227.249 (Default)</option>
|
||||||
|
<option value="wordpress">20.255.60.183 (Backup)</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
@@ -377,6 +550,10 @@ ${getToolWebviewStyles()}
|
|||||||
copyResultBtn.disabled = !previewUrl;
|
copyResultBtn.disabled = !previewUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('serverSelect').addEventListener('change', (e) => {
|
||||||
|
vscode.postMessage({ type: 'saveServer', value: e.target.value });
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('pickFolder').addEventListener('click', () => {
|
document.getElementById('pickFolder').addEventListener('click', () => {
|
||||||
vscode.postMessage({ type: 'pickFolder' });
|
vscode.postMessage({ type: 'pickFolder' });
|
||||||
});
|
});
|
||||||
@@ -402,7 +579,7 @@ ${getToolWebviewStyles()}
|
|||||||
}
|
}
|
||||||
statusEl.textContent = 'Uploading...';
|
statusEl.textContent = 'Uploading...';
|
||||||
statusEl.classList.add('is-busy');
|
statusEl.classList.add('is-busy');
|
||||||
vscode.postMessage({ type: 'upload', rows });
|
vscode.postMessage({ type: 'upload', rows, server: document.getElementById('serverSelect').value });
|
||||||
});
|
});
|
||||||
openResultBtn.addEventListener('click', () => {
|
openResultBtn.addEventListener('click', () => {
|
||||||
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
|
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
|
||||||
@@ -418,6 +595,10 @@ ${getToolWebviewStyles()}
|
|||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
|
if (m.type === 'restoreServer') {
|
||||||
|
document.getElementById('serverSelect').value = m.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (m.type === 'filesSelected') {
|
if (m.type === 'filesSelected') {
|
||||||
addFiles(m.files || []);
|
addFiles(m.files || []);
|
||||||
} else if (m.type === 'status') {
|
} else if (m.type === '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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
766
standalone/device_simulator.go
Normal file
766
standalone/device_simulator.go
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceSimulatorDevice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SW int `json:"sw"`
|
||||||
|
SH int `json:"sh"`
|
||||||
|
SCR int `json:"scr"`
|
||||||
|
Notch string `json:"notch"`
|
||||||
|
NW int `json:"nw,omitempty"`
|
||||||
|
NH int `json:"nh,omitempty"`
|
||||||
|
NR int `json:"nr,omitempty"`
|
||||||
|
PS int `json:"ps,omitempty"`
|
||||||
|
SAT int `json:"sat"`
|
||||||
|
SAB int `json:"sab"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceSimulatorDevices = []deviceSimulatorDevice{
|
||||||
|
{ID: "pixel-8", Name: "Google Pixel 8", SW: 411, SH: 914, SCR: 22, Notch: "punch", PS: 14, SAT: 28, SAB: 24},
|
||||||
|
{ID: "iphone-14", Name: "iPhone 14", SW: 390, SH: 844, SCR: 38, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
|
||||||
|
{ID: "iphone-14-plus", Name: "iPhone 14 Plus", SW: 428, SH: 926, SCR: 40, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
|
||||||
|
{ID: "iphone-14-pro", Name: "iPhone 14 Pro", SW: 393, SH: 852, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
||||||
|
{ID: "iphone-14-pro-max", Name: "iPhone 14 Pro Max", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
||||||
|
{ID: "iphone-15", Name: "iPhone 15", SW: 390, SH: 844, SCR: 38, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
||||||
|
{ID: "iphone-15-plus", Name: "iPhone 15 Plus", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
||||||
|
{ID: "iphone-15-pro", Name: "iPhone 15 Pro", SW: 393, SH: 852, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
||||||
|
{ID: "iphone-15-pro-max", Name: "iPhone 15 Pro Max", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
||||||
|
{ID: "iphone-16", Name: "iPhone 16", SW: 390, SH: 844, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
||||||
|
{ID: "iphone-16-plus", Name: "iPhone 16 Plus", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
|
||||||
|
{ID: "iphone-16-pro", Name: "iPhone 16 Pro", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
||||||
|
{ID: "iphone-16-pro-max", Name: "iPhone 16 Pro Max", SW: 440, SH: 956, SCR: 44, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
||||||
|
{ID: "iphone-17", Name: "iPhone 17", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
||||||
|
{ID: "iphone-17-pro", Name: "iPhone 17 Pro", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
||||||
|
{ID: "iphone-17-pro-max", Name: "iPhone 17 Pro Max", SW: 440, SH: 956, SCR: 44, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
||||||
|
{ID: "iphone-air", Name: "iPhone Air", SW: 420, SH: 912, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
|
||||||
|
{ID: "iphone-se3", Name: "iPhone SE (3rd gen)", SW: 375, SH: 667, SCR: 4, Notch: "none", SAT: 20, SAB: 0},
|
||||||
|
{ID: "galaxy-s24", Name: "Samsung Galaxy S24", SW: 360, SH: 780, SCR: 20, Notch: "punch", PS: 12, SAT: 28, SAB: 24},
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
devicesJSON, _ := json.Marshal(deviceSimulatorDevices)
|
||||||
|
body := `
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Device Simulator</h2>
|
||||||
|
<p class="tool-description">Preview HTML playables inside mobile device frames with notch and Dynamic Island overlays.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header device-settings-header">
|
||||||
|
<h3 class="panel-title">Input</h3>
|
||||||
|
<button id="settingsBtn" class="icon-btn" title="Settings" aria-label="Settings" onclick="openSettingsModal('device-simulator')">⚙</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-label">Playable HTML</div>
|
||||||
|
<div id="dropZone" class="drop-zone">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickBtn" class="secondary">Select File</button>
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone-text">or drop an HTML file here</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" id="fileName" style="margin-top:8px;">(no file selected)</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="sim-panel" id="simPanel">
|
||||||
|
<div class="panel-header sim-header">
|
||||||
|
<h3 class="panel-title">Playable</h3>
|
||||||
|
<div class="sim-meta">
|
||||||
|
<span id="fileLabel"></span>
|
||||||
|
<span id="dimLabel"></span>
|
||||||
|
</div>
|
||||||
|
<div class="title-actions">
|
||||||
|
<select id="deviceSelect" class="title-select" title="Device"></select>
|
||||||
|
<select id="languageSelect" class="title-select" title="Language" aria-label="Language"></select>
|
||||||
|
<button id="orientBtn" class="icon-btn title-orient" title="Switch to Landscape" aria-label="Switch to Landscape"><span class="orient-icon">📱</span></button>
|
||||||
|
<button id="muteBtn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button>
|
||||||
|
<button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="previewArea"><div id="screenScaler"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sim-panel {
|
||||||
|
display: none;
|
||||||
|
height: min(720px, calc(100vh - 280px));
|
||||||
|
min-height: 520px;
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: rgba(128,128,128,0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sim-panel.active { display: flex; }
|
||||||
|
.sim-header { display:flex; align-items:center; gap:8px; }
|
||||||
|
.sim-meta {
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #a7a7a7;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
#fileLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#dimLabel { white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.title-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||||
|
.title-select { width:180px; min-width:120px; }
|
||||||
|
.title-orient { min-height:28px; }
|
||||||
|
.orient-icon { display:block; transition:transform 120ms ease; }
|
||||||
|
.title-orient.landscape .orient-icon { transform:rotate(90deg); }
|
||||||
|
.icon-btn {
|
||||||
|
width:28px;
|
||||||
|
height:28px;
|
||||||
|
min-height:28px;
|
||||||
|
padding:0;
|
||||||
|
display:grid;
|
||||||
|
place-items:center;
|
||||||
|
font-size:15px;
|
||||||
|
line-height:1;
|
||||||
|
background:#3a3d41;
|
||||||
|
color:#ddd;
|
||||||
|
border:1px solid #444;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background:#45494e; }
|
||||||
|
.icon-btn[aria-pressed="true"] { color:#89d185; }
|
||||||
|
.device-settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
#previewArea {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#screenScaler { transform-origin: top center; flex: 0 0 auto; }
|
||||||
|
#deviceScreen {
|
||||||
|
--cutout-bg:#000;
|
||||||
|
position:relative;
|
||||||
|
overflow:hidden;
|
||||||
|
background:#000;
|
||||||
|
box-shadow:0 0 0 1px rgba(255,255,255,.08),0 0 0 3px #1a1a1a,0 0 0 4px rgba(255,255,255,.05),0 8px 32px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
#deviceScreen iframe { display:block; border:0; position:absolute; inset:0; background:#000; }
|
||||||
|
.cutout { position:absolute; pointer-events:none; z-index:10; background:var(--cutout-bg); }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.sim-panel { height:auto; min-height:420px; }
|
||||||
|
.sim-header { flex-wrap:wrap; }
|
||||||
|
.sim-meta { order:3; width:100%; margin-left:0; }
|
||||||
|
.title-actions { margin-left:auto; }
|
||||||
|
.title-select { width:min(180px, 42vw); }
|
||||||
|
#previewArea { min-height:420px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let DEFAULT_DEVICES = ` + string(devicesJSON) + `;
|
||||||
|
let 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 = DEFAULT_DEVICES.slice();
|
||||||
|
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||||
|
let language = 'en';
|
||||||
|
let landscape = false;
|
||||||
|
let currentPath = '';
|
||||||
|
let currentContent = '';
|
||||||
|
let currentBlobUrl = '';
|
||||||
|
let loadVersion = 0;
|
||||||
|
let muted = false;
|
||||||
|
const mediaVolumes = new WeakMap();
|
||||||
|
let muteObserver = null;
|
||||||
|
let muteDocument = null;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const fileNameEl = document.getElementById('fileName');
|
||||||
|
const fileLabel = document.getElementById('fileLabel');
|
||||||
|
const dimLabel = document.getElementById('dimLabel');
|
||||||
|
const simPanel = document.getElementById('simPanel');
|
||||||
|
const sel = document.getElementById('deviceSelect');
|
||||||
|
const languageSelect = document.getElementById('languageSelect');
|
||||||
|
|
||||||
|
function basename(p) {
|
||||||
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||||
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
|
}
|
||||||
|
function setBusy(text) {
|
||||||
|
statusEl.textContent = text || '';
|
||||||
|
statusEl.classList.toggle('is-busy', !!text);
|
||||||
|
}
|
||||||
|
populateDeviceSelect(device.id);
|
||||||
|
populateLanguageSelect();
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
languageSelect.addEventListener('change', () => {
|
||||||
|
language = languageSelect.value || LANGUAGES[0].code;
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
function populateDeviceSelect(selectedId) {
|
||||||
|
sel.innerHTML = '';
|
||||||
|
devices.forEach((d, i) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = String(i);
|
||||||
|
o.textContent = d.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
const foundIndex = devices.findIndex(d => d.id === selectedId);
|
||||||
|
const index = foundIndex >= 0 ? foundIndex : 0;
|
||||||
|
sel.value = String(index);
|
||||||
|
device = devices[index] || DEFAULT_DEVICES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 normalizeDeviceList(value) {
|
||||||
|
const list = Array.isArray(value) ? value : (value && Array.isArray(value.devices) ? value.devices : []);
|
||||||
|
return list.map(normalizeDevice).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDevice(raw, index) {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const name = String(raw.name || '').trim();
|
||||||
|
const notch = ['none', 'notch', 'island', 'punch'].includes(raw.notch) ? raw.notch : 'none';
|
||||||
|
const d = {
|
||||||
|
id: String(raw.id || slugify(name) || ('device-' + Date.now() + '-' + index)).trim(),
|
||||||
|
name: name || 'Custom Device',
|
||||||
|
sw: intInRange(raw.sw, 1, 5000),
|
||||||
|
sh: intInRange(raw.sh, 1, 5000),
|
||||||
|
scr: intInRange(raw.scr, 0, 500),
|
||||||
|
notch: notch,
|
||||||
|
sat: intInRange(raw.sat, 0, 500),
|
||||||
|
sab: intInRange(raw.sab, 0, 500)
|
||||||
|
};
|
||||||
|
if (!d.sw || !d.sh) return null;
|
||||||
|
if (raw.nw !== undefined) d.nw = intInRange(raw.nw, 0, 1000);
|
||||||
|
if (raw.nh !== undefined) d.nh = intInRange(raw.nh, 0, 1000);
|
||||||
|
if (raw.nr !== undefined) d.nr = intInRange(raw.nr, 0, 500);
|
||||||
|
if (raw.ps !== undefined) d.ps = intInRange(raw.ps, 0, 500);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intInRange(value, min, max) {
|
||||||
|
const n = Math.round(Number(value));
|
||||||
|
if (!Number.isFinite(n)) return min;
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDevicesByName(arr) {
|
||||||
|
return arr.slice().sort(function(a, b) {
|
||||||
|
const na = a.name.toLowerCase(), nb = b.name.toLowerCase();
|
||||||
|
return na < nb ? -1 : na > nb ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('orientBtn').addEventListener('click', (event) => {
|
||||||
|
landscape = !landscape;
|
||||||
|
renderOrientationButton();
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
function renderOrientationButton() {
|
||||||
|
const btn = document.getElementById('orientBtn');
|
||||||
|
btn.classList.toggle('landscape', landscape);
|
||||||
|
btn.title = landscape ? 'Switch to Portrait' : 'Switch to Landscape';
|
||||||
|
btn.setAttribute('aria-label', landscape ? 'Switch to Portrait' : 'Switch to Landscape');
|
||||||
|
}
|
||||||
|
document.getElementById('pickBtn').addEventListener('click', async () => {
|
||||||
|
setBusy('Opening file picker...');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/device-simulator/pick', { method:'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) await loadPath(j.path, true);
|
||||||
|
else setBusy('');
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.textContent = 'Error: ' + (e.message || e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||||
|
if (currentPath) loadPath(currentPath, false);
|
||||||
|
});
|
||||||
|
document.getElementById('muteBtn').addEventListener('click', () => {
|
||||||
|
muted = !muted;
|
||||||
|
renderMuteButton();
|
||||||
|
applyMute();
|
||||||
|
});
|
||||||
|
setupDropZone('dropZone', statusEl, (j) => {
|
||||||
|
if (j.paths && j.paths.length) loadPath(j.paths[0], true);
|
||||||
|
else if (j.skipped) statusEl.textContent = 'Drop an HTML file.';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPath(path, resetMute) {
|
||||||
|
currentPath = path;
|
||||||
|
setBusy('Reading ' + basename(path) + '...');
|
||||||
|
const r = await fetch('/api/device-simulator/load', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({ path })
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
if (j.error) {
|
||||||
|
statusEl.textContent = 'Error: ' + j.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentContent = j.content || '';
|
||||||
|
fileNameEl.textContent = j.name;
|
||||||
|
fileLabel.textContent = j.name;
|
||||||
|
statusEl.textContent = '';
|
||||||
|
if (resetMute) {
|
||||||
|
muted = false;
|
||||||
|
renderMuteButton();
|
||||||
|
}
|
||||||
|
simPanel.classList.add('active');
|
||||||
|
renderScreen();
|
||||||
|
loadContent(currentContent);
|
||||||
|
}
|
||||||
|
function sw() { return landscape ? device.sh : device.sw; }
|
||||||
|
function sh() { return landscape ? device.sw : device.sh; }
|
||||||
|
function buildCutout() {
|
||||||
|
const d = device;
|
||||||
|
if (d.notch === 'notch') {
|
||||||
|
const r = Math.round((d.nh || 0) * 0.5);
|
||||||
|
if (!landscape) return '<div class="cutout" style="top:0;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:0 0 ' + r + 'px ' + r + 'px;"></div>';
|
||||||
|
return '<div class="cutout" style="left:0;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:0 ' + r + 'px ' + r + 'px 0;"></div>';
|
||||||
|
}
|
||||||
|
if (d.notch === 'island') {
|
||||||
|
if (!landscape) return '<div class="cutout" style="top:12px;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:' + d.nr + 'px;"></div>';
|
||||||
|
return '<div class="cutout" style="left:12px;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:' + d.nr + 'px;"></div>';
|
||||||
|
}
|
||||||
|
if (d.notch === 'punch') {
|
||||||
|
const ps = d.ps || 12;
|
||||||
|
if (!landscape) return '<div class="cutout" style="top:10px;left:50%;transform:translateX(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
||||||
|
return '<div class="cutout" style="left:10px;top:50%;transform:translateY(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function buildHomeBar() {
|
||||||
|
if (!device.sab) return '';
|
||||||
|
const base = 'position:absolute;background:rgba(255,255,255,0.3);border-radius:3px;pointer-events:none;z-index:10;';
|
||||||
|
if (!landscape) return '<div style="' + base + 'bottom:8px;left:50%;transform:translateX(-50%);width:130px;height:5px;"></div>';
|
||||||
|
return '<div style="' + base + 'right:8px;top:50%;transform:translateY(-50%);width:5px;height:100px;"></div>';
|
||||||
|
}
|
||||||
|
function renderScreen() {
|
||||||
|
const W = sw(), H = sh();
|
||||||
|
const scaler = document.getElementById('screenScaler');
|
||||||
|
scaler.innerHTML = '<div id="deviceScreen" style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
|
||||||
|
buildCutout() +
|
||||||
|
'<iframe id="previewFrame" width="' + W + '" height="' + H + '" scrolling="no" frameborder="0" allow="autoplay;pointer-lock"></iframe>' +
|
||||||
|
buildHomeBar() +
|
||||||
|
'</div>';
|
||||||
|
dimLabel.textContent = W + ' x ' + H;
|
||||||
|
document.getElementById('previewFrame').addEventListener('load', applyMute);
|
||||||
|
updateScale();
|
||||||
|
}
|
||||||
|
function loadContent(html) {
|
||||||
|
const frame = document.getElementById('previewFrame');
|
||||||
|
if (!frame) return;
|
||||||
|
if (currentBlobUrl) {
|
||||||
|
URL.revokeObjectURL(currentBlobUrl);
|
||||||
|
currentBlobUrl = '';
|
||||||
|
}
|
||||||
|
if (currentPath) {
|
||||||
|
frame.src = previewUrl();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||||
|
currentBlobUrl = URL.createObjectURL(blob);
|
||||||
|
frame.src = currentBlobUrl;
|
||||||
|
}
|
||||||
|
function previewUrl() {
|
||||||
|
loadVersion += 1;
|
||||||
|
return '/api/device-simulator/preview/index.html?path=' + encodeURIComponent(currentPath) +
|
||||||
|
'&lang=' + encodeURIComponent(language) +
|
||||||
|
'&v=' + loadVersion;
|
||||||
|
}
|
||||||
|
function injectMuteBridge(html) {
|
||||||
|
const languageBridge =
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(function(){try{' +
|
||||||
|
'var url=new URL(window.location.href);' +
|
||||||
|
'url.searchParams.set("lang","' + language + '");' +
|
||||||
|
'history.replaceState(null,"",url.href);' +
|
||||||
|
'}catch(e){}})();' +
|
||||||
|
'</scr' + 'ipt>';
|
||||||
|
const bridge = languageBridge +
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(function(){' +
|
||||||
|
'if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;' +
|
||||||
|
'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, function(match) { return match + bridge; });
|
||||||
|
}
|
||||||
|
return bridge + html;
|
||||||
|
}
|
||||||
|
function renderMuteButton() {
|
||||||
|
const btn = document.getElementById('muteBtn');
|
||||||
|
btn.innerHTML = muted ? '🔈' : '🔇';
|
||||||
|
btn.title = muted ? 'Unmute' : 'Mute';
|
||||||
|
btn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
|
||||||
|
btn.setAttribute('aria-pressed', muted ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
function applyMute() {
|
||||||
|
const frame = document.getElementById('previewFrame');
|
||||||
|
if (!frame || !frame.contentWindow) return;
|
||||||
|
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||||
|
try {
|
||||||
|
frame.contentWindow.__hplMuted = muted;
|
||||||
|
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
||||||
|
const doc = frame.contentWindow.document;
|
||||||
|
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
|
if (muteDocument !== doc) {
|
||||||
|
if (muteObserver) muteObserver.disconnect();
|
||||||
|
muteDocument = doc;
|
||||||
|
muteObserver = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
if (node.matches && node.matches('audio,video')) syncMediaMute(node);
|
||||||
|
if (node.querySelectorAll) node.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
muteObserver.observe(doc.documentElement, { childList:true, subtree:true });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
function syncMediaMute(media) {
|
||||||
|
if (muted) {
|
||||||
|
if (!mediaVolumes.has(media)) mediaVolumes.set(media, media.volume);
|
||||||
|
media.muted = true;
|
||||||
|
media.volume = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
media.muted = false;
|
||||||
|
if (mediaVolumes.has(media)) media.volume = mediaVolumes.get(media);
|
||||||
|
}
|
||||||
|
function updateScale() {
|
||||||
|
const area = document.getElementById('previewArea');
|
||||||
|
const screen = document.getElementById('deviceScreen');
|
||||||
|
const scaler = document.getElementById('screenScaler');
|
||||||
|
if (!screen || !area || !scaler) return;
|
||||||
|
scaler.style.transform = 'none';
|
||||||
|
const nw = screen.offsetWidth, nh = screen.offsetHeight;
|
||||||
|
const aw = area.clientWidth - 40, ah = area.clientHeight - 40;
|
||||||
|
const scale = Math.min(aw / nw, ah / nh, 1);
|
||||||
|
scaler.style.transform = 'scale(' + scale + ')';
|
||||||
|
scaler.style.marginBottom = Math.round(nh * (scale - 1)) + 'px';
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', updateScale);
|
||||||
|
new ResizeObserver(updateScale).observe(document.getElementById('previewArea'));
|
||||||
|
|
||||||
|
(function fetchRemoteData() {
|
||||||
|
fetch('/api/device-simulator/remoteDevices').then(r => r.json()).then(data => {
|
||||||
|
if (data.error) return;
|
||||||
|
const newDefaults = normalizeDeviceList(data);
|
||||||
|
if (!newDefaults.length) return;
|
||||||
|
DEFAULT_DEVICES = sortDevicesByName(newDefaults);
|
||||||
|
devices = DEFAULT_DEVICES.slice();
|
||||||
|
const currentId = device ? device.id : null;
|
||||||
|
const stillExists = currentId && devices.some(d => d.id === currentId);
|
||||||
|
populateDeviceSelect(stillExists ? currentId : devices[0].id);
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
fetch('/api/device-simulator/remoteLanguages').then(r => r.json()).then(data => {
|
||||||
|
if (data.error) return;
|
||||||
|
const list = Array.isArray(data) ? data : (data && Array.isArray(data.languages) ? data.languages : []);
|
||||||
|
const valid = list.filter(l => l && l.name && l.code);
|
||||||
|
if (!valid.length) return;
|
||||||
|
const prev = language;
|
||||||
|
LANGUAGES = valid;
|
||||||
|
populateLanguageSelect();
|
||||||
|
const stillValid = LANGUAGES.some(l => l.code === prev);
|
||||||
|
language = stillValid ? prev : LANGUAGES[0].code;
|
||||||
|
languageSelect.value = language;
|
||||||
|
}).catch(() => {});
|
||||||
|
})();
|
||||||
|
</script>`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/device-simulator", "Device Simulator", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceSimulatorPick(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths := PickFiles("Select HTML file", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
|
||||||
|
path := ""
|
||||||
|
if len(paths) > 0 {
|
||||||
|
path = paths[0]
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"path": path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceSimulatorLoad(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Path == "" || !isHTMLPath(req.Path) {
|
||||||
|
writeJSON(w, map[string]any{"error": "Pick an HTML file."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
writeJSON(w, map[string]any{"error": "Expected an HTML file, got a folder."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"name": filepath.Base(req.Path),
|
||||||
|
"path": req.Path,
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reGDriveFileIDSim = regexp.MustCompile(`/file/d/([a-zA-Z0-9_-]+)`)
|
||||||
|
var reGDriveIDParamSim = regexp.MustCompile(`[?&]id=([a-zA-Z0-9_-]+)`)
|
||||||
|
|
||||||
|
func resolveSimGDriveURL(rawURL string) string {
|
||||||
|
if m := reGDriveFileIDSim.FindStringSubmatch(rawURL); m != nil {
|
||||||
|
return "https://drive.google.com/uc?export=download&id=" + m[1]
|
||||||
|
}
|
||||||
|
if m := reGDriveIDParamSim.FindStringSubmatch(rawURL); m != nil {
|
||||||
|
return "https://drive.google.com/uc?export=download&id=" + m[1]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
type simRemoteCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
devices string
|
||||||
|
languages string
|
||||||
|
devURL string
|
||||||
|
langURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
var simCache simRemoteCache
|
||||||
|
|
||||||
|
func fetchRemoteJSON(rawURL string) (string, error) {
|
||||||
|
url := resolveSimGDriveURL(rawURL)
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
|
||||||
|
loc := resp.Header.Get("Location")
|
||||||
|
if loc != "" {
|
||||||
|
return fetchRemoteJSON(loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(body, &v); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid JSON: %w", err)
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceSimulatorRemoteDevicesEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
url := strings.TrimSpace(cfg.DeviceSimulator.DevicesURL)
|
||||||
|
if url == "" {
|
||||||
|
writeJSON(w, map[string]any{"error": "devicesUrl not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
simCache.mu.Lock()
|
||||||
|
if simCache.devURL == url && simCache.devices != "" {
|
||||||
|
cached := simCache.devices
|
||||||
|
simCache.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(cached))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
simCache.mu.Unlock()
|
||||||
|
data, err := fetchRemoteJSON(url)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
simCache.mu.Lock()
|
||||||
|
simCache.devURL = url
|
||||||
|
simCache.devices = data
|
||||||
|
simCache.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceSimulatorRemoteLanguagesEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
url := strings.TrimSpace(cfg.DeviceSimulator.LanguagesURL)
|
||||||
|
if url == "" {
|
||||||
|
writeJSON(w, map[string]any{"error": "languagesUrl not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
simCache.mu.Lock()
|
||||||
|
if simCache.langURL == url && simCache.languages != "" {
|
||||||
|
cached := simCache.languages
|
||||||
|
simCache.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(cached))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
simCache.mu.Unlock()
|
||||||
|
data, err := fetchRemoteJSON(url)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
simCache.mu.Lock()
|
||||||
|
simCache.langURL = url
|
||||||
|
simCache.languages = data
|
||||||
|
simCache.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(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
|
||||||
|
}
|
||||||
@@ -16,13 +16,16 @@ 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 := `
|
||||||
<header class="tool-header">
|
<header class="tool-header" style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;">
|
||||||
<h2 class="tool-title">HPL Toolbox</h2>
|
<div>
|
||||||
<p class="tool-description">Standalone build. Pick a tool to open.</p>
|
<h2 class="tool-title">HPL Toolbox</h2>
|
||||||
|
<p class="tool-description">Standalone build. Pick a tool to open.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="openSettingsModal()" title="Settings" aria-label="Settings" style="flex-shrink:0;width:32px;height:32px;padding:0;display:grid;place-items:center;font-size:18px;background:#3a3d41;color:#ddd;border:1px solid #444;border-radius:4px;cursor:pointer;">⚙</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="home-tools">
|
<div class="home-tools">
|
||||||
` + items.String() + `
|
` + items.String() + `
|
||||||
@@ -43,6 +46,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,
|
||||||
@@ -261,6 +311,27 @@ const SharedCSS = `
|
|||||||
.err { color: #f48771; white-space: pre-wrap; }
|
.err { color: #f48771; white-space: pre-wrap; }
|
||||||
.ok { color: #89d185; }
|
.ok { color: #89d185; }
|
||||||
.hint { font-size: 12px; opacity: 0.7; }
|
.hint { font-size: 12px; opacity: 0.7; }
|
||||||
|
.settings-modal { position:fixed; inset:0; z-index:200; display:none; align-items:center; justify-content:center; padding:18px; background:rgba(0,0,0,.48); }
|
||||||
|
.settings-modal.active { display:flex; }
|
||||||
|
.settings-modal-dialog { width:min(840px,94vw); min-height:min(560px,80vh); max-height:calc(100vh - 36px); display:flex; flex-direction:column; border:1px solid var(--tool-border); border-radius:var(--tool-radius); background:#1e1e1e; box-shadow:0 16px 48px rgba(0,0,0,.45); overflow:hidden; }
|
||||||
|
.settings-modal-dialog > .panel-header { flex-shrink:0; }
|
||||||
|
.settings-modal-body { overflow-y:auto; padding:12px; flex:1 1 0; min-height:0; }
|
||||||
|
.settings-modal-body .tool-panel { margin-bottom:var(--tool-gap-lg); scroll-margin-top:8px; }
|
||||||
|
@keyframes settings-flash { 0%,100%{box-shadow:none} 25%,75%{box-shadow:0 0 0 2px #007fd4} }
|
||||||
|
.settings-highlight { animation:settings-flash 1.2s ease forwards; }
|
||||||
|
.settings-grid { display:flex; flex-direction:column; gap:12px; }
|
||||||
|
.settings-label { display:flex; flex-direction:column; gap:5px; color:#a7a7a7; font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; max-width:100%; }
|
||||||
|
.settings-label input[type=text] { width:100%; }
|
||||||
|
.settings-label.settings-checkbox { flex-direction:row; align-items:center; gap:8px; text-transform:none; letter-spacing:0; font-size:13px; font-weight:400; color:#ddd; cursor:pointer; }
|
||||||
|
.settings-hint { color:#a7a7a7; font-size:11px; font-weight:400; letter-spacing:0; text-transform:none; }
|
||||||
|
.settings-save-status { font-size:12px; color:#89d185; min-height:20px; }
|
||||||
|
.settings-save-status.err { color:#f48771; }
|
||||||
|
.advanced-details { margin-bottom:var(--tool-gap-lg); }
|
||||||
|
.advanced-summary { display:inline-flex; align-items:center; gap:6px; margin-bottom:10px; padding:5px 10px; color:#a7a7a7; font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; border:1px solid var(--tool-border); border-radius:var(--tool-radius); background:rgba(128,128,128,.08); cursor:pointer; user-select:none; list-style:none; }
|
||||||
|
.advanced-summary::-webkit-details-marker { display:none; }
|
||||||
|
.advanced-summary::before { content:"▶"; font-size:9px; transition:transform 120ms ease; }
|
||||||
|
details[open] > .advanced-summary::before { transform:rotate(90deg); }
|
||||||
|
.advanced-summary:hover { color:#ddd; border-color:#555; }
|
||||||
`
|
`
|
||||||
|
|
||||||
const SharedDropZoneScript = `
|
const SharedDropZoneScript = `
|
||||||
@@ -426,13 +497,15 @@ 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"},
|
||||||
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
|
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
|
||||||
{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"},
|
||||||
|
{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 +543,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 {
|
||||||
@@ -499,6 +576,7 @@ func navInitials(label string) string {
|
|||||||
func Page(activePath, title, body string) string {
|
func Page(activePath, title, body string) string {
|
||||||
cfg := LoadConfig()
|
cfg := LoadConfig()
|
||||||
betaToolsEnabled := cfg.BetaToolsEnabled
|
betaToolsEnabled := cfg.BetaToolsEnabled
|
||||||
|
randomizeUA := cfg.Applovin.RandomizeUserAgent == nil || *cfg.Applovin.RandomizeUserAgent
|
||||||
var tabs strings.Builder
|
var tabs strings.Builder
|
||||||
for _, n := range visibleNavItems(betaToolsEnabled) {
|
for _, n := range visibleNavItems(betaToolsEnabled) {
|
||||||
cls := ""
|
cls := ""
|
||||||
@@ -509,7 +587,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 +613,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 +632,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,7 +707,201 @@ 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);
|
||||||
|
}
|
||||||
|
function openSettingsModal(sectionId) {
|
||||||
|
document.getElementById('settingsModal').classList.add('active');
|
||||||
|
if (sectionId) {
|
||||||
|
const el = document.getElementById('settings-section-' + sectionId);
|
||||||
|
if (el) {
|
||||||
|
const details = el.closest('details');
|
||||||
|
if (details) details.open = true;
|
||||||
|
setTimeout(function() {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
el.classList.add('settings-highlight');
|
||||||
|
setTimeout(function() { el.classList.remove('settings-highlight'); }, 1300);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeSettingsModal() {
|
||||||
|
document.getElementById('settingsModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && document.getElementById('settingsModal').classList.contains('active')) closeSettingsModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.getElementById('settingsModalClose').addEventListener('click', closeSettingsModal);
|
||||||
|
document.getElementById('settingsModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeSettingsModal();
|
||||||
|
});
|
||||||
|
document.getElementById('settingsModalForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const status = document.getElementById('settingsModalStatus');
|
||||||
|
const getVal = (name) => (form.querySelector('[name="' + name + '"]') || {}).value || '';
|
||||||
|
const getChecked = (name) => !!(form.querySelector('[name="' + name + '"]') || {}).checked;
|
||||||
|
const payload = {
|
||||||
|
plec: { uploadUrl: getVal('plec.uploadUrl'), originUrl: getVal('plec.originUrl'), previewBase: getVal('plec.previewBase'), skipExistsCheck: getChecked('plec.skipExistsCheck') },
|
||||||
|
applovin: { cookie: getVal('applovin.cookie'), delayMs: parseInt(getVal('applovin.delayMs'), 10) || 0, randomizeUserAgent: getChecked('applovin.randomizeUserAgent') },
|
||||||
|
sendToMobile: { port: parseInt(getVal('sendToMobile.port'), 10) || 0 },
|
||||||
|
projectInitManifestUrl: getVal('projectInitManifestUrl'),
|
||||||
|
deviceSimulator: { devicesUrl: getVal('deviceSimulator.devicesUrl'), languagesUrl: getVal('deviceSimulator.languagesUrl') },
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
status.textContent = 'Saved.';
|
||||||
|
status.className = 'settings-save-status';
|
||||||
|
setTimeout(closeSettingsModal, 900);
|
||||||
|
} else {
|
||||||
|
status.textContent = 'Error: ' + (j.error || 'unknown');
|
||||||
|
status.className = 'settings-save-status err';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = 'Error: ' + (err.message || err);
|
||||||
|
status.className = 'settings-save-status err';
|
||||||
|
}
|
||||||
|
setTimeout(function() { status.textContent = ''; status.className = 'settings-save-status'; }, 4000);
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<div id="settingsModal" class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
|
||||||
|
<div class="settings-modal-dialog">
|
||||||
|
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
||||||
|
<h3 id="settingsModalTitle" class="panel-title">Settings</h3>
|
||||||
|
<button id="settingsModalClose" type="button" class="icon-btn" style="margin-left:auto;" title="Close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-modal-body">
|
||||||
|
<form id="settingsModalForm">
|
||||||
|
<section id="settings-section-device-simulator" class="tool-panel">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">Device Simulator</h3></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="settings-label">Devices JSON URL
|
||||||
|
<input type="text" name="deviceSimulator.devicesUrl" value="` + htmlEsc(cfg.DeviceSimulator.DevicesURL) + `" placeholder="Google Drive share link or direct JSON URL" />
|
||||||
|
<span class="settings-hint">Remote devices.json — fetched on open, falls back to built-in list if empty or unreachable.</span>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">Languages JSON URL
|
||||||
|
<input type="text" name="deviceSimulator.languagesUrl" value="` + htmlEsc(cfg.DeviceSimulator.LanguagesURL) + `" placeholder="Google Drive share link or direct JSON URL" />
|
||||||
|
<span class="settings-hint">Remote languages.json — fetched on open, falls back to built-in list if empty or unreachable.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="settings-section-initialize-project" class="tool-panel">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">Initialize Project</h3></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="settings-label">Manifest URL
|
||||||
|
<input type="text" name="projectInitManifestUrl" value="` + htmlEsc(cfg.ProjectInitManifestURL) + `" placeholder="Google Drive share link or direct JSON URL" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<details class="advanced-details">
|
||||||
|
<summary class="advanced-summary">Advanced</summary>
|
||||||
|
<section class="tool-panel">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">PLEC Upload</h3></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="settings-label">Upload URL
|
||||||
|
<input type="text" name="plec.uploadUrl" value="` + htmlEsc(cfg.Plec.UploadUrl) + `" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">Origin URL
|
||||||
|
<input type="text" name="plec.originUrl" value="` + htmlEsc(cfg.Plec.OriginUrl) + `" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">Preview Base URL
|
||||||
|
<input type="text" name="plec.previewBase" value="` + htmlEsc(cfg.Plec.PreviewBase) + `" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-label settings-checkbox">
|
||||||
|
<input type="checkbox" name="plec.skipExistsCheck" ` + checkedAttr(cfg.Plec.SkipExistsCheck) + ` />
|
||||||
|
Skip duplicate filename check
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="tool-panel">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">AppLovin Playable Preview</h3></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="settings-label">__Host-APPLOVINID Cookie
|
||||||
|
<input type="text" name="applovin.cookie" value="` + htmlEsc(cfg.Applovin.Cookie) + `" placeholder="Log in at p.applov.in and copy from DevTools" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">Upload delay (ms)
|
||||||
|
<input type="text" name="applovin.delayMs" value="` + intStr(cfg.Applovin.DelayMs) + `" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-label settings-checkbox">
|
||||||
|
<input type="checkbox" name="applovin.randomizeUserAgent" ` + checkedAttr(randomizeUA) + ` />
|
||||||
|
Randomize User-Agent on every request
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="tool-panel">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">Send To Mobile</h3></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="settings-label">LAN server port
|
||||||
|
<input type="text" name="sendToMobile.port" value="` + intStr(cfg.SendToMobile.Port) + `" inputmode="numeric" />
|
||||||
|
<span class="settings-hint">0 = OS-assigned random port</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</details>
|
||||||
|
<div class="action-row" style="margin-top:4px;">
|
||||||
|
<button type="submit">Save Settings</button>
|
||||||
|
<span id="settingsModalStatus" class="settings-save-status"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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,23 @@ 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 /settings", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) })
|
||||||
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 +175,16 @@ 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)
|
||||||
|
mux.HandleFunc("GET /api/device-simulator/remoteDevices", DeviceSimulatorRemoteDevicesEndpoint)
|
||||||
|
mux.HandleFunc("GET /api/device-simulator/remoteLanguages", DeviceSimulatorRemoteLanguagesEndpoint)
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
mux.HandleFunc("POST /api/settings", SettingsSaveEndpoint)
|
||||||
|
|
||||||
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, "_") }
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type PlecConfig struct {
|
|||||||
OriginUrl string `json:"originUrl"`
|
OriginUrl string `json:"originUrl"`
|
||||||
PreviewBase string `json:"previewBase"`
|
PreviewBase string `json:"previewBase"`
|
||||||
SkipExistsCheck bool `json:"skipExistsCheck"`
|
SkipExistsCheck bool `json:"skipExistsCheck"`
|
||||||
|
WpUsername string `json:"wpUsername"`
|
||||||
|
WpPassword string `json:"wpPassword"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApplovinConfig struct {
|
type ApplovinConfig struct {
|
||||||
@@ -28,12 +30,19 @@ type SendToMobileConfig struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeviceSimulatorConfig struct {
|
||||||
|
DevicesURL string `json:"devicesUrl"`
|
||||||
|
LanguagesURL string `json:"languagesUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
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"`
|
DeviceSimulator DeviceSimulatorConfig `json:"deviceSimulator"`
|
||||||
LastPickDir string `json:"lastPickDir,omitempty"`
|
BetaToolsEnabled bool `json:"betaToolsEnabled"`
|
||||||
|
LastPickDir string `json:"lastPickDir,omitempty"`
|
||||||
|
ProjectInitManifestURL string `json:"projectInitManifestUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configMu sync.Mutex
|
var configMu sync.Mutex
|
||||||
@@ -62,9 +71,15 @@ func defaultConfig() AppConfig {
|
|||||||
OriginUrl: "http://167.99.227.249",
|
OriginUrl: "http://167.99.227.249",
|
||||||
PreviewBase: "https://playable.applovindemo.com/phaser/",
|
PreviewBase: "https://playable.applovindemo.com/phaser/",
|
||||||
SkipExistsCheck: false,
|
SkipExistsCheck: false,
|
||||||
|
WpUsername: "pm",
|
||||||
|
WpPassword: "nRLz#LV@y#)q@CW@z8fGEPyI",
|
||||||
},
|
},
|
||||||
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
||||||
SendToMobile: SendToMobileConfig{Port: 0},
|
SendToMobile: SendToMobileConfig{Port: 0},
|
||||||
|
DeviceSimulator: DeviceSimulatorConfig{
|
||||||
|
DevicesURL: "https://drive.google.com/file/d/1w04wviCmgNqtDCO1GLyYfwEeWpjBPGJ4/view?usp=drive_link",
|
||||||
|
LanguagesURL: "https://drive.google.com/file/d/1I3ZiI8CLU2JxYZEtWCz6Vr0r8Uj2732f/view?usp=drive_link",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
urlpkg "net/url"
|
urlpkg "net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,7 +24,13 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="tool-panel">
|
<section class="tool-panel">
|
||||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Inputs</h3>
|
||||||
|
<select id="serverSelect">
|
||||||
|
<option value="legacy">167.99.227.249 (Default)</option>
|
||||||
|
<option value="wordpress">20.255.60.183 (Backup)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<div id="dropZone" class="drop-zone">
|
<div id="dropZone" class="drop-zone">
|
||||||
@@ -54,10 +61,15 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
<style>
|
<style>
|
||||||
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
||||||
#rows input[type=text] { width: 100%; }
|
#rows input[type=text] { width: 100%; }
|
||||||
|
.panel-header { display: flex; align-items: center; }
|
||||||
|
#serverSelect { margin-left:auto; padding:3px 6px; font-size:12px; cursor:pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const PAGE_SIZE = 5;
|
const PAGE_SIZE = 5;
|
||||||
|
const serverSelect = document.getElementById('serverSelect');
|
||||||
|
serverSelect.value = localStorage.getItem('plec.lastServer') || 'legacy';
|
||||||
|
serverSelect.addEventListener('change', () => localStorage.setItem('plec.lastServer', serverSelect.value));
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
let rows = [];
|
let rows = [];
|
||||||
let page = 0;
|
let page = 0;
|
||||||
@@ -206,7 +218,7 @@ document.getElementById('upload').addEventListener('click', async () => {
|
|||||||
const res = await fetch('/api/plec/upload', {
|
const res = await fetch('/api/plec/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ rows }),
|
body: JSON.stringify({ rows, server: serverSelect.value }),
|
||||||
});
|
});
|
||||||
await readJSONStream(res, (msg) => {
|
await readJSONStream(res, (msg) => {
|
||||||
if (msg.type === 'status') {
|
if (msg.type === 'status') {
|
||||||
@@ -299,7 +311,8 @@ type plecRow struct {
|
|||||||
func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
send := newJSONStream(w)
|
send := newJSONStream(w)
|
||||||
var req struct {
|
var req struct {
|
||||||
Rows []plecRow `json:"rows"`
|
Rows []plecRow `json:"rows"`
|
||||||
|
Server string `json:"server"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
send(map[string]any{"type": "error", "message": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
@@ -335,6 +348,14 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Server == "wordpress" {
|
||||||
|
plecUploadWordPress(send, cfg, valid)
|
||||||
|
} else {
|
||||||
|
plecUploadLegacy(send, cfg, valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plecUploadLegacy(send func(any), cfg PlecConfig, valid []plecRow) {
|
||||||
stamp := time.Now().Format("20060102150405")
|
stamp := time.Now().Format("20060102150405")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -357,9 +378,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
send(map[string]any{"type": "error", "message": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
|
iter := encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
|
||||||
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
|
|
||||||
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
|
|
||||||
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
|
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
|
||||||
send(map[string]any{"type": "error", "message": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -408,6 +427,171 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
|
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wpAuthenticate(send func(any), baseUrl, username, password string) (*http.Client, string, error) {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{Jar: jar}
|
||||||
|
|
||||||
|
// Pre-seed the test cookie WordPress checks for
|
||||||
|
testURL, _ := urlpkg.Parse(baseUrl)
|
||||||
|
jar.SetCookies(testURL, []*http.Cookie{{Name: "wordpress_test_cookie", Value: "WP Cookie check"}})
|
||||||
|
|
||||||
|
send(map[string]any{"type": "status", "message": "Authenticating with backup server..."})
|
||||||
|
|
||||||
|
form := urlpkg.Values{
|
||||||
|
"log": {username},
|
||||||
|
"pwd": {password},
|
||||||
|
"wp-submit": {"Log In"},
|
||||||
|
"redirect_to": {"/wp-admin/"},
|
||||||
|
"testcookie": {"1"},
|
||||||
|
}
|
||||||
|
loginReq, _ := http.NewRequest("POST", baseUrl+"/wp-login.php", strings.NewReader(form.Encode()))
|
||||||
|
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
loginReq.Header.Set("Origin", baseUrl)
|
||||||
|
loginReq.Header.Set("Referer", baseUrl+"/wp-login.php")
|
||||||
|
loginReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
|
||||||
|
|
||||||
|
loginResp, err := client.Do(loginReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("login request failed: %w", err)
|
||||||
|
}
|
||||||
|
io.ReadAll(loginResp.Body)
|
||||||
|
loginResp.Body.Close()
|
||||||
|
|
||||||
|
// After following redirects, check final URL — wp-admin means success
|
||||||
|
if strings.Contains(loginResp.Request.URL.String(), "wp-login") {
|
||||||
|
return nil, "", fmt.Errorf("WordPress login failed — check wpUsername / wpPassword in settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
send(map[string]any{"type": "status", "message": "Fetching upload token..."})
|
||||||
|
|
||||||
|
pageReq, _ := http.NewRequest("GET", baseUrl+"/file-upload/", nil)
|
||||||
|
pageReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
|
||||||
|
pageResp, err := client.Do(pageReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("nonce fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
pageBody, _ := io.ReadAll(pageResp.Body)
|
||||||
|
pageResp.Body.Close()
|
||||||
|
|
||||||
|
nonceRx := regexp.MustCompile(`"nonce"\s*:\s*"([a-f0-9]+)"`)
|
||||||
|
m := nonceRx.FindSubmatch(pageBody)
|
||||||
|
if m == nil {
|
||||||
|
return nil, "", fmt.Errorf("could not extract nonce from backup server page")
|
||||||
|
}
|
||||||
|
return client, string(m[1]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func plecUploadWordPress(send func(any), cfg PlecConfig, valid []plecRow) {
|
||||||
|
const baseUrl = "http://20.255.60.183"
|
||||||
|
|
||||||
|
if cfg.WpUsername == "" || cfg.WpPassword == "" {
|
||||||
|
send(map[string]any{"type": "error", "message": "WordPress credentials not set. Configure wpUsername / wpPassword in settings."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, nonce, err := wpAuthenticate(send, baseUrl, cfg.WpUsername, cfg.WpPassword)
|
||||||
|
if err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("action", "plec_upload_files")
|
||||||
|
_ = mw.WriteField("nonce", nonce)
|
||||||
|
|
||||||
|
type rowMeta struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
IterationName string `json:"iterationName"`
|
||||||
|
}
|
||||||
|
var rowsMeta []rowMeta
|
||||||
|
|
||||||
|
for i, row := range valid {
|
||||||
|
idx := i + 1
|
||||||
|
fieldName := fmt.Sprintf("file_%d", idx)
|
||||||
|
sanitized := strings.ReplaceAll(row.Iteration, " ", "")
|
||||||
|
filename := sanitized + ".html"
|
||||||
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filename)})
|
||||||
|
data, err := os.ReadFile(row.Path)
|
||||||
|
if err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fw, err := createFormFileWithType(mw, fieldName, filename, "text/html")
|
||||||
|
if err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(data); err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rowsMeta = append(rowsMeta, rowMeta{Field: fieldName, IterationName: sanitized})
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsJSON, _ := json.Marshal(rowsMeta)
|
||||||
|
_ = mw.WriteField("rows", string(rowsJSON))
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))})
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", baseUrl+"/wp-admin/admin-ajax.php", &buf)
|
||||||
|
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
httpReq.Header.Set("Origin", baseUrl)
|
||||||
|
httpReq.Header.Set("Referer", baseUrl+"/file-upload/")
|
||||||
|
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
|
||||||
|
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
|
httpReq.Header.Set("Accept", "*/*")
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": "Network error: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
send(map[string]any{"type": "error", "message": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var preview string
|
||||||
|
if data, ok := parsed["data"].(map[string]any); ok {
|
||||||
|
if files, ok := data["files"].([]any); ok && len(files) > 0 {
|
||||||
|
params := urlpkg.Values{"theme": {"calcite"}}
|
||||||
|
nonceRx := regexp.MustCompile(`(?i)\.html?$`)
|
||||||
|
for i, f := range files {
|
||||||
|
if fm, ok := f.(map[string]any); ok {
|
||||||
|
orig, _ := fm["original"].(string)
|
||||||
|
saved, _ := fm["saved"].(string)
|
||||||
|
n := nonceRx.ReplaceAllString(orig, "")
|
||||||
|
params.Set(fmt.Sprintf("n%d", i+1), n)
|
||||||
|
params.Set(fmt.Sprintf("m%d", i+1), saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preview = baseUrl + "/preview?" + params.Encode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preview == "" {
|
||||||
|
if v, ok := parsed["preview"].(string); ok {
|
||||||
|
preview = v
|
||||||
|
} else if v, ok := parsed["previewURL"].(string); ok {
|
||||||
|
preview = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
|
||||||
|
}
|
||||||
|
|
||||||
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
|
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
|||||||
328
standalone/project_init.go
Normal file
328
standalone/project_init.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
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) {
|
||||||
|
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="openSettingsModal('initialize-project')">⚙</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="(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;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
93
standalone/settings.go
Normal file
93
standalone/settings.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func SettingsSaveEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Plec struct {
|
||||||
|
UploadUrl string `json:"uploadUrl"`
|
||||||
|
OriginUrl string `json:"originUrl"`
|
||||||
|
PreviewBase string `json:"previewBase"`
|
||||||
|
SkipExistsCheck bool `json:"skipExistsCheck"`
|
||||||
|
WpUsername string `json:"wpUsername"`
|
||||||
|
WpPassword string `json:"wpPassword"`
|
||||||
|
} `json:"plec"`
|
||||||
|
Applovin struct {
|
||||||
|
Cookie string `json:"cookie"`
|
||||||
|
DelayMs int `json:"delayMs"`
|
||||||
|
RandomizeUserAgent *bool `json:"randomizeUserAgent"`
|
||||||
|
} `json:"applovin"`
|
||||||
|
SendToMobile struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
} `json:"sendToMobile"`
|
||||||
|
DeviceSimulator struct {
|
||||||
|
DevicesURL string `json:"devicesUrl"`
|
||||||
|
LanguagesURL string `json:"languagesUrl"`
|
||||||
|
} `json:"deviceSimulator"`
|
||||||
|
ProjectInitManifestURL string `json:"projectInitManifestUrl"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := LoadConfig()
|
||||||
|
cfg.Plec.UploadUrl = strings.TrimSpace(req.Plec.UploadUrl)
|
||||||
|
cfg.Plec.OriginUrl = strings.TrimSpace(req.Plec.OriginUrl)
|
||||||
|
cfg.Plec.PreviewBase = strings.TrimSpace(req.Plec.PreviewBase)
|
||||||
|
cfg.Plec.SkipExistsCheck = req.Plec.SkipExistsCheck
|
||||||
|
cfg.Plec.WpUsername = strings.TrimSpace(req.Plec.WpUsername)
|
||||||
|
cfg.Plec.WpPassword = req.Plec.WpPassword
|
||||||
|
cfg.Applovin.Cookie = strings.TrimSpace(req.Applovin.Cookie)
|
||||||
|
cfg.Applovin.DelayMs = req.Applovin.DelayMs
|
||||||
|
cfg.Applovin.RandomizeUserAgent = req.Applovin.RandomizeUserAgent
|
||||||
|
cfg.SendToMobile.Port = req.SendToMobile.Port
|
||||||
|
cfg.DeviceSimulator.DevicesURL = strings.TrimSpace(req.DeviceSimulator.DevicesURL)
|
||||||
|
cfg.DeviceSimulator.LanguagesURL = strings.TrimSpace(req.DeviceSimulator.LanguagesURL)
|
||||||
|
if req.ProjectInitManifestURL != "" {
|
||||||
|
cfg.ProjectInitManifestURL = strings.TrimSpace(req.ProjectInitManifestURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate remote cache when URLs change
|
||||||
|
simCache.mu.Lock()
|
||||||
|
if simCache.devURL != cfg.DeviceSimulator.DevicesURL {
|
||||||
|
simCache.devURL = ""
|
||||||
|
simCache.devices = ""
|
||||||
|
}
|
||||||
|
if simCache.langURL != cfg.DeviceSimulator.LanguagesURL {
|
||||||
|
simCache.langURL = ""
|
||||||
|
simCache.languages = ""
|
||||||
|
}
|
||||||
|
simCache.mu.Unlock()
|
||||||
|
|
||||||
|
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 htmlEsc(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkedAttr(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "checked"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func intStr(n int) string {
|
||||||
|
return fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
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",
|
||||||
@@ -45,7 +53,7 @@
|
|||||||
"description": "Convert Playworks HTML to per-network variants",
|
"description": "Convert Playworks HTML to per-network variants",
|
||||||
"command": "hplToolbox.openPlayworksConverter",
|
"command": "hplToolbox.openPlayworksConverter",
|
||||||
"path": "/playworks",
|
"path": "/playworks",
|
||||||
"beta": true
|
"beta": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mintegral",
|
"id": "mintegral",
|
||||||
@@ -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