Compare commits
4 Commits
c7ad1e14ca
...
playworks-
| Author | SHA1 | Date | |
|---|---|---|---|
| 75a506a505 | |||
| 698223234d | |||
| 9e82656eeb | |||
| d384c1be3a |
232
aiAssets/GUIDE.md
Normal file
232
aiAssets/GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# AGENTS.md — Playable Ads
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
Phaser 3.90 · TypeScript · Vite + vite-plugin-singlefile · WebP · MP3 96kbps
|
||||||
|
Networks: Applovin (al) · GoogleAds (gg) · Ironsource (is) · Mintegral (mtg) · Facebook (fb) · Unity (un) · Vungle (vu) · Moloco (mo) · TikTok (tt)
|
||||||
|
|
||||||
|
## Porting to a new project
|
||||||
|
1. Update naming fields (3LC, vendor, concept name) in [scripts/build-all.mjs](scripts/build-all.mjs) — drives the output filename
|
||||||
|
2. Update store URLs in [constants.ts](src/constants.ts) (iOS + Android)
|
||||||
|
3. Adjust the Depth Map below to match the project's layer needs
|
||||||
|
4. Update [iteration.ts](src/iteration.ts) A/B config shape for this project's variants
|
||||||
|
5. Replace `Assets/` with project assets; update asset manifests in [BootScene.ts](src/scenes/BootScene.ts)
|
||||||
|
6. Rewrite `game/` modules for the new mechanic(s) — keep the single-responsibility split
|
||||||
|
7. Update this AGENTS.md's Depth Map and any project-specific pitfalls
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.ts # Bootstrap, resize, MRAID gating, lifecycle stubs on window
|
||||||
|
constants.ts # Design coords (1080×1920), store URLs
|
||||||
|
networks.ts # triggerCTA(), initMraid(), bindLifecycle(), notifyGameX()
|
||||||
|
analytics.ts # trackEvent() → SDK
|
||||||
|
iteration.ts # A/B iteration config
|
||||||
|
utils/
|
||||||
|
responsive.ts # sx(), sy(), sd() — coordinate helpers
|
||||||
|
scenes/
|
||||||
|
BootScene.ts # Critical preload + deferred asset manifest
|
||||||
|
GameScene.ts # Orchestrator only — wires game/ modules, handles lifecycle
|
||||||
|
cta.ts # End-card layout + redirectToStore() → notifyGameClose() + triggerCTA()
|
||||||
|
game/ # Single-responsibility modules (no direct ad-SDK calls)
|
||||||
|
# …one file per distinct mechanic or UI widget
|
||||||
|
Assets/
|
||||||
|
scripts/build-all.mjs # Single build → per-network HTML variants
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsibility split
|
||||||
|
- **`game/`** modules each own a single concern (one mechanic, one widget, one system).
|
||||||
|
They receive the Phaser scene as a constructor arg and own their own game objects.
|
||||||
|
They never call ad-SDK functions (`triggerCTA`, `notifyGameX`, `trackEvent`).
|
||||||
|
- **`GameScene.ts`** is the wiring layer: creates modules, passes data between them,
|
||||||
|
and calls SDK helpers at the correct lifecycle moments.
|
||||||
|
- **`scenes/cta.ts`** owns end-card presentation and the store-redirect sequence.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- 60 FPS target; never below 30 on mid-range Android
|
||||||
|
- `dist/index.html` < 5 MB; WebP images, short audio
|
||||||
|
- No hardcoded px — `sx()`, `sy()`, `sd()` only
|
||||||
|
- No audio autoplay — unmute on first `pointerdown`
|
||||||
|
- `for` loops in game logic; pool objects; no GC
|
||||||
|
- `setDisplaySize(sd())` on images — never `setScale`
|
||||||
|
- Never dim via `body`/page background
|
||||||
|
|
||||||
|
## Phaser Config
|
||||||
|
```ts
|
||||||
|
{ type: Phaser.AUTO, transparent: true,
|
||||||
|
scale: { mode: Phaser.Scale.NONE, width: 1080, height: 1920 },
|
||||||
|
render: { antialias: true, pixelArt: false } }
|
||||||
|
```
|
||||||
|
`Scale.NONE` — manual sizing; CSS = viewport px; internal = viewport × DPR.
|
||||||
|
`antialias: true` — required to avoid jagged edges on scaled WebP assets (end-card logo, CTA, score pill). Never set to `false`.
|
||||||
|
|
||||||
|
## Responsive (1080×1920 ref)
|
||||||
|
```ts
|
||||||
|
const s = Math.min(viewW / 1080, viewH / 1920)
|
||||||
|
const offX = (viewW - 1080 * s) / 2
|
||||||
|
const offY = (viewH - 1920 * s) / 2
|
||||||
|
// sx/sy/sd apply offX, offY, s
|
||||||
|
```
|
||||||
|
- `update()` polls `visualViewport` per-frame for rotation
|
||||||
|
- On change: `game.scale.resize(vw*dpr, vh*dpr)` → `relayout()`
|
||||||
|
- `bindResponsiveResize()`: rAF debounce + 100/300/600ms retries
|
||||||
|
|
||||||
|
## CTA — SDK Priority (triggerCTA fallback chain)
|
||||||
|
```
|
||||||
|
1. ExitApi.exit() → GoogleAds (gg)
|
||||||
|
2. FbPlayableAd.onCTAClick() → Facebook (fb)
|
||||||
|
3. Luna.Unity.Playable → Unity (un)
|
||||||
|
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
|
||||||
|
5. window.install() → Mintegral (mtg)
|
||||||
|
6. window.openAppStore() → (runtime fallback)
|
||||||
|
7. window.clickTag → Moloco (mo)
|
||||||
|
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
|
||||||
|
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
|
||||||
|
10. mraid.open(url) → Applovin (al) / Ironsource (is)
|
||||||
|
11. window.open(storeUrl) → Fallback (all others)
|
||||||
|
```
|
||||||
|
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
|
||||||
|
|
||||||
|
## Analytics — SDK Priority
|
||||||
|
```
|
||||||
|
ALPlayableAnalytics.trackEvent(e) → Applovin (al)
|
||||||
|
playableSDK.reportEvent(e) → (runtime fallback if SDK present)
|
||||||
|
console.log('[Analytics]', e) → All other networks
|
||||||
|
```
|
||||||
|
Events: `DISPLAYED` · `CTA_CLICKED` · `ENDCARD_SHOWN` · `CHALLENGE_STARTED` · `CHALLENGE_SOLVED`
|
||||||
|
|
||||||
|
## Game Lifecycle (all networks)
|
||||||
|
`main.ts` exposes stubs on `window` so every network's preview tool detects them.
|
||||||
|
Stubs only set if the SDK hasn't already provided its own (`typeof` guard).
|
||||||
|
```
|
||||||
|
gameReady() → stub in main.ts; Mintegral also gets onload body attr from build
|
||||||
|
gameStart() → notifyGameStart() in GameScene.startPlaying()
|
||||||
|
gameEnd() → notifyGameEnd() in GameScene.triggerFail() / triggerWin()
|
||||||
|
gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCTA()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
||||||
|
| Network | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events |
|
||||||
|
| Mintegral | `window.message` → `onPause` / `onResume` |
|
||||||
|
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
||||||
|
| Applovin / Ironsource | MRAID viewable state (gated in initMraid) |
|
||||||
|
| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
||||||
|
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
|
||||||
|
|
||||||
|
| Network | Tag | Injected | Zipped | Included |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Applovin | al | `mraid.js` | — | ✓ |
|
||||||
|
| Google | gg | `exitapi.js` | ✓ | ✓ |
|
||||||
|
| Ironsource | is | `mraid.js` | — | ✓ |
|
||||||
|
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
||||||
|
| Facebook | fb | — | — | ✓ |
|
||||||
|
| Unity | un | — | — | ✓ |
|
||||||
|
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
||||||
|
| Moloco | mo | — | — | ✓ |
|
||||||
|
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
||||||
|
|
||||||
|
|
||||||
|
Networks marked **Zipped** ingest zipped creatives — [scripts/build-all.mjs](scripts/build-all.mjs) writes a `.zip` containing the HTML (one zip per variant, containing just that HTML at the root named index.html). Toggled per-network via `zip: true` in the `NETWORKS` array.
|
||||||
|
|
||||||
|
Networks marked **Included** are built by default. Networks not marked are prepared for but not built. Toggle per-network via `included: true` in the `NETWORKS` array.
|
||||||
|
|
||||||
|
Output is grouped by **iteration length** (e.g. `2words/`, `4words/`, `full/`) then by **network** (e.g. `Applovin/`, `Google/`, `Mintegral/`), with all per-network HTML or ZIP variants for that iteration sitting together inside the folder:
|
||||||
|
|
||||||
|
`dist/<length>/<3lc>_<creative-type>_<vendor>_<concept-name>_<concept-num>_<gameplay>_<ugc>_<seasonal>_<lang>_<length>_<size>_<network>`
|
||||||
|
|
||||||
|
Example layout:
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
2words/
|
||||||
|
Applovin/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
||||||
|
Google/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
||||||
|
index.zip
|
||||||
|
Ironsource/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
||||||
|
…one HTML or ZIP per network…
|
||||||
|
4words/
|
||||||
|
full/
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| 3LC | 3-letter project code | `ws` (Wordscapes), `pp` (PeoplePuzzle) |
|
||||||
|
| Creative Type | Format identifier | `mip` |
|
||||||
|
| Vendor | Studio / vendor code | `grhpl` |
|
||||||
|
| Concept Name | Creative concept (append `var1`, `var2`… for variants) | `wbinspiredvar1`, `wbinspiredvar2`, `burglar` |
|
||||||
|
| Concept # | Iteration number, zero-padded | `01`, `02`, `03` |
|
||||||
|
| Gameplay | Gameplay style | `real`, `cartoon` |
|
||||||
|
| UGC | UGC presence | `na`, `ugc`, `nougc` |
|
||||||
|
| Seasonal | Seasonal tie-in | `noseason`, `xmas` |
|
||||||
|
| Language | Locale code | `en` |
|
||||||
|
| Length | Run length | `2words`, `4words`, `7words`, `full` (Wordscapes); `2clk`, `10clk`, `120sec` (other projects) |
|
||||||
|
| Size | Region / size code | `na` |
|
||||||
|
| Network | Network tag (last segment) | `al`, `gg`, `is`, `mtg`, `fb`, `un`, `vu`, `mo`, `tt` |
|
||||||
|
|
||||||
|
Examples (this project):
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html` (cta2, default theme)
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar1_04_real_na_noseason_en_full_na_fb.html` (full, default theme)
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar2_01_real_na_noseason_en_2words_na_gg.html` (cta2, v1 theme)
|
||||||
|
|
||||||
|
Naming fields are defined in [scripts/build-all.mjs](scripts/build-all.mjs) — update them when porting.
|
||||||
|
|
||||||
|
## Asset Loading
|
||||||
|
- **Critical** (BootScene): first-frame assets, target < 200 KB
|
||||||
|
- **Deferred**: audio, overlays, secondary UI — loads during intro
|
||||||
|
- Gameplay start waits on a deferred-ready flag set when the secondary manifest finishes loading
|
||||||
|
|
||||||
|
## Depth Map (adjust per project)
|
||||||
|
Define depths as named constants in [constants.ts](src/constants.ts) — never use magic numbers in scene code.
|
||||||
|
|
||||||
|
| Layer | Depth |
|
||||||
|
|---|---|
|
||||||
|
| Background | 0 |
|
||||||
|
| Game objects | 1–10 |
|
||||||
|
| Logo | 17 |
|
||||||
|
| Dim overlay | 20 |
|
||||||
|
| Fail/win UI | 21 |
|
||||||
|
| EndCard input | 25 |
|
||||||
|
|
||||||
|
## Ship Checklist
|
||||||
|
- [ ] < 5 MB, all WebP
|
||||||
|
- [ ] FPS ≥ 55 under CPU throttle
|
||||||
|
- [ ] Portrait + landscape pass
|
||||||
|
- [ ] MRAID ready before gameplay
|
||||||
|
- [ ] Audio muted by default
|
||||||
|
- [ ] CTA fires in fallback
|
||||||
|
- [ ] No `console.error`
|
||||||
|
- [ ] No `type="module"` or `crossorigin` on `<script>` in output HTML
|
||||||
|
- [ ] Lifecycle: gameReady / gameStart / gameEnd / gameClose detected by all network preview tools
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
| Issue | Fix |
|
||||||
|
|---|---|
|
||||||
|
| Rotation stuck | `Scale.NONE` + per-frame poll |
|
||||||
|
| Mobile aliasing | Canvas = viewport × DPR |
|
||||||
|
| FPS drop on resize | rAF debounce |
|
||||||
|
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
||||||
|
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
||||||
|
| Bundle > 5 MB | WebP q75, shorter audio |
|
||||||
|
| Audio blocked | Gate on `pointerdown` |
|
||||||
|
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
||||||
|
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
||||||
|
| Network preview checklist fail | Expose lifecycle stubs on `window` + call `notifyX()` with `typeof` guards |
|
||||||
|
| `playableSDK.reportEvent is not a function` | Applovin preview injects `playableSDK` without `reportEvent` — guard with `typeof playableSDK.reportEvent === 'function'`, not just `typeof playableSDK !== 'undefined'` |
|
||||||
|
| iPhone black screen (Android works) | **Only one `Phaser.Game` per page, ever.** iOS WKWebView — used by Applovin/Ironsource preview apps and most in-app ad containers — caps or outright rejects the 2nd WebGL context. If a background scene is spun up as a separate `new Phaser.Game()` synchronously at module load, it can throw before `await initMraid()` and before the font promise resolves, so the rest of `main.ts` never runs → black screen with no visible error. Render the background as a CSS `background-image` on `#bg-container` (cover-sized) instead — zero WebGL contexts for the background, and nothing to throw at module-load time. iOS-debug path: enable Safari → Advanced → Web Inspector on the iPhone, connect to a Mac, open Develop → iPhone → preview WebView, read the Console. Without a Mac, temporarily install top-of-`main.ts` `window.addEventListener('error',…)` and `unhandledrejection` handlers that write the error into `document.body.innerHTML` so the failure is visible on-screen. |
|
||||||
|
| iOS hangs on font load | `document.fonts.load()` silently stalls in WKWebView when `@font-face` uses base64 `data:` URLs. Race against a 1.5 s timeout: `Promise.race([Promise.all([…fonts]), new Promise(r=>setTimeout(r,1500))]).then(startGame)` |
|
||||||
|
| iOS hangs on MRAID ready | `mraid.addEventListener('ready', …)` can miss the event if the container fires it before the listener attaches. `initMraid()` must self-resolve after ~2 s so `startGame()` always runs; also check `mraid.getState() !== 'loading'` first. |
|
||||||
|
| External `src=` survives build | `vite-plugin-singlefile` only inlines assets *imported from JS*. A raw `<img src="src/assets/…">` in `index.html` ships as a literal relative path and fails in ad sandboxes, producing inconsistent per-device load failures. Leave DOM `<img>` elements with no `src`; assign via JS from `assets.ts` at boot (`setEndcardHtmlAssets()`). |
|
||||||
|
| Audio keeps playing when ad hides | Add `visibilitychange` listener in the sound module — mute Phaser's sound system and `suspend()` the `AudioContext` when `document.hidden`, resume on return. Spec requires audio stop on hide/close. |
|
||||||
|
| `mraid.open()` silently no-ops | Guard with `mraid.getState() !== 'loading'` before calling; fall through to `window.open()` if still loading. |
|
||||||
|
| Previewer black screen with `Cannot read properties of null (reading 'appendChild')` in Phaser boot | Two compounding causes: (1) `vite-plugin-singlefile` hoists the bundled script into `<head>`, and the build strips `type="module"`, so the script runs **synchronously before `<body>` is parsed** — `document.body` is null. Wrap main.ts in a `DOMContentLoaded` gate (`if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()`). (2) `parent: 'game'` (string selector) fails when the previewer sandbox strips custom DOM; create `<div id="game">` at runtime inside `boot()` and pass the **element reference** (not the string) to `Phaser.Game({ parent })`. The cascading `ScaleManager.resize → Cannot set properties of undefined (setting 'width')` errors all trace back to `game.canvas` never being created when either of these fail. |
|
||||||
|
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
|
||||||
|
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top` → `window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
|
||||||
|
|
||||||
|
## Ironsource — Runtime Analysis
|
||||||
|
Ironsource requires every `_is.html` build to pass **LevelPlay → Creative Management → Playable Workshop** (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.
|
||||||
7
aiAssets/Playworks_UnityNetwork.html
Normal file
7
aiAssets/Playworks_UnityNetwork.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_al.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_al.html
Normal file
File diff suppressed because one or more lines are too long
8
aiAssets/testOutput/Playworks_UnityNetwork_fb.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_fb.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_gg.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_gg.zip
Normal file
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_is.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_is.html
Normal file
File diff suppressed because one or more lines are too long
8
aiAssets/testOutput/Playworks_UnityNetwork_mo.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_mo.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip
Normal file
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_un.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_un.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_vu.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_vu.zip
Normal file
Binary file not shown.
BIN
dist/hpl-toolbox-0.1.5.exe
vendored
BIN
dist/hpl-toolbox-0.1.5.exe
vendored
Binary file not shown.
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
Binary file not shown.
10
package.json
10
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "hpl-toolbox",
|
"name": "hpl-toolbox",
|
||||||
"displayName": "HPL Toolbox",
|
"displayName": "HPL Toolbox",
|
||||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker, Daily Update. Local HTML Host.",
|
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker. Local HTML Host.",
|
||||||
"version": "0.1.5",
|
"version": "0.1.5",
|
||||||
"publisher": "hesukastro",
|
"publisher": "hesukastro",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
@@ -34,13 +34,13 @@
|
|||||||
"command": "hplToolbox.openMraidChecker",
|
"command": "hplToolbox.openMraidChecker",
|
||||||
"title": "HPL Toolbox: Open MRAID Checker"
|
"title": "HPL Toolbox: Open MRAID Checker"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"command": "hplToolbox.openDailyUpdate",
|
|
||||||
"title": "HPL Toolbox: Open Daily Update"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"command": "hplToolbox.openSendToMobile",
|
"command": "hplToolbox.openSendToMobile",
|
||||||
"title": "HPL Toolbox: Open Send To Mobile"
|
"title": "HPL Toolbox: Open Send To Mobile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openPlayworksConverter",
|
||||||
|
"title": "HPL Toolbox: Open Playworks Converter"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"viewsContainers": {
|
"viewsContainers": {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { openPlecUpload } from './tools/plecUpload';
|
|||||||
import { openApplovinUpload } from './tools/applovinUpload';
|
import { openApplovinUpload } from './tools/applovinUpload';
|
||||||
import { openBase64Scanner } from './tools/base64Scanner';
|
import { openBase64Scanner } from './tools/base64Scanner';
|
||||||
import { openMraidChecker } from './tools/mraidChecker';
|
import { openMraidChecker } from './tools/mraidChecker';
|
||||||
import { openDailyUpdate } from './tools/dailyUpdate';
|
|
||||||
import { openSendToMobile } from './tools/sendToMobile';
|
import { openSendToMobile } from './tools/sendToMobile';
|
||||||
|
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
@@ -13,8 +13,8 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
|
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
|
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(context)),
|
|
||||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||||
|
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,20 +31,21 @@ const TOOLS: ToolDefinition[] = [
|
|||||||
description: 'Check MRAID requirements and best practices',
|
description: 'Check MRAID requirements and best practices',
|
||||||
beta: true,
|
beta: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
command: 'hplToolbox.openDailyUpdate',
|
|
||||||
title: 'Daily Update',
|
|
||||||
description: 'Compose & copy a daily status',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
command: 'hplToolbox.openSendToMobile',
|
command: 'hplToolbox.openSendToMobile',
|
||||||
title: 'Send To Mobile',
|
title: 'Send To Mobile',
|
||||||
description: 'Share HTML to a phone via LAN + QR',
|
description: 'Share HTML to a phone via LAN + QR',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openPlayworksConverter',
|
||||||
|
title: 'Playworks Converter',
|
||||||
|
description: 'Convert Playworks HTML to per-network variants',
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
constructor(private readonly context: vscode.ExtensionContext) { }
|
||||||
|
|
||||||
resolveWebviewView(view: vscode.WebviewView) {
|
resolveWebviewView(view: vscode.WebviewView) {
|
||||||
view.webview.options = { enableScripts: true };
|
view.webview.options = { enableScripts: true };
|
||||||
@@ -63,7 +64,7 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getHtml(version: string, betaToolsEnabled: boolean): string {
|
function getHtml(version: string, betaToolsEnabled: boolean): string {
|
||||||
const visibleTools = TOOLS.filter(tool => betaToolsEnabled || !tool.beta);
|
const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta));
|
||||||
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length;
|
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length;
|
||||||
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
@@ -150,6 +151,10 @@ ${toolButtons}
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] {
|
||||||
|
return [...tools].sort((a, b) => 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-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>
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import * as vscode from 'vscode';
|
|
||||||
import { singletonPanel } from './shared';
|
|
||||||
|
|
||||||
const PROJECT_KEY = 'hplToolbox.dailyUpdate.lastProject';
|
|
||||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
|
||||||
|
|
||||||
export function openDailyUpdate(context: vscode.ExtensionContext) {
|
|
||||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.dailyUpdate', 'Daily Update');
|
|
||||||
if (!isNew) return;
|
|
||||||
|
|
||||||
const lastProject = context.globalState.get<string>(PROJECT_KEY, '');
|
|
||||||
panel.webview.html = getHtml(lastProject);
|
|
||||||
|
|
||||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
|
||||||
if (msg.type === 'submit') {
|
|
||||||
const text = formatMessage(msg.payload);
|
|
||||||
await vscode.env.clipboard.writeText(text);
|
|
||||||
await context.globalState.update(PROJECT_KEY, msg.payload.project);
|
|
||||||
vscode.window.showInformationMessage('Daily update copied to clipboard.');
|
|
||||||
panel.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessage(p: { date: string; status: string; project: string; remarks: string }): string {
|
|
||||||
const bullets = p.remarks
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((l) => l.replace(/^\s*[-*]\s*/, '').trim())
|
|
||||||
.filter((l) => l.length > 0)
|
|
||||||
.map((l) => `- ${l}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`Date: ${formatDate(p.date)}`,
|
|
||||||
`Status: ${p.status}`,
|
|
||||||
`Project: ${p.project}`,
|
|
||||||
`Remarks:`,
|
|
||||||
bullets,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (p.status === 'For OT') {
|
|
||||||
lines.push('@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace');
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
||||||
if (!m) return iso;
|
|
||||||
return `${m[2]}/${m[3]}/${m[1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAttr(s: string): string {
|
|
||||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHtml(lastProject: string): string {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
|
|
||||||
<style>
|
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; max-width: 520px; }
|
|
||||||
label { display: block; margin-top: 12px; font-size: 12px; opacity: 0.85; }
|
|
||||||
input, select, textarea {
|
|
||||||
width: 100%; box-sizing: border-box; margin-top: 4px; padding: 6px 8px;
|
|
||||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
|
||||||
border: 1px solid var(--vscode-input-border, transparent); font-family: inherit; font-size: 13px;
|
|
||||||
}
|
|
||||||
textarea { min-height: 96px; resize: vertical; }
|
|
||||||
button {
|
|
||||||
margin-top: 16px; padding: 8px 16px; cursor: pointer;
|
|
||||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
|
||||||
border: none; font-size: 13px;
|
|
||||||
}
|
|
||||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2 style="margin:0 0 4px 0;">Daily Update</h2>
|
|
||||||
<div style="font-size:12px;opacity:0.7;">Submit copies the formatted text to your clipboard.</div>
|
|
||||||
|
|
||||||
<label for="date">Date</label>
|
|
||||||
<input type="date" id="date" value="${today}" />
|
|
||||||
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status">
|
|
||||||
<option>Started</option>
|
|
||||||
<option>Progress</option>
|
|
||||||
<option>Finished</option>
|
|
||||||
<option>Client Feedback</option>
|
|
||||||
<option>For OT</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="project">Project</label>
|
|
||||||
<input type="text" id="project" placeholder="Project name" value="${escapeAttr(lastProject)}" />
|
|
||||||
|
|
||||||
<label for="remarks">Remarks</label>
|
|
||||||
<textarea id="remarks" placeholder="What did you do?"></textarea>
|
|
||||||
|
|
||||||
<button id="submit">Submit</button>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const vscode = acquireVsCodeApi();
|
|
||||||
document.getElementById('submit').addEventListener('click', () => {
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'submit',
|
|
||||||
payload: {
|
|
||||||
date: document.getElementById('date').value,
|
|
||||||
status: document.getElementById('status').value,
|
|
||||||
project: document.getElementById('project').value.trim(),
|
|
||||||
remarks: document.getElementById('remarks').value.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
504
src/tools/playworksConverter.ts
Normal file
504
src/tools/playworksConverter.ts
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as cp from 'child_process';
|
||||||
|
import { singletonPanel } from './shared';
|
||||||
|
|
||||||
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
|
interface ConvertOptions {
|
||||||
|
sourcePath: string;
|
||||||
|
outputDir: string;
|
||||||
|
networks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkResult {
|
||||||
|
network: string;
|
||||||
|
file: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||||
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter');
|
||||||
|
if (!isNew) return;
|
||||||
|
|
||||||
|
panel.webview.html = getHtml();
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'pickSource': {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: true,
|
||||||
|
canSelectFolders: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
filters: { HTML: ['html', 'htm'] },
|
||||||
|
openLabel: 'Select Playworks HTML',
|
||||||
|
});
|
||||||
|
if (!picked?.[0]) return;
|
||||||
|
const srcPath = picked[0].fsPath;
|
||||||
|
const dir = path.dirname(srcPath);
|
||||||
|
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'pickOutput': {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Output Folder',
|
||||||
|
});
|
||||||
|
if (!picked?.[0]) return;
|
||||||
|
panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'convert': {
|
||||||
|
const opts: ConvertOptions = msg.opts;
|
||||||
|
if (!fs.existsSync(opts.sourcePath)) {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(opts.outputDir)) {
|
||||||
|
try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let html: string;
|
||||||
|
try {
|
||||||
|
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
||||||
|
} catch (e: any) {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: NetworkResult[] = [];
|
||||||
|
for (const network of opts.networks) {
|
||||||
|
try {
|
||||||
|
const filePath = await convertNetwork(html, opts, network);
|
||||||
|
results.push({ network, file: filePath, ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
results.push({ network, file: '', ok: false, error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'openOutput': {
|
||||||
|
vscode.env.openExternal(vscode.Uri.file(msg.dir));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']);
|
||||||
|
|
||||||
|
function sourceBaseName(sourcePath: string): string {
|
||||||
|
return path.basename(sourcePath, path.extname(sourcePath))
|
||||||
|
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
|
||||||
|
const transformed = transformHtml(html, network);
|
||||||
|
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||||
|
const baseName = sourceBaseName(opts.sourcePath);
|
||||||
|
const htmlFileName = `${baseName}_${network}.html`;
|
||||||
|
|
||||||
|
if (needsZip) {
|
||||||
|
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||||
|
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`);
|
||||||
|
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||||
|
try {
|
||||||
|
await createZip(tmpPath, 'index.html', zipPath);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = path.join(opts.outputDir, htmlFileName);
|
||||||
|
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformHtml(html: string, network: string): string {
|
||||||
|
let result = html;
|
||||||
|
|
||||||
|
// Inject into </head>: lifecycle stubs only (must register luna:build listener
|
||||||
|
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
|
||||||
|
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
|
||||||
|
let headInject = buildLifecycleScript();
|
||||||
|
if (network === 'al' || network === 'is') {
|
||||||
|
headInject += '<script src="mraid.js"></script>';
|
||||||
|
} else if (network === 'gg') {
|
||||||
|
headInject += '<script src="exitapi.js"></script>';
|
||||||
|
}
|
||||||
|
result = result.replace('</head>', headInject + '\n</head>');
|
||||||
|
|
||||||
|
// Body-level flags
|
||||||
|
if (network === 'vu') {
|
||||||
|
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>');
|
||||||
|
} else if (network === 'mtg') {
|
||||||
|
result = result.replace('<body>', '<body onload="gameReady()">');
|
||||||
|
} else if (network === 'tt') {
|
||||||
|
result = result.replace('<body>', '<body>\n<script>window.__TIKTOK__=true;</script>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the CTA script
|
||||||
|
result = replaceCTAScript(result, network);
|
||||||
|
|
||||||
|
// Unity: rewrite window.top → window.self (Luna static scan requirement)
|
||||||
|
if (network === 'un') {
|
||||||
|
result = result.split('window.top').join('window.self');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCTAScript(html: string, network: string): string {
|
||||||
|
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
||||||
|
const markerIdx = html.lastIndexOf(marker);
|
||||||
|
if (markerIdx === -1) return html;
|
||||||
|
|
||||||
|
const scriptOpenIdx = html.lastIndexOf('<script>', markerIdx);
|
||||||
|
if (scriptOpenIdx === -1) return html;
|
||||||
|
|
||||||
|
const scriptCloseIdx = html.indexOf('</script>', markerIdx);
|
||||||
|
if (scriptCloseIdx === -1) return html;
|
||||||
|
|
||||||
|
const before = html.slice(0, scriptOpenIdx);
|
||||||
|
const after = html.slice(scriptCloseIdx + '</script>'.length);
|
||||||
|
|
||||||
|
// Console restore runs after the bundle (which overrides console), before CTA
|
||||||
|
return before + buildConsoleRestoreScript() + buildCTAScript(network) + after;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores native console methods overridden by the Playworks bundle,
|
||||||
|
// using a temporary iframe to get a fresh un-patched console reference.
|
||||||
|
function buildConsoleRestoreScript(): string {
|
||||||
|
return [
|
||||||
|
'<script>(function(){',
|
||||||
|
'try{',
|
||||||
|
'var f=document.createElement("iframe");',
|
||||||
|
'f.style.display="none";',
|
||||||
|
'document.documentElement.appendChild(f);',
|
||||||
|
'var nc=f.contentWindow.console;',
|
||||||
|
'document.documentElement.removeChild(f);',
|
||||||
|
'var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];',
|
||||||
|
'methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});',
|
||||||
|
'}catch(e){}',
|
||||||
|
'})();</script>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events.
|
||||||
|
// Registered in <head> so our luna:build listener fires BEFORE the Playworks bundle
|
||||||
|
// listeners (which synchronously dispatch luna:start inside their luna:build handler).
|
||||||
|
// One-time guards prevent double-calls:
|
||||||
|
// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it
|
||||||
|
// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners)
|
||||||
|
function buildLifecycleScript(): string {
|
||||||
|
return [
|
||||||
|
'<script>(function(){',
|
||||||
|
'if(typeof window.gameReady!=="function")window.gameReady=function(){};',
|
||||||
|
'if(typeof window.gameStart!=="function")window.gameStart=function(){};',
|
||||||
|
'if(typeof window.gameEnd!=="function")window.gameEnd=function(){};',
|
||||||
|
'if(typeof window.gameClose!=="function")window.gameClose=function(){};',
|
||||||
|
'var _grOnce=false,_gsOnce=false;',
|
||||||
|
'window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});',
|
||||||
|
'window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});',
|
||||||
|
'window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});',
|
||||||
|
'})();</script>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCTAScript(network: string): string {
|
||||||
|
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
|
||||||
|
// gameClose must fire before every redirect
|
||||||
|
const closeCall = `try{window.gameClose();}catch(e){}`;
|
||||||
|
|
||||||
|
let ctaLogic: string;
|
||||||
|
switch (network) {
|
||||||
|
case 'al':
|
||||||
|
case 'is':
|
||||||
|
// MRAID — mraid.js injected in <head>
|
||||||
|
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`;
|
||||||
|
break;
|
||||||
|
case 'un':
|
||||||
|
// Unity Ads — MRAID-based; window.top already rewritten separately
|
||||||
|
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`;
|
||||||
|
break;
|
||||||
|
case 'fb':
|
||||||
|
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'gg':
|
||||||
|
// Google — exitapi.js injected in <head>
|
||||||
|
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'mo':
|
||||||
|
ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'vu':
|
||||||
|
// Vungle — window.__VUNGLE__ flag set in body
|
||||||
|
ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`;
|
||||||
|
break;
|
||||||
|
case 'mtg':
|
||||||
|
// Mintegral — onload="gameReady()" set on body
|
||||||
|
ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'tt':
|
||||||
|
// TikTok — window.__TIKTOK__ flag set in body
|
||||||
|
ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ctaLogic = `${closeCall}window.open(o,"_blank");`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),${urlSetup}${ctaLogic}}}))}));</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Remove existing zip if present (Compress-Archive would fail otherwise)
|
||||||
|
if (fs.existsSync(destZipPath)) {
|
||||||
|
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const psScript = [
|
||||||
|
'Add-Type -AssemblyName System.IO.Compression.FileSystem;',
|
||||||
|
`$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`,
|
||||||
|
`[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`,
|
||||||
|
'$zip.Dispose()',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stderr = '';
|
||||||
|
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Webview UI ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getHtml(): string {
|
||||||
|
const networks = [
|
||||||
|
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
||||||
|
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
||||||
|
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
|
||||||
|
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
|
||||||
|
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
|
||||||
|
{ tag: 'mo', label: 'Moloco', note: 'HTML' },
|
||||||
|
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
|
||||||
|
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
|
||||||
|
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const checkboxRows = networks.map(n => `
|
||||||
|
<label class="net-row">
|
||||||
|
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked />
|
||||||
|
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span>
|
||||||
|
<span class="net-note">${n.note}</span>
|
||||||
|
</label>`).join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 10px; font-size: 13px; }
|
||||||
|
h2 { font-size: 14px; margin: 0 0 14px 0; font-weight: 600; }
|
||||||
|
.section { margin-bottom: 14px; }
|
||||||
|
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.65; margin-bottom: 5px; }
|
||||||
|
.row { display: flex; gap: 6px; align-items: center; }
|
||||||
|
input[type=text] {
|
||||||
|
flex: 1; padding: 4px 7px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border: 1px solid var(--vscode-input-border, #555);
|
||||||
|
border-radius: 2px; font-size: 12px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 2px;
|
||||||
|
background: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border: 1px solid var(--vscode-panel-border, #444);
|
||||||
|
}
|
||||||
|
button:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||||
|
button.primary {
|
||||||
|
background: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
border: none; padding: 6px 18px; font-size: 13px;
|
||||||
|
}
|
||||||
|
button.primary:hover { background: var(--vscode-button-hoverBackground); }
|
||||||
|
button:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
.net-grid { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.net-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
|
||||||
|
.net-label { min-width: 100px; font-weight: 500; }
|
||||||
|
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
||||||
|
.net-note { font-size: 11px; opacity: 0.6; }
|
||||||
|
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.toggle-row button { font-size: 11px; padding: 2px 8px; }
|
||||||
|
.actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; }
|
||||||
|
#status { margin-top: 12px; font-size: 12px; opacity: 0.75; min-height: 16px; }
|
||||||
|
.results { margin-top: 10px; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.res-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--vscode-editor-font-family); }
|
||||||
|
.mark { font-weight: bold; width: 14px; flex-shrink: 0; }
|
||||||
|
.ok { color: #3fb950; }
|
||||||
|
.bad { color: #f85149; }
|
||||||
|
.res-net { min-width: 70px; font-weight: 500; }
|
||||||
|
.res-file { opacity: 0.65; word-break: break-all; }
|
||||||
|
.res-err { color: #f85149; opacity: 0.9; }
|
||||||
|
hr { border: none; border-top: 1px solid var(--vscode-panel-border, #444); margin: 14px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Playworks Converter</h2>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Source HTML (Playworks export)</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
|
||||||
|
<button id="pickSrc">Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Output Folder</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||||
|
<button id="pickOut">Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Networks to generate</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<button id="selectAll">All</button>
|
||||||
|
<button id="selectNone">None</button>
|
||||||
|
</div>
|
||||||
|
<div class="net-grid">${checkboxRows}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" id="convert" disabled>Convert</button>
|
||||||
|
<button id="openOut" style="display:none">Open Output Folder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
<div class="results" id="results"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let outputDir = '';
|
||||||
|
|
||||||
|
const srcEl = document.getElementById('src');
|
||||||
|
const outEl = document.getElementById('outDir');
|
||||||
|
const convertBtn = document.getElementById('convert');
|
||||||
|
const openOutBtn = document.getElementById('openOut');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
|
||||||
|
function updateConvertBtn() {
|
||||||
|
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||||
|
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
|
||||||
|
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
||||||
|
document.getElementById('selectAll').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.getElementById('selectNone').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||||
|
outEl.addEventListener('input', updateConvertBtn);
|
||||||
|
|
||||||
|
openOutBtn.addEventListener('click', () => {
|
||||||
|
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('convert').addEventListener('click', () => {
|
||||||
|
const networks = [...document.querySelectorAll('.net-cb')]
|
||||||
|
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||||
|
statusEl.textContent = 'Converting...';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
openOutBtn.style.display = 'none';
|
||||||
|
convertBtn.disabled = true;
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'convert',
|
||||||
|
opts: {
|
||||||
|
sourcePath: srcEl.value,
|
||||||
|
outputDir: outEl.value,
|
||||||
|
networks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (msg.type === 'sourcePicked') {
|
||||||
|
srcEl.value = msg.path;
|
||||||
|
if (!outEl.value && msg.dir) {
|
||||||
|
outEl.value = msg.dir;
|
||||||
|
outputDir = msg.dir;
|
||||||
|
}
|
||||||
|
updateConvertBtn();
|
||||||
|
} else if (msg.type === 'outputPicked') {
|
||||||
|
outEl.value = msg.path;
|
||||||
|
outputDir = msg.path;
|
||||||
|
updateConvertBtn();
|
||||||
|
} else if (msg.type === 'convertDone') {
|
||||||
|
convertBtn.disabled = false;
|
||||||
|
updateConvertBtn();
|
||||||
|
if (msg.error) {
|
||||||
|
statusEl.textContent = 'Error: ' + msg.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = msg.results || [];
|
||||||
|
const ok = results.filter(r => r.ok).length;
|
||||||
|
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
for (const r of results) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'res-row';
|
||||||
|
const mark = document.createElement('span');
|
||||||
|
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||||
|
mark.textContent = r.ok ? '✓' : '✗';
|
||||||
|
const net = document.createElement('span');
|
||||||
|
net.className = 'res-net';
|
||||||
|
net.textContent = r.network;
|
||||||
|
const detail = document.createElement('span');
|
||||||
|
if (r.ok) {
|
||||||
|
detail.className = 'res-file';
|
||||||
|
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||||
|
} else {
|
||||||
|
detail.className = 'res-err';
|
||||||
|
detail.textContent = r.error || 'Unknown error';
|
||||||
|
}
|
||||||
|
row.append(mark, net, detail);
|
||||||
|
resultsEl.appendChild(row);
|
||||||
|
}
|
||||||
|
if (ok > 0) {
|
||||||
|
outputDir = outEl.value;
|
||||||
|
openOutBtn.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DailyPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cfg := LoadConfig()
|
|
||||||
today := time.Now().Format("2006-01-02")
|
|
||||||
lastProject := html.EscapeString(cfg.DailyUpdate.LastProject)
|
|
||||||
|
|
||||||
body := `
|
|
||||||
<h2>Daily Update</h2>
|
|
||||||
<div class="hint" style="margin-bottom:12px;">Submit copies the formatted text to your clipboard.</div>
|
|
||||||
|
|
||||||
<div style="max-width:520px;">
|
|
||||||
<label style="display:block;margin-top:8px;font-size:12px;opacity:0.85;">Date</label>
|
|
||||||
<input type="date" id="date" value="` + today + `" style="width:100%;" />
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Status</label>
|
|
||||||
<select id="status" style="width:100%;">
|
|
||||||
<option>Started</option><option>Progress</option><option>Finished</option>
|
|
||||||
<option>Client Feedback</option><option>For OT</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Project</label>
|
|
||||||
<input type="text" id="project" placeholder="Project name" value="` + lastProject + `" style="width:100%;" />
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Remarks</label>
|
|
||||||
<textarea id="remarks" placeholder="What did you do?" style="width:100%;min-height:96px;"></textarea>
|
|
||||||
|
|
||||||
<button id="submit" style="margin-top:16px;">Submit</button>
|
|
||||||
<div id="status-msg" style="margin-top:12px;min-height:18px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.getElementById('submit').addEventListener('click', async () => {
|
|
||||||
const payload = {
|
|
||||||
date: document.getElementById('date').value,
|
|
||||||
status: document.getElementById('status').value,
|
|
||||||
project: document.getElementById('project').value.trim(),
|
|
||||||
remarks: document.getElementById('remarks').value.trim(),
|
|
||||||
};
|
|
||||||
const statusEl = document.getElementById('status-msg');
|
|
||||||
statusEl.textContent = 'Copying...';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/daily/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const j = await res.json();
|
|
||||||
if (j.ok) {
|
|
||||||
statusEl.innerHTML = '<span class="ok">Copied to clipboard.</span>';
|
|
||||||
} else {
|
|
||||||
statusEl.innerHTML = '<span class="err">' + (j.error || 'Failed') + '</span>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
`
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
_, _ = w.Write([]byte(Page("/daily", "Daily Update", body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
type dailySubmitReq struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Project string `json:"project"`
|
|
||||||
Remarks string `json:"remarks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func DailySubmit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req dailySubmitReq
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text := formatDailyMessage(req)
|
|
||||||
if err := CopyToClipboard(text); err != nil {
|
|
||||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg := LoadConfig()
|
|
||||||
cfg.DailyUpdate.LastProject = req.Project
|
|
||||||
_ = SaveConfig(cfg)
|
|
||||||
writeJSON(w, map[string]any{"ok": true, "text": text})
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDailyMessage(p dailySubmitReq) string {
|
|
||||||
bullets := []string{}
|
|
||||||
for _, line := range strings.Split(p.Remarks, "\n") {
|
|
||||||
t := strings.TrimRight(line, "\r")
|
|
||||||
t = regexp.MustCompile(`^\s*[-*]\s*`).ReplaceAllString(t, "")
|
|
||||||
t = strings.TrimSpace(t)
|
|
||||||
if t != "" {
|
|
||||||
bullets = append(bullets, "- "+t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines := []string{
|
|
||||||
"Date: " + formatDailyDate(p.Date),
|
|
||||||
"Status: " + p.Status,
|
|
||||||
"Project: " + p.Project,
|
|
||||||
"Remarks:",
|
|
||||||
strings.Join(bullets, "\n"),
|
|
||||||
}
|
|
||||||
if p.Status == "For OT" {
|
|
||||||
lines = append(lines, "@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace")
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDailyDate(iso string) string {
|
|
||||||
t, err := time.Parse("2006-01-02", iso)
|
|
||||||
if err != nil {
|
|
||||||
return iso
|
|
||||||
}
|
|
||||||
return t.Format("01/02/2006")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,29 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
func HomePage(w http.ResponseWriter, r *http.Request) {
|
func HomePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
var items strings.Builder
|
||||||
|
for _, n := range visibleNavItems(cfg.BetaToolsEnabled) {
|
||||||
|
if n.Path == "/" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := n.Label
|
||||||
|
if n.Beta {
|
||||||
|
label += ` <span style="font-size:10px;opacity:0.65;text-transform:uppercase;">Beta</span>`
|
||||||
|
}
|
||||||
|
items.WriteString(`<li><a href="` + n.Path + `" style="color:#9cdcfe;">` + label + `</a> — ` + n.Description + `</li>`)
|
||||||
|
}
|
||||||
|
|
||||||
body := `
|
body := `
|
||||||
<h2>HPL Toolbox</h2>
|
<h2>HPL Toolbox</h2>
|
||||||
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
||||||
<ul style="line-height:1.9;">
|
<ul style="line-height:1.9;">
|
||||||
<li><a href="/plec" style="color:#9cdcfe;">PLEC Upload</a> — Upload HTML to the internal PLEC server</li>
|
` + items.String() + `
|
||||||
<li><a href="/applovin" style="color:#9cdcfe;">AppLovin Demo Upload</a> — Upload to p.applov.in (QR preview)</li>
|
|
||||||
<li><a href="/base64" style="color:#9cdcfe;">Base64 Scanner</a> — Find non-base64 assets in HTML</li>
|
|
||||||
<li><a href="/daily" style="color:#9cdcfe;">Daily Update</a> — Compose & copy a daily status</li>
|
|
||||||
<li><a href="/mobile" style="color:#9cdcfe;">Send To Mobile</a> — Share HTML to a phone via LAN + QR</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
`
|
`
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|||||||
11
standalone/json.go
Normal file
11
standalone/json.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
const SharedCSS = `
|
const SharedCSS = `
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
@@ -17,8 +22,13 @@ const SharedCSS = `
|
|||||||
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
|
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
|
||||||
.topbar a:hover { background: #2a2d2e; }
|
.topbar a:hover { background: #2a2d2e; }
|
||||||
.topbar a.active { background: #094771; color: #fff; }
|
.topbar a.active { background: #094771; color: #fff; }
|
||||||
|
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
|
||||||
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; }
|
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; }
|
||||||
.content { padding: 16px; max-width: 980px; margin: 0 auto; }
|
.content { padding: 16px; max-width: 980px; margin: 0 auto; }
|
||||||
|
.beta-tools-footer { max-width: 980px; margin: 24px auto 16px auto; padding: 12px 16px 0; border-top: 1px solid #333; }
|
||||||
|
.beta-tools-footer button { width: 100%; text-align: left; background: #3a3d41; color: #ddd; }
|
||||||
|
.beta-tools-footer button:hover { background: #45494e; }
|
||||||
|
.beta-note { display: block; margin-top: 3px; opacity: 0.65; font-size: 11px; }
|
||||||
h2 { margin-top: 0; font-weight: 500; }
|
h2 { margin-top: 0; font-weight: 500; }
|
||||||
input[type=text], input[type=date], select, textarea {
|
input[type=text], input[type=date], select, textarea {
|
||||||
box-sizing: border-box; padding: 6px 8px;
|
box-sizing: border-box; padding: 6px 8px;
|
||||||
@@ -42,26 +52,79 @@ const SharedCSS = `
|
|||||||
.hint { font-size: 12px; opacity: 0.7; }
|
.hint { font-size: 12px; opacity: 0.7; }
|
||||||
`
|
`
|
||||||
|
|
||||||
type navItem struct{ Path, Label string }
|
type navItem struct {
|
||||||
|
Path string
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Beta bool
|
||||||
|
}
|
||||||
|
|
||||||
var navItems = []navItem{
|
var navItems = []navItem{
|
||||||
{"/", "Home"},
|
{Path: "/", Label: "Home"},
|
||||||
{"/plec", "PLEC Upload"},
|
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
|
||||||
{"/applovin", "AppLovin Upload"},
|
{Path: "/applovin", Label: "AppLovin Upload", Description: "Upload to p.applov.in (QR preview)"},
|
||||||
{"/base64", "Base64 Scanner"},
|
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
|
||||||
{"/daily", "Daily Update"},
|
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true},
|
||||||
{"/mobile", "Send To Mobile"},
|
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
|
||||||
|
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
||||||
|
out := make([]navItem, 0, len(navItems))
|
||||||
|
for _, n := range navItems {
|
||||||
|
if n.Beta && !betaToolsEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return sortNavItemsForDisplay(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortNavItemsForDisplay(items []navItem) []navItem {
|
||||||
|
out := append([]navItem(nil), items...)
|
||||||
|
for i := 0; i < len(out); i++ {
|
||||||
|
for j := i + 1; j < len(out); j++ {
|
||||||
|
if out[i].Beta && !out[j].Beta {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func betaToolCount() int {
|
||||||
|
count := 0
|
||||||
|
for _, n := range navItems {
|
||||||
|
if n.Beta {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func Page(activePath, title, body string) string {
|
func Page(activePath, title, body string) string {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
betaToolsEnabled := cfg.BetaToolsEnabled
|
||||||
var tabs strings.Builder
|
var tabs strings.Builder
|
||||||
for _, n := range navItems {
|
for _, n := range visibleNavItems(betaToolsEnabled) {
|
||||||
cls := ""
|
cls := ""
|
||||||
if n.Path == activePath {
|
if n.Path == activePath {
|
||||||
cls = " class=\"active\""
|
cls = " class=\"active\""
|
||||||
}
|
}
|
||||||
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + n.Label + `</a>`)
|
label := n.Label
|
||||||
|
if n.Beta {
|
||||||
|
label += `<span class="beta-pill">Beta</span>`
|
||||||
|
}
|
||||||
|
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + label + `</a>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
betaButtonLabel := "Enable Beta Tools"
|
||||||
|
betaButtonNote := "Hidden beta tools: " + strconv.Itoa(betaToolCount())
|
||||||
|
if betaToolsEnabled {
|
||||||
|
betaButtonLabel = "Disable Beta Tools"
|
||||||
|
betaButtonNote = "Beta tools are visible in this standalone launcher."
|
||||||
|
}
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html><head>
|
<html><head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -70,5 +133,43 @@ func Page(activePath, title, body string) string {
|
|||||||
</head><body>
|
</head><body>
|
||||||
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
|
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
|
||||||
<div class="content">` + body + `</div>
|
<div class="content">` + body + `</div>
|
||||||
|
<div class="beta-tools-footer">
|
||||||
|
<button id="betaToolsToggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `<span class="beta-note">` + betaButtonNote + `</span></button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
||||||
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
|
await fetch('/api/betaTools', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: !enabled }),
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body></html>`
|
</body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolAttr(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := LoadConfig()
|
||||||
|
cfg.BetaToolsEnabled = req.Enabled
|
||||||
|
if err := SaveConfig(cfg); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,17 +110,16 @@ func buildMux() *http.ServeMux {
|
|||||||
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)
|
||||||
mux.HandleFunc("GET /daily", DailyPage)
|
mux.HandleFunc("GET /mraid", MraidPage)
|
||||||
mux.HandleFunc("GET /mobile", MobilePage)
|
mux.HandleFunc("GET /mobile", MobilePage)
|
||||||
|
mux.HandleFunc("GET /playworks", PlayworksPage)
|
||||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||||
|
|
||||||
// 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)
|
||||||
// Daily Update
|
|
||||||
mux.HandleFunc("POST /api/daily/submit", DailySubmit)
|
|
||||||
|
|
||||||
// PLEC
|
// PLEC
|
||||||
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
||||||
@@ -137,11 +136,21 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
|
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
|
||||||
mux.HandleFunc("POST /api/base64/scan", Base64Scan)
|
mux.HandleFunc("POST /api/base64/scan", Base64Scan)
|
||||||
|
|
||||||
|
// MRAID Checker
|
||||||
|
mux.HandleFunc("POST /api/mraid/pickFolder", MraidPickFolder)
|
||||||
|
mux.HandleFunc("POST /api/mraid/pickFiles", MraidPickFiles)
|
||||||
|
mux.HandleFunc("POST /api/mraid/scan", MraidScan)
|
||||||
|
|
||||||
// Send To Mobile
|
// Send To Mobile
|
||||||
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
||||||
mux.HandleFunc("POST /api/mobile/start", MobileStart)
|
mux.HandleFunc("POST /api/mobile/start", MobileStart)
|
||||||
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
||||||
|
|
||||||
|
// Playworks Converter
|
||||||
|
mux.HandleFunc("POST /api/playworks/pickSource", PlayworksPickSource)
|
||||||
|
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
||||||
|
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +165,10 @@ func FocusEndpoint(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
user32 = syscall.NewLazyDLL("user32.dll")
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
procShowWindow = user32.NewProc("ShowWindow")
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
procSetForeground = user32.NewProc("SetForegroundWindow")
|
procSetForeground = user32.NewProc("SetForegroundWindow")
|
||||||
procIsIconic = user32.NewProc("IsIconic")
|
procIsIconic = user32.NewProc("IsIconic")
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
376
standalone/mraid.go
Normal file
376
standalone/mraid.go
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mraidIssue struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Line int `json:"line,omitempty"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidResult struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Issues []mraidIssue `json:"issues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidRule struct {
|
||||||
|
ID string
|
||||||
|
Severity string
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
Reference string
|
||||||
|
Test func(mraidContext, mraidRule) *mraidIssue
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidContext struct {
|
||||||
|
HTML string
|
||||||
|
ScriptText string
|
||||||
|
MraidCallLines map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := `
|
||||||
|
<h2>MRAID Checker</h2>
|
||||||
|
<p class="hint">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||||
|
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||||
|
<button id="pickFolder">Browse Folder...</button>
|
||||||
|
<button id="pickFiles">Pick Files...</button>
|
||||||
|
<button id="scan">Scan</button>
|
||||||
|
</div>
|
||||||
|
<div id="selection" class="hint"></div>
|
||||||
|
<div id="status" style="margin-top:8px;"></div>
|
||||||
|
<div id="results" style="margin-top:8px;"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-result { padding:8px 0; border-bottom:1px solid #333; }
|
||||||
|
.file-head { display:flex; gap:8px; align-items:baseline; font-family:Consolas, monospace; font-size:12px; }
|
||||||
|
.mark { width:20px; flex-shrink:0; font-weight:bold; }
|
||||||
|
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||||
|
.file-name { word-break:break-all; }
|
||||||
|
.counts { opacity:0.7; margin-left:auto; white-space:nowrap; }
|
||||||
|
.issues { margin:6px 0 0 28px; padding:0; }
|
||||||
|
.issue { list-style:none; margin:6px 0; }
|
||||||
|
.badge { display:inline-block; min-width:82px; margin-right:6px; padding:1px 5px; border-radius:3px; font-size:11px; text-align:center; }
|
||||||
|
.requirement { color:#ffb4ad; background:rgba(248,81,73,0.18); }
|
||||||
|
.best-practice { color:#ffd580; background:rgba(210,153,34,0.18); }
|
||||||
|
.issue-title { font-weight:600; }
|
||||||
|
.issue-detail { opacity:0.72; margin-top:2px; }
|
||||||
|
.summary { opacity:0.85; margin-bottom:6px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const folderEl = document.getElementById('folder');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
const selectionEl = document.getElementById('selection');
|
||||||
|
let pickedFiles = [];
|
||||||
|
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||||
|
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); }
|
||||||
|
folderEl.addEventListener('input', clearFiles);
|
||||||
|
|
||||||
|
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||||
|
});
|
||||||
|
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.paths && j.paths.length) {
|
||||||
|
pickedFiles = j.paths;
|
||||||
|
folderEl.value = '';
|
||||||
|
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('scan').addEventListener('click', async () => {
|
||||||
|
statusEl.textContent = 'Scanning...';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
let payload;
|
||||||
|
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||||
|
else {
|
||||||
|
const folder = folderEl.value.trim();
|
||||||
|
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||||
|
payload = { folder };
|
||||||
|
}
|
||||||
|
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
||||||
|
const total = j.results.length;
|
||||||
|
const withIssues = j.results.filter(r => !r.ok).length;
|
||||||
|
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||||
|
const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||||
|
const skipped = Number(j.skipped || 0);
|
||||||
|
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
|
||||||
|
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
for (const rr of j.results) {
|
||||||
|
const reqCount = rr.issues.filter(i => i.severity === 'requirement').length;
|
||||||
|
const bpCount = rr.issues.filter(i => i.severity === 'best-practice').length;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row-result';
|
||||||
|
row.innerHTML = '<div class="file-head"><span class="mark ' + (rr.ok ? 'ok' : 'bad') + '">' + (rr.ok ? 'OK' : 'X') + '</span><span class="file-name">' + escapeText(rr.file) + '</span><span class="counts">' + reqCount + ' req / ' + bpCount + ' bp</span></div>';
|
||||||
|
if (!rr.ok) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'issues';
|
||||||
|
for (const i of rr.issues) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'issue';
|
||||||
|
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||||
|
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
row.appendChild(list);
|
||||||
|
}
|
||||||
|
resultsEl.appendChild(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/mraid", "MRAID Checker", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]any{"path": PickFolder("Select folder to scan", "")})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths := PickFiles("Select HTML files", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, true, "")
|
||||||
|
writeJSON(w, map[string]any{"paths": paths})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
Files []string `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []string
|
||||||
|
var baseDir string
|
||||||
|
skipped := 0
|
||||||
|
if len(req.Files) > 0 {
|
||||||
|
var existing []string
|
||||||
|
for _, f := range req.Files {
|
||||||
|
if fileExists(f) {
|
||||||
|
existing = append(existing, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range existing {
|
||||||
|
if isSupportedMraidBuild(f) {
|
||||||
|
targets = append(targets, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipped = len(existing) - len(targets)
|
||||||
|
if len(targets) > 0 {
|
||||||
|
baseDir = commonBaseDir(targets)
|
||||||
|
}
|
||||||
|
} else if req.Folder != "" && fileExists(req.Folder) {
|
||||||
|
all := collectHTMLFiles(req.Folder)
|
||||||
|
for _, f := range all {
|
||||||
|
if isSupportedMraidBuild(f) {
|
||||||
|
targets = append(targets, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipped = len(all) - len(targets)
|
||||||
|
baseDir = req.Folder
|
||||||
|
} else {
|
||||||
|
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "error": "No AppLovin, ironSource, or Unity HTML builds were found."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []mraidResult{}
|
||||||
|
for _, file := range targets {
|
||||||
|
data, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
issues := checkMraid(string(data))
|
||||||
|
display := file
|
||||||
|
if baseDir != "" {
|
||||||
|
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
|
||||||
|
display = rel
|
||||||
|
} else {
|
||||||
|
display = filepath.Base(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"results": results, "skipped": skipped})
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||||
|
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||||
|
mraidBuildNameRx = regexp.MustCompile(`(?i)(^|[/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([/._ -]|$)`)
|
||||||
|
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||||
|
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||||
|
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||||
|
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
|
||||||
|
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
|
||||||
|
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const mraidReference = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
|
||||||
|
|
||||||
|
func isSupportedMraidBuild(filePath string) bool {
|
||||||
|
normalized := strings.ReplaceAll(strings.ToLower(filePath), `\`, `/`)
|
||||||
|
return mraidBuildNameRx.MatchString(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMraid(htmlSrc string) []mraidIssue {
|
||||||
|
ctx := mraidContext{
|
||||||
|
HTML: htmlSrc,
|
||||||
|
ScriptText: extractMraidScriptText(htmlSrc),
|
||||||
|
MraidCallLines: collectMraidCallLines(htmlSrc),
|
||||||
|
}
|
||||||
|
var out []mraidIssue
|
||||||
|
for _, rule := range mraidRules {
|
||||||
|
if found := rule.Test(ctx, rule); found != nil {
|
||||||
|
out = append(out, *found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []mraidIssue{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMraidScriptText(htmlSrc string) string {
|
||||||
|
var parts []string
|
||||||
|
for _, m := range mraidScriptRx.FindAllStringSubmatch(htmlSrc, -1) {
|
||||||
|
parts = append(parts, m[1])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectMraidCallLines(htmlSrc string) map[string]int {
|
||||||
|
out := map[string]int{}
|
||||||
|
for idx, line := range strings.Split(htmlSrc, "\n") {
|
||||||
|
for _, m := range mraidCallRx.FindAllStringSubmatch(line, -1) {
|
||||||
|
if _, ok := out[m[1]]; !ok {
|
||||||
|
out[m[1]] = idx + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
|
||||||
|
func mraidHasCall(ctx mraidContext, method string) bool {
|
||||||
|
_, ok := ctx.MraidCallLines[method]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }
|
||||||
|
|
||||||
|
func mraidHasEventListener(ctx mraidContext, eventName string) bool {
|
||||||
|
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
|
||||||
|
return rx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasSupportCheck(ctx mraidContext, feature string) bool {
|
||||||
|
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*supports\s*\(\s*['"]` + regexp.QuoteMeta(feature) + `['"]`)
|
||||||
|
return rx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasReadyGate(ctx mraidContext) bool {
|
||||||
|
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||||
|
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
|
||||||
|
if line > 0 {
|
||||||
|
found.Line = line
|
||||||
|
}
|
||||||
|
return &found
|
||||||
|
}
|
||||||
|
|
||||||
|
var mraidRules = []mraidRule{
|
||||||
|
{ID: "mraid-reference", Severity: "requirement", Title: "MRAID API reference not found", Detail: "Playable HTML should use the injected mraid object when it depends on an MRAID container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasReference(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "ready-gate", Severity: "requirement", Title: "MRAID ready state is not guarded", Detail: "Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "error-listener", Severity: "best-practice", Title: "No MRAID error listener found", Detail: "Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "viewability", Severity: "best-practice", Title: "Viewability handling not found", Detail: "Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange") || mraidHasEventListener(ctx, "exposureChange") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "open-api", Severity: "requirement", Title: "Browser navigation used instead of mraid.open", Detail: "Use mraid.open(url) for click-through navigation inside an MRAID ad container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if (mraidWindowOpenRx.MatchString(ctx.ScriptText) || mraidLocationNavRx.MatchString(ctx.ScriptText)) && !mraidHasCall(ctx, "open") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "close-api", Severity: "best-practice", Title: "No mraid.close call found", Detail: "If the creative renders its own close control, wire that control to mraid.close().", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without supports check", Detail: "Check mraid.supports(\"expand\") before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "expand") && !mraidHasSupportCheck(ctx, "expand") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without supports check", Detail: "Check mraid.supports(\"resize\") before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "resize") && !mraidHasSupportCheck(ctx, "resize") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without supports check", Detail: "Check mraid.supports(\"orientation\") before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasSupportCheck(ctx, "orientation") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "size-change", Severity: "best-practice", Title: "No sizeChange listener found", Detail: "Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
depends := mraidHasCall(ctx, "getMaxSize") || mraidHasCall(ctx, "getScreenSize") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "setOrientationProperties")
|
||||||
|
if depends && !mraidHasEventListener(ctx, "sizeChange") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "state-change", Severity: "best-practice", Title: "No stateChange listener found", Detail: "Listen for stateChange when using expand, resize, close, or other state-changing container behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
changes := mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "close")
|
||||||
|
if changes && !mraidHasEventListener(ctx, "stateChange") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
@@ -27,16 +27,12 @@ type SendToMobileConfig struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyUpdateConfig struct {
|
|
||||||
LastProject string `json:"lastProject"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
||||||
DailyUpdate DailyUpdateConfig `json:"dailyUpdate"`
|
BetaToolsEnabled bool `json:"betaToolsEnabled"`
|
||||||
LastPickDir string `json:"lastPickDir,omitempty"`
|
LastPickDir string `json:"lastPickDir,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configMu sync.Mutex
|
var configMu sync.Mutex
|
||||||
@@ -68,7 +64,6 @@ func defaultConfig() AppConfig {
|
|||||||
},
|
},
|
||||||
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
||||||
SendToMobile: SendToMobileConfig{Port: 0},
|
SendToMobile: SendToMobileConfig{Port: 0},
|
||||||
DailyUpdate: DailyUpdateConfig{LastProject: ""},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
385
standalone/playworks.go
Normal file
385
standalone/playworks.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type playworksConvertOptions struct {
|
||||||
|
SourcePath string `json:"sourcePath"`
|
||||||
|
OutputDir string `json:"outputDir"`
|
||||||
|
Networks []string `json:"networks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type playworksNetworkResult struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
File string `json:"file"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := `
|
||||||
|
<h2>Playworks Converter</h2>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section { margin-bottom:14px; }
|
||||||
|
.section-label { font-size:11px; text-transform:uppercase; letter-spacing:0.5px; opacity:0.65; margin-bottom:5px; }
|
||||||
|
.row { display:flex; gap:6px; align-items:center; }
|
||||||
|
.net-grid { display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.net-row { display:flex; align-items:center; gap:8px; padding:3px 0; cursor:pointer; user-select:none; }
|
||||||
|
.net-label { min-width:100px; font-weight:500; }
|
||||||
|
.net-tag { font-size:10px; opacity:0.6; font-weight:400; }
|
||||||
|
.net-note { font-size:11px; opacity:0.6; }
|
||||||
|
.toggle-row { display:flex; gap:8px; margin-bottom:6px; }
|
||||||
|
.toggle-row button { font-size:11px; padding:2px 8px; }
|
||||||
|
.actions { display:flex; gap:10px; align-items:center; margin-top:16px; }
|
||||||
|
#status { margin-top:12px; font-size:12px; opacity:0.75; min-height:16px; }
|
||||||
|
.results { margin-top:10px; display:flex; flex-direction:column; gap:3px; }
|
||||||
|
.res-row { display:flex; align-items:center; gap:8px; font-size:12px; font-family:Consolas, monospace; }
|
||||||
|
.mark { font-weight:bold; width:14px; flex-shrink:0; }
|
||||||
|
.ok { color:#3fb950; }
|
||||||
|
.bad { color:#f85149; }
|
||||||
|
.res-net { min-width:70px; font-weight:500; }
|
||||||
|
.res-file { opacity:0.65; word-break:break-all; }
|
||||||
|
.res-err { color:#f85149; opacity:0.9; }
|
||||||
|
hr { border:none; border-top:1px solid #333; margin:14px 0; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Source HTML (Playworks export)</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" />
|
||||||
|
<button id="pickSrc">Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Output Folder</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
|
||||||
|
<button id="pickOut">Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Networks to generate</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<button id="selectAll">All</button>
|
||||||
|
<button id="selectNone">None</button>
|
||||||
|
</div>
|
||||||
|
<div class="net-grid">
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="is" checked /><span class="net-label">Ironsource <span class="net-tag">is</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="un" checked /><span class="net-label">Unity <span class="net-tag">un</span></span><span class="net-note">HTML (window.top to self)</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="fb" checked /><span class="net-label">Facebook <span class="net-tag">fb</span></span><span class="net-note">HTML</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="gg" checked /><span class="net-label">Google Ads <span class="net-tag">gg</span></span><span class="net-note">ZIP + exitapi.js in head</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" checked /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="convert" disabled>Convert</button>
|
||||||
|
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
<div class="results" id="results"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let outputDir = '';
|
||||||
|
const srcEl = document.getElementById('src');
|
||||||
|
const outEl = document.getElementById('outDir');
|
||||||
|
const convertBtn = document.getElementById('convert');
|
||||||
|
const openOutBtn = document.getElementById('openOut');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
|
||||||
|
function updateConvertBtn() {
|
||||||
|
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||||
|
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pickSrc').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/playworks/pickSource', { method:'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) {
|
||||||
|
srcEl.value = j.path;
|
||||||
|
if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; }
|
||||||
|
updateConvertBtn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('pickOut').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/playworks/pickOutput', { method:'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
|
||||||
|
});
|
||||||
|
document.getElementById('selectAll').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.getElementById('selectNone').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||||
|
outEl.addEventListener('input', updateConvertBtn);
|
||||||
|
openOutBtn.addEventListener('click', () => {
|
||||||
|
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('convert').addEventListener('click', async () => {
|
||||||
|
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
|
||||||
|
statusEl.textContent = 'Converting...';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
openOutBtn.style.display = 'none';
|
||||||
|
convertBtn.disabled = true;
|
||||||
|
const res = await fetch('/api/playworks/convert', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, networks })
|
||||||
|
});
|
||||||
|
const msg = await res.json();
|
||||||
|
convertBtn.disabled = false;
|
||||||
|
updateConvertBtn();
|
||||||
|
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||||
|
const results = msg.results || [];
|
||||||
|
const ok = results.filter(r => r.ok).length;
|
||||||
|
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
for (const r of results) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'res-row';
|
||||||
|
const mark = document.createElement('span');
|
||||||
|
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||||
|
mark.textContent = r.ok ? '✓' : '✗';
|
||||||
|
const net = document.createElement('span');
|
||||||
|
net.className = 'res-net';
|
||||||
|
net.textContent = r.network;
|
||||||
|
const detail = document.createElement('span');
|
||||||
|
if (r.ok) {
|
||||||
|
detail.className = 'res-file';
|
||||||
|
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||||
|
} else {
|
||||||
|
detail.className = 'res-err';
|
||||||
|
detail.textContent = r.error || 'Unknown error';
|
||||||
|
}
|
||||||
|
row.append(mark, net, detail);
|
||||||
|
resultsEl.appendChild(row);
|
||||||
|
}
|
||||||
|
if (ok > 0) { outputDir = outEl.value; openOutBtn.style.display = ''; }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/playworks", "Playworks Converter", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
picked := PickFiles("Select Playworks HTML", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
|
||||||
|
if len(picked) == 0 {
|
||||||
|
writeJSON(w, map[string]any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0])})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]any{"path": PickFolder("Select Output Folder", "")})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var opts playworksConvertOptions
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !fileExists(opts.SourcePath) {
|
||||||
|
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(opts.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []playworksNetworkResult{}
|
||||||
|
for _, network := range opts.Networks {
|
||||||
|
filePath, err := convertPlayworksNetwork(string(data), opts, network)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$`)
|
||||||
|
|
||||||
|
func playworksSourceBaseName(sourcePath string) string {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
|
||||||
|
return playworksSuffixRx.ReplaceAllString(base, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
|
||||||
|
transformed := transformPlayworksHTML(htmlSrc, network)
|
||||||
|
baseName := playworksSourceBaseName(opts.SourcePath)
|
||||||
|
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
|
||||||
|
|
||||||
|
if playworksNeedsZip(network) {
|
||||||
|
zipPath := filepath.Join(opts.OutputDir, fmt.Sprintf("%s_%s.zip", baseName, network))
|
||||||
|
if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return zipPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outPath := filepath.Join(opts.OutputDir, htmlFileName)
|
||||||
|
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return outPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func playworksNeedsZip(network string) bool {
|
||||||
|
return network == "gg" || network == "vu" || network == "mtg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformPlayworksHTML(htmlSrc, network string) string {
|
||||||
|
result := htmlSrc
|
||||||
|
headInject := buildPlayworksLifecycleScript()
|
||||||
|
if network == "al" || network == "is" {
|
||||||
|
headInject += `<script src="mraid.js"></script>`
|
||||||
|
} else if network == "gg" {
|
||||||
|
headInject += `<script src="exitapi.js"></script>`
|
||||||
|
}
|
||||||
|
result = strings.Replace(result, "</head>", headInject+"\n</head>", 1)
|
||||||
|
|
||||||
|
if network == "vu" {
|
||||||
|
result = strings.Replace(result, "<body>", `<body>
|
||||||
|
<script>window.__VUNGLE__=true;</script>`, 1)
|
||||||
|
} else if network == "mtg" {
|
||||||
|
result = strings.Replace(result, "<body>", `<body onload="gameReady()">`, 1)
|
||||||
|
} else if network == "tt" {
|
||||||
|
result = strings.Replace(result, "<body>", `<body>
|
||||||
|
<script>window.__TIKTOK__=true;</script>`, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = replacePlayworksCTAScript(result, network)
|
||||||
|
if network == "un" {
|
||||||
|
result = strings.ReplaceAll(result, "window.top", "window.self")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePlayworksCTAScript(htmlSrc, network string) string {
|
||||||
|
marker := "Luna.Unity.Playable.InstallFullGame=function"
|
||||||
|
markerIdx := strings.LastIndex(htmlSrc, marker)
|
||||||
|
if markerIdx == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptOpenIdx := strings.LastIndex(htmlSrc[:markerIdx], "<script>")
|
||||||
|
if scriptOpenIdx == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptCloseRel := strings.Index(htmlSrc[markerIdx:], "</script>")
|
||||||
|
if scriptCloseRel == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptCloseIdx := markerIdx + scriptCloseRel
|
||||||
|
return htmlSrc[:scriptOpenIdx] + buildPlayworksConsoleRestoreScript() + buildPlayworksCTAScript(network) + htmlSrc[scriptCloseIdx+len("</script>"):]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksConsoleRestoreScript() string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
`<script>(function(){`,
|
||||||
|
`try{`,
|
||||||
|
`var f=document.createElement("iframe");`,
|
||||||
|
`f.style.display="none";`,
|
||||||
|
`document.documentElement.appendChild(f);`,
|
||||||
|
`var nc=f.contentWindow.console;`,
|
||||||
|
`document.documentElement.removeChild(f);`,
|
||||||
|
`var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];`,
|
||||||
|
`methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});`,
|
||||||
|
`}catch(e){}`,
|
||||||
|
`})();</script>`,
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksLifecycleScript() string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
`<script>(function(){`,
|
||||||
|
`if(typeof window.gameReady!=="function")window.gameReady=function(){};`,
|
||||||
|
`if(typeof window.gameStart!=="function")window.gameStart=function(){};`,
|
||||||
|
`if(typeof window.gameEnd!=="function")window.gameEnd=function(){};`,
|
||||||
|
`if(typeof window.gameClose!=="function")window.gameClose=function(){};`,
|
||||||
|
`var _grOnce=false,_gsOnce=false;`,
|
||||||
|
`window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});`,
|
||||||
|
`window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});`,
|
||||||
|
`window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});`,
|
||||||
|
`})();</script>`,
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksCTAScript(network string) string {
|
||||||
|
urlSetup := `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`
|
||||||
|
closeCall := `try{window.gameClose();}catch(e){}`
|
||||||
|
ctaLogic := closeCall + `window.open(o,"_blank");`
|
||||||
|
switch network {
|
||||||
|
case "al", "is":
|
||||||
|
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`
|
||||||
|
case "un":
|
||||||
|
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`
|
||||||
|
case "fb":
|
||||||
|
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`
|
||||||
|
case "gg":
|
||||||
|
ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`
|
||||||
|
case "mo":
|
||||||
|
ctaLogic = closeCall + `if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`
|
||||||
|
case "vu":
|
||||||
|
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
|
||||||
|
case "mtg":
|
||||||
|
ctaLogic = closeCall + `if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`
|
||||||
|
case "tt":
|
||||||
|
ctaLogic = closeCall + `if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`
|
||||||
|
}
|
||||||
|
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),` + urlSetup + ctaLogic + `}}))}));</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSingleFileZip(data []byte, nameInZip, destZipPath string) error {
|
||||||
|
_ = os.Remove(destZipPath)
|
||||||
|
out, err := os.Create(destZipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(out)
|
||||||
|
entry, err := zw.Create(nameInZip)
|
||||||
|
if err != nil {
|
||||||
|
_ = zw.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := entry.Write(data); err != nil {
|
||||||
|
_ = zw.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zw.Close()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user