Repository: lvcabral/brs-engine
Pull request #402 (2024-11-24 17:53:03)

Added support for `RokuBrowser` library and `roNDK` component.
Two new events added to the API:
- `launch`: triggered by `roNDK.start()`
- `browser`: triggered by `RokuBrowser` library

sonarcloud[bot]: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [1 Security Hotspot](https://sonarcloud.io/project/security_hotspots?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [4 New issues](https://sonarcloud.io/project/issues?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=lvcabral_brs-emu&pullRequest=402&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=lvcabral_brs-emu&pullRequest=402&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=lvcabral_brs-emu&pullRequest=402&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=lvcabral_brs-emu&pullRequest=402&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=lvcabral_brs-emu&pullRequest=402&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=lvcabral_brs-emu&pullRequest=402&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=lvcabral_brs-emu&pullRequest=402&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=lvcabral_brs-emu&pullRequest=402&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=lvcabral_brs-emu&pullRequest=402&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=lvcabral_brs-emu&pullRequest=402)



Patch:
diff --git a/app/index.html b/app/index.html
index 90a5ea05..0ad1f7b4 100644
--- a/app/index.html
+++ b/app/index.html
@@ -102,6 +102,9 @@
             </div>
         </div>
     </div>
+    <script src="https://cdn.jsdelivr.net/npm/browserfs@1.4.3/dist/browserfs.min.js"
+        integrity="sha384-L07lTedW5DiD4A4BIBVv5x29NyDiU/Kt9CglteSqOggJlnvE9QPH3fTv4j+yqGoI"
+        crossorigin="anonymous"></script>
     <script type="text/javascript" src="lib/brs.api.js"></script>
     <script type="text/javascript" src="index.js"></script>
 </body>
diff --git a/app/index.js b/app/index.js
index 113078f9..d6a2e7c5 100644
--- a/app/index.js
+++ b/app/index.js
@@ -19,6 +19,7 @@ const passwordDialog = document.getElementById("passwordDialog");
 
 // Channel status object
 let currentApp = { id: "", running: false };
+let currentZip = null;
 let debugMode = "continue";
 
 // Start the engine
@@ -63,22 +64,19 @@ brs.subscribe("app", (event, data) => {
     } else if (event === "started") {
         currentApp = data;
         stats.style.visibility = "visible";
-    } else if (event === "closed" || event === "error") {
-        currentApp = { id: "", running: false };
-        display.style.opacity = 0;
-        channelInfo.innerHTML = "<br/>";
-        fileButton.style.visibility = "visible";
-        loading.style.visibility = "hidden";
-        stats.style.visibility = "hidden";
-        channelIcons("visible");
-        fileSelector.value = null;
-        if (document.fullscreenElement) {
-            document.exitFullscreen().catch((err) => {
-                console.error(
-                    `Error attempting to exit fullscreen mode: ${err.message} (${err.name})`
-                );
-            });
+    } else if (event === "launch") {
+        if (data?.app) {
+            brs.terminate("EXIT_USER_NAV");
+            currentApp = { id: "", running: false };
+            currentZip = null;
+            loadZip(data.app);
+        }
+    } else if (event === "browser") {
+        if (data?.url && currentZip) {
+            openBrowser(data.url, data.width, data.height);
         }
+    } else if (event === "closed" || event === "error") {
+        closeApp();
     } else if (event === "debug") {
         if (["stop", "pause", "continue"].includes(data.level)) {
             debugMode = data.level;
@@ -127,7 +125,8 @@ function runFile(file, password = "") {
         reader.onload = function (evt) {
             // file is loaded
             if (password !== null) {
-                brs.execute(file.name, evt.target.result, {
+                currentZip = evt.target.result;
+                brs.execute(file.name, currentZip, {
                     clearDisplayOnExit: true,
                     muteSound: false,
                     execSource: "auto-run-dev",
@@ -157,6 +156,7 @@ function loadZip(zip) {
             if (response.status === 200 || response.status === 0) {
                 return response.blob().then(function (zipBlob) {
                     zipBlob.arrayBuffer().then(function (zipData) {
+                        currentZip = zipData;
                         brs.execute(zip, zipData, { execSource: "homescreen" });
                         display.focus();
                     });
@@ -171,6 +171,24 @@ function loadZip(zip) {
         });
 }
 
+// Clear App data and display
+function closeApp() {
+    currentApp = { id: "", running: false };
+    currentZip = null;
+    display.style.opacity = 0;
+    channelInfo.innerHTML = "<br/>";
+    fileButton.style.visibility = "visible";
+    loading.style.visibility = "hidden";
+    stats.style.visibility = "hidden";
+    channelIcons("visible");
+    fileSelector.value = null;
+    if (document.fullscreenElement) {
+        document.exitFullscreen().catch((err) => {
+            console.error(`Error attempting to exit fullscreen mode: ${err.message} (${err.name})`);
+        });
+    }
+}
+
 // Display Fullscreen control
 display.addEventListener("dblclick", function (event) {
     event.preventDefault();
@@ -247,3 +265,115 @@ window.onblur = function () {
         brs.debug("pause");
     }
 };
+
+// Browser Event Handler
+function openBrowser(url, width, height) {
+    if (!url.startsWith("file://")) {
+        openPopup(url, width, height);
+        return;
+    }
+    if (!url.startsWith("file:///pkg_")) {
+        console.error("Error: Invalid local file path!", data.url);
+        return;
+    }
+    const Buffer = BrowserFS.BFSRequire("buffer").Buffer;
+    const filePath = url.replace("file:///pkg_", "");
+
+    // Configure BrowserFS
+    const fsOptions = {
+        fs: "MountableFileSystem",
+        options: {
+            "pkg:": {
+                fs: "ZipFS",
+                options: {
+                    zipData: Buffer.from(currentZip),
+                },
+            },
+        },
+    };
+    BrowserFS.configure(fsOptions, (err) => {
+        if (err) {
+            return console.error(err);
+        }
+        OpenLocalFile(filePath, width, height);
+    });
+}
+
+// Function called when BrowserFS is ready
+function OpenLocalFile(filePath, width, height) {
+    const path = BrowserFS.BFSRequire("path");
+    const basePath = path.dirname(filePath);
+    const fs = BrowserFS.BFSRequire("fs");
+    fs.readFile(`pkg:/${filePath}`, "utf8", function (err, data) {
+        if (err) {
+            return console.error(err);
+        }
+        replaceRelativePaths(data, fs, basePath)
+            .then((updatedData) => {
+                // Open a new window to display the content
+                const popup = openPopup("about:blank", width, height);
+                popup?.document?.write(updatedData);
+            })
+            .catch((err) => {
+                console.error("Error processing images:", err);
+            });
+    });
+}
+
+// Function to replace relative paths with base64 URLs and add crossorigin attribute
+function replaceRelativePaths(html, fs, basePath) {
+    const imgRegex = /<img[^>]+src="([^">]+)"/g;
+    let match;
+    const promises = [];
+
+    function processMatch(match, p1) {
+        return new Promise((resolve, reject) => {
+            // Check if the path is relative
+            if (p1.startsWith("http://") || p1.startsWith("https://")) {
+                // Add crossorigin attribute to non-local images if it doesn't already exist
+                if (!match[0].includes("crossorigin=")) {
+                    html = html.replace(
+                        match[0],
+                        match[0].replace("<img", "<img crossorigin='anonymous'")
+                    );
+                }
+                resolve();
+                return;
+            }
+            const path = BrowserFS.BFSRequire("path");
+            const resolvedPath = path.resolve(basePath, p1);
+            fs.readFile(`pkg:/${resolvedPath}`, function (err, fileData) {
+                if (err) {
+                    console.error(err);
+                    reject(err);
+                    return;
+                }
+                const base64Data = btoa(String.fromCharCode.apply(null, new Uint8Array(fileData)));
+                let mimeType = "application/octet-stream";
+                const extension = path.extname(p1).replace(/^\./, "");
+                if (extension !== "") {
+                    mimeType = `image/${extension}`;
+                }
+                const dataURL = `data:${mimeType};base64,${base64Data}`;
+                html = html.replace(p1, dataURL);
+                resolve();
+            });
+        });
+    }
+
+    while ((match = imgRegex.exec(html)) !== null) {
+        promises.push(processMatch(match, match[1]));
+    }
+
+    return Promise.all(promises).then(() => html);
+}
+
+function openPopup(url, width, height) {
+    const newWindow = window.open(url, "_blank", `width=${width},height=${height},popup`);
+    if (newWindow) {
+        newWindow.focus();
+    } else {
+        console.error("Failed to open popup window");
+    }
+    return newWindow;
+}
diff --git a/docs/engine-api.md b/docs/engine-api.md
index 109d3f03..3b2c2ab4 100644
--- a/docs/engine-api.md
+++ b/docs/engine-api.md
@@ -45,5 +45,7 @@ Check the [documentation](docs/integrating.md) to learn how to start using it. T
 | control    | Triggered when a control key is sent to the engine | object: `{key: string, mod: number}`. The property `key` contains an [ECP key code](https://developer.roku.com/en-gb/docs/developer-program/dev-tools/external-control-api.md#keypress-key-values) and `mod` contains `0` for keydown or `100` for keyup |
 | redraw     | Triggered when the display canvas is redrawn/resized | boolean: If `true` the display canvas is in **full screen** mode |
 | resolution | Triggered when the emulated screen resolution changes (controlled via BrightScript) | object: `{width: integer, height: integer}` |
+| launch     | Triggered when the method `roNDK.start()` is called from BrightScript | object: `{app: string, params: string}`. The object's `params` property is formated as CSV |
+| browser    | Triggered when the library sub `RokuBrowser` is called from BrightScript | object: `{url: string, width: integer, height: integer}`. The parameters `width` and `height` defines the dimentions of the browser window |
 | debug      | Triggered when debug messages arrive from the worker library (BrightScript Interpreter) | object: `{level: string, content: string}`, levels are: `print`, `beacon`, `warning`, `error`, `stop`, `pause`, `continue` |
 | error      | Triggered when the any execution exception happens on the API library | string: The message describing the error |
diff --git a/src/api/index.ts b/src/api/index.ts
index 367bf9f3..1b4810e6 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -270,7 +270,7 @@ export function execute(filePath: string, fileData: any, options: any = {}) {
 }
 
 // Restore engine state and terminate Worker
-export function terminate(reason: AppExitReason) {
+export function terminate(reason: AppExitReason = AppExitReason.UNKNOWN) {
     if (currentApp.running) {
         deviceDebug(`beacon,${getNow()} [beacon.report] |AppExitComplete\r\n`);
         deviceDebug(`print,------ Finished '${currentApp.title}' execution [${reason}] ------\r\n`);
@@ -453,6 +453,25 @@ function workerCallback(event: MessageEvent) {
         notifyAll("reset");
     } else if (event.data.startsWith("version,")) {
         notifyAll("version", event.data.slice(8));
+    } else if (event.data.startsWith("ndk,")) {
+        const data = event.data.split(",");
+        if (data[1] === "roku_browser") {
+            const params = JSON.parse(data.slice(2).join(","));
+            let winDim = deviceData.displayMode === "1080p" ? ["1920", "1080"] : ["1280", "720"];
+            if (
+                params.windowSize?.toLowerCase() === "1280x720" ||
+                params.windowSize?.toLowerCase() === "1920x1080"
+            ) {
+                winDim = params.windowSize.split("x");
+            }
+            notifyAll("browser", {
+                url: params.url,
+                width: parseInt(winDim[0]),
+                height: parseInt(winDim[1]),
+            });
+        } else {
+            notifyAll("launch", { app: data[1], params: data[2] });
+        }
     }
 }
 
diff --git a/src/api/package.ts b/src/api/package.ts
index e74a01c8..80f6bb13 100644
--- a/src/api/package.ts
+++ b/src/api/package.ts
@@ -20,7 +20,7 @@ import {
     AppData,
     AppExitReason,
 } from "../worker/common";
-import models from "../worker/common/models.csv";
+import models from "../worker/libraries/common/models.csv";
 import packageInfo from "../../package.json";
 
 // Default Device Data
diff --git a/src/worker/brsTypes/components/BrsObjects.ts b/src/worker/brsTypes/components/BrsObjects.ts
index 11258f06..24b3f50e 100644
--- a/src/worker/brsTypes/components/BrsObjects.ts
+++ b/src/worker/brsTypes/components/BrsObjects.ts
@@ -49,6 +49,7 @@ import { Int64 } from "../Int64";
 import { RoInvalid } from "./RoInvalid";
 import { RoFunction } from "./RoFunction";
 import { Callable } from "../Callable";
+import { RoNDK } from "./RoNDK";
 
 // Class to define a case-insensitive map of BrightScript objects.
 class BrsObjectsMap {
@@ -165,4 +166,5 @@ export const BrsObjects = new BrsObjectsMap([
     ["roXMLElement", (interpreter: Interpreter) => new RoXMLElement()],
     ["roURLTransfer", (interpreter: Interpreter) => new RoURLTransfer(interpreter)],
     ["roInvalid", (interpreter: Interpreter) => new RoInvalid()],
+    ["roNDK", (interpreter: Interpreter) => new RoNDK()],
 ]);
diff --git a/src/worker/brsTypes/components/RoInput.ts b/src/worker/brsTypes/components/RoInput.ts
index 9fbeb7ae..a0aa5981 100644
--- a/src/worker/brsTypes/components/RoInput.ts
+++ b/src/worker/brsTypes/components/RoInput.ts
@@ -35,7 +35,7 @@ export class RoInput extends BrsComponent implements BrsValue {
     // ifInput ------------------------------------------------------------------------------------
 
     /** Sets the current Order which must be an roList of roAssociativeArray items. */
-    private readonly eventResponse = new Callable("setOrder", {
+    private readonly eventResponse = new Callable("eventResponse", {
         signature: {
             args: [new StdlibArgument("aa", ValueKind.Object)],
             returns: ValueKind.Boolean,
diff --git a/src/worker/brsTypes/components/RoNDK.ts b/src/worker/brsTypes/components/RoNDK.ts
new file mode 100644
index 00000000..a9957fb7
--- /dev/null
+++ b/src/worker/brsTypes/components/RoNDK.ts
@@ -0,0 +1,46 @@
+import { BrsValue, ValueKind, BrsBoolean, BrsString } from "../BrsType";
+import { BrsComponent } from "./BrsComponent";
+import { BrsType, Int32, isBrsString, RoArray } from "..";
+import { Callable, StdlibArgument } from "../Callable";
+import { Interpreter } from "../../interpreter";
+
+export class RoNDK extends BrsComponent implements BrsValue {
+    readonly kind = ValueKind.Object;
+
+    constructor() {
+        super("roNDK");
+        this.registerMethods({
+            ifNDK: [this.start],
+        });
+    }
+
+    toString(parent?: BrsType): string {
+        return "<Component: roNDK>";
+    }
+
+    equalTo(other: BrsType) {
+        return BrsBoolean.False;
+    }
+
+    // ifNDK ------------------------------------------------------------------------------------
+
+    /** Starts a NDK application. There is no public documentation for this component
+     *  this implementation is based on some examples found on the internet.
+     */
+    private readonly start = new Callable("start", {
+        signature: {
+            args: [
+                new StdlibArgument("app", ValueKind.String),
+                new StdlibArgument("params", ValueKind.Object, new RoArray([])),
+                new StdlibArgument("env", ValueKind.Object, new RoArray([])),
+            ],
+            returns: ValueKind.Int32,
+        },
+        impl: (_: Interpreter, app: BrsString, params: RoArray, _env: RoArray) => {
+            const stringParams = params.elements.filter((el) => isBrsString(el));
+            const csvParams = stringParams.map((el) => el.toString()).join(",");
+            postMessage(`ndk,${app.value},${csvParams}`);
+            return new Int32(0);
+        },
+    });
+}
diff --git a/src/worker/index.ts b/src/worker/index.ts
index 33626072..09923a56 100644
--- a/src/worker/index.ts
+++ b/src/worker/index.ts
@@ -20,6 +20,7 @@ import {
 import { FileSystem } from "./interpreter/FileSystem";
 import { Lexeme, Lexer, Token } from "./lexer";
 import { Parser, Stmt } from "./parser";
+import { ParseResults } from "./parser/Parser";
 import * as PP from "./preprocessor";
 import * as BrsError from "./Error";
 import * as BrsTypes from "./brsTypes";
@@ -29,9 +30,10 @@ import * as crypto from "crypto";
 import * as fs from "fs";
 import { encode, decode } from "@msgpack/msgpack";
 import { zlibSync, unzlibSync } from "fflate";
-import bslCore from "./common/v30/bslCore.brs";
-import bslDefender from "./common/v30/bslDefender.brs";
-import Roku_Ads from "./common/Roku_Ads.brs";
+import bslCore from "./libraries/common/v30/bslCore.brs";
+import bslDefender from "./libraries/common/v30/bslDefender.brs";
+import Roku_Ads from "./libraries/roku_ads/Roku_Ads.brs";
+import RokuBrowser from "./libraries/roku_browser/RokuBrowser.brs";
 import packageInfo from "../../package.json";
 
 export * as lexer from "./lexer";
@@ -367,6 +369,10 @@ function setupDeviceData(device: DeviceInfo, interpreter: Interpreter) {
         volume.mkdirSync("/LibCore/v30");
         volume.writeFileSync("/LibCore/v30/bslCore.brs", bslCore);
         volume.writeFileSync("/LibCore/v30/bslDefender.brs", bslDefender);
+        volume.mkdirSync("/roku_ads");
+        volume.writeFileSync("/roku_ads/Roku_Ads.brs", Roku_Ads);
+        volume.mkdirSync("/roku_browser");
+        volume.writeFileSync("/roku_browser/RokuBrowser.brs", RokuBrowser);
     }
 }
 
@@ -564,9 +570,6 @@ export function lexParseSync(
     const allStatements = new Array<Stmt.Statement>();
     const lib = new Map<string, string>();
     let tokens: Token[] = [];
-    lib.set("v30/bslDefender.brs", "");
-    lib.set("v30/bslCore.brs", "");
-    lib.set("Roku_Ads.brs", "");
     lexer.onError(logError);
     parser.onError(logError);
     let exitReason = AppExitReason.FINISHED;
@@ -590,15 +593,7 @@ export function lexParseSync(
             exitReason = AppExitReason.CRASHED;
             break;
         }
-        if (parseResults.libraries.get("v30/bslDefender.brs") === true) {
-            lib.set("v30/bslDefender.brs", bslDefender);
-            lib.set("v30/bslCore.brs", bslCore);
-        } else if (parseResults.libraries.get("v30/bslCore.brs") === true) {
-            lib.set("v30/bslCore.brs", bslCore);
-        }
-        if (parseResults.libraries.get("Roku_Ads.brs") === true) {
-            lib.set("Roku_Ads.brs", Roku_Ads);
-        }
+        parseLibraries(parseResults, lib);
         allStatements.push(...parseResults.statements);
     }
     if (password.length === 0) {
@@ -714,9 +709,6 @@ function runBinary(
     const parser = new Parser();
     const allStatements = new Array<Stmt.Statement>();
     const lib = new Map<string, string>();
-    lib.set("v30/bslDefender.brs", "");
-    lib.set("v30/bslCore.brs", "");
-    lib.set("Roku_Ads.brs", "");
     lexer.onError(logError);
     parser.onError(logError);
     let tokens: Token[] = [];
@@ -746,15 +738,7 @@ function runBinary(
             if (parseResults.errors.length > 0 || parseResults.statements.length === 0) {
                 return { exitReason: AppExitReason.CRASHED };
             }
-            if (parseResults.libraries.get("v30/bslDefender.brs") === true) {
-                lib.set("v30/bslDefender.brs", bslDefender);
-                lib.set("v30/bslCore.brs", bslCore);
-            } else if (parseResults.libraries.get("v30/bslCore.brs") === true) {
-                lib.set("v30/bslCore.brs", bslCore);
-            }
-            if (parseResults.libraries.get("Roku_Ads.brs") === true) {
-                lib.set("Roku_Ads.brs", Roku_Ads);
-            }
+            parseLibraries(parseResults, lib);
             allStatements.push(...parseResults.statements);
             tokens = [];
         }
@@ -778,6 +762,34 @@ function runBinary(
     return { exitReason: exitReason };
 }
 
+/**
+ * Evaluates parsed BrightScript code and add Libraries source
+ * @param parseResults ParseResults object with the parsed code
+ * @param lib Collection with the libraries source code
+ */
+function parseLibraries(parseResults: ParseResults, lib: Map<string, string>) {
+    // Initialize Libraries on first run
+    if (!lib.has("v30/bslDefender.brs")) {
+        lib.set("v30/bslDefender.brs", "");
+        lib.set("v30/bslCore.brs", "");
+        lib.set("Roku_Ads.brs", "");
+        lib.set("RokuBrowser.brs", "");
+    }
+    // Check for Libraries and add to the collection
+    if (parseResults.libraries.get("v30/bslDefender.brs") === true) {
+        lib.set("v30/bslDefender.brs", bslDefender);
+        lib.set("v30/bslCore.brs", bslCore);
+    } else if (parseResults.libraries.get("v30/bslCore.brs") === true) {
+        lib.set("v30/bslCore.brs", bslCore);
+    }
+    if (parseResults.libraries.get("Roku_Ads.brs") === true) {
+        lib.set("Roku_Ads.brs", Roku_Ads);
+    }
+    if (parseResults.libraries.get("RokuBrowser.brs") === true) {
+        lib.set("RokuBrowser.brs", RokuBrowser);
+    }
+}
+
 /**
  * Logs a detected BRS error to the renderer process.
  * @param err the error to log
diff --git a/src/worker/common/models.csv b/src/worker/libraries/common/models.csv
similarity index 96%
rename from src/worker/common/models.csv
rename to src/worker/libraries/common/models.csv
index 994db604..588b9210 100644
--- a/src/worker/common/models.csv
+++ b/src/worker/libraries/common/models.csv
@@ -40,6 +40,7 @@ N1101,Roku HD-XR Classic,STB,directfb,hd,D0
 4660X,Roku Ultra (2017),STB,opengl,fhd,YJ
 4670X,Roku Ultra (2019),STB,opengl,fhd,YJ
 4800X,Roku Ultra (2020),STB,opengl,fhd,X0
+4850X,Roku Ultra (2024),STB,opengl,fhd,X0
 5000X,Roku TV (MIPS),TV,directfb,hd,2N
 6000X,4K Roku TV,TV,opengl,fhd,YW
 7000X,4K Roku TV (TCL),TV,opengl,fhd,YN
diff --git a/src/worker/common/v30/bslCore.brs b/src/worker/libraries/common/v30/bslCore.brs
similarity index 100%
rename from src/worker/common/v30/bslCore.brs
rename to src/worker/libraries/common/v30/bslCore.brs
diff --git a/src/worker/common/v30/bslDefender.brs b/src/worker/libraries/common/v30/bslDefender.brs
similarity index 100%
rename from src/worker/common/v30/bslDefender.brs
rename to src/worker/libraries/common/v30/bslDefender.brs
diff --git a/src/worker/common/Roku_Ads.brs b/src/worker/libraries/roku_ads/Roku_Ads.brs
similarity index 100%
rename from src/worker/common/Roku_Ads.brs
rename to src/worker/libraries/roku_ads/Roku_Ads.brs
diff --git a/src/worker/libraries/roku_browser/RokuBrowser.brs b/src/worker/libraries/roku_browser/RokuBrowser.brs
new file mode 100644
index 00000000..0547ab21
--- /dev/null
+++ b/src/worker/libraries/roku_browser/RokuBrowser.brs
@@ -0,0 +1,10 @@
+sub RokuBrowser(ndk as object, app as string, options as object)
+    if ndk <> invalid and app <> invalid and app.trim() <> ""
+        params = []
+        if getInterface(options, "ifAssociativeArray") <> invalid
+            options.url = app
+            params.push(formatJson(options))
+        end if
+        ndk.start("roku_browser", params)
+    end if
+end sub
\ No newline at end of file
diff --git a/src/worker/parser/Parser.ts b/src/worker/parser/Parser.ts
index 4f4883f4..8e1f1e5a 100644
--- a/src/worker/parser/Parser.ts
+++ b/src/worker/parser/Parser.ts
@@ -157,7 +157,7 @@ const disallowedIdentifiers = new Set(
 const bsLibraries = new Map<string, boolean>();
 
 /** The results of a Parser's parsing pass. */
-interface ParseResults {
+export interface ParseResults {
     /** The statements produced by the parser. */
     statements: Stmt.Statement[];
     /** The errors encountered by the Parser. */
@@ -175,6 +175,7 @@ export class Parser {
         bsLibraries.set("v30/bslCore.brs", false);
         bsLibraries.set("v30/bslDefender.brs", false);
         bsLibraries.set("Roku_Ads.brs", false);
+        bsLibraries.set("RokuBrowser.brs", false);
         this.stats.set(Lexeme.Integer, new Set());
         this.stats.set(Lexeme.String, new Set());
         this.stats.set(Lexeme.Float, new Set());
@@ -896,7 +897,9 @@ export class Parser {
                 } else {
                     addErrorAtLocation(
                         libraryStatement.location,
-                        `Invalid library "${key}"! Supported: "v30/bslCore.brs", "v30/bslDefender.brs" or "Roku_Ads.brs"`
+                        `Invalid library "${key}"! Supported: ${Array.from(bsLibraries.keys()).join(
+                            ", "
+                        )}`
                     );
                 }
             }
diff --git a/src/worker/stdlib/Json.ts b/src/worker/stdlib/Json.ts
index 9d1c15f2..0626e293 100644
--- a/src/worker/stdlib/Json.ts
+++ b/src/worker/stdlib/Json.ts
@@ -59,14 +59,13 @@ function brsValueOf(x: any): BrsType {
  * Converts a BrsType value to its representation as a JSON string. If no such
  * representation is possible, throws an Error. Objects with cyclical references
  * are rejected.
- * @param {Interpreter} interpreter An Interpreter.
  * @param {BrsType} x Some BrsType value.
  * @param {Set<BrsAggregate>} visited An optional Set of visited of RoArray or
  *   RoAssociativeArray. If not provided, a new Set will be created.
  * @return {string} The JSON string representation of `x`.
  * @throws {Error} If `x` cannot be represented as a JSON string.
  */
-function jsonOf(interpreter: Interpreter, x: BrsType, flags: number = 0, key: string = ""): string {
+function jsonOf(x: BrsType, flags: number = 0, key: string = ""): string {
     switch (x.kind) {
         case ValueKind.Invalid:
             return "null";
@@ -84,7 +83,7 @@ function jsonOf(interpreter: Interpreter, x: BrsType, flags: number = 0, key: st
                     .getElements()
                     .map((k: BrsString) => {
                         key = k.toString();
-                        return `"${key}":${jsonOf(interpreter, x.get(k), flags, key)}`;
+                        return `"${key}":${jsonOf(x.get(k), flags, key)}`;
                     })
                     .join(",")}}`;
             }
@@ -92,12 +91,12 @@ function jsonOf(interpreter: Interpreter, x: BrsType, flags: number = 0, key: st
                 return `[${x
                     .getElements()
                     .map((el: BrsType) => {
-                        return jsonOf(interpreter, el, flags, key);
+                        return jsonOf(el, flags, key);
                     })
                     .join(",")}]`;
             }
             if (isUnboxable(x)) {
-                return jsonOf(interpreter, x.unbox(), flags, key);
+                return jsonOf(x.unbox(), flags, key);
             }
             break;
         case ValueKind.Callable:
@@ -143,7 +142,7 @@ export const FormatJson = new Callable("FormatJson", {
     },
     impl: (interpreter: Interpreter, x: BrsType, flags: Int32) => {
         try {
-            return new BrsString(jsonOf(interpreter, x, flags.getValue()));
+            return new BrsString(jsonOf(x, flags.getValue()));
         } catch (err: any) {
             if (err instanceof RangeError) {
                 err = new Error("Nested object reference");


Test patch:
diff --git a/test/simulator/filesystem.brs b/test/simulator/filesystem.brs
index f24f5521..1994ffe2 100644
--- a/test/simulator/filesystem.brs
+++ b/test/simulator/filesystem.brs
@@ -27,4 +27,8 @@ sub main()
     print fs.GetDirectoryListing("tmp:/")
     print fs.delete("tmp:/source")
     print fs.GetDirectoryListing("tmp:/")
+    print fs.GetDirectoryListing("common:/")
+    print fs.GetDirectoryListing("common:/roku_browser/")
+    print ReadAsciiFile("common:/roku_browser/RokuBrowser.brs")
+    print fs.GetDirectoryListing("common:/fonts/")
 end sub
\ No newline at end of file