From 5e67ffbbdafee3261843fe70cb0a0c8a4315dddf Mon Sep 17 00:00:00 2001 From: Mohamed Alarade Date: Sun, 25 Oct 2026 17:44:09 +0100 Subject: [PATCH 1/2] Add embedded Code navigation to the desktop workspace --- packages/core-ui/src/SignedInShell.tsx | 12 +- packages/core-ui/src/WorkspaceModes.tsx | 56 ++++++ packages/core-ui/src/app-navigation.ts | 3 +- packages/core-ui/src/styles.css | 1 + packages/core-ui/src/workspace-modes.css | 5 + packages/core-ui/src/workspace-modes.ts | 13 ++ .../scripts/configure-editor-preview.cjs | 39 +++++ packages/desktop/src/main.cjs | 23 ++- packages/desktop/src/nabu-editor.cjs | 160 ++++++++++++++++++ packages/desktop/src/preload.cjs | 10 ++ packages/desktop/test/nabu-editor.test.cjs | 117 +++++++++++++ 11 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 packages/core-ui/src/WorkspaceModes.tsx create mode 100644 packages/core-ui/src/workspace-modes.css create mode 100644 packages/core-ui/src/workspace-modes.ts create mode 100644 packages/desktop/scripts/configure-editor-preview.cjs create mode 100644 packages/desktop/src/nabu-editor.cjs create mode 100644 packages/desktop/test/nabu-editor.test.cjs diff --git a/packages/core-ui/src/SignedInShell.tsx b/packages/core-ui/src/SignedInShell.tsx index a2f40058..9aacb682 100644 --- a/packages/core-ui/src/SignedInShell.tsx +++ b/packages/core-ui/src/SignedInShell.tsx @@ -1,4 +1,5 @@ import {PersonalityLibrary} from './PersonalityLibrary'; +import {WorkspaceModes,editorBounds,useNabuAvailability} from './WorkspaceModes'; import {TradingWorkspace} from './TradingWorkspace'; import {AppsWorkspace} from './AppsWorkspace'; import {SpecialtyLibrary} from './SpecialtyLibrary'; @@ -31,7 +32,7 @@ import { CommandPalette } from './CommandPalette'; import { ChatSearchController } from './chat-search'; import type { Command } from './CommandPalette'; -type Page = 'trading' | 'apps' | 'approvals' | 'chat' | 'genvr' | 'agents' | 'board' | 'memory' | 'skills' | 'mcp' | 'terminal' | 'account' | 'settings' | 'about' | 'specialties' | 'personalities'; +type Page = 'code' | 'trading' | 'apps' | 'approvals' | 'chat' | 'genvr' | 'agents' | 'board' | 'memory' | 'skills' | 'mcp' | 'terminal' | 'account' | 'settings' | 'about' | 'specialties' | 'personalities'; /** Sections that own the sidebar with their own places (the chat list shows only in Chat). */ const OWN_SIDEBAR: ReadonlyArray = ['memory', 'settings', 'agents', 'skills', 'mcp', 'terminal','specialties','personalities','board','approvals','apps','trading']; const pages: ReadonlyArray<{ key: Page; label: string; icon: IconName | 'board' }> = [ @@ -56,6 +57,10 @@ const pages: ReadonlyArray<{ key: Page; label: string; icon: IconName | 'board' export function SignedInShell({ client, notice }: { client: SessionClient; notice: ReactNode }) { const state = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot); const [page, setPage] = useState('chat'); + const nativeEditor=useNabuAvailability(); + useEffect(()=>{if(page!=='code'){const {top,bottom}=editorBounds();void window.dproApp?.nabu?.select('chat',top,bottom).catch(()=>{});}},[page]); + useEffect(()=>()=>{void window.dproApp?.nabu?.select('chat',48,32).catch(()=>{});},[]); + const openCode=()=>{setPage('code');const {top,bottom}=editorBounds();void window.dproApp?.nabu?.select('code',top,bottom).catch(()=>{});}; const appearance = useAppTheme(); const theme = appearance.theme; const setTheme = (fn: (v: 'light' | 'dark') => 'light' | 'dark') => appearance.setPreference(fn(theme)); @@ -91,6 +96,7 @@ export function SignedInShell({ client, notice }: { client: SessionClient; notic {id:'genvr',label:'GenVR',icon:,run:()=>setPage('genvr')}, ...(user.role==='owner'?[ {id:'terminal',label:'Terminal',icon:,run:()=>setPage('terminal')}, + ...(nativeEditor?[{id:'code',label:'Code',icon:,run:openCode}]:[]), {id:'skills',label:'Skills',icon:,run:()=>setPage('skills')}, {id:'personalities',label:'Personalities',icon:,run:()=>setPage('personalities')}, {id:'specialties',label:'Specialties',icon:,run:()=>setPage('specialties')}, @@ -120,13 +126,14 @@ export function SignedInShell({ client, notice }: { client: SessionClient; notic { id: 'rail', group: 'Actions', icon:, label: rail ? 'Hide the sidebar' : 'Show the sidebar', run: () => setRail(v => !v) }, ]; const active = chatState?.conversations.find(c => c.id === chatState.activeId); - const pageTitle = page === 'personalities'?'Personalities':page === 'trading'?'Trading':page === 'specialties'?'Specialties':page === 'chat' && active ? (active.title || 'Chat') : page === 'skills' ? 'Skills' : page === 'mcp' ? 'MCP connections' : page === 'terminal' ? 'Terminal' : pages.find(p => p.key === page)?.label ?? ''; + const pageTitle = page === 'code'?'Code':page === 'personalities'?'Personalities':page === 'trading'?'Trading':page === 'specialties'?'Specialties':page === 'chat' && active ? (active.title || 'Chat') : page === 'skills' ? 'Skills' : page === 'mcp' ? 'MCP connections' : page === 'terminal' ? 'Terminal' : pages.find(p => p.key === page)?.label ?? ''; const openCards = boardState?.boards.reduce((a, b) => a + b.cards, 0); const sidebarShown = rail && (page === 'chat' || ownSidebar); return
{choosingChat&&chat&&setChoosingChat(false)}/>}
+ {if(mode==='code')setPage('code');else if(page==='code')setPage('chat');}}/>
{page === 'chat' && } @@ -173,6 +180,7 @@ export function SignedInShell({ client, notice }: { client: SessionClient; notic } + {user.role==='owner'&&} diff --git a/packages/core-ui/src/WorkspaceModes.tsx b/packages/core-ui/src/WorkspaceModes.tsx new file mode 100644 index 00000000..9174c8be --- /dev/null +++ b/packages/core-ui/src/WorkspaceModes.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react'; +import { validWorkspaceState } from './workspace-modes'; +import type { WorkspaceMode, WorkspaceModeState } from './workspace-modes'; + +export function editorBounds() { + return {top:Math.max(32,Math.min(160,document.querySelector('.titlebar')?.getBoundingClientRect().bottom||48)),bottom:Math.max(0,Math.min(96,document.querySelector('.app-shell > footer')?.getBoundingClientRect().height||32))}; +} + +/** Native capability only: ordinary browser and remote-server clients remain unchanged. */ +export function useNabuAvailability() { + const [available,setAvailable]=useState(false); + const bridge=typeof window==='undefined'?undefined:window.dproApp?.nabu; + useEffect(()=>{ + if(!bridge)return; + let active=true; + const update=(value:unknown)=>{if(active&&validWorkspaceState(value))setAvailable(value.available);}; + const unsubscribe=bridge.onState(update); + void bridge.status().then(update).catch(()=>{}); + return()=>{active=false;unsubscribe();}; + },[bridge]); + return available; +} + +export function WorkspaceModes({onModeChange}:{onModeChange:(mode:WorkspaceMode)=>void}) { + const [state, setState] = useState(null); + const ref = useRef(null); + const bridge = typeof window==='undefined'?undefined:window.dproApp?.nabu; + const previousMode=useRef('chat'); + const onMode=useRef(onModeChange);onMode.current=onModeChange; + useEffect(() => { + if (!bridge) return; + let active = true; + const update = (value: unknown) => { + if (!active || !validWorkspaceState(value)) return; + setState(value); + if(previousMode.current!==value.mode){previousMode.current=value.mode;onMode.current(value.mode);} + }; + const unsubscribe = bridge.onState(update); + void bridge.status().then(update).catch(() => {}); + return () => { active = false; unsubscribe(); }; + }, [bridge]); + useEffect(() => { + const header = ref.current?.closest('header') || document.querySelector('.titlebar'); + if (!header || !bridge) return; + header.dataset.nabuMode = state?.mode || 'chat'; + const measure = () => {const {top,bottom}=editorBounds();bridge.layout(top,bottom);}; + measure(); + const observer = new ResizeObserver(measure); observer.observe(header); + const footer=document.querySelector('.app-shell > footer');if(footer)observer.observe(footer); + return () => { observer.disconnect(); delete header.dataset.nabuMode; }; + }, [bridge, state?.available, state?.mode]); + if (!bridge || !state?.available) return null; + return
+ {state.error || (state.busy ? 'Opening editor…' : '')} +
; +} diff --git a/packages/core-ui/src/app-navigation.ts b/packages/core-ui/src/app-navigation.ts index 52713e50..cfd6c60c 100644 --- a/packages/core-ui/src/app-navigation.ts +++ b/packages/core-ui/src/app-navigation.ts @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; +import type { NabuBridge } from './workspace-modes'; export type AppDestination = 'home' | 'settings' | 'account' | 'about'; type AppInfo = { version: string; connection: 'local' | 'remote' }; -declare global { interface Window { dproApp?: { onNavigate: (fn: (page: AppDestination) => void) => () => void; info: () => Promise }; } } +declare global { interface Window { dproApp?: { nabu?: NabuBridge; onNavigate: (fn: (page: AppDestination) => void) => () => void; info: () => Promise }; } } export function useAppNavigation(open: (page: AppDestination) => void) { useEffect(() => window.dproApp?.onNavigate(page => { if (['home', 'settings', 'account', 'about'].includes(page)) open(page); }), [open]); } diff --git a/packages/core-ui/src/styles.css b/packages/core-ui/src/styles.css index 9450bcca..b1d074cb 100644 --- a/packages/core-ui/src/styles.css +++ b/packages/core-ui/src/styles.css @@ -1,3 +1,4 @@ +@import './workspace-modes.css'; @import "./personality-library.css"; @import './trading-workspace.css'; :root { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; font-size: 13px; line-height: 1.5; } diff --git a/packages/core-ui/src/workspace-modes.css b/packages/core-ui/src/workspace-modes.css new file mode 100644 index 00000000..89b20211 --- /dev/null +++ b/packages/core-ui/src/workspace-modes.css @@ -0,0 +1,5 @@ +.workspace-modes { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.workspace-mode-notice { font-size: 12px; max-inline-size: 24em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.workspace-mode-notice:empty { display: none; } +.titlebar[data-nabu-mode="code"] .titlebar-tools { visibility: hidden; pointer-events: none; } +@media (max-width: 1000px) { .workspace-mode-notice { max-inline-size: 12em; } } diff --git a/packages/core-ui/src/workspace-modes.ts b/packages/core-ui/src/workspace-modes.ts new file mode 100644 index 00000000..23633f37 --- /dev/null +++ b/packages/core-ui/src/workspace-modes.ts @@ -0,0 +1,13 @@ +export type WorkspaceMode = 'code' | 'chat'; +export type WorkspaceModeState = { available: boolean; mode: WorkspaceMode; busy: boolean; error: string | null }; +export type NabuBridge = { + status(): Promise; + select(mode: WorkspaceMode, top: number, bottom: number): Promise; + layout(top: number, bottom: number): void; + onState(callback: (state: WorkspaceModeState) => void): () => void; +}; +export function validWorkspaceState(value: unknown): value is WorkspaceModeState { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return typeof v.available === 'boolean' && (v.mode === 'code' || v.mode === 'chat') && typeof v.busy === 'boolean' && (v.error === null || (typeof v.error === 'string' && v.error.length <= 160)); +} diff --git a/packages/desktop/scripts/configure-editor-preview.cjs b/packages/desktop/scripts/configure-editor-preview.cjs new file mode 100644 index 00000000..05c4ef58 --- /dev/null +++ b/packages/desktop/scripts/configure-editor-preview.cjs @@ -0,0 +1,39 @@ +'use strict'; +/** Attach an existing Electron app to the local Dpro development checkout. No downloads. */ +const fs = require('node:fs'); +const path = require('node:path'); +const { compatibleRuntime, loopbackOrigin } = require('../src/nabu-editor.cjs'); + +function configure({ appDir, runtimeRoot, node, profile, origin }) { + if (process.platform !== 'darwin') throw Error('This local preview launcher requires macOS'); + if (!loopbackOrigin(origin)) throw Error('The preview requires a loopback Dpro address'); + if (!compatibleRuntime(runtimeRoot)) throw Error('Compatible editor runtime not found'); + for (const value of [appDir, runtimeRoot, node, profile]) if (!value || !path.isAbsolute(value)) throw Error('Absolute paths required'); + if (!fs.statSync(node).isFile()) throw Error('Node runtime not found'); + const resources = path.join(appDir, 'Contents', 'Resources'); + if (!fs.statSync(resources).isDirectory()) throw Error('Existing Electron app not found'); + const destination = path.join(resources, 'app'); + if (fs.existsSync(destination) || fs.existsSync(path.join(resources, 'app.asar'))) throw Error('Refusing to replace an existing application'); + const main = path.resolve(__dirname, '../src/main.cjs'); + fs.mkdirSync(profile, { recursive: true, mode: 0o700 }); + const settings = path.join(profile, 'desktop.json'); + if (!fs.existsSync(settings)) fs.writeFileSync(settings, JSON.stringify({ mode: 'remote', remoteUrl: origin }), { mode: 0o600 }); + fs.mkdirSync(destination); + fs.writeFileSync(path.join(destination, 'package.json'), JSON.stringify({ name: 'dpro-local-preview', productName: 'Dpro Agent', version: require('../package.json').version, main: 'bootstrap.cjs', private: true })); + fs.writeFileSync(path.join(destination, 'bootstrap.cjs'), `'use strict';\nObject.assign(process.env,${JSON.stringify({ DPRO_NABU_ROOT: runtimeRoot, DPRO_DESKTOP_NODE: node, DPRO_DESKTOP_PROFILE_DIR: profile })});\nrequire(${JSON.stringify(main)});\n`); + return appDir; +} + +if (require.main === module) { + const keys = { '--app': 'appDir', '--runtime-root': 'runtimeRoot', '--node': 'node', '--profile': 'profile', '--origin': 'origin' }; + const options = {}; + try { + for (let i = 2; i < process.argv.length; i += 2) { + const key = keys[process.argv[i]]; + if (!key || !process.argv[i + 1] || key in options) throw Error('Unknown, duplicate or incomplete option'); + options[key] = process.argv[i + 1]; + } + console.log(configure(options)); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} +module.exports = { configure }; diff --git a/packages/desktop/src/main.cjs b/packages/desktop/src/main.cjs index aa959970..739bebff 100644 --- a/packages/desktop/src/main.cjs +++ b/packages/desktop/src/main.cjs @@ -10,13 +10,15 @@ * on this computer); nothing runs locally. * - Quit (menu, Cmd/Ctrl+Q, last window closed) stops the backend first. */ -const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require('electron'); +const electron = require('electron'); +const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = electron; const fs = require('node:fs'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); const { createLocalRuntime, openLog } = require('./local-runtime.cjs'); const { resolvePayload, backendArgs, checkPayload } = require('./payload.cjs'); const { loadSettings, saveSettings, remoteOrigin } = require('./settings.cjs'); +const { installNabuEditor } = require('./nabu-editor.cjs'); const discovery = require('./data-discovery.cjs'); // Keep the existing profile before Electron derives storage from the display name. @@ -32,6 +34,8 @@ let log = null; let appOrigin = null; let quitting = false; let stopped = false; +let nabuEditor = null; +let editorLog = null; if (!app.requestSingleInstanceLock()) { app.quit(); @@ -296,6 +300,13 @@ async function boot() { hardenSession(); buildMenu(); mainWindow = createMainWindow(); + const payload = resolvePayload({ packaged: app.isPackaged, resourcesPath: process.resourcesPath }); + nabuEditor = installNabuEditor({ + win: mainWindow, electron, getOrigin: () => appOrigin, + runtimeRoot: process.env.DPRO_NABU_ROOT || path.join(process.resourcesPath, 'nabu-runtime'), + node: process.env.DPRO_DESKTOP_NODE || payload.node, dataDir: path.join(settingsDir(), 'nabu-editor'), + onLog: line => { editorLog ||= openLog(path.join(settingsDir(), 'logs', 'editor.log')); editorLog.write(line); }, + }); await applyMode(); } @@ -307,9 +318,13 @@ app.on('before-quit', event => { event.preventDefault(); if (quitting) return; quitting = true; - stopLocal() - .catch(error => { if (log) log.write(`stop failed: ${error.message}`); }) - .finally(() => { stopped = true; if (log) log.close(); app.quit(); }); + Promise.resolve(nabuEditor?.requestClose()).then(async allowed => { + if (allowed === false) { quitting = false; return false; } + try { await nabuEditor?.stop(); } finally { await stopLocal(); } + return true; + }) + .catch(error => { quitting = false; if (log) log.write(`stop failed: ${error.message}`); editorLog?.write(`Editor shutdown failed: ${error.message}`); return false; }) + .then(allowed => { if (allowed === false) return; stopped = true; if (log) log.close(); editorLog?.close(); app.quit(); }); }); app.on('web-contents-created', (_event, contents) => { diff --git a/packages/desktop/src/nabu-editor.cjs b/packages/desktop/src/nabu-editor.cjs new file mode 100644 index 00000000..afd9393e --- /dev/null +++ b/packages/desktop/src/nabu-editor.cjs @@ -0,0 +1,160 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const CHANNELS = Object.freeze({ status: 'dpro:nabu-status', select: 'dpro:nabu-select', layout: 'dpro:nabu-layout', state: 'dpro:nabu-state' }); + +function compatibleRuntime(root) { + try { + if (!root || !path.isAbsolute(root)) return false; + const manifest = path.join(root, 'nabu', 'runtime.json'); + if (fs.statSync(manifest).size > 8192) return false; + const value = JSON.parse(fs.readFileSync(manifest, 'utf8')); + return value.protocolVersion === 1 && value.entry === 'dpro/editor-runtime.mjs' && fs.statSync(path.join(root, 'nabu', value.entry)).isFile(); + } catch { return false; } +} + +function loopbackOrigin(value) { + try { const u = new URL(value); return u.protocol === 'http:' && u.hostname === '127.0.0.1' && !!u.port && u.origin === value; } catch { return false; } +} + +/** Optional desktop adapter. The editor implementation stays in the shared Nabu runtime. */ +function installNabuEditor({ win, electron, getOrigin, runtimeRoot, node, dataDir, createHost, onLog = () => {} }) { + const { ipcMain, WebContentsView, dialog } = electron; + const installed = createHost ? true : compatibleRuntime(runtimeRoot); + let mode = 'chat', desired = 'chat', busy = false, error = null, top = 48, bottom = 32; + let view = null, host = null, starting = null, address = null, editorOrigin = null; + let activated = false; + let generation = 0, disposed = false, closePromise = null, stopPromise = null, approvedClose = false; + const cleanup = []; + const listen = (target, name, callback) => { target.on(name, callback); cleanup.push(() => target.removeListener(name, callback)); }; + const available = () => installed && loopbackOrigin(getOrigin()); + const state = () => ({ available: available(), mode, busy, error }); + const notify = () => { if (!disposed && !win.webContents.isDestroyed()) win.webContents.send(CHANNELS.state, state()); }; + const allowed = event => !disposed && available() && event.sender === win.webContents && event.senderFrame === win.webContents.mainFrame && (() => { try { return new URL(event.senderFrame.url).origin === getOrigin(); } catch { return false; } })(); + const validBottom = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 96; + const validTop = value => typeof value === 'number' && Number.isFinite(value) && value >= 32 && value <= 160; + const layout = () => { + if (!view || disposed || win.isDestroyed()) return; + const [width, height] = win.getContentSize(); + const zoom = win.webContents.getZoomFactor(); + const y = Math.min(Math.round(top * zoom), Math.max(0, height - 1)); + const footer = Math.round(bottom * zoom); + view.setBounds({ x: 0, y, width, height: Math.max(1, height - y - footer) }); + }; + const hide = () => { desired = mode = 'chat'; busy = false; generation++; view?.setVisible(false); notify(); }; + const runtimeExited = () => { address = null; host = null; starting = null; error = 'The editor stopped. Select Code to reconnect.'; hide(); }; + async function ready() { + if (address) return address; + if (starting) return starting; + starting = (async () => { + const factory = createHost || (await import(pathToFileURL(path.join(runtimeRoot, 'nabu/dpro/editor-runtime.mjs')).href)); + if (disposed) throw Error('Stopped'); + if (!createHost && factory.protocolVersion !== 1) throw Error('Unsupported editor runtime'); + host = (createHost || factory.createEditorHost)({ node, dataDir, onLog, onExit: runtimeExited }); + const result = await host.start(); + const u = new URL(result.url); + if (!loopbackOrigin(result.origin) || u.origin !== result.origin || !u.searchParams.get('tkn')) throw Error('Invalid editor address'); + address = result; + return result; + })(); + try { return await starting; } + catch (failure) { starting = null; const failedHost = host; host = null; await failedHost?.stop().catch(() => {}); throw failure; } + } + const shortcut = (event, input) => { + const key = input.code === 'Digit1' ? '1' : input.code === 'Digit2' ? '2' : input.key; + if (input.type !== 'keyDown' || !input.alt || input.shift || !(input.meta || input.control) || !['1', '2'].includes(key) || !available() || !activated) return; + event.preventDefault(); void select(key === '1' ? 'code' : 'chat'); + }; + async function select(next) { + if (!available() || disposed) return state(); + const epoch = ++generation; + desired = next; error = null; + if (next === 'chat') { mode = 'chat'; busy = false; view?.setVisible(false); win.webContents.focus(); notify(); return state(); } + busy = true; notify(); + try { + const target = await ready(); + if (disposed || epoch !== generation || desired !== 'code') return state(); + if (!view?.webContents || view.webContents.isDestroyed()) { + view = new WebContentsView({ webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, partition: 'persist:dpro-nabu-code' } }); + view.setVisible(false); + win.contentView.addChildView(view); + view.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + view.webContents.session.setPermissionCheckHandler(() => false); + view.webContents.session.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)); + const guard = (event, url) => { try { if (new URL(url).origin === editorOrigin) return; } catch {} event.preventDefault(); }; + listen(view.webContents, 'will-navigate', guard); listen(view.webContents, 'will-redirect', guard); + listen(view.webContents, 'will-attach-webview', event => event.preventDefault()); + listen(view.webContents, 'before-input-event', shortcut); + listen(view.webContents, 'render-process-gone', () => { editorOrigin = null; error = 'The editor stopped. Select Code to reconnect.'; hide(); }); + } + if (editorOrigin !== target.origin || !view.webContents.getURL()) { + editorOrigin = target.origin; + await view.webContents.loadURL(target.url); + } + if (disposed || epoch !== generation || desired !== 'code') return state(); + mode = 'code'; busy = false; layout(); view.setVisible(true); view.webContents.focus(); notify(); + } catch { + if (!disposed && epoch === generation) { editorOrigin = null; error = 'The editor could not open. Select Code to try again.'; hide(); } + } + return state(); + } + function requestClose() { + if (closePromise) return closePromise; + if (!view?.webContents || view.webContents.isDestroyed()) return Promise.resolve(true); + const contents = view.webContents; + const pending = new Promise(resolve => { + const finish = value => { onLog(value?'Editor close approved.':'Editor close cancelled.'); contents.removeListener('destroyed', closed); contents.removeListener('will-prevent-unload', prevented); resolve(value); }; + const closed = () => finish(true); + const prevented = event => { + const discard = dialog.showMessageBoxSync(win, { type: 'warning', buttons: ['Keep Working', 'Discard Changes'], defaultId: 0, cancelId: 0, noLink: true, message: 'The editor has unsaved work.', detail: 'Keep working to save your files before closing.' }) === 1; + if (discard) event.preventDefault(); else finish(false); + }; + contents.once('destroyed', closed); contents.once('will-prevent-unload', prevented); + contents.close({ waitForBeforeUnload: true }); + }); + closePromise = pending; + void pending.finally(() => { if (closePromise === pending) closePromise = null; }); + return pending; + } + const statusHandler = event => allowed(event) ? state() : null; + const selectHandler = (event, message) => { + if (!allowed(event) || !message || Object.keys(message).sort().join(',') !== 'bottom,mode,top' || !['code', 'chat'].includes(message.mode) || !validTop(message.top) || !validBottom(message.bottom)) return null; + top = message.top; bottom = message.bottom; if(message.mode==='code')activated=true; return select(message.mode); + }; + const layoutHandler = (event, value) => { if (allowed(event) && value && Object.keys(value).sort().join(',') === 'bottom,top' && validTop(value.top) && validBottom(value.bottom)) { top = value.top; bottom = value.bottom; layout(); } }; + ipcMain.handle(CHANNELS.status, statusHandler); ipcMain.handle(CHANNELS.select, selectHandler); + listen(ipcMain, CHANNELS.layout, layoutHandler); + cleanup.push(() => { ipcMain.removeHandler(CHANNELS.status); ipcMain.removeHandler(CHANNELS.select); }); + listen(win, 'resize', layout); + listen(win.webContents, 'did-start-navigation', (_event,_url,inPlace,isMainFrame) => {if(isMainFrame&&!inPlace)hide();}); + listen(win.webContents, 'render-process-gone', hide); + listen(win.webContents, 'before-input-event', shortcut); + listen(win.webContents, 'did-start-loading', hide); + listen(win, 'close', event => { + if (approvedClose || !view || view.webContents.isDestroyed()) return; + event.preventDefault(); + void requestClose().then(ok => { if (ok && !win.isDestroyed()) { approvedClose = true; win.close(); } }); + }); + const stop = () => { + if (stopPromise) return stopPromise; + disposed = true; generation++; + for (const remove of cleanup.splice(0)) remove(); + stopPromise = (async () => { + try { + if (view) { + if (!win.isDestroyed() && win.contentView.children.includes(view)) win.contentView.removeChildView(view); + const contents=view.webContents; + if (contents && !contents.isDestroyed()) contents.close(); + view = null; + } + } finally { await host?.stop(); } + onLog('Editor runtime stopped.'); + })(); + return stopPromise; + }; + listen(win, 'closed', () => { void stop(); }); + return { requestClose, stop, state }; +} +module.exports = { installNabuEditor, compatibleRuntime, loopbackOrigin, CHANNELS }; diff --git a/packages/desktop/src/preload.cjs b/packages/desktop/src/preload.cjs index 3b357119..d2941eb5 100644 --- a/packages/desktop/src/preload.cjs +++ b/packages/desktop/src/preload.cjs @@ -18,6 +18,16 @@ if (window.location.protocol === 'file:') { // Public application metadata and fixed navigation only; never filesystem or authentication access. contextBridge.exposeInMainWorld('dproApp', { + nabu: { + status: () => ipcRenderer.invoke('dpro:nabu-status'), + select: (mode, top, bottom) => ipcRenderer.invoke('dpro:nabu-select', { mode, top, bottom }), + layout: (top, bottom) => ipcRenderer.send('dpro:nabu-layout', { top, bottom }), + onState: callback => { + const listener = (_event, value) => callback(value); + ipcRenderer.on('dpro:nabu-state', listener); + return () => ipcRenderer.removeListener('dpro:nabu-state', listener); + }, + }, chooseProjectFolders: () => ipcRenderer.invoke('dpro:choose-project-folders'), info: () => ipcRenderer.invoke('dpro:app-info'), onNavigate: callback => { diff --git a/packages/desktop/test/nabu-editor.test.cjs b/packages/desktop/test/nabu-editor.test.cjs new file mode 100644 index 00000000..8272a9f1 --- /dev/null +++ b/packages/desktop/test/nabu-editor.test.cjs @@ -0,0 +1,117 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const { installNabuEditor, loopbackOrigin, CHANNELS } = require('../src/nabu-editor.cjs'); + +class Contents extends EventEmitter { + mainFrame = { url: 'http://127.0.0.1:42522/' }; + sent = []; url = ''; destroyed = false; loads = 0; dirty = false; + session = { setPermissionCheckHandler() {}, setPermissionRequestHandler() {} }; + send(channel, value) { this.sent.push({ channel, value }); } + getZoomFactor() { return 1; } + isDestroyed() { return this.destroyed; } + setWindowOpenHandler(fn) { this.open = fn; } + getURL() { return this.url; } + async loadURL(url) { this.loads++; if (this.failLoad) throw Error('Offline'); this.url = url; } + focus() {} + close() { + let discard = false; + if (this.dirty) this.emit('will-prevent-unload', { preventDefault() { discard = true; } }); + if (this.dirty && !discard) return; + this.destroyed = true; this.emit('destroyed'); + } +} +function fixture({ start, origin = 'http://127.0.0.1:42522' } = {}) { + const ipc = new EventEmitter(); ipc.handlers = new Map(); + ipc.handle = (key, fn) => ipc.handlers.set(key, fn); ipc.removeHandler = key => ipc.handlers.delete(key); + const win = new EventEmitter(); win.webContents = new Contents(); win.isDestroyed = () => false; + win.getContentSize = () => [1280, 720]; win.close = () => win.emit('closed'); + const views = []; + win.contentView = { children:[],addChildView(view) {this.children.push(view);},removeChildView(view) {this.children=this.children.filter(x=>x!==view);} }; + class View { webContents = new Contents(); constructor(options) { this.options = options; views.push(this); } setVisible(value) { this.visible = value; } setBounds(value) { this.bounds = value; } } + let starts = 0, stops = 0, discard = 0; + const host = { async start() { starts++; return start ? await start() : { origin: 'http://127.0.0.1:42630', url: 'http://127.0.0.1:42630/?tkn=private-token' }; }, async stop() { stops++; } }; + const controller = installNabuEditor({ win, electron: { ipcMain: ipc, WebContentsView: View, dialog: { showMessageBoxSync() { return discard; } } }, getOrigin: () => origin, createHost: () => host }); + const event = () => ({ sender: win.webContents, senderFrame: win.webContents.mainFrame }); + const select = (mode, extras = {}) => ipc.handlers.get(CHANNELS.select)(event(), { mode, top: 48, bottom: 32, ...extras }); + return { win, ipc, views, controller, event, select, starts: () => starts, stops: () => stops, discard: () => { discard = 1; } }; +} + +test('only a precise loopback application can activate the local editor', async () => { + for (const origin of ['https://remote.example', 'http://localhost:42522', 'file:///tmp/page', 'http://127.0.0.1:42522/path']) assert.equal(loopbackOrigin(origin), false); + const f = fixture({ origin: 'https://remote.example' }); + assert.equal(await f.select('code'), null); assert.equal(f.starts(), 0); await f.controller.stop(); +}); +test('foreign contents, subframes, extra keys and invalid layout cannot activate Code', async () => { + const f = fixture(); const handle = f.ipc.handlers.get(CHANNELS.select); + assert.equal(await handle({ sender: new Contents(), senderFrame: {} }, { mode: 'code', top: 48 }), null); + assert.equal(await handle({ sender: f.win.webContents, senderFrame: { url: f.win.webContents.mainFrame.url } }, { mode: 'code', top: 48 }), null); + for (const extra of [{ command: 'run' }, { top: NaN }, { top: 1 }, { top: 200 }, {bottom:NaN}, {bottom:-1}, {bottom:97}]) assert.equal(await f.select('code', extra), null); + assert.equal(f.starts(), 0); await f.controller.stop(); +}); +test('Code lives in one retained child view, and mode state never exposes the connection token', async () => { + const f = fixture(); await f.select('code'); const v = f.views[0]; + assert.equal(f.controller.state().mode, 'code'); assert.equal(v.visible, true); + assert.deepEqual(v.bounds, { x: 0, y: 48, width: 1280, height: 640 }); + assert.equal(v.options.webPreferences.sandbox, true); assert.equal(v.options.webPreferences.nodeIntegration, false); + await f.select('chat'); await f.select('code'); + assert.equal(f.views.length, 1); assert.equal(v.webContents.loads, 1); assert.equal(f.starts(), 1); + assert.equal(JSON.stringify(f.win.webContents.sent).includes('private-token'), false); + await f.controller.stop(); assert.equal(f.stops(), 1); assert.equal(f.ipc.handlers.size, 0); +}); +test('choosing Chat during startup never switches back when startup completes', async () => { + let resolve; const f = fixture({ start: () => new Promise(r => { resolve = r; }) }); + const pending = f.select('code'); await new Promise(r => setImmediate(r)); + await f.select('chat'); resolve({ origin: 'http://127.0.0.1:42630', url: 'http://127.0.0.1:42630/?tkn=private-token' }); + await pending; assert.equal(f.views.length, 0); assert.equal(f.controller.state().mode, 'chat'); await f.controller.stop(); +}); +test('Code and Chat shortcuts work from the editor without granting Dpro IPC to it', async () => { + const f = fixture(); await f.select('code'); + const v = f.views[0]; v.webContents.emit('before-input-event', { preventDefault() {} }, { type: 'keyDown', code: 'Digit2', key: 'â„¢', meta: true, alt: true }); + assert.equal(v.visible, false); assert.equal(f.controller.state().mode, 'chat'); + assert.equal(await f.ipc.handlers.get(CHANNELS.status)({ sender: v.webContents, senderFrame: v.webContents.mainFrame }), null); + await f.controller.stop(); +}); +test('unsaved work defaults to keep working, can be retried and requires explicit discard', async () => { + const f = fixture(); await f.select('code'); const v = f.views[0]; v.webContents.dirty = true; + assert.equal(await f.controller.requestClose(), false); assert.equal(v.webContents.destroyed, false); + f.discard(); assert.equal(await f.controller.requestClose(), true); assert.equal(v.webContents.destroyed, true); + await f.controller.stop(); +}); +test('a crashed renderer can reload and refuses navigation outside its authenticated host', async () => { + const f = fixture(); await f.select('code'); const wc = f.views[0].webContents; + let blocked = false; wc.emit('will-navigate', { preventDefault() { blocked = true; } }, 'https://external.example'); assert.equal(blocked, true); + wc.emit('render-process-gone'); assert.equal(f.controller.state().mode, 'chat'); + await f.select('code'); assert.equal(wc.loads, 2); assert.equal(f.controller.state().mode, 'code'); await f.controller.stop(); +}); +test('closing during startup stops the owned host and ignores its late ready event', async () => { + let resolve; const f = fixture({ start: () => new Promise(r => { resolve = r; }) }); + const pending = f.select('code'); await new Promise(r => setImmediate(r)); await f.controller.stop(); + resolve({ origin: 'http://127.0.0.1:42630', url: 'http://127.0.0.1:42630/?tkn=private-token' }); await pending; + assert.equal(f.views.length, 0); assert.equal(f.stops(), 1); +}); + +// The surrounding Dpro navigation must remain reachable while editing. +test('editor bounds preserve the footer and follow zoom without reloading the editor',async()=>{ + const f=fixture();await f.select('code');const view=f.views[0]; + f.win.webContents.getZoomFactor=()=>1.25; + f.ipc.emit(CHANNELS.layout,f.event(),{top:40,bottom:24}); + assert.deepEqual(view.bounds,{x:0,y:50,width:1280,height:640}); + f.ipc.emit(CHANNELS.layout,f.event(),{top:40,bottom:-1}); + assert.deepEqual(view.bounds,{x:0,y:50,width:1280,height:640}); + assert.equal(view.webContents.loads,1);await f.controller.stop(); +}); + +test('reloading or losing the Dpro renderer hides the local editor',async()=>{ + const f=fixture();await f.select('code');const view=f.views[0]; + f.win.webContents.emit('did-start-navigation',{},'http://127.0.0.1:42522/',false,true); + assert.equal(view.visible,false);assert.equal(f.controller.state().mode,'chat'); + await f.select('code');f.win.webContents.emit('render-process-gone'); + assert.equal(view.visible,false);assert.equal(view.webContents.loads,1); + await f.controller.stop(); +}); + +test('shutdown tolerates Electron releasing the closed child webContents',async()=>{ + const f=fixture();await f.select('code');assert.equal(await f.controller.requestClose(),true); + f.views[0].webContents=undefined;await f.controller.stop();assert.equal(f.stops(),1); +}); From df428511022d815f68503c2b505f82491f9bff4a Mon Sep 17 00:00:00 2001 From: Mohamed Alarade Date: Sun, 25 Oct 2026 19:05:00 +0100 Subject: [PATCH 2/2] Bind runtime validation to file handles and preserve existing settings --- .../scripts/configure-editor-preview.cjs | 11 +++++-- packages/desktop/src/nabu-editor.cjs | 12 +++++-- packages/desktop/test/nabu-editor.test.cjs | 33 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/desktop/scripts/configure-editor-preview.cjs b/packages/desktop/scripts/configure-editor-preview.cjs index 05c4ef58..25010258 100644 --- a/packages/desktop/scripts/configure-editor-preview.cjs +++ b/packages/desktop/scripts/configure-editor-preview.cjs @@ -4,6 +4,13 @@ const fs = require('node:fs'); const path = require('node:path'); const { compatibleRuntime, loopbackOrigin } = require('../src/nabu-editor.cjs'); +function initializeSettings(settings, origin) { + try { + fs.writeFileSync(settings, JSON.stringify({ mode: 'remote', remoteUrl: origin }), { mode: 0o600, flag: 'wx' }); + return true; + } catch (error) { if (error.code === 'EEXIST') return false; throw error; } +} + function configure({ appDir, runtimeRoot, node, profile, origin }) { if (process.platform !== 'darwin') throw Error('This local preview launcher requires macOS'); if (!loopbackOrigin(origin)) throw Error('The preview requires a loopback Dpro address'); @@ -17,7 +24,7 @@ function configure({ appDir, runtimeRoot, node, profile, origin }) { const main = path.resolve(__dirname, '../src/main.cjs'); fs.mkdirSync(profile, { recursive: true, mode: 0o700 }); const settings = path.join(profile, 'desktop.json'); - if (!fs.existsSync(settings)) fs.writeFileSync(settings, JSON.stringify({ mode: 'remote', remoteUrl: origin }), { mode: 0o600 }); + initializeSettings(settings, origin); fs.mkdirSync(destination); fs.writeFileSync(path.join(destination, 'package.json'), JSON.stringify({ name: 'dpro-local-preview', productName: 'Dpro Agent', version: require('../package.json').version, main: 'bootstrap.cjs', private: true })); fs.writeFileSync(path.join(destination, 'bootstrap.cjs'), `'use strict';\nObject.assign(process.env,${JSON.stringify({ DPRO_NABU_ROOT: runtimeRoot, DPRO_DESKTOP_NODE: node, DPRO_DESKTOP_PROFILE_DIR: profile })});\nrequire(${JSON.stringify(main)});\n`); @@ -36,4 +43,4 @@ if (require.main === module) { console.log(configure(options)); } catch (error) { console.error(error.message); process.exitCode = 1; } } -module.exports = { configure }; +module.exports = { configure, initializeSettings }; diff --git a/packages/desktop/src/nabu-editor.cjs b/packages/desktop/src/nabu-editor.cjs index afd9393e..1547032e 100644 --- a/packages/desktop/src/nabu-editor.cjs +++ b/packages/desktop/src/nabu-editor.cjs @@ -6,13 +6,19 @@ const { pathToFileURL } = require('node:url'); const CHANNELS = Object.freeze({ status: 'dpro:nabu-status', select: 'dpro:nabu-select', layout: 'dpro:nabu-layout', state: 'dpro:nabu-state' }); function compatibleRuntime(root) { + let fd; try { if (!root || !path.isAbsolute(root)) return false; - const manifest = path.join(root, 'nabu', 'runtime.json'); - if (fs.statSync(manifest).size > 8192) return false; - const value = JSON.parse(fs.readFileSync(manifest, 'utf8')); + fd = fs.openSync(path.join(root, 'nabu', 'runtime.json'), fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + const info = fs.fstatSync(fd); + if (!info.isFile() || info.size > 8192) return false; + const bytes = Buffer.alloc(8193); + const count = fs.readSync(fd, bytes, 0, bytes.length, 0); + if (count > 8192) return false; + const value = JSON.parse(bytes.subarray(0, count).toString('utf8')); return value.protocolVersion === 1 && value.entry === 'dpro/editor-runtime.mjs' && fs.statSync(path.join(root, 'nabu', value.entry)).isFile(); } catch { return false; } + finally { if (fd !== undefined) fs.closeSync(fd); } } function loopbackOrigin(value) { diff --git a/packages/desktop/test/nabu-editor.test.cjs b/packages/desktop/test/nabu-editor.test.cjs index 8272a9f1..d61a3cad 100644 --- a/packages/desktop/test/nabu-editor.test.cjs +++ b/packages/desktop/test/nabu-editor.test.cjs @@ -115,3 +115,36 @@ test('shutdown tolerates Electron releasing the closed child webContents',async( const f=fixture();await f.select('code');assert.equal(await f.controller.requestClose(),true); f.views[0].webContents=undefined;await f.controller.stop();assert.equal(f.stops(),1); }); + +// These fixtures exercise files created by a competing writer without replacing its data. +test('preview settings creation never overwrites an existing or linked account file',t=>{ + const fs=require('node:fs'),os=require('node:os'),path=require('node:path'); + const {initializeSettings}=require('../scripts/configure-editor-preview.cjs'); + const root=fs.mkdtempSync(path.join(os.tmpdir(),'dpro-preview-settings-')); + t.after(()=>fs.rmSync(root,{recursive:true,force:true})); + const settings=path.join(root,'desktop.json'); + assert.equal(initializeSettings(settings,'http://127.0.0.1:42522'),true); + const original=fs.readFileSync(settings,'utf8'); + assert.equal(initializeSettings(settings,'http://127.0.0.1:42524'),false); + assert.equal(fs.readFileSync(settings,'utf8'),original); + if(process.platform!=='win32'){ + const link=path.join(root,'linked.json');fs.symlinkSync(settings,link); + assert.equal(initializeSettings(link,'http://127.0.0.1:42524'),false); + assert.equal(fs.readFileSync(settings,'utf8'),original); + } +}); +test('runtime validation binds its size check and bounded read to the opened manifest',t=>{ + const fs=require('node:fs'),os=require('node:os'),path=require('node:path'); + const {compatibleRuntime}=require('../src/nabu-editor.cjs'); + const root=fs.mkdtempSync(path.join(os.tmpdir(),'dpro-runtime-manifest-')); + t.after(()=>fs.rmSync(root,{recursive:true,force:true})); + fs.mkdirSync(path.join(root,'nabu/dpro'),{recursive:true}); + const manifest=path.join(root,'nabu/runtime.json'); + fs.writeFileSync(manifest,JSON.stringify({protocolVersion:1,entry:'dpro/editor-runtime.mjs'})); + fs.writeFileSync(path.join(root,'nabu/dpro/editor-runtime.mjs'),''); + const fstat=fs.fstatSync;let swapped=false; + fs.fstatSync=fd=>{const info=fstat(fd);if(!swapped){swapped=true;fs.renameSync(manifest,manifest+'.old');fs.writeFileSync(manifest,'invalid replacement');}return info;}; + try {assert.equal(compatibleRuntime(root),true);assert.equal(swapped,true);}finally{fs.fstatSync=fstat;} + assert.equal(compatibleRuntime(root),false); + fs.writeFileSync(manifest,' '.repeat(8193));assert.equal(compatibleRuntime(root),false); +});