diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0da4b2b..0ce9720 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,16 +1,34 @@ +name: Build on: push: branches: [ master ] + workflow_dispatch: jobs: build: strategy: matrix: - os: [ windows-latest, macos-latest, ubuntu-latest ] + include: + - os: windows-latest + artifact-name: windows-installer + artifact-path: dist/*.msi + - os: macos-latest + artifact-name: macos-installer + artifact-path: dist/*.dmg + - os: ubuntu-latest + artifact-name: linux-installer + artifact-path: dist/*.deb runs-on: ${{matrix.os}} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 - run: npm install - run: npm run build env: GH_TOKEN: ${{secrets.GITHUB_TOKEN}} USE_HARD_LINKS: false + - uses: actions/upload-artifact@v4 + with: + name: ${{matrix.artifact-name}} + path: ${{matrix.artifact-path}} diff --git a/LogoMacOS.png b/LogoMacOS.png new file mode 100644 index 0000000..7629497 Binary files /dev/null and b/LogoMacOS.png differ diff --git a/ReEnvision AgentOS.png b/ReEnvision AgentOS.png new file mode 100644 index 0000000..819d484 Binary files /dev/null and b/ReEnvision AgentOS.png differ diff --git a/browser-manager.js b/browser-manager.js new file mode 100644 index 0000000..70ce762 --- /dev/null +++ b/browser-manager.js @@ -0,0 +1,106 @@ +/** + * Browser Lifecycle Manager for WebOS Edge Node + * + * Manages a single warm Chromium instance that persists across requests. + * Function nodes access the browser via Node-RED's global context using + * global.get('getBrowser'), which returns a Promise. + * + * Puppeteer is resolved from the Node-RED userDir (installed on first run). + */ + +var path = require('path'); + +var browser = null; +var launching = false; +var pendingCallbacks = []; + +function createBrowserManager(userDir) { + + async function getBrowser() { + // Return existing connected browser + if (browser && browser.isConnected()) { + return browser; + } + + // If already launching, queue this caller + if (launching) { + return new Promise(function (resolve, reject) { + pendingCallbacks.push({ resolve: resolve, reject: reject }); + }); + } + + launching = true; + + try { + // Resolve puppeteer from the Node-RED user directory + var puppeteerPath = path.join(userDir, 'node_modules', 'puppeteer'); + var puppeteer = require(puppeteerPath); + + browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + // Fix for Sandpack cross-origin iframe network timeouts + '--disable-features=IsolateOrigins,site-per-process', + '--disable-web-security', + '--disable-site-isolation-trials', + // Fix for WebGL / Three.js "Context Lost" crashes + '--use-gl=swiftshader', + '--ignore-gpu-blocklist', + '--disable-gpu' + ], + defaultViewport: { width: 1280, height: 800 } + }); + + browser.on('disconnected', function () { + console.log('Chromium browser disconnected'); + browser = null; + }); + + // Resolve any callers that were waiting + pendingCallbacks.forEach(function (cb) { cb.resolve(browser); }); + pendingCallbacks = []; + + console.log('Chromium browser launched successfully'); + return browser; + } catch (err) { + // Reject any callers that were waiting + pendingCallbacks.forEach(function (cb) { cb.reject(err); }); + pendingCallbacks = []; + throw err; + } finally { + launching = false; + } + } + + async function closeBrowser() { + if (browser) { + try { + await browser.close(); + console.log('Chromium browser closed'); + } catch (e) { + console.error('Error closing Chromium:', e.message); + } + browser = null; + } + } + + function isReady() { + try { + require.resolve(path.join(userDir, 'node_modules', 'puppeteer')); + return true; + } catch (e) { + return false; + } + } + + return { + getBrowser: getBrowser, + closeBrowser: closeBrowser, + isReady: isReady + }; +} + +module.exports = createBrowserManager; diff --git a/build/icon.png b/build/icon.png index 947b81c..8ea6a76 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/build/iconTemplate@4x.png b/build/iconTemplate@4x.png index 1573edd..819d484 100644 Binary files a/build/iconTemplate@4x.png and b/build/iconTemplate@4x.png differ diff --git a/default-flows.json b/default-flows.json new file mode 100644 index 0000000..e7d0108 --- /dev/null +++ b/default-flows.json @@ -0,0 +1,291 @@ +[ + { + "id": "webos-services-tab", + "type": "tab", + "label": "WebOS System Services", + "disabled": false, + "info": "Local Edge Node services for the browser-based WebOS.\nExposes headless browser capabilities (screenshots, scraping, PDF generation)\nvia localhost HTTP endpoints that the WebOS can call." + }, + { + "id": "webos-comment", + "type": "comment", + "z": "webos-services-tab", + "name": "WebOS Local Edge Node - Headless Browser Services", + "info": "These endpoints provide RPC-style access to a warm Chromium instance.\nThe WebOS (running in the user's browser) makes fetch() calls to these\nlocalhost endpoints to perform tasks that require a full browser engine.\n\nEndpoints:\n- POST /os/screenshot - Capture a screenshot of a URL or HTML\n- POST /os/scrape - Extract text content from a JavaScript-rendered page\n- POST /os/pdf - Generate a PDF from HTML content\n- GET /os/proxy?url=... - Reverse proxy that strips X-Frame-Options/CSP for iframe embedding\n- POST /os/browser/action - Stateful remote browser control (goto, click, type, screenshot)", + "x": 310, + "y": 40, + "wires": [] + }, + { + "id": "screenshot-in", + "type": "http in", + "z": "webos-services-tab", + "name": "POST /os/screenshot", + "url": "/os/screenshot", + "method": "post", + "upload": false, + "swaggerDoc": "", + "x": 150, + "y": 120, + "wires": [ + [ + "screenshot-func" + ] + ] + }, + { + "id": "screenshot-func", + "type": "function", + "z": "webos-services-tab", + "name": "Take Screenshot", + "func": "const getBrowser = global.get('getBrowser');\nif (!getBrowser) {\n msg.payload = { success: false, error: 'Browser service not available. Puppeteer may still be installing.' };\n msg.statusCode = 503;\n return msg;\n}\n\nconst url = msg.payload.url;\nconst html = msg.payload.html;\n\nif (!url && !html) {\n msg.payload = { success: false, error: 'Provide either \\\"url\\\" or \\\"html\\\" in the request body.' };\n msg.statusCode = 400;\n return msg;\n}\n\ntry {\n const browser = await getBrowser();\n const page = await browser.newPage();\n try {\n await page.setViewport({ width: 1280, height: 800 });\n if (url) {\n await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });\n } else {\n await page.setContent(html, { waitUntil: 'networkidle2', timeout: 60000 });\n }\n // Wait for Sandpack preview iframe to render if present\n try {\n const iframeHandle = await page.waitForSelector('.sp-preview-iframe', { timeout: 30000 });\n // Wait for Sandpack to finish compiling inside the iframe\n await new Promise(r => setTimeout(r, 3000));\n } catch (e) {\n // No Sandpack iframe found — proceed with screenshot as normal\n }\n const screenshot = await page.screenshot({ encoding: 'base64', fullPage: false });\n msg.payload = { success: true, image: 'data:image/png;base64,' + screenshot };\n } finally {\n await page.close();\n }\n} catch (err) {\n msg.payload = { success: false, error: err.message };\n msg.statusCode = 500;\n}\nreturn msg;", + "outputs": 1, + "timeout": 60, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 380, + "y": 120, + "wires": [ + [ + "screenshot-out" + ] + ] + }, + { + "id": "screenshot-out", + "type": "http response", + "z": "webos-services-tab", + "name": "Response", + "statusCode": "", + "headers": {}, + "x": 570, + "y": 120, + "wires": [] + }, + { + "id": "scrape-in", + "type": "http in", + "z": "webos-services-tab", + "name": "POST /os/scrape", + "url": "/os/scrape", + "method": "post", + "upload": false, + "swaggerDoc": "", + "x": 140, + "y": 240, + "wires": [ + [ + "scrape-func" + ] + ] + }, + { + "id": "scrape-func", + "type": "function", + "z": "webos-services-tab", + "name": "Scrape Page Text", + "func": "const getBrowser = global.get('getBrowser');\nif (!getBrowser) {\n msg.payload = { success: false, error: 'Browser service not available. Puppeteer may still be installing.' };\n msg.statusCode = 503;\n return msg;\n}\n\nconst url = msg.payload.url;\nif (!url) {\n msg.payload = { success: false, error: 'Provide \"url\" in the request body.' };\n msg.statusCode = 400;\n return msg;\n}\n\ntry {\n const browser = await getBrowser();\n const page = await browser.newPage();\n try {\n await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });\n const text = await page.evaluate(() => document.body.innerText);\n const title = await page.evaluate(() => document.title);\n msg.payload = { success: true, title: title, text: text, url: url };\n } finally {\n await page.close();\n }\n} catch (err) {\n msg.payload = { success: false, error: err.message };\n msg.statusCode = 500;\n}\nreturn msg;", + "outputs": 1, + "timeout": 60, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 380, + "y": 240, + "wires": [ + [ + "scrape-out" + ] + ] + }, + { + "id": "scrape-out", + "type": "http response", + "z": "webos-services-tab", + "name": "Response", + "statusCode": "", + "headers": {}, + "x": 570, + "y": 240, + "wires": [] + }, + { + "id": "pdf-in", + "type": "http in", + "z": "webos-services-tab", + "name": "POST /os/pdf", + "url": "/os/pdf", + "method": "post", + "upload": false, + "swaggerDoc": "", + "x": 130, + "y": 360, + "wires": [ + [ + "pdf-func" + ] + ] + }, + { + "id": "pdf-func", + "type": "function", + "z": "webos-services-tab", + "name": "Generate PDF", + "func": "const getBrowser = global.get('getBrowser');\nif (!getBrowser) {\n msg.payload = { success: false, error: 'Browser service not available. Puppeteer may still be installing.' };\n msg.statusCode = 503;\n return msg;\n}\n\nconst html = msg.payload.html;\nif (!html) {\n msg.payload = { success: false, error: 'Provide \"html\" in the request body.' };\n msg.statusCode = 400;\n return msg;\n}\n\ntry {\n const browser = await getBrowser();\n const page = await browser.newPage();\n try {\n await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30000 });\n const pdf = await page.pdf({ format: 'A4', printBackground: true });\n const base64 = pdf.toString('base64');\n msg.payload = { success: true, pdf: 'data:application/pdf;base64,' + base64 };\n } finally {\n await page.close();\n }\n} catch (err) {\n msg.payload = { success: false, error: err.message };\n msg.statusCode = 500;\n}\nreturn msg;", + "outputs": 1, + "timeout": 60, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 370, + "y": 360, + "wires": [ + [ + "pdf-out" + ] + ] + }, + { + "id": "pdf-out", + "type": "http response", + "z": "webos-services-tab", + "name": "Response", + "statusCode": "", + "headers": {}, + "x": 570, + "y": 360, + "wires": [] + }, + { + "id": "proxy-in", + "type": "http in", + "z": "webos-services-tab", + "name": "GET /os/proxy", + "url": "/os/proxy", + "method": "get", + "upload": false, + "swaggerDoc": "", + "x": 140, + "y": 480, + "wires": [ + [ + "proxy-func" + ] + ] + }, + { + "id": "proxy-func", + "type": "function", + "z": "webos-services-tab", + "name": "Iframe Reverse Proxy", + "func": "const targetUrl = msg.req.query.url;\n\nif (!targetUrl) {\n msg.payload = \"Missing URL parameter\";\n msg.statusCode = 400;\n return msg;\n}\n\nlet parsed;\ntry {\n parsed = new URL(targetUrl);\n} catch (e) {\n msg.payload = \"Invalid URL\";\n msg.statusCode = 400;\n return msg;\n}\n\nconst client = parsed.protocol === \"https:\" ? https : http;\n\nreturn new Promise((resolve) => {\n const options = {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === \"https:\" ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: \"GET\",\n headers: {\n \"User-Agent\": msg.req.headers[\"user-agent\"] || \"AgentOS-Proxy\"\n }\n };\n\n const proxyReq = client.request(options, (response) => {\n const chunks = [];\n\n msg.headers = Object.assign({}, response.headers);\n delete msg.headers[\"x-frame-options\"];\n delete msg.headers[\"content-security-policy\"];\n\n msg.statusCode = response.statusCode;\n\n response.on(\"data\", (chunk) => {\n chunks.push(chunk);\n });\n\n response.on(\"end\", () => {\n const raw = Buffer.concat(chunks);\n const contentType = (response.headers[\"content-type\"] || \"\").toLowerCase();\n if (contentType.includes(\"text/html\")) {\n let html = raw.toString(\"utf8\");\n const baseTag = '';\n if (/]*>/i.test(html)) {\n html = html.replace(/]*)>/i, '' + baseTag);\n } else {\n html = baseTag + html;\n }\n msg.payload = html;\n } else {\n msg.payload = raw;\n }\n resolve(msg);\n });\n });\n\n proxyReq.on(\"error\", (err) => {\n msg.payload = \"Proxy error: \" + err.message;\n msg.statusCode = 500;\n resolve(msg);\n });\n\n proxyReq.end();\n});", + "outputs": 1, + "timeout": 60, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [ + { + "var": "http", + "module": "http" + }, + { + "var": "https", + "module": "https" + } + ], + "x": 380, + "y": 480, + "wires": [ + [ + "proxy-out" + ] + ] + }, + { + "id": "proxy-out", + "type": "http response", + "z": "webos-services-tab", + "name": "Response", + "statusCode": "", + "headers": {}, + "x": 570, + "y": 480, + "wires": [] + }, + { + "id": "browser-action-comment", + "type": "comment", + "z": "webos-services-tab", + "name": "Remote Browser Control — Stateful Puppeteer API", + "info": "Persistent browser session with saved cookies via userDataDir.\nPOST /os/browser/action with { action, url?, selector?, text? }\nActions: goto, click, type, screenshot\nAlways returns a base64 screenshot + DOM text.", + "x": 310, + "y": 560, + "wires": [] + }, + { + "id": "browser-action-in", + "type": "http in", + "z": "webos-services-tab", + "name": "POST /os/browser/action", + "url": "/os/browser/action", + "method": "post", + "upload": false, + "swaggerDoc": "", + "x": 170, + "y": 620, + "wires": [ + [ + "browser-action-func" + ] + ] + }, + { + "id": "browser-action-func", + "type": "function", + "z": "webos-services-tab", + "name": "Remote Browser Control", + "func": "const action = msg.payload.action;\nif (!action) {\n msg.payload = { success: false, error: \"Missing action field. Use: goto, click, type, screenshot\" };\n msg.statusCode = 400;\n return msg;\n}\n\ntry {\n let browser = global.get(\"activeBrowser\");\n let page = global.get(\"activePage\");\n\n if (!browser || !browser.isConnected()) {\n const userDataDir = path.join(os.homedir(), \".node-red-standalone\", \"chrome-profile-data\");\n browser = await puppeteer.launch({\n headless: true,\n userDataDir: userDataDir,\n args: [\"--no-sandbox\", \"--disable-setuid-sandbox\"]\n });\n page = await browser.newPage();\n await page.setViewport({ width: 1280, height: 800 });\n global.set(\"activeBrowser\", browser);\n global.set(\"activePage\", page);\n }\n\n if (!page || page.isClosed()) {\n page = await browser.newPage();\n await page.setViewport({ width: 1280, height: 800 });\n global.set(\"activePage\", page);\n }\n\n if (action === \"goto\") {\n if (!msg.payload.url) {\n msg.payload = { success: false, error: \"Missing url for goto action\" };\n msg.statusCode = 400;\n return msg;\n }\n await page.goto(msg.payload.url, { waitUntil: \"networkidle2\" });\n } else if (action === \"click\") {\n if (!msg.payload.selector) {\n msg.payload = { success: false, error: \"Missing selector for click action\" };\n msg.statusCode = 400;\n return msg;\n }\n await page.click(msg.payload.selector);\n } else if (action === \"type\") {\n if (!msg.payload.selector || !msg.payload.text) {\n msg.payload = { success: false, error: \"Missing selector or text for type action\" };\n msg.statusCode = 400;\n return msg;\n }\n await page.type(msg.payload.selector, msg.payload.text);\n } else if (action === \"screenshot\") {\n // Just take the screenshot below\n } else {\n msg.payload = { success: false, error: \"Unknown action: \" + action + \". Use: goto, click, type, screenshot\" };\n msg.statusCode = 400;\n return msg;\n }\n\n const screenshot = await page.screenshot({ encoding: \"base64\" });\n const dom = await page.evaluate(() => document.body.innerText.substring(0, 5000));\n\n msg.payload = { success: true, screenshot: screenshot, dom: dom };\n return msg;\n} catch (err) {\n msg.payload = { success: false, error: err.message };\n msg.statusCode = 500;\n return msg;\n}", + "outputs": 1, + "timeout": 60, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [ + { + "var": "puppeteer", + "module": "puppeteer" + }, + { + "var": "path", + "module": "path" + }, + { + "var": "os", + "module": "os" + } + ], + "x": 420, + "y": 620, + "wires": [ + [ + "browser-action-out" + ] + ] + }, + { + "id": "browser-action-out", + "type": "http response", + "z": "webos-services-tab", + "name": "Response", + "statusCode": "", + "headers": {}, + "x": 630, + "y": 620, + "wires": [] + } +] diff --git a/main.js b/main.js index 4349904..4e9ef14 100755 --- a/main.js +++ b/main.js @@ -1,5 +1,5 @@ /** - * Copyright OpenJS Foundation and other contributors + * Copyright OpenJS Foundation and other contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,38 +23,91 @@ var expressApp = require('express')(); var server = http.createServer(expressApp); var RED = require('node-red'); var { app, Menu, dialog, shell, Tray } = require('electron'); -var log = require('electron-log'); +var log = require('electron-log/main'); +var createBrowserManager = require('./browser-manager'); Object.assign(console, log.functions); var tray = null; +// When using asar, unpacked files live under app.asar.unpacked instead of app.asar +var unpackedDir = __dirname.replace('app.asar', 'app.asar.unpacked'); + var settings = { uiHost: '127.0.0.1', uiPort: process.env.PORT || 1880, httpAdminRoot: '/red', httpNodeRoot: '/', userDir: path.join(os.homedir(), '.node-red-standalone'), - editorTheme: { projects: { enabled: true } } + flowFile: 'flows.json', + + // Allow the WebOS (browser) to call local Node-RED HTTP endpoints + httpNodeCors: { + origin: '*', + methods: 'GET,PUT,POST,DELETE,OPTIONS' + }, + + // Allow Function nodes to require() external npm packages + functionExternalModules: true, + + editorTheme: { + projects: { enabled: true }, + theme: 'midnight-red', + page: { + title: 'AgentOS', + favicon: path.join(unpackedDir, 'build', 'icon.png'), + css: [path.join(unpackedDir, 'theme', 'agentos.css')], + tabicon: { + icon: path.join(unpackedDir, 'build', 'icon.png'), + colour: '#6B5CE7' + } + }, + header: { + title: 'AgentOS', + image: path.join(unpackedDir, 'build', 'icon.png') + }, + deployButton: { + type: 'simple', + label: 'Deploy' + }, + tours: false + } +}; + +// Browser lifecycle manager — provides a warm Chromium instance to flow endpoints +var browserManager = createBrowserManager(settings.userDir); +settings.functionGlobalContext = { + getBrowser: browserManager.getBrowser }; + var url = 'http://' + settings.uiHost + ':' + settings.uiPort + settings.httpAdminRoot; -process.execPath = 'node'; if (process.platform === 'darwin') { process.env.PATH += ':/usr/local/bin'; app.dock.hide(); } + +// Graceful shutdown — kill lingering Chromium processes +function gracefulShutdown(code) { + browserManager.closeBrowser().finally(function () { + app.exit(code); + }); +} + +process.on('SIGTERM', function () { gracefulShutdown(0); }); +process.on('SIGINT', function () { gracefulShutdown(0); }); + if (!app.requestSingleInstanceLock()) { shell.openExternal(url); app.quit(); } else { RED.hooks.add("postInstall", function (event, done) { var cmd = (process.platform === 'win32') ? 'npm.cmd' : 'npm'; - var args = ['install', 'electron-rebuild']; - child_process.execFile(cmd, args, { cwd: settings.userDir }, function (error) { + var args = ['install', '@electron/rebuild']; + child_process.execFile(cmd, args, { cwd: settings.userDir, shell: true }, function (error) { if (!error) { var cmd2 = path.join('node_modules', '.bin', (process.platform === 'win32') ? 'electron-rebuild.cmd' : 'electron-rebuild'); var args2 = ['-v', process.versions.electron]; - child_process.execFile(cmd2, args2, { cwd: event.dir }, function (error2) { + child_process.execFile(cmd2, args2, { cwd: event.dir, shell: true }, function (error2) { if (!error2) { done(); } else { @@ -65,6 +118,52 @@ if (!app.requestSingleInstanceLock()) { } }); }); + + // First-run setup: ensure userDir exists and seed/merge default WebOS flows + if (!fs.existsSync(settings.userDir)) { + fs.mkdirSync(settings.userDir, { recursive: true }); + } + var flowsFile = path.join(settings.userDir, settings.flowFile); + var defaultFlowsPath = path.join(__dirname, 'default-flows.json'); + if (!fs.existsSync(flowsFile)) { + // Brand-new install — copy the whole file + if (fs.existsSync(defaultFlowsPath)) { + fs.copyFileSync(defaultFlowsPath, flowsFile); + console.log('Default WebOS System Services flows installed'); + } + } else if (fs.existsSync(defaultFlowsPath)) { + // Existing install — merge any missing/updated default nodes into user flows + try { + var existingFlows = JSON.parse(fs.readFileSync(flowsFile, 'utf8')); + var defaultFlows = JSON.parse(fs.readFileSync(defaultFlowsPath, 'utf8')); + var existingById = {}; + existingFlows.forEach(function (node) { existingById[node.id] = node; }); + var added = 0; + var updated = 0; + defaultFlows.forEach(function (defaultNode) { + if (!existingById[defaultNode.id]) { + existingFlows.push(defaultNode); + added++; + } else if (JSON.stringify(existingById[defaultNode.id]) !== JSON.stringify(defaultNode)) { + // Replace the existing node with the updated default + for (var i = 0; i < existingFlows.length; i++) { + if (existingFlows[i].id === defaultNode.id) { + existingFlows[i] = defaultNode; + break; + } + } + updated++; + } + }); + if (added > 0 || updated > 0) { + fs.writeFileSync(flowsFile, JSON.stringify(existingFlows, null, 4)); + console.log('WebOS System Services flows synced (' + added + ' added, ' + updated + ' updated)'); + } + } catch (err) { + console.error('Failed to merge default flows:', err.message); + } + } + RED.init(server, settings); expressApp.use(settings.httpAdminRoot, RED.httpAdmin); expressApp.use(settings.httpNodeRoot, RED.httpNode); @@ -74,26 +173,39 @@ if (!app.requestSingleInstanceLock()) { }); server.listen(settings.uiPort, settings.uiHost, function () { RED.start().then(function () { + + // Install Puppeteer in userDir if not already present (downloads Chromium) + var puppeteerCheck = path.join(settings.userDir, 'node_modules', 'puppeteer'); + if (!fs.existsSync(puppeteerCheck)) { + console.log('Installing Puppeteer (first-run setup, this downloads Chromium)...'); + var npmCmd = (process.platform === 'win32') ? 'npm.cmd' : 'npm'; + child_process.execFile(npmCmd, ['install', 'puppeteer'], { cwd: settings.userDir, shell: true }, function (error) { + if (error) { + console.error('Failed to install Puppeteer:', error.message); + } else { + console.log('Puppeteer installed successfully — WebOS browser services are ready'); + } + }); + } + app.whenReady().then(function () { - var icon = (process.platform === 'darwin') ? 'iconTemplate.png' : 'icon.png'; - tray = new Tray(path.join(__dirname, 'build' ,icon)); - tray.setToolTip('Node-RED'); + tray = new Tray(path.join(unpackedDir, 'build', 'icon.png')); + tray.setToolTip('AgentOS'); tray.on('click', function () { shell.openExternal(url); }); tray.setContextMenu(Menu.buildFromTemplate([ { - label: 'Node-RED', click: function () { + label: 'AgentOS', click: function () { shell.openExternal(url); } }, { label: 'Quit', click: function () { - app.exit(1); + gracefulShutdown(0); } } ])); - shell.openExternal(url); }); }).catch(function (error) { dialog.showErrorBox('Error', error.toString()); diff --git a/package.json b/package.json index fe220b3..7f192c5 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,37 @@ { "name": "node-red-installer", - "version": "2.2.2", + "version": "4.1.5", "main": "main.js", + "engines": { + "node": ">=22" + }, "build": { "appId": "com.electron.node-red", - "productName": "Node-RED", - "asar": false, + "productName": "AgentOS", + "asar": true, + "asarUnpack": [ + "build/**", + "theme/**", + "node_modules/@node-red/**", + "node_modules/@node-red-contrib-themes/**" + ], "files": [ "**/*", { "from": "node_modules/@node-red", "to": "node_modules/@node-red" }, + { + "from": "node_modules/@node-red-contrib-themes", + "to": "node_modules/@node-red-contrib-themes" + }, { "from": "build", "to": "build" + }, + { + "from": "theme", + "to": "theme" } ], "win": { @@ -50,22 +67,27 @@ }, "scripts": { "start": "electron .", - "build": "electron-builder" + "rebrand": "node scripts/rebrand.js", + "build": "node scripts/rebrand.js && electron-builder" }, "dependencies": { - "electron-log": "4.4.6", - "node-red": "2.2.2" + "@node-red-contrib-themes/theme-collection": "^4.1.1", + "electron-log": "5.4.3", + "node-red": "4.1.5" }, "devDependencies": { - "electron": "17.0.0", - "electron-builder": "22.14.2" + "electron": "35.7.5", + "electron-builder": "26.7.0" }, - "description": "Node-RED installer", + "description": "AgentOS with WebOS Edge Node browser services", "keywords": [ "node-red", "installer", "standalone", - "electron" + "electron", + "webos", + "edge-node", + "puppeteer" ], "license": "Apache-2.0", "homepage": "https://nodered.org", diff --git a/scripts/rebrand.js b/scripts/rebrand.js new file mode 100644 index 0000000..37b239f --- /dev/null +++ b/scripts/rebrand.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Replaces Node-RED editor logos with AgentOS branding. + * Run before electron-builder to ensure the packaged app has custom icons. + */ +var fs = require('fs'); +var path = require('path'); + +var base = path.join(__dirname, '..'); +var iconSrc = path.join(base, 'build', 'icon.png'); +var editorImages = path.join(base, 'node_modules', '@node-red', 'editor-client', 'public', 'red', 'images'); +var editorPublic = path.join(base, 'node_modules', '@node-red', 'editor-client', 'public'); +var themeFile = path.join(base, 'node_modules', '@node-red', 'editor-api', 'lib', 'editor', 'theme.js'); + +// Read the source icon as base64 for embedding in SVGs +var iconBase64 = fs.readFileSync(iconSrc).toString('base64'); + +function createSvgWrapper(width, height) { + return '\n' + + '\n' + + ' \n' + + ''; +} + +// Replace PNG logo +console.log('Replacing node-red-256.png...'); +fs.copyFileSync(iconSrc, path.join(editorImages, 'node-red-256.png')); + +// Replace SVG logos with embedded PNG +var svgFiles = [ + { name: 'node-red.svg', w: 100, h: 100 }, + { name: 'node-red-256.svg', w: 256, h: 256 }, + { name: 'node-red-icon.svg', w: 100, h: 100 }, + { name: 'node-red-icon-black.svg', w: 100, h: 100 } +]; + +svgFiles.forEach(function(f) { + console.log('Replacing ' + f.name + '...'); + fs.writeFileSync(path.join(editorImages, f.name), createSvgWrapper(f.w, f.h)); +}); + +// Replace favicon.ico with icon.png (browsers handle PNG favicons fine) +console.log('Replacing favicon.ico...'); +fs.copyFileSync(iconSrc, path.join(editorPublic, 'favicon.ico')); + +// Update default titles in theme.js +console.log('Updating theme defaults...'); +var themeContent = fs.readFileSync(themeFile, 'utf8'); +themeContent = themeContent.replace(/title: "Node-RED"/g, 'title: "AgentOS"'); +fs.writeFileSync(themeFile, themeContent); + +console.log('Rebranding complete!'); diff --git a/theme/agentos.css b/theme/agentos.css new file mode 100644 index 0000000..d66e8aa --- /dev/null +++ b/theme/agentos.css @@ -0,0 +1,573 @@ +/* + * AgentOS Neomorphic Dark Theme — overlay for midnight-red base + * + * This file is loaded AFTER the midnight-red theme via editorTheme.page.css. + * It overrides CSS variables and adds neomorphic shadow effects to give the + * Node-RED editor a modern, branded "AgentOS" look. + * + * Upgrade-safe: lives outside node_modules, referenced by main.js config. + */ + +/* ========================================================================= + 1. COLOR PALETTE — override midnight-red's dark gray with blue-gray + ========================================================================= */ + +:root { + /* Backgrounds */ + --red-ui-primary-background: #1e1e2e; + --red-ui-secondary-background: #262638; + --red-ui-secondary-background-selected: #2e2e44; + --red-ui-secondary-background-inactive: #232336; + --red-ui-secondary-background-hover: #2a2a40; + --red-ui-secondary-background-disabled: #252538; + --red-ui-tertiary-background: #282840; + + /* Borders */ + --red-ui-primary-border-color: #1a1a2a; + --red-ui-secondary-border-color: #1c1c2e; + --red-ui-tertiary-border-color: #1e1e30; + + /* Shadows */ + --red-ui-shadow: rgba(0, 0, 0, 0.4); + + /* Accent — AgentOS purple */ + --red-ui-deploy-button-background: #6B5CE7; + --red-ui-deploy-button-background-hover: #7d6ff0; + --red-ui-deploy-button-background-active: #5a4bd6; + --red-ui-deploy-button-background-disabled: #3a3a52; + --red-ui-deploy-button-background-disabled-hover: #444460; + --red-ui-deploy-button-color: #fff; + --red-ui-deploy-button-color-active: #e0e0e0; + + /* Primary action buttons use purple */ + --red-ui-workspace-button-background-primary: #6B5CE7; + --red-ui-workspace-button-background-primary-hover: #5a4bd6; + + /* Header */ + --red-ui-header-background: #161624; + --red-ui-header-menu-background: #1a1a2c; + --red-ui-header-menu-color: #e0e0e0; + --red-ui-header-text-color: #e0e0e0; + + /* Tabs */ + --red-ui-tab-background: #262638; + --red-ui-tab-background-active: #282840; + --red-ui-tab-background-selected: #2e2e44; + --red-ui-tab-background-inactive: #232336; + --red-ui-tab-background-hover: #2a2a40; + + /* Palette */ + --red-ui-palette-header-background: #1e1e2e; + --red-ui-palette-content-background: #262638; + + /* Form elements */ + --red-ui-form-background: #262638; + --red-ui-form-input-background: #1e1e2e; + --red-ui-form-input-background-disabled: #252538; + --red-ui-form-input-border-color: #333350; + --red-ui-form-input-focus-color: rgba(107, 92, 231, 0.7); + --red-ui-form-button-background: #2a2a40; + --red-ui-form-tips-background: #1e1e2e; + + /* Text colors */ + --red-ui-primary-text-color: #e0e0e0; + --red-ui-secondary-text-color: #a0a0b8; + --red-ui-tertiary-text-color: #7878a0; + --red-ui-text-color-link: #9a8cf0; + + /* Code editor */ + --red-ui-text-editor-background: #141422; + --red-ui-text-editor-gutter-background: #18182a; + --red-ui-text-editor-gutter-active-line-background: #1e1e34; + --red-ui-text-editor-active-line-background: #1e1e34; + --red-ui-text-editor-selection-background: #3a3a5c; + + /* Lists */ + --red-ui-list-item-background: #262638; + --red-ui-list-item-background-hover: #2a2a40; + --red-ui-list-item-background-selected: #2e2e44; + + /* Popover / tooltips */ + --red-ui-popover-background: #1e1e2e; + --red-ui-popover-border: #333350; + --red-ui-popover-color: #e0e0e0; + + /* Event log */ + --red-ui-event-log-background: #1a1a2c; + --red-ui-event-log-active-line-background: #1e1e2e; + --red-ui-event-log-selection-background: #2e2e44; + + /* Workspace buttons */ + --red-ui-workspace-button-background: #262638; + --red-ui-workspace-button-background-hover: #2a2a40; + --red-ui-workspace-button-background-active: #2e2e44; +} + +/* ========================================================================= + 2. NEOMORPHIC HEADER + ========================================================================= */ + +#red-ui-header { + background: linear-gradient(180deg, #1c1c30 0%, #161624 100%) !important; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.5), + 0 1px 0 rgba(107, 92, 231, 0.15) inset !important; + border-bottom: 1px solid rgba(107, 92, 231, 0.2) !important; +} + +/* Header title text glow */ +#red-ui-header .red-ui-header-logo .red-ui-logo-text, +.red-ui-header-logo span { + text-shadow: 0 0 20px rgba(107, 92, 231, 0.3); +} + +/* ========================================================================= + 3. NEOMORPHIC DEPLOY BUTTON + ========================================================================= */ + +#red-ui-header-button-deploy { + background: linear-gradient(135deg, #7d6ff0 0%, #6B5CE7 50%, #5a4bd6 100%) !important; + border: none !important; + border-radius: 8px !important; + box-shadow: + 3px 3px 8px rgba(0, 0, 0, 0.4), + -2px -2px 6px rgba(107, 92, 231, 0.15), + 0 0 12px rgba(107, 92, 231, 0.2) !important; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important; + transition: all 0.2s ease !important; + padding: 4px 18px !important; +} + +#red-ui-header-button-deploy:hover { + background: linear-gradient(135deg, #8a7ef5 0%, #7d6ff0 50%, #6B5CE7 100%) !important; + box-shadow: + 2px 2px 6px rgba(0, 0, 0, 0.4), + -1px -1px 4px rgba(107, 92, 231, 0.2), + 0 0 18px rgba(107, 92, 231, 0.3) !important; + transform: translateY(-1px); +} + +#red-ui-header-button-deploy:active { + transform: translateY(1px); + box-shadow: + inset 2px 2px 4px rgba(0, 0, 0, 0.3), + 0 0 8px rgba(107, 92, 231, 0.15) !important; +} + +/* ========================================================================= + 4. NEOMORPHIC SIDEBAR + ========================================================================= */ + +#red-ui-sidebar { + background: #1e1e2e !important; + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3) inset !important; +} + +.red-ui-sidebar-header { + background: #1a1a2c !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; +} + +/* Sidebar tab bar */ +#red-ui-sidebar .red-ui-tabs { + background: #1a1a2c !important; +} + +#red-ui-sidebar ul.red-ui-tabs li { + border-radius: 6px 6px 0 0 !important; +} + +#red-ui-sidebar ul.red-ui-tabs li.active { + background: #262638 !important; + box-shadow: 0 -2px 6px rgba(107, 92, 231, 0.1) !important; +} + +/* Info / Help / Debug panels */ +.red-ui-sidebar-node-config, +.red-ui-sidebar-context, +.red-ui-sidebar-info { + background: #1e1e2e !important; +} + +/* ========================================================================= + 5. NEOMORPHIC PALETTE (left panel) + ========================================================================= */ + +#red-ui-palette { + background: #1e1e2e !important; + box-shadow: 4px 0 12px rgba(0, 0, 0, 0.3) inset !important; +} + +/* Palette category headers — neomorphic raised pill */ +.red-ui-palette-header { + background: #232336 !important; + border-radius: 6px !important; + margin: 3px 6px !important; + box-shadow: + 2px 2px 5px rgba(0, 0, 0, 0.35), + -2px -2px 5px rgba(255, 255, 255, 0.03) !important; + border: none !important; + transition: all 0.15s ease !important; +} + +.red-ui-palette-header:hover { + background: #282840 !important; + box-shadow: + 2px 2px 5px rgba(0, 0, 0, 0.4), + -2px -2px 5px rgba(255, 255, 255, 0.04), + 0 0 6px rgba(107, 92, 231, 0.08) !important; +} + +/* Palette nodes — subtle raised look */ +.red-ui-palette-node { + border-radius: 6px !important; + box-shadow: + 2px 2px 4px rgba(0, 0, 0, 0.3), + -1px -1px 3px rgba(255, 255, 255, 0.02) !important; + margin: 2px 4px !important; + transition: all 0.15s ease !important; +} + +.red-ui-palette-node:hover { + box-shadow: + 3px 3px 6px rgba(0, 0, 0, 0.35), + -2px -2px 4px rgba(255, 255, 255, 0.03), + 0 0 8px rgba(107, 92, 231, 0.12) !important; + transform: translateY(-1px); +} + +/* Palette search input — inset well */ +#red-ui-palette-search input { + background: #141422 !important; + border: 1px solid #333350 !important; + border-radius: 8px !important; + box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.4), + inset -2px -2px 5px rgba(255, 255, 255, 0.02) !important; + color: #e0e0e0 !important; + padding: 6px 12px !important; + transition: all 0.2s ease !important; +} + +#red-ui-palette-search input:focus { + border-color: rgba(107, 92, 231, 0.5) !important; + box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.4), + inset -2px -2px 5px rgba(255, 255, 255, 0.02), + 0 0 8px rgba(107, 92, 231, 0.2) !important; +} + +/* ========================================================================= + 6. NEOMORPHIC WORKSPACE (canvas area) + ========================================================================= */ + +#red-ui-workspace { + background: #1e1e2e !important; +} + +/* Workspace chart / canvas */ +#red-ui-workspace-chart { + background: #1a1a2c !important; +} + +/* Flow tab bar */ +#red-ui-workspace .red-ui-tabs { + background: #1e1e2e !important; +} + +#red-ui-workspace ul.red-ui-tabs li { + border-radius: 6px 6px 0 0 !important; + transition: all 0.15s ease !important; +} + +#red-ui-workspace ul.red-ui-tabs li.active, +#red-ui-workspace ul.red-ui-tabs li.red-ui-tab-selected { + box-shadow: 0 -2px 6px rgba(107, 92, 231, 0.15) !important; + border-top: 2px solid #6B5CE7 !important; +} + +/* ========================================================================= + 7. NEOMORPHIC FLOW NODES (on canvas) + ========================================================================= */ + +/* Node body rectangles — soft raised appearance */ +.red-ui-flow-node-group rect.red-ui-flow-node, +.red-ui-flow-node rect { + rx: 8 !important; + ry: 8 !important; + filter: drop-shadow(2px 3px 6px rgba(0, 0, 0, 0.4)) !important; +} + +/* Selected node — purple glow */ +.red-ui-flow-node-selected rect, +g.red-ui-flow-node-group.red-ui-flow-node-selected rect { + filter: drop-shadow(0 0 8px rgba(107, 92, 231, 0.5)) !important; + stroke: #6B5CE7 !important; + stroke-width: 2 !important; +} + +/* Node status dot */ +.red-ui-flow-node-status-dot { + filter: drop-shadow(0 0 3px rgba(107, 92, 231, 0.3)) !important; +} + +/* Wires */ +.red-ui-flow-link-line { + stroke-width: 2 !important; +} + +/* ========================================================================= + 8. NEOMORPHIC TRAY (node edit panel) + ========================================================================= */ + +.red-ui-tray { + background: #1e1e2e !important; + box-shadow: -6px 0 20px rgba(0, 0, 0, 0.5) !important; +} + +.red-ui-tray-header { + background: #1a1a2c !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; + border-bottom: 1px solid rgba(107, 92, 231, 0.15) !important; +} + +.red-ui-tray-footer { + background: #1a1a2c !important; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3) !important; +} + +/* Tray footer buttons */ +.red-ui-tray-footer button.red-ui-button { + border-radius: 6px !important; + transition: all 0.15s ease !important; +} + +.red-ui-tray-footer button.primary { + background: #6B5CE7 !important; + border-color: #5a4bd6 !important; + box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.3), + -1px -1px 4px rgba(107, 92, 231, 0.1) !important; +} + +.red-ui-tray-footer button.primary:hover { + background: #7d6ff0 !important; + box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.3), + 0 0 10px rgba(107, 92, 231, 0.2) !important; +} + +/* ========================================================================= + 9. NEOMORPHIC FORM INPUTS + ========================================================================= */ + +.red-ui-tray input[type="text"], +.red-ui-tray input[type="password"], +.red-ui-tray input[type="number"], +.red-ui-tray textarea, +.red-ui-tray select, +.red-ui-editor input[type="text"], +.red-ui-editor textarea, +.red-ui-editor select { + background: #141422 !important; + border: 1px solid #333350 !important; + border-radius: 6px !important; + box-shadow: inset 2px 2px 4px rgba(0, 0, 0, 0.35), + inset -2px -2px 4px rgba(255, 255, 255, 0.02) !important; + color: #e0e0e0 !important; + transition: all 0.2s ease !important; +} + +.red-ui-tray input[type="text"]:focus, +.red-ui-tray input[type="password"]:focus, +.red-ui-tray textarea:focus, +.red-ui-tray select:focus, +.red-ui-editor input[type="text"]:focus, +.red-ui-editor textarea:focus, +.red-ui-editor select:focus { + border-color: rgba(107, 92, 231, 0.5) !important; + box-shadow: inset 2px 2px 4px rgba(0, 0, 0, 0.35), + inset -2px -2px 4px rgba(255, 255, 255, 0.02), + 0 0 6px rgba(107, 92, 231, 0.2) !important; + outline: none !important; +} + +/* ========================================================================= + 10. NEOMORPHIC DIALOGS & NOTIFICATIONS + ========================================================================= */ + +.red-ui-editor-dialog { + background: #1e1e2e !important; + border: 1px solid #333350 !important; + border-radius: 12px !important; + box-shadow: + 8px 8px 24px rgba(0, 0, 0, 0.5), + -4px -4px 12px rgba(255, 255, 255, 0.03) !important; +} + +/* Notification popup */ +.red-ui-notification { + background: #262638 !important; + border-left: 4px solid #6B5CE7 !important; + border-radius: 8px !important; + box-shadow: + 4px 4px 12px rgba(0, 0, 0, 0.4), + -2px -2px 8px rgba(255, 255, 255, 0.02) !important; +} + +/* ========================================================================= + 11. NEOMORPHIC HEADER MENU DROPDOWN + ========================================================================= */ + +#red-ui-header-shade, +.red-ui-menu-dropdown { + background: #1a1a2c !important; + border: 1px solid #333350 !important; + border-radius: 8px !important; + box-shadow: + 6px 6px 16px rgba(0, 0, 0, 0.5), + -3px -3px 8px rgba(255, 255, 255, 0.02) !important; + overflow: hidden !important; +} + +.red-ui-menu-dropdown li a:hover { + background: #2a2a40 !important; +} + +.red-ui-menu-dropdown li.active > a { + color: #6B5CE7 !important; +} + +/* ========================================================================= + 12. SCROLLBARS — thin & themed + ========================================================================= */ + +::-webkit-scrollbar { + width: 8px !important; + height: 8px !important; +} + +::-webkit-scrollbar-track { + background: #141422 !important; + border-radius: 4px !important; +} + +::-webkit-scrollbar-thumb { + background: #3a3a56 !important; + border-radius: 4px !important; + border: 1px solid #141422 !important; +} + +::-webkit-scrollbar-thumb:hover { + background: #4a4a6a !important; +} + +::-webkit-scrollbar-corner { + background: #141422 !important; +} + +/* ========================================================================= + 13. HEADER MENU BUTTONS — neomorphic subtle raised + ========================================================================= */ + +#red-ui-header .button { + border-radius: 6px !important; + transition: all 0.15s ease !important; +} + +#red-ui-header .button:hover { + background: rgba(107, 92, 231, 0.12) !important; + box-shadow: 0 0 8px rgba(107, 92, 231, 0.1) !important; +} + +#red-ui-header .button.active, +#red-ui-header .button.selected { + background: rgba(107, 92, 231, 0.18) !important; +} + +/* ========================================================================= + 14. GENERAL BUTTONS — purple accent for primary actions + ========================================================================= */ + +button.red-ui-button.primary, +.red-ui-editableList-addButton button { + background: #6B5CE7 !important; + border-color: #5a4bd6 !important; + border-radius: 6px !important; + color: #fff !important; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3), + -1px -1px 3px rgba(107, 92, 231, 0.08) !important; + transition: all 0.15s ease !important; +} + +button.red-ui-button.primary:hover, +.red-ui-editableList-addButton button:hover { + background: #7d6ff0 !important; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3), + 0 0 10px rgba(107, 92, 231, 0.15) !important; +} + +/* Secondary buttons */ +button.red-ui-button { + border-radius: 6px !important; + transition: all 0.15s ease !important; +} + +/* ========================================================================= + 15. SUBTLE TRANSITIONS & MICRO-ANIMATIONS + ========================================================================= */ + +.red-ui-palette-node, +.red-ui-sidebar-header, +.red-ui-tray, +#red-ui-header .button, +button.red-ui-button { + transition: all 0.15s ease !important; +} + +/* Purple focus ring for accessibility */ +*:focus-visible { + outline: 2px solid rgba(107, 92, 231, 0.6) !important; + outline-offset: 2px !important; +} + +/* ========================================================================= + 16. SEARCH DIALOG + ========================================================================= */ + +.red-ui-search-container { + background: #1e1e2e !important; + border: 1px solid #333350 !important; + border-radius: 12px !important; + box-shadow: + 8px 8px 24px rgba(0, 0, 0, 0.5), + -4px -4px 12px rgba(255, 255, 255, 0.03) !important; + overflow: hidden !important; +} + +.red-ui-search-container input { + background: #141422 !important; + border-radius: 8px !important; +} + +/* ========================================================================= + 17. TYPE SEARCH (quick-add nodes) + ========================================================================= */ + +.red-ui-type-search { + background: #1e1e2e !important; + border-radius: 10px !important; + box-shadow: + 6px 6px 18px rgba(0, 0, 0, 0.5), + -3px -3px 10px rgba(255, 255, 255, 0.02) !important; +} + +.red-ui-type-search-input { + background: #141422 !important; + border-radius: 8px !important; +} + +/* ========================================================================= + 18. WORKSPACE FOOTER (debug, info status bar) + ========================================================================= */ + +#red-ui-workspace-footer { + background: #161624 !important; + border-top: 1px solid rgba(107, 92, 231, 0.1) !important; +}