diff --git a/dist/hpl-toolbox-0.1.5.exe b/dist/hpl-toolbox-0.1.5.exe index 697b5a4..e187e95 100644 Binary files a/dist/hpl-toolbox-0.1.5.exe and b/dist/hpl-toolbox-0.1.5.exe differ diff --git a/dist/hpl-toolbox-0.1.5.vsix b/dist/hpl-toolbox-0.1.5.vsix index 0a2fa56..7d132e8 100644 Binary files a/dist/hpl-toolbox-0.1.5.vsix and b/dist/hpl-toolbox-0.1.5.vsix differ diff --git a/package.json b/package.json index 1ebb725..41438ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "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", "publisher": "hesukastro", "license": "UNLICENSED", @@ -34,10 +34,6 @@ "command": "hplToolbox.openMraidChecker", "title": "HPL Toolbox: Open MRAID Checker" }, - { - "command": "hplToolbox.openDailyUpdate", - "title": "HPL Toolbox: Open Daily Update" - }, { "command": "hplToolbox.openSendToMobile", "title": "HPL Toolbox: Open Send To Mobile" diff --git a/src/extension.ts b/src/extension.ts index dda811a..561a058 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,6 @@ import { openPlecUpload } from './tools/plecUpload'; import { openApplovinUpload } from './tools/applovinUpload'; import { openBase64Scanner } from './tools/base64Scanner'; import { openMraidChecker } from './tools/mraidChecker'; -import { openDailyUpdate } from './tools/dailyUpdate'; import { openSendToMobile } from './tools/sendToMobile'; import { openPlayworksConverter } from './tools/playworksConverter'; @@ -14,7 +13,6 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)), vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(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.openPlayworksConverter', () => openPlayworksConverter(context)), vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context)) diff --git a/src/launcherView.ts b/src/launcherView.ts index 0649706..2ba0936 100644 --- a/src/launcherView.ts +++ b/src/launcherView.ts @@ -31,11 +31,6 @@ const TOOLS: ToolDefinition[] = [ description: 'Check MRAID requirements and best practices', beta: true, }, - { - command: 'hplToolbox.openDailyUpdate', - title: 'Daily Update', - description: 'Compose & copy a daily status', - }, { command: 'hplToolbox.openSendToMobile', title: 'Send To Mobile', @@ -45,11 +40,12 @@ const TOOLS: ToolDefinition[] = [ command: 'hplToolbox.openPlayworksConverter', title: 'Playworks Converter', description: 'Convert Playworks HTML to per-network variants', + beta: true, }, ]; export class LauncherViewProvider implements vscode.WebviewViewProvider { - constructor(private readonly context: vscode.ExtensionContext) {} + constructor(private readonly context: vscode.ExtensionContext) { } resolveWebviewView(view: vscode.WebviewView) { view.webview.options = { enableScripts: true }; @@ -68,7 +64,7 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider { } 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 toolButtons = visibleTools.map(renderToolButton).join('\n'); return ` @@ -155,6 +151,10 @@ ${toolButtons} `; } +function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] { + return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta))); +} + function renderToolButton(tool: ToolDefinition): string { return ` - - - -`; -} diff --git a/standalone/daily.go b/standalone/daily.go deleted file mode 100644 index 3ac3e3f..0000000 --- a/standalone/daily.go +++ /dev/null @@ -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 := ` -

Daily Update

-
Submit copies the formatted text to your clipboard.
- -
- - - - - - - - - - - - - -
-
- - -` - 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) -} diff --git a/standalone/home.go b/standalone/home.go index bdf5d53..26f475d 100644 --- a/standalone/home.go +++ b/standalone/home.go @@ -1,17 +1,29 @@ package main -import "net/http" +import ( + "net/http" + "strings" +) 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 += ` Beta` + } + items.WriteString(`
  • ` + label + ` — ` + n.Description + `
  • `) + } + body := `

    HPL Toolbox

    Standalone build. Pick a tool from the top bar.

    ` w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/standalone/json.go b/standalone/json.go new file mode 100644 index 0000000..c82b62d --- /dev/null +++ b/standalone/json.go @@ -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) +} diff --git a/standalone/layout.go b/standalone/layout.go index 10f2a73..290214b 100644 --- a/standalone/layout.go +++ b/standalone/layout.go @@ -1,6 +1,11 @@ package main -import "strings" +import ( + "encoding/json" + "net/http" + "strconv" + "strings" +) const SharedCSS = ` :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:hover { background: #2a2d2e; } .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; } .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; } input[type=text], input[type=date], select, textarea { box-sizing: border-box; padding: 6px 8px; @@ -42,26 +52,79 @@ const SharedCSS = ` .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{ - {"/", "Home"}, - {"/plec", "PLEC Upload"}, - {"/applovin", "AppLovin Upload"}, - {"/base64", "Base64 Scanner"}, - {"/daily", "Daily Update"}, - {"/mobile", "Send To Mobile"}, + {Path: "/", Label: "Home"}, + {Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"}, + {Path: "/applovin", Label: "AppLovin Upload", Description: "Upload to p.applov.in (QR preview)"}, + {Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"}, + {Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true}, + {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 { + cfg := LoadConfig() + betaToolsEnabled := cfg.BetaToolsEnabled var tabs strings.Builder - for _, n := range navItems { + for _, n := range visibleNavItems(betaToolsEnabled) { cls := "" if n.Path == activePath { cls = " class=\"active\"" } - tabs.WriteString(`` + n.Label + ``) + label := n.Label + if n.Beta { + label += `Beta` + } + tabs.WriteString(`` + label + ``) } + + 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 ` @@ -70,5 +133,43 @@ func Page(activePath, title, body string) string {
    ` + tabs.String() + `v` + AppVersion + `
    ` + body + `
    + + ` } + +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}) +} diff --git a/standalone/main.go b/standalone/main.go index d36d857..6438e8a 100644 --- a/standalone/main.go +++ b/standalone/main.go @@ -110,17 +110,16 @@ func buildMux() *http.ServeMux { mux.HandleFunc("GET /plec", PlecPage) mux.HandleFunc("GET /applovin", ApplovinPage) mux.HandleFunc("GET /base64", Base64Page) - mux.HandleFunc("GET /daily", DailyPage) + mux.HandleFunc("GET /mraid", MraidPage) mux.HandleFunc("GET /mobile", MobilePage) + mux.HandleFunc("GET /playworks", PlayworksPage) mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript) // Shared API mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint) mux.HandleFunc("POST /api/open", OpenEndpoint) mux.HandleFunc("POST /api/focus", FocusEndpoint) - - // Daily Update - mux.HandleFunc("POST /api/daily/submit", DailySubmit) + mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint) // PLEC 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/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 mux.HandleFunc("POST /api/mobile/pick", MobilePick) mux.HandleFunc("POST /api/mobile/start", MobileStart) 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 } @@ -156,10 +165,10 @@ func FocusEndpoint(w http.ResponseWriter, r *http.Request) { } var ( - user32 = syscall.NewLazyDLL("user32.dll") - procShowWindow = user32.NewProc("ShowWindow") - procSetForeground = user32.NewProc("SetForegroundWindow") - procIsIconic = user32.NewProc("IsIconic") + user32 = syscall.NewLazyDLL("user32.dll") + procShowWindow = user32.NewProc("ShowWindow") + procSetForeground = user32.NewProc("SetForegroundWindow") + procIsIconic = user32.NewProc("IsIconic") ) const ( diff --git a/standalone/mraid.go b/standalone/mraid.go new file mode 100644 index 0000000..5d34f33 --- /dev/null +++ b/standalone/mraid.go @@ -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 := ` +

    MRAID Checker

    +

    Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.

    +
    + + + + +
    +
    +
    +
    + + + + +` + 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)]*>(.*?)`) + 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 + }}, +} diff --git a/standalone/platform.go b/standalone/platform.go index da90e60..fdabbd0 100644 --- a/standalone/platform.go +++ b/standalone/platform.go @@ -27,16 +27,12 @@ type SendToMobileConfig struct { Port int `json:"port"` } -type DailyUpdateConfig struct { - LastProject string `json:"lastProject"` -} - type AppConfig struct { - Plec PlecConfig `json:"plec"` - Applovin ApplovinConfig `json:"applovin"` - SendToMobile SendToMobileConfig `json:"sendToMobile"` - DailyUpdate DailyUpdateConfig `json:"dailyUpdate"` - LastPickDir string `json:"lastPickDir,omitempty"` + Plec PlecConfig `json:"plec"` + Applovin ApplovinConfig `json:"applovin"` + SendToMobile SendToMobileConfig `json:"sendToMobile"` + BetaToolsEnabled bool `json:"betaToolsEnabled"` + LastPickDir string `json:"lastPickDir,omitempty"` } var configMu sync.Mutex @@ -68,7 +64,6 @@ func defaultConfig() AppConfig { }, Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000}, SendToMobile: SendToMobileConfig{Port: 0}, - DailyUpdate: DailyUpdateConfig{LastProject: ""}, } } diff --git a/standalone/playworks.go b/standalone/playworks.go new file mode 100644 index 0000000..8894457 --- /dev/null +++ b/standalone/playworks.go @@ -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 := ` +

    Playworks Converter

    + + + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + +
    + + +
    +
    + + + + + + + + + +
    +
    + +
    + + +
    + +
    +
    + + +` + 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 += `` + } else if network == "gg" { + headInject += `` + } + result = strings.Replace(result, "", headInject+"\n", 1) + + if network == "vu" { + result = strings.Replace(result, "", ` +`, 1) + } else if network == "mtg" { + result = strings.Replace(result, "", ``, 1) + } else if network == "tt" { + result = strings.Replace(result, "", ` +`, 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], "") + if scriptCloseRel == -1 { + return htmlSrc + } + scriptCloseIdx := markerIdx + scriptCloseRel + return htmlSrc[:scriptOpenIdx] + buildPlayworksConsoleRestoreScript() + buildPlayworksCTAScript(network) + htmlSrc[scriptCloseIdx+len(""):] +} + +func buildPlayworksConsoleRestoreScript() string { + return strings.Join([]string{ + ``, + }, "") +} + +func buildPlayworksLifecycleScript() string { + return strings.Join([]string{ + ``, + }, "") +} + +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 `` +} + +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() +}