diff --git a/package.json b/package.json index 7ab14d2..37770b7 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, Daily Update. Local HTML Host.", + "description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker, Daily Update. Local HTML Host.", "version": "0.1.5", "publisher": "hesukastro", "license": "UNLICENSED", @@ -30,6 +30,10 @@ "command": "hplToolbox.openBase64Scanner", "title": "HPL Toolbox: Open Base64 Scanner" }, + { + "command": "hplToolbox.openMraidChecker", + "title": "HPL Toolbox: Open MRAID Checker" + }, { "command": "hplToolbox.openDailyUpdate", "title": "HPL Toolbox: Open Daily Update" diff --git a/src/extension.ts b/src/extension.ts index bfbf484..b491e32 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import { LauncherViewProvider } from './launcherView'; 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'; @@ -11,6 +12,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)), 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.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context)) diff --git a/src/launcherView.ts b/src/launcherView.ts index 4de1324..71a76c2 100644 --- a/src/launcherView.ts +++ b/src/launcherView.ts @@ -1,28 +1,86 @@ import * as vscode from 'vscode'; +const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled'; + +interface ToolDefinition { + command: string; + title: string; + description: string; + beta?: boolean; +} + +const TOOLS: ToolDefinition[] = [ + { + command: 'hplToolbox.openPlecUpload', + title: 'PLEC Upload', + description: 'Upload HTML to internal PLEC server', + }, + { + command: 'hplToolbox.openApplovinUpload', + title: 'AppLovin Demo Upload', + description: 'Upload to p.applov.in (QR preview)', + }, + { + command: 'hplToolbox.openBase64Scanner', + title: 'Base64 Scanner', + description: 'Find non-base64 assets in HTML', + }, + { + command: 'hplToolbox.openMraidChecker', + title: 'MRAID Checker', + 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', + description: 'Share HTML to a phone via LAN + QR', + }, +]; + export class LauncherViewProvider implements vscode.WebviewViewProvider { constructor(private readonly context: vscode.ExtensionContext) {} resolveWebviewView(view: vscode.WebviewView) { view.webview.options = { enableScripts: true }; const version = this.context.extension.packageJSON.version as string; - view.webview.html = getHtml(version); - view.webview.onDidReceiveMessage((msg) => { + const betaToolsEnabled = this.context.globalState.get(BETA_TOOLS_ENABLED_KEY, false); + view.webview.html = getHtml(version, betaToolsEnabled); + view.webview.onDidReceiveMessage(async (msg) => { if (msg?.type === 'open' && typeof msg.command === 'string') { vscode.commands.executeCommand(msg.command); + } else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') { + await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled); + view.webview.html = getHtml(version, msg.enabled); } }); } } -function getHtml(version: string): string { +function getHtml(version: string, betaToolsEnabled: boolean): string { + const visibleTools = 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 `

HPL Toolbox ${version}

- - - - - +
+${toolButtons} +
+
+ +
`; } + +function renderToolButton(tool: ToolDefinition): string { + return ` `; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ch] ?? ch)); +} diff --git a/src/tools/mraidChecker.ts b/src/tools/mraidChecker.ts new file mode 100644 index 0000000..bd38a78 --- /dev/null +++ b/src/tools/mraidChecker.ts @@ -0,0 +1,496 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { singletonPanel } from './shared'; + +const store: { panel: vscode.WebviewPanel | null } = { panel: null }; + +type Severity = 'requirement' | 'best-practice'; + +interface MraidIssue { + id: string; + severity: Severity; + title: string; + detail: string; + line?: number; + reference: string; +} + +interface MraidResult { + file: string; + ok: boolean; + issues: MraidIssue[]; +} + +interface MraidRule { + id: string; + severity: Severity; + title: string; + detail: string; + reference: string; + test: (ctx: ScanContext) => MraidIssue | null; +} + +interface ScanContext { + html: string; + scriptText: string; + lowerHtml: string; + lowerScript: string; + mraidCallLines: Map; +} + +export function openMraidChecker(_context: vscode.ExtensionContext) { + const { panel, isNew } = singletonPanel(store, 'hplToolbox.mraidChecker', 'MRAID Checker'); + if (!isNew) return; + + panel.webview.html = getHtml(); + panel.webview.onDidReceiveMessage(async (msg) => { + if (msg.type === 'ready') { + const ws = vscode.workspace.workspaceFolders?.[0]; + if (ws) { + const distPath = path.join(ws.uri.fsPath, 'dist'); + if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) { + panel.webview.postMessage({ type: 'folderPicked', path: distPath }); + } + } + } else if (msg.type === 'pickFolder') { + const picked = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Select Folder', + }); + if (picked && picked[0]) { + panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath }); + } + } else if (msg.type === 'pickFiles') { + const picked = await vscode.window.showOpenDialog({ + canSelectFolders: false, + canSelectFiles: true, + canSelectMany: true, + openLabel: 'Select Files', + filters: { 'HTML': ['html', 'htm'] }, + }); + if (picked && picked.length) { + panel.webview.postMessage({ type: 'filesPicked', paths: picked.map(u => u.fsPath) }); + } + } else if (msg.type === 'scan') { + const folder: string | undefined = msg.folder; + const files: string[] | undefined = msg.files; + let targets: string[] = []; + let baseDir = ''; + let skipped = 0; + if (files && files.length) { + const existing = files.filter(f => fs.existsSync(f)); + targets = existing.filter(isSupportedMraidBuild); + skipped = existing.length - targets.length; + baseDir = targets.length ? commonBaseDir(targets) : ''; + } else if (folder && fs.existsSync(folder)) { + const allHtmlFiles = await collectHtmlFiles(folder); + targets = allHtmlFiles.filter(isSupportedMraidBuild); + skipped = allHtmlFiles.length - targets.length; + baseDir = folder; + } else { + panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' }); + return; + } + + if (targets.length === 0) { + panel.webview.postMessage({ + type: 'results', + results: [], + skipped, + error: 'No AppLovin, ironSource, or Unity HTML builds were found.', + }); + return; + } + + const results: MraidResult[] = []; + for (const file of targets) { + try { + const content = fs.readFileSync(file, 'utf8'); + const issues = checkMraid(content); + const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file; + results.push({ file: display, ok: issues.length === 0, issues }); + } catch { + // Skip unreadable files, matching the scanner pattern used elsewhere. + } + } + panel.webview.postMessage({ type: 'results', results, skipped }); + } + }); +} + +function commonBaseDir(files: string[]): string { + if (files.length === 0) return ''; + if (files.length === 1) return path.dirname(files[0]); + const split = files.map(f => path.dirname(f).split(path.sep)); + const first = split[0]; + let i = 0; + while (i < first.length && split.every(parts => parts[i] === first[i])) i++; + return first.slice(0, i).join(path.sep); +} + +async function collectHtmlFiles(root: string): Promise { + const out: string[] = []; + const stack: string[] = [root]; + while (stack.length) { + const dir = stack.pop()!; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (e.name === 'node_modules' || e.name === '.git') continue; + stack.push(full); + } else if (e.isFile() && /\.html?$/i.test(e.name)) { + out.push(full); + } + } + } + return out; +} + +function checkMraid(html: string): MraidIssue[] { + const scriptText = extractScriptText(html); + const ctx: ScanContext = { + html, + scriptText, + lowerHtml: html.toLowerCase(), + lowerScript: scriptText.toLowerCase(), + mraidCallLines: collectMraidCallLines(html), + }; + + return MRAID_RULES + .map(rule => rule.test(ctx)) + .filter((issue): issue is MraidIssue => issue !== null); +} + +function extractScriptText(html: string): string { + const scripts: string[] = []; + const scriptRegex = /]*>([\s\S]*?)<\/script>/gi; + let m: RegExpExecArray | null; + while ((m = scriptRegex.exec(html)) !== null) { + scripts.push(m[1]); + } + return scripts.join('\n'); +} + +function collectMraidCallLines(html: string): Map { + const calls = new Map(); + const lines = html.split(/\r?\n/); + const callRegex = /\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(/g; + for (let i = 0; i < lines.length; i++) { + let m: RegExpExecArray | null; + while ((m = callRegex.exec(lines[i])) !== null) { + if (!calls.has(m[1])) calls.set(m[1], i + 1); + } + } + return calls; +} + +function isSupportedMraidBuild(filePath: string): boolean { + const normalized = filePath.toLowerCase().replace(/\\/g, '/'); + return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized); +} + +function hasMraidReference(ctx: ScanContext): boolean { + return /\bmraid\b/i.test(ctx.html); +} + +function hasMraidCall(ctx: ScanContext, method: string): boolean { + return ctx.mraidCallLines.has(method); +} + +function mraidLine(ctx: ScanContext, method: string): number | undefined { + return ctx.mraidCallLines.get(method); +} + +function issue(rule: MraidRule, line?: number): MraidIssue { + return { + id: rule.id, + severity: rule.severity, + title: rule.title, + detail: rule.detail, + line, + reference: rule.reference, + }; +} + +function hasReadyGate(ctx: ScanContext): boolean { + return ( + /mraid\s*\.\s*addEventListener\s*\(\s*['"]ready['"]/i.test(ctx.scriptText) || + /mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]/i.test(ctx.scriptText) || + /['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText) + ); +} + +function hasEventListener(ctx: ScanContext, eventName: string): boolean { + const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i'); + return pattern.test(ctx.scriptText); +} + +function hasSupportCheck(ctx: ScanContext, feature: string): boolean { + const pattern = new RegExp(`mraid\\s*\\.\\s*supports\\s*\\(\\s*['"]${feature}['"]`, 'i'); + return pattern.test(ctx.scriptText); +} + +function usesDirectBrowserNavigation(ctx: ScanContext): boolean { + return ( + /\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) || + /\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText) + ); +} + +const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/'; + +const MRAID_RULES: 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: MRAID_REFERENCE, + test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']), + }, + { + 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: MRAID_REFERENCE, + test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']), + }, + { + 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: MRAID_REFERENCE, + test: (ctx) => !hasMraidReference(ctx) || hasEventListener(ctx, 'error') ? null : issue(MRAID_RULES_BY_ID['error-listener']), + }, + { + 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: MRAID_REFERENCE, + test: (ctx) => { + const hasVisibilitySignal = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange'); + return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']); + }, + }, + { + 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: MRAID_REFERENCE, + test: (ctx) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null, + }, + { + 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: MRAID_REFERENCE, + test: (ctx) => { + const appearsToHaveCloseUi = /\b(close|dismiss|skip)\b/i.test(ctx.html); + return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null; + }, + }, + { + id: 'expand-support', + severity: 'requirement', + title: 'mraid.expand used without supports check', + detail: 'Check mraid.supports("expand") before using expand behavior.', + reference: MRAID_REFERENCE, + test: (ctx) => hasMraidCall(ctx, 'expand') && !hasSupportCheck(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null, + }, + { + id: 'resize-support', + severity: 'requirement', + title: 'mraid.resize used without supports check', + detail: 'Check mraid.supports("resize") before using resize behavior.', + reference: MRAID_REFERENCE, + test: (ctx) => hasMraidCall(ctx, 'resize') && !hasSupportCheck(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null, + }, + { + id: 'orientation-support', + severity: 'best-practice', + title: 'Orientation properties set without supports check', + detail: 'Check mraid.supports("orientation") before requiring orientation behavior.', + reference: MRAID_REFERENCE, + test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasSupportCheck(ctx, 'orientation') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null, + }, + { + 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: MRAID_REFERENCE, + test: (ctx) => { + const dependsOnSize = hasMraidCall(ctx, 'getMaxSize') || hasMraidCall(ctx, 'getScreenSize') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'setOrientationProperties'); + return dependsOnSize && !hasEventListener(ctx, 'sizeChange') ? issue(MRAID_RULES_BY_ID['size-change']) : null; + }, + }, + { + 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: MRAID_REFERENCE, + test: (ctx) => { + const changesState = hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'close'); + return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null; + }, + }, +]; + +const MRAID_RULES_BY_ID = MRAID_RULES.reduce>((acc, rule) => { + acc[rule.id] = rule; + return acc; +}, {}); + +function getHtml(): string { + return ` + + + + + + +

MRAID Checker

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