/**
* Roku ECP (External Control Protocol) + Dev Web Server Client
*
* Official Roku API Reference:
* - ECP: developer.roku.com/docs/developer-program/debugging/external-control-api.md
* - Dev Server: developer.roku.com/docs/developer-program/getting-started/developer-setup.md
*/
import { XMLParser } from 'fast-xml-parser';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as http from 'node:http';
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
textNodeName: '#text',
});
export interface RokuConfig {
host: string;
password: string;
projectRoot: string;
appServerUrl?: string;
}
export interface DeviceInfo {
[key: string]: string;
}
export interface RokuApp {
id: string;
name: string;
version: string;
type?: string;
}
export interface MediaPlayerState {
error: string;
state: string;
plugin?: Record<string, string>;
format?: Record<string, string>;
position?: string;
duration?: string;
runtime?: string;
streamSegment?: Record<string, string>;
[key: string]: unknown;
}
export interface SgNodeInfo {
xml: string;
nodeCount?: number;
}
export class RokuClient {
private host: string;
private password: string;
private projectRoot: string;
private appServerUrl: string;
constructor(config: RokuConfig) {
this.host = config.host;
this.password = config.password;
this.projectRoot = config.projectRoot;
this.appServerUrl = config.appServerUrl || 'https://casualgame.megafuncreative.com/server';
}
// ─── ECP Helpers ────────────────────────────────────────
private async ecpGet(path: string): Promise<string> {
const url = `http://${this.host}:8060${path}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`ECP GET ${path} failed: ${res.status} ${res.statusText}`);
return res.text();
}
private async ecpPost(path: string, body?: string): Promise<string> {
const url = `http://${this.host}:8060${path}`;
const res = await fetch(url, {
method: 'POST',
body: body || '',
});
if (!res.ok) throw new Error(`ECP POST ${path} failed: ${res.status} ${res.statusText}`);
return res.text();
}
private parseXml(xml: string): Record<string, unknown> {
return xmlParser.parse(xml) as Record<string, unknown>;
}
// ─── Device Info ────────────────────────────────────────
async getDeviceInfo(): Promise<DeviceInfo> {
const xml = await this.ecpGet('/query/device-info');
const parsed = this.parseXml(xml);
const info = (parsed as Record<string, Record<string, unknown>>)['device-info'] || {};
const result: DeviceInfo = {};
for (const [key, value] of Object.entries(info)) {
result[key] = String(value ?? '');
}
return result;
}
// ─── Resolution Check ──────────────────────────────────
async checkResolution(): Promise<{
deviceResolution: string;
manifestResolutions: string[];
hdResourcesExist: boolean;
fhdResourcesExist: boolean;
issues: string[];
}> {
const info = await this.getDeviceInfo();
const deviceRes = info['ui-resolution'] || 'unknown';
// Parse manifest
const manifestPath = path.join(this.projectRoot, 'manifest');
let manifestResolutions: string[] = [];
if (fs.existsSync(manifestPath)) {
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
const match = manifestContent.match(/ui_resolutions\s*=\s*(.+)/);
if (match) {
manifestResolutions = match[1].split(',').map(s => s.trim());
}
}
// Check image resources
const hdDir = path.join(this.projectRoot, 'images', 'hd');
const fhdDir = path.join(this.projectRoot, 'images', 'fhd');
const hdResourcesExist = fs.existsSync(hdDir);
const fhdResourcesExist = fs.existsSync(fhdDir);
const issues: string[] = [];
if (manifestResolutions.includes('hd') && !hdResourcesExist) {
issues.push('manifest declares HD but images/hd/ directory is missing');
}
if (manifestResolutions.includes('fhd') && !fhdResourcesExist) {
issues.push('manifest declares FHD but images/fhd/ directory is missing');
}
if (deviceRes === '720p' && !manifestResolutions.includes('hd')) {
issues.push(`Device is 720p but manifest does not include 'hd' in ui_resolutions`);
}
if (deviceRes === '1080p' && !manifestResolutions.includes('fhd')) {
issues.push(`Device is 1080p but manifest does not include 'fhd' in ui_resolutions`);
}
return { deviceResolution: deviceRes, manifestResolutions, hdResourcesExist, fhdResourcesExist, issues };
}
// ─── Apps ──────────────────────────────────────────────
async getApps(): Promise<RokuApp[]> {
const xml = await this.ecpGet('/query/apps');
const parsed = this.parseXml(xml);
const apps = (parsed as Record<string, Record<string, unknown>>)['apps'];
if (!apps) return [];
let appList: unknown[] = [];
const appData = (apps as Record<string, unknown>)['app'];
if (Array.isArray(appData)) appList = appData;
else if (appData) appList = [appData];
return appList.map((a: unknown) => {
const app = a as Record<string, string>;
return {
id: app['@_id'] || '',
name: app['#text'] || '',
version: app['@_version'] || '',
type: app['@_type'] || '',
};
});
}
async getActiveApp(): Promise<RokuApp | null> {
const xml = await this.ecpGet('/query/active-app');
const parsed = this.parseXml(xml);
const activeApp = (parsed as Record<string, Record<string, unknown>>)['active-app'];
if (!activeApp) return null;
const app = (activeApp as Record<string, unknown>)['app'] as Record<string, string> | undefined;
if (!app) return null;
return {
id: app['@_id'] || '',
name: app['#text'] || '',
version: app['@_version'] || '',
};
}
// ─── Key Press ─────────────────────────────────────────
async keypress(key: string): Promise<void> {
await this.ecpPost(`/keypress/${encodeURIComponent(key)}`);
}
async keypressSequence(keys: string[], delayMs: number = 500): Promise<void> {
for (const key of keys) {
await this.keypress(key);
if (delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}
// ─── Launch / Deep Link ────────────────────────────────
async launch(appId: string = 'dev', params?: Record<string, string>): Promise<void> {
let queryString = '';
if (params) {
const qs = new URLSearchParams(params).toString();
queryString = qs ? `?${qs}` : '';
}
await this.ecpPost(`/launch/${encodeURIComponent(appId)}${queryString}`);
}
async deepLink(contentId: string, mediaType: string, appId: string = 'dev'): Promise<void> {
await this.launch(appId, { contentId, mediaType });
}
// ─── Input ─────────────────────────────────────────────
async input(params: Record<string, string>): Promise<void> {
const qs = new URLSearchParams(params).toString();
await this.ecpPost(`/input?${qs}`);
}
// ─── Media Player ──────────────────────────────────────
async getMediaPlayer(): Promise<MediaPlayerState> {
const xml = await this.ecpGet('/query/media-player');
const parsed = this.parseXml(xml);
const player = (parsed as Record<string, Record<string, unknown>>)['player'] || {};
return {
error: String(player['@_error'] ?? ''),
state: String(player['@_state'] ?? 'close'),
plugin: player['plugin'] as Record<string, string> | undefined,
format: player['format'] as Record<string, string> | undefined,
position: player['position'] != null ? String(player['position']) : undefined,
duration: player['duration'] != null ? String(player['duration']) : undefined,
runtime: player['runtime'] != null ? String(player['runtime']) : undefined,
streamSegment: player['stream_segment'] as Record<string, string> | undefined,
};
}
// ─── DRM Check ─────────────────────────────────────────
async checkDrm(): Promise<{
drmActive: string;
state: string;
format: Record<string, string>;
guidance: string;
}> {
const mediaState = await this.getMediaPlayer();
const format = (mediaState.format || {}) as Record<string, string>;
const drm = format['@_drm'] || 'none';
let guidance = '';
if (drm === 'none') {
guidance = 'No DRM active. If content should be protected, configure drmParams with keySystem (widevine recommended), licenseServerURL.';
} else if (drm === 'playready') {
guidance = 'PlayReady active. Note: Roku is deprecating PlayReady for US/CA/LATAM (June 2025). Migrate to Widevine.';
} else if (drm === 'widevine') {
guidance = 'Widevine active — this is the recommended DRM. Supported on Roku OS 9.4+.';
}
return { drmActive: drm, state: mediaState.state, format, guidance };
}
// ─── Performance ───────────────────────────────────────
async getPerformance(): Promise<Record<string, unknown>> {
const xml = await this.ecpGet('/query/chanperf');
return this.parseXml(xml);
}
async getGraphicsFps(): Promise<Record<string, unknown>> {
const xml = await this.ecpGet('/query/graphics-frame-rate');
return this.parseXml(xml);
}
// ─── SceneGraph Nodes ──────────────────────────────────
async getSgNodes(mode: string = 'all', options?: { sizes?: boolean; countOnly?: boolean; nodeId?: string }): Promise<SgNodeInfo> {
let endpoint = `/query/sgnodes/${encodeURIComponent(mode)}`;
const params: string[] = [];
if (options?.sizes) params.push('sizes=true');
if (options?.countOnly) params.push('count_only=true');
if (options?.nodeId && mode === 'nodes') {
params.push(`node-id=${encodeURIComponent(options.nodeId)}`);
}
if (params.length) endpoint += '?' + params.join('&');
const xml = await this.ecpGet(endpoint);
const parsed = this.parseXml(xml);
const nodeCount = (parsed as Record<string, Record<string, string>>)?.['node-count']
? parseInt(String((parsed as Record<string, Record<string, string>>)['node-count']), 10)
: undefined;
return { xml, nodeCount };
}
// ─── Registry ──────────────────────────────────────────
async getRegistry(appId: string = 'dev'): Promise<Record<string, unknown>> {
const xml = await this.ecpGet(`/query/registry/${encodeURIComponent(appId)}`);
return this.parseXml(xml);
}
// ─── App State ─────────────────────────────────────────
async getAppState(appId: string = 'dev'): Promise<Record<string, unknown>> {
const xml = await this.ecpGet(`/query/app-state/${encodeURIComponent(appId)}`);
return this.parseXml(xml);
}
// ─── Screenshot ────────────────────────────────────────
async screenshot(): Promise<{ imageData: Buffer; contentType: string }> {
const url = `http://${this.host}/plugin_inspect`;
const auth = Buffer.from(`rokudev:${this.password}`).toString('base64');
// First, navigate to the screenshot page
const formData = 'mysubmit=Screenshot';
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData,
});
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const html = await res.text();
// Extract image URL from response
const imgMatch = html.match(/src="(\/pkgs\/dev\.(?:jpg|png|bmp))"/i)
|| html.match(/src="(\/tmp\/plugin\/[^"]+)"/i);
if (!imgMatch) {
throw new Error('Could not find screenshot image URL in response');
}
const imgUrl = `http://${this.host}${imgMatch[1]}`;
const imgRes = await fetch(imgUrl, {
headers: { 'Authorization': `Basic ${auth}` },
});
if (!imgRes.ok) throw new Error(`Screenshot image download failed: ${imgRes.status}`);
const contentType = imgRes.headers.get('content-type') || 'image/jpg';
const arrayBuffer = await imgRes.arrayBuffer();
return { imageData: Buffer.from(arrayBuffer), contentType };
}
// ─── Deploy ────────────────────────────────────────────
async deploy(rootDir?: string): Promise<string> {
// Dynamic import for roku-deploy (CommonJS module)
const rokuDeployModule = await import('roku-deploy');
const rokuDeploy = rokuDeployModule.rokuDeploy || rokuDeployModule.default || rokuDeployModule;
const configPath = path.join(this.projectRoot, 'rokudeploy.json');
let fileConfig: Record<string, unknown> = {};
if (fs.existsSync(configPath)) {
fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
const deployConfig = {
...fileConfig,
host: this.host,
password: this.password,
rootDir: rootDir || this.projectRoot,
};
await rokuDeploy.deploy(deployConfig);
return `Successfully deployed to ${this.host}`;
}
// ─── Server Health Check ───────────────────────────────
async serverCheck(url?: string): Promise<{
url: string;
status: number;
responseTimeMs: number;
body: string;
ok: boolean;
}> {
const targetUrl = url || `${this.appServerUrl}/version_check.php?platform=roku&version=1.0.0`;
const start = Date.now();
try {
const res = await fetch(targetUrl);
const elapsed = Date.now() - start;
const body = await res.text();
return { url: targetUrl, status: res.status, responseTimeMs: elapsed, body: body.substring(0, 500), ok: res.ok };
} catch (err) {
const elapsed = Date.now() - start;
return { url: targetUrl, status: 0, responseTimeMs: elapsed, body: String(err), ok: false };
}
}
// ─── RAF Check ─────────────────────────────────────────
checkRafManifest(): {
rafEnabled: boolean;
bsLibsRequired: boolean;
bsLibsCommented: boolean;
issues: string[];
guidance: string[];
} {
const manifestPath = path.join(this.projectRoot, 'manifest');
const issues: string[] = [];
const guidance: string[] = [];
let rafEnabled = false;
let bsLibsRequired = false;
let bsLibsCommented = false;
if (!fs.existsSync(manifestPath)) {
issues.push('manifest file not found');
return { rafEnabled, bsLibsRequired, bsLibsCommented, issues, guidance };
}
const content = fs.readFileSync(manifestPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.match(/bs_const\s*=\s*RAF_ENABLED\s*=\s*True/i)) {
rafEnabled = true;
}
if (trimmed.match(/^bs_libs_required\s*=.*roku_ads/i)) {
bsLibsRequired = true;
}
if (trimmed.match(/^'.*bs_libs_required\s*=.*roku_ads/i)) {
bsLibsCommented = true;
}
}
if (rafEnabled && !bsLibsRequired) {
issues.push('RAF_ENABLED=True but bs_libs_required=roku_ads_lib is missing or commented out');
}
if (bsLibsCommented) {
issues.push("bs_libs_required line is commented out with ' — uncomment for production");
}
if (!rafEnabled) {
guidance.push('RAF is disabled (test mode). Set bs_const=RAF_ENABLED=True for production.');
}
if (rafEnabled && bsLibsRequired) {
guidance.push('RAF configuration looks correct for production.');
}
return { rafEnabled, bsLibsRequired, bsLibsCommented, issues, guidance };
}
// ─── Accessibility Check ───────────────────────────────
async checkAccessibility(): Promise<{
deviceSettings: { captionsMode: string; audioGuideEnabled: string };
sgNodeAnalysis: { totalNodes: number; nodesWithDescription: number; nodesWithoutDescription: string[] };
issues: string[];
guidance: string[];
}> {
const info = await this.getDeviceInfo();
const captionsMode = info['captions-mode'] || 'unknown';
const audioGuideEnabled = info['audio-guide-enabled'] || 'unknown';
// Get SG nodes to check for description attributes
let totalNodes = 0;
let nodesWithDescription = 0;
const nodesWithoutDescription: string[] = [];
try {
const sgResult = await this.getSgNodes('all');
// Parse node types from the XML
const nodeMatches = sgResult.xml.match(/<[^/][^>]*?(?:subtype|type)="([^"]*)"[^>]*>/gi) || [];
totalNodes = nodeMatches.length;
// Check for description attributes in interactive nodes
const interactiveNodeTypes = ['Button', 'ButtonGroup', 'Label', 'Poster', 'LayoutGroup', 'MarkupGrid', 'RowList'];
for (const nodeType of interactiveNodeTypes) {
const pattern = new RegExp(`<[^>]*(?:subtype|type)="${nodeType}"[^>]*>`, 'gi');
const matches = sgResult.xml.match(pattern) || [];
for (const match of matches) {
if (!match.includes('description=')) {
nodesWithoutDescription.push(nodeType);
} else {
nodesWithDescription++;
}
}
}
} catch {
// SG node inspection may not be available
}
const issues: string[] = [];
const guidance: string[] = [];
if (nodesWithoutDescription.length > 0) {
const unique = [...new Set(nodesWithoutDescription)];
issues.push(`Interactive nodes missing 'description' attribute for Screen Reader: ${unique.join(', ')}`);
}
guidance.push('Roku requires apps to support Audio Guide (Screen Reader). Add description attributes to interactive SG nodes.');
guidance.push('Supported caption formats: SMPTE-TT, EIA-608/708, WebVTT');
guidance.push(`Current device settings: captions=${captionsMode}, audioGuide=${audioGuideEnabled}`);
return {
deviceSettings: { captionsMode, audioGuideEnabled },
sgNodeAnalysis: { totalNodes, nodesWithDescription, nodesWithoutDescription: [...new Set(nodesWithoutDescription)] },
issues,
guidance,
};
}
}