From 7e6028b1134d230e8dc36c605735b83873b02102 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Wed, 17 Jun 2026 19:01:15 +0000 Subject: [PATCH] feat: add desktop computer use runtime --- .../workflows/desktop-macos-branch-build.yml | 77 + .github/workflows/desktop-macos-release.yml | 1 + electron/computerAgent.js | 225 +++ electron/main.js | 131 +- package-lock.json | 1416 ++++++++++++++++- package.json | 4 + server/computer-use-agent.ts | 260 +++ server/computer-use-mcp.ts | 388 +++++ server/index.js | 10 + .../modules/computer-use/computer-executor.ts | 242 +++ .../computer-use/computer-use-mcp.routes.ts | 118 ++ .../computer-use/computer-use.routes.ts | 212 ++- .../computer-use/computer-use.service.ts | 891 ++++++++++- .../desktop-agent-relay.service.ts | 129 ++ server/modules/computer-use/index.ts | 2 + .../desktop-agent-websocket.service.ts | 42 + .../services/websocket-server.service.ts | 6 + .../computer-use/view/ComputerUsePanel.tsx | 472 +++++- src/components/main-content/types/types.ts | 1 + .../main-content/view/MainContent.tsx | 27 +- .../view/subcomponents/MainContentHeader.tsx | 2 + .../subcomponents/MainContentTabSwitcher.tsx | 11 +- .../settings/hooks/useSettingsController.ts | 2 +- src/components/settings/types/types.ts | 2 +- src/components/settings/view/Settings.tsx | 3 + .../settings/view/SettingsSidebar.tsx | 3 +- .../ComputerUseSettingsTab.tsx | 189 +++ src/i18n/locales/en/settings.json | 1 + 28 files changed, 4741 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/desktop-macos-branch-build.yml create mode 100644 electron/computerAgent.js create mode 100644 server/computer-use-agent.ts create mode 100644 server/computer-use-mcp.ts create mode 100644 server/modules/computer-use/computer-executor.ts create mode 100644 server/modules/computer-use/computer-use-mcp.routes.ts create mode 100644 server/modules/computer-use/desktop-agent-relay.service.ts create mode 100644 server/modules/computer-use/index.ts create mode 100644 server/modules/websocket/services/desktop-agent-websocket.service.ts create mode 100644 src/components/settings/view/tabs/computer-use-settings/ComputerUseSettingsTab.tsx diff --git a/.github/workflows/desktop-macos-branch-build.yml b/.github/workflows/desktop-macos-branch-build.yml new file mode 100644 index 00000000..377a0618 --- /dev/null +++ b/.github/workflows/desktop-macos-branch-build.yml @@ -0,0 +1,77 @@ +name: Desktop macOS Branch Build + +on: + workflow_dispatch: + +jobs: + build-macos: + name: Build macOS desktop artifact + runs-on: macos-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Resolve artifact metadata + id: artifact + run: | + SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')" + echo "name=CloudCLI-macOS-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Verify signing secrets are configured + run: | + test -n "$CSC_LINK" + test -n "$CSC_KEY_PASSWORD" + test -n "$APPLE_ID" + test -n "$APPLE_APP_SPECIFIC_PASSWORD" + test -n "$APPLE_TEAM_ID" + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Build signed and notarized macOS artifacts + run: npm run desktop:dist:mac -- --publish never + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Verify macOS artifacts + run: | + test -n "$(find release -maxdepth 1 -name '*.dmg' -print -quit)" + test -n "$(find release -maxdepth 1 -name '*.zip' -print -quit)" + shasum -a 256 release/*.{dmg,zip} > release/SHASUMS256.txt + cat release/SHASUMS256.txt + + - name: Upload branch build artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.name }} + path: | + release/*.dmg + release/*.zip + release/*.yml + release/*.blockmap + release/SHASUMS256.txt + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/desktop-macos-release.yml b/.github/workflows/desktop-macos-release.yml index d8893018..307b4a79 100644 --- a/.github/workflows/desktop-macos-release.yml +++ b/.github/workflows/desktop-macos-release.yml @@ -92,6 +92,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.release.outputs.tag }} + target_commitish: ${{ github.sha }} name: ${{ steps.release.outputs.release_name }} prerelease: ${{ inputs.prerelease }} fail_on_unmatched_files: false diff --git a/electron/computerAgent.js b/electron/computerAgent.js new file mode 100644 index 00000000..fab9cb49 --- /dev/null +++ b/electron/computerAgent.js @@ -0,0 +1,225 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const IPC_PREFIX = '@@CUAGENT@@'; + +function getDesktopPath() { + const currentPath = process.env.PATH || ''; + const commonPaths = process.platform === 'win32' + ? [] + : ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']; + return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter); +} + +function getNodeRuntime(isPackaged) { + if (isPackaged && process.versions.electron) { + return { command: process.execPath, env: { ELECTRON_RUN_AS_NODE: '1' } }; + } + if (process.env.npm_node_execpath) { + return { command: process.env.npm_node_execpath, env: {} }; + } + return { command: 'node', env: {} }; +} + +/** Converts an environment access URL (https://x) to its desktop-agent ws URL. */ +function toAgentWsUrl(httpUrl) { + try { + const parsed = new URL(httpUrl); + parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:'; + parsed.pathname = '/desktop-agent'; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return null; + } +} + +/** + * Manages the standalone Computer Use desktop agent process. While the user has + * Computer Use enabled, this keeps an agent connected to every running cloud + * environment so hosted sessions can drive this machine. The local CloudCLI + * server is not involved. + */ +export class ComputerAgentController { + constructor({ appRoot, settingsPath, isPackaged = false, getRunningEnvironmentUrls, promptConsent, onChange }) { + this.appRoot = appRoot; + this.settingsPath = settingsPath; + this.isPackaged = isPackaged; + this.getRunningEnvironmentUrls = getRunningEnvironmentUrls; + this.promptConsent = promptConsent; + this.onChange = onChange; + this.settings = { enabled: false, consentMode: 'ask' }; + this.child = null; + this.connectedUrls = new Set(); + this.currentTargets = []; + this.stdoutBuffer = ''; + } + + getSettings() { + return { ...this.settings }; + } + + getState() { + return { + enabled: this.settings.enabled, + consentMode: this.settings.consentMode, + running: Boolean(this.child), + connectedCount: this.connectedUrls.size, + targetCount: this.currentTargets.length, + }; + } + + async loadSettings() { + try { + const raw = await fs.readFile(this.settingsPath, 'utf8'); + const stored = JSON.parse(raw); + this.settings = { + enabled: Boolean(stored.enabled), + consentMode: stored.consentMode === 'auto' ? 'auto' : 'ask', + }; + } catch { + this.settings = { enabled: false, consentMode: 'ask' }; + } + return this.settings; + } + + async saveSettings(next) { + this.settings = { + enabled: Boolean(next.enabled), + consentMode: next.consentMode === 'auto' ? 'auto' : 'ask', + }; + await fs.mkdir(path.dirname(this.settingsPath), { recursive: true }); + await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8'); + await this.sync(); + this.onChange?.(); + return this.settings; + } + + /** Reconciles the agent process with the current settings + environments. */ + async sync() { + const targets = this.settings.enabled ? (this.getRunningEnvironmentUrls?.() || []) : []; + const wsTargets = targets.map(toAgentWsUrl).filter(Boolean); + + const sameTargets = + wsTargets.length === this.currentTargets.length && + wsTargets.every((url) => this.currentTargets.includes(url)); + + if (!this.settings.enabled || wsTargets.length === 0) { + this.stop(); + this.currentTargets = []; + return; + } + + if (this.child && sameTargets) { + return; // already running with the right targets + } + + this.currentTargets = wsTargets; + this.restart(wsTargets); + } + + restart(wsTargets) { + this.stop(); + + const agentEntry = process.env.CLOUDCLI_COMPUTER_AGENT_ENTRY + || path.join(this.appRoot, 'dist-server', 'server', 'computer-use-agent.js'); + const runtime = getNodeRuntime(this.isPackaged); + + this.child = spawn(runtime.command, [agentEntry], { + cwd: this.appRoot, + env: { + ...process.env, + ...runtime.env, + PATH: getDesktopPath(), + CLOUDCLI_DESKTOP_AGENT_URLS: wsTargets.join(','), + CLOUDCLI_COMPUTER_USE_CONSENT_MODE: this.settings.consentMode, + }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + + this.connectedUrls = new Set(); + + this.child.once('error', (error) => { + console.error('[ComputerAgent] failed to start:', error.message); + this.child = null; + this.onChange?.(); + }); + + this.child.stdout?.on('data', (chunk) => this.handleStdout(String(chunk))); + this.child.stderr?.on('data', (chunk) => { + for (const line of String(chunk).split(/\r?\n/)) { + if (line.trim()) console.error('[ComputerAgent]', line); + } + }); + + this.child.once('exit', (code) => { + console.log(`[ComputerAgent] exited (code ${code ?? 'null'})`); + this.child = null; + this.connectedUrls = new Set(); + this.onChange?.(); + }); + + this.onChange?.(); + } + + handleStdout(chunk) { + this.stdoutBuffer += chunk; + const lines = this.stdoutBuffer.split('\n'); + this.stdoutBuffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith(IPC_PREFIX)) { + if (trimmed) console.log('[ComputerAgent]', trimmed); + continue; + } + let payload; + try { + payload = JSON.parse(trimmed.slice(IPC_PREFIX.length).trim()); + } catch { + continue; + } + void this.handleAgentEvent(payload); + } + } + + async handleAgentEvent(payload) { + switch (payload.type) { + case 'connected': + this.connectedUrls.add(payload.url); + this.onChange?.(); + break; + case 'disconnected': + this.connectedUrls.delete(payload.url); + this.onChange?.(); + break; + case 'consent-request': { + const allow = await this.promptConsent?.(payload.sessionId); + this.sendToChild({ type: 'consent-response', sessionId: payload.sessionId, allow: Boolean(allow) }); + break; + } + default: + break; + } + } + + sendToChild(message) { + if (this.child?.stdin?.writable) { + this.child.stdin.write(`${IPC_PREFIX} ${JSON.stringify(message)}\n`); + } + } + + revokeSession(sessionId) { + this.sendToChild({ type: 'revoke-session', sessionId }); + } + + stop() { + if (!this.child) return; + const child = this.child; + this.child = null; + this.connectedUrls = new Set(); + try { child.kill('SIGTERM'); } catch { /* noop */ } + } +} diff --git a/electron/main.js b/electron/main.js index 323e8c1a..3a5e749c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,9 +1,10 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from 'electron'; +import { app, BrowserWindow, clipboard, dialog, ipcMain, shell, systemPreferences } from 'electron'; import { spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { CloudController } from './cloud.js'; +import { ComputerAgentController } from './computerAgent.js'; import { DesktopWindowManager } from './desktopWindow.js'; import { LocalServerController } from './localServer.js'; import { TabsController } from './tabs.js'; @@ -22,6 +23,7 @@ let activeTarget = { kind: 'launcher', name: APP_NAME, url: null }; let desktopWindow = null; let localServer = null; let cloud = null; +let computerAgent = null; let isQuitting = false; let isRefreshingCloud = false; @@ -52,6 +54,34 @@ function getSettingsPath() { return path.join(app.getPath('userData'), 'desktop-settings.json'); } +function getComputerUseSettingsPath() { + return path.join(app.getPath('userData'), 'computer-use-settings.json'); +} + +function getRunningEnvironmentUrls() { + return cloud.getEnvironments() + .filter((environment) => environment.status === 'running') + .map((environment) => cloud.getEnvironmentUrl(environment)) + .filter(Boolean); +} + +async function promptComputerUseConsent(sessionId) { + const { response } = await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, { + type: 'warning', + buttons: ['Allow this session', 'Deny'], + defaultId: 0, + cancelId: 1, + title: 'Computer Use request', + message: 'An agent wants to control this computer', + detail: [ + 'A cloud agent is requesting control of your mouse, keyboard, and screen for this session.', + 'Approval lasts for this session only. You can stop it any time from the Computer panel.', + sessionId ? `\nSession: ${sessionId}` : '', + ].join('\n'), + }); + return response === 0; +} + function getDisplayTargetName() { return activeTarget?.name || APP_NAME; } @@ -108,6 +138,7 @@ function getDesktopState() { tabs: tabs.getSerializableTabs(), activeTabId: tabs.activeTabId, environments: cloud.getEnvironments().map(serializeEnvironment), + computerUse: computerAgent?.getState() || { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 }, }; } @@ -217,18 +248,87 @@ async function copyDiagnostics() { }); } -async function showComputerUsePreview() { - await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, { +async function showMacComputerUsePermissions() { + if (process.platform !== 'darwin') return; + const screenStatus = systemPreferences.getMediaAccessStatus('screen'); + const accessibilityTrusted = systemPreferences.isTrustedAccessibilityClient(false); + const detail = [ + `Screen Recording: ${screenStatus === 'granted' ? 'granted' : 'not granted'}`, + `Accessibility: ${accessibilityTrusted ? 'granted' : 'not granted'}`, + '', + 'Computer Use needs both permissions to capture the screen and control the mouse and keyboard.', + 'After granting a permission, fully quit and reopen CloudCLI so the change takes effect.', + ].join('\n'); + + const { response } = await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, { type: 'info', - buttons: ['OK'], - title: 'Computer Use Preview', - message: 'Computer use needs an explicit safety gate before it can run.', + buttons: ['Open Screen Recording', 'Open Accessibility', 'Close'], + defaultId: 0, + cancelId: 2, + title: 'Computer Use Permissions', + message: 'Grant macOS permissions for Computer Use', + detail, + }); + + if (response === 0) { + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); + } else if (response === 1) { + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'); + } +} + +// Desktop control for cloud Computer Use: the desktop acts as a TeamViewer-style +// agent for hosted environments. Enabling here lets cloud agents drive THIS +// machine; the user picks whether to auto-connect or be asked per session. +async function showComputerUsePreview() { + const state = computerAgent?.getState() || { enabled: false, consentMode: 'ask' }; + const buttons = []; + const actions = []; + + if (!state.enabled) { + buttons.push('Enable — ask each session'); actions.push({ kind: 'enable', consentMode: 'ask' }); + buttons.push('Enable — auto-connect'); actions.push({ kind: 'enable', consentMode: 'auto' }); + } else { + buttons.push('Disable Computer Use'); actions.push({ kind: 'disable' }); + const otherMode = state.consentMode === 'auto' ? 'ask' : 'auto'; + buttons.push(`Switch to ${otherMode === 'auto' ? 'auto-connect' : 'ask each session'}`); + actions.push({ kind: 'enable', consentMode: otherMode }); + } + if (process.platform === 'darwin') { + buttons.push('macOS Permissions…'); actions.push({ kind: 'permissions' }); + } + buttons.push('Close'); actions.push({ kind: 'close' }); + + const statusLine = state.enabled + ? `Enabled — ${state.consentMode === 'auto' ? 'auto-connect' : 'ask each session'} · ${state.connectedCount || 0} environment(s) linked` + : 'Disabled'; + + const { response } = await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, { + type: 'question', + buttons, + defaultId: 0, + cancelId: buttons.length - 1, + title: 'Computer Use (Desktop Agent)', + message: 'Let cloud agents control this computer', detail: [ - 'The desktop shell is ready for controlled automation hooks, but full computer use is not enabled yet.', + `Status: ${statusLine}`, '', - 'Before this is exposed, CloudCLI needs per-session consent, a stop control, screen-capture permission checks, app/window scoping, and a provider-specific action loop.', + 'When enabled, agents running in your CloudCLI cloud environments can see this screen and drive its mouse and keyboard.', + '• Ask each session: you approve a prompt the first time each session wants control.', + '• Auto-connect: sessions can act without a prompt.', + process.platform === 'linux' ? '\nLinux needs X utilities (libxtst, imagemagick) installed to capture the screen and drive input.' : '', ].join('\n'), }); + + const action = actions[response]; + if (!action) return; + if (action.kind === 'enable') { + await computerAgent?.saveSettings({ enabled: true, consentMode: action.consentMode }); + } else if (action.kind === 'disable') { + await computerAgent?.saveSettings({ enabled: false, consentMode: state.consentMode }); + } else if (action.kind === 'permissions') { + await showMacComputerUsePermissions(); + } } async function refreshCloudEnvironments({ showErrors = false } = {}) { @@ -253,6 +353,8 @@ async function refreshCloudEnvironments({ showErrors = false } = {}) { throw error; } finally { isRefreshingCloud = false; + // Reconcile the Computer Use desktop agent with the latest running environments. + void computerAgent?.sync().catch((error) => console.error('[ComputerAgent] sync failed:', error?.message || error)); syncDesktopState(); } } @@ -658,6 +760,10 @@ function registerAppEvents() { } }); + app.on('before-quit', () => { + computerAgent?.stop(); + }); + app.on('before-quit', (event) => { if (isQuitting || !localServer?.hasOwnedServer()) return; if (localServer.getSettings().keepLocalServerRunning) { @@ -770,9 +876,18 @@ async function bootstrap() { callbackUrl: CALLBACK_URL, onChange: syncDesktopState, }); + computerAgent = new ComputerAgentController({ + appRoot: getAppRoot(), + settingsPath: getComputerUseSettingsPath(), + isPackaged: app.isPackaged, + getRunningEnvironmentUrls, + promptConsent: promptComputerUseConsent, + onChange: syncDesktopState, + }); await localServer.loadDesktopSettings(); await cloud.loadCloudAccount(); + await computerAgent.loadSettings(); registerProtocolHandler(); registerIpcHandlers(); diff --git a/package-lock.json b/package-lock.json index 31301958..d8c5eb9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@codemirror/merge": "^6.11.1", "@codemirror/theme-one-dark": "^6.1.2", "@iarna/toml": "^2.2.5", + "@nut-tree-fork/nut-js": "^4.2.6", "@octokit/rest": "^22.0.0", "@openai/codex-sdk": "^0.125.0", "@replit/codemirror-minimap": "^0.5.2", @@ -65,6 +66,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", + "screenshot-desktop": "^1.15.4", "tailwind-merge": "^3.3.1", "web-push": "^3.6.7", "ws": "^8.14.2" @@ -113,6 +115,10 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.0.4" + }, + "optionalDependencies": { + "@nut-tree-fork/nut-js": "^4.2.6", + "screenshot-desktop": "^1.15.4" } }, "node_modules/@alloc/quick-lru": { @@ -3084,6 +3090,456 @@ "node": ">=18.0.0" } }, + "node_modules/@jimp/bmp": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.22.12.tgz", + "integrity": "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12", + "bmp-js": "^0.1.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/core": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.22.12.tgz", + "integrity": "sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12", + "any-base": "^1.1.0", + "buffer": "^5.2.0", + "exif-parser": "^0.1.12", + "file-type": "^16.5.4", + "isomorphic-fetch": "^3.0.0", + "pixelmatch": "^4.0.2", + "tinycolor2": "^1.6.0" + } + }, + "node_modules/@jimp/custom": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", + "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/core": "^0.22.12" + } + }, + "node_modules/@jimp/gif": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.22.12.tgz", + "integrity": "sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12", + "gifwrap": "^0.10.1", + "omggif": "^1.0.9" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/jpeg": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.22.12.tgz", + "integrity": "sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12", + "jpeg-js": "^0.4.4" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", + "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", + "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.22.12.tgz", + "integrity": "sha512-SWVXx1yiuj5jZtMijqUfvVOJBwOifFn0918ou4ftoHgegc5aHWW5dZbYPjvC9fLpvz7oSlptNl2Sxr1zwofjTg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", + "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12", + "tinycolor2": "^1.6.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.22.12.tgz", + "integrity": "sha512-Eo3DmfixJw3N79lWk8q/0SDYbqmKt1xSTJ69yy8XLYQj9svoBbyRpSnHR+n9hOw5pKXytHwUW6nU4u1wegHNoQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5", + "@jimp/plugin-scale": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.22.12.tgz", + "integrity": "sha512-z0w/1xH/v/knZkpTNx+E8a7fnasQ2wHG5ze6y5oL2dhH1UufNua8gLQXlv8/W56+4nJ1brhSd233HBJCo01BXA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-crop": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5", + "@jimp/plugin-scale": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", + "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.22.12.tgz", + "integrity": "sha512-qpRM8JRicxfK6aPPqKZA6+GzBwUIitiHaZw0QrJ64Ygd3+AsTc7BXr+37k2x7QcyCvmKXY4haUrSIsBug4S3CA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.22.12.tgz", + "integrity": "sha512-jYgGdSdSKl1UUEanX8A85v4+QUm+PE8vHFwlamaKk89s+PXQe7eVE3eNeSZX4inCq63EHL7cX580dMqkoC3ZLw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.22.12.tgz", + "integrity": "sha512-LGuUTsFg+fOp6KBKrmLkX4LfyCy8IIsROwoUvsUPKzutSqMJnsm3JGDW2eOmWIS/jJpPaeaishjlxvczjgII+Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.22.12.tgz", + "integrity": "sha512-m251Rop7GN8W0Yo/rF9LWk6kNclngyjIJs/VXHToGQ6EGveOSTSQaX2Isi9f9lCDLxt+inBIb7nlaLLxnvHX8Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-rotate": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-gaussian": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.22.12.tgz", + "integrity": "sha512-sBfbzoOmJ6FczfG2PquiK84NtVGeScw97JsCC3rpQv1PHVWyW+uqWFF53+n3c8Y0P2HWlUjflEla2h/vWShvhg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-invert": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.22.12.tgz", + "integrity": "sha512-N+6rwxdB+7OCR6PYijaA/iizXXodpxOGvT/smd/lxeXsZ/empHmFFFJ/FaXcYh19Tm04dGDaXcNF/dN5nm6+xQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.22.12.tgz", + "integrity": "sha512-4AWZg+DomtpUA099jRV8IEZUfn1wLv6+nem4NRJC7L/82vxzLCgXKTxvNvBcNmJjT9yS1LAAmiJGdWKXG63/NA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-normalize": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.22.12.tgz", + "integrity": "sha512-0So0rexQivnWgnhacX4cfkM2223YdExnJTTy6d06WbkfZk5alHUx8MM3yEzwoCN0ErO7oyqEWRnEkGC+As1FtA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.22.12.tgz", + "integrity": "sha512-c7TnhHlxm87DJeSnwr/XOLjJU/whoiKYY7r21SbuJ5nuH+7a78EW1teOaj5gEr2wYEd7QtkFqGlmyGXY/YclyQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12", + "load-bmfont": "^1.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", + "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", + "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5", + "@jimp/plugin-crop": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-scale": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", + "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-shadow": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.22.12.tgz", + "integrity": "sha512-FX8mTJuCt7/3zXVoeD/qHlm4YH2bVqBuWQHXSuBK054e7wFRnRnbSLPUqAwSeYP3lWqpuQzJtgiiBxV3+WWwTg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blur": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.22.12.tgz", + "integrity": "sha512-4x5GrQr1a/9L0paBC/MZZJjjgjxLYrqSmWd+e+QfAEPvmRxdRoQ5uKEuNgXnm9/weHQBTnQBQsOY2iFja+XGAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-color": ">=0.8.0", + "@jimp/plugin-resize": ">=0.8.0" + } + }, + "node_modules/@jimp/plugins": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.22.12.tgz", + "integrity": "sha512-yBJ8vQrDkBbTgQZLty9k4+KtUQdRjsIDJSPjuI21YdVeqZxYywifHl4/XWILoTZsjTUASQcGoH0TuC0N7xm3ww==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/plugin-blit": "^0.22.12", + "@jimp/plugin-blur": "^0.22.12", + "@jimp/plugin-circle": "^0.22.12", + "@jimp/plugin-color": "^0.22.12", + "@jimp/plugin-contain": "^0.22.12", + "@jimp/plugin-cover": "^0.22.12", + "@jimp/plugin-crop": "^0.22.12", + "@jimp/plugin-displace": "^0.22.12", + "@jimp/plugin-dither": "^0.22.12", + "@jimp/plugin-fisheye": "^0.22.12", + "@jimp/plugin-flip": "^0.22.12", + "@jimp/plugin-gaussian": "^0.22.12", + "@jimp/plugin-invert": "^0.22.12", + "@jimp/plugin-mask": "^0.22.12", + "@jimp/plugin-normalize": "^0.22.12", + "@jimp/plugin-print": "^0.22.12", + "@jimp/plugin-resize": "^0.22.12", + "@jimp/plugin-rotate": "^0.22.12", + "@jimp/plugin-scale": "^0.22.12", + "@jimp/plugin-shadow": "^0.22.12", + "@jimp/plugin-threshold": "^0.22.12", + "timm": "^1.6.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/png": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.22.12.tgz", + "integrity": "sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/utils": "^0.22.12", + "pngjs": "^6.0.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/tiff": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.22.12.tgz", + "integrity": "sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg==", + "license": "MIT", + "optional": true, + "dependencies": { + "utif2": "^4.0.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/types": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.22.12.tgz", + "integrity": "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/bmp": "^0.22.12", + "@jimp/gif": "^0.22.12", + "@jimp/jpeg": "^0.22.12", + "@jimp/png": "^0.22.12", + "@jimp/tiff": "^0.22.12", + "timm": "^1.6.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/utils": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.22.12.tgz", + "integrity": "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "regenerator-runtime": "^0.13.3" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -3827,6 +4283,175 @@ "node": ">=10" } }, + "node_modules/@nut-tree-fork/default-clipboard-provider": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/default-clipboard-provider/-/default-clipboard-provider-4.2.6.tgz", + "integrity": "sha512-Hzqj57rheIMGtsS4zK4//kOhaX5FxMluOiz+4TVaHXx+idZS/bPhZwd8e6o1w1GT0PVJOUIP+4CdUe//k5VRig==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "clipboardy": "2.3.0" + } + }, + "node_modules/@nut-tree-fork/libnut": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/libnut/-/libnut-4.2.6.tgz", + "integrity": "sha512-2FCiTBokMGrMl4eL/trEIO+mtpkXpdPHoVKdTBmW8UBIbhCbrCKmnXb2skWGfVs+U3q7o5EYDjVTNUYaUWbaxQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@nut-tree-fork/libnut-darwin": "2.7.5", + "@nut-tree-fork/libnut-linux": "2.7.5", + "@nut-tree-fork/libnut-win32": "2.7.5" + }, + "engines": { + "node": ">=10.15.3" + } + }, + "node_modules/@nut-tree-fork/libnut-darwin": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/libnut-darwin/-/libnut-darwin-2.7.5.tgz", + "integrity": "sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q==", + "cpu": [ + "x64", + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "bindings": "1.5.0" + }, + "engines": { + "node": ">=10.15.3" + }, + "optionalDependencies": { + "@nut-tree-fork/node-mac-permissions": "2.2.1" + } + }, + "node_modules/@nut-tree-fork/libnut-linux": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/libnut-linux/-/libnut-linux-2.7.5.tgz", + "integrity": "sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg==", + "cpu": [ + "x64", + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "bindings": "1.5.0" + }, + "engines": { + "node": ">=10.15.3" + }, + "optionalDependencies": { + "@nut-tree-fork/node-mac-permissions": "2.2.1" + } + }, + "node_modules/@nut-tree-fork/libnut-win32": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/libnut-win32/-/libnut-win32-2.7.5.tgz", + "integrity": "sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw==", + "cpu": [ + "x64", + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "bindings": "1.5.0" + }, + "engines": { + "node": ">=10.15.3" + }, + "optionalDependencies": { + "@nut-tree-fork/node-mac-permissions": "2.2.1" + } + }, + "node_modules/@nut-tree-fork/node-mac-permissions": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/node-mac-permissions/-/node-mac-permissions-2.2.1.tgz", + "integrity": "sha512-iSfOTDiBZ7VDa17PoQje5rUaZSvSAaq+XEyXCmhPuQwV5XuNU02Grv6oFhsdpz89w7+UvB/8KX/cX5IYQ5o2Bw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "bindings": "1.5.0", + "node-addon-api": "5.0.0" + } + }, + "node_modules/@nut-tree-fork/node-mac-permissions/node_modules/node-addon-api": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.0.0.tgz", + "integrity": "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==", + "license": "MIT", + "optional": true + }, + "node_modules/@nut-tree-fork/nut-js": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/nut-js/-/nut-js-4.2.6.tgz", + "integrity": "sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg==", + "cpu": [ + "x64", + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux", + "darwin", + "win32" + ], + "dependencies": { + "@nut-tree-fork/default-clipboard-provider": "4.2.6", + "@nut-tree-fork/libnut": "4.2.6", + "@nut-tree-fork/provider-interfaces": "4.2.6", + "@nut-tree-fork/shared": "4.2.6", + "jimp": "0.22.10", + "node-abort-controller": "3.1.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@nut-tree-fork/provider-interfaces": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/provider-interfaces/-/provider-interfaces-4.2.6.tgz", + "integrity": "sha512-brtRegDkLSV0sa5DUAigjWf6hCoamBNPb/hKK9AQlW+j3BxQ/8djaEdEB2cihqUh1ZjEtgPyXRqpCWSdKCX68A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@nut-tree-fork/shared": "4.2.6" + } + }, + "node_modules/@nut-tree-fork/shared": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@nut-tree-fork/shared/-/shared-4.2.6.tgz", + "integrity": "sha512-xZaa0YtJt/DDDq/i1vZkabjq8HOWzfhXieMai61cMbYD11J6VhAfhV23ZtQEM02WG7nc2LKjl4UwRnQCteikwA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "jimp": "0.22.10", + "node-abort-controller": "3.1.1" + } + }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -5133,6 +5758,13 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT", + "optional": true + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -6220,6 +6852,19 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6402,6 +7047,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT", + "optional": true + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -6807,6 +7459,27 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -7332,6 +8005,13 @@ "dev": true, "license": "MIT" }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT", + "optional": true + }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", @@ -7473,6 +8153,16 @@ "node": "*" } }, + "node_modules/buffer-equal": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", + "integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -7865,6 +8555,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/centra": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", + "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "follow-redirects": "^1.15.6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8091,6 +8791,185 @@ "node": ">= 12" } }, + "node_modules/clipboardy": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.3.0.tgz", + "integrity": "sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "arch": "^2.1.1", + "execa": "^1.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clipboardy/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "license": "MIT", + "optional": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/clipboardy/node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "license": "MIT", + "optional": true, + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clipboardy/node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "license": "MIT", + "optional": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clipboardy/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "optional": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clipboardy/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clipboardy/node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "license": "MIT", + "optional": true, + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/clipboardy/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/clipboardy/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/clipboardy/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "license": "MIT", + "optional": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clipboardy/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clipboardy/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/clipboardy/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -8349,7 +9228,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -9240,6 +10119,12 @@ "node": ">=0.10.0" } }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", + "optional": true + }, "node_modules/dompurify": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", @@ -10549,6 +11434,16 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -10556,6 +11451,16 @@ "dev": true, "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -10619,6 +11524,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==", + "optional": true + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -10930,6 +11841,24 @@ "node": ">= 12" } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "optional": true, + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -11042,6 +11971,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -11212,7 +12162,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -11426,6 +12376,17 @@ "node": ">= 14" } }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "license": "MIT", + "optional": true, + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -11520,6 +12481,17 @@ "node": ">=10.13.0" } }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "license": "MIT", + "optional": true, + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -12288,6 +13260,23 @@ "node": ">= 4" } }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "license": "MIT", + "optional": true + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -12383,7 +13372,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -12737,6 +13726,13 @@ "node": ">=8" } }, + "node_modules/is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -13121,6 +14117,17 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "optional": true, + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/issue-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", @@ -13189,6 +14196,19 @@ "node": ">=10" } }, + "node_modules/jimp": { + "version": "0.22.10", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-0.22.10.tgz", + "integrity": "sha512-lCaHIJAgTOsplyJzC1w/laxSxrbSsEBw4byKwXgUdMmh+ayPsnidTblenQm+IvhIs44Gcuvlb6pd2LQ0wcKaKg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@jimp/custom": "^0.22.10", + "@jimp/plugins": "^0.22.10", + "@jimp/types": "^0.22.10", + "regenerator-runtime": "^0.13.3" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -13208,6 +14228,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/js-base64": { "version": "3.7.7", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", @@ -13639,6 +14666,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/load-bmfont": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.2.tgz", + "integrity": "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer-equal": "0.0.1", + "mime": "^1.3.4", + "parse-bmfont-ascii": "^1.0.3", + "parse-bmfont-binary": "^1.0.5", + "parse-bmfont-xml": "^1.1.4", + "phin": "^3.7.1", + "xhr": "^2.0.1", + "xtend": "^4.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -15065,6 +16109,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-document": { + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "dom-walk": "^0.1.0" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -15434,6 +16488,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-abi": { "version": "3.75.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", @@ -15458,6 +16519,13 @@ "node": ">=10" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-addon-api": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", @@ -15888,6 +16956,13 @@ "dev": true, "license": "MIT" }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT", + "optional": true + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -16061,6 +17136,16 @@ "node": ">=8" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -16168,6 +17253,31 @@ "node": ">=6" } }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "license": "MIT", + "optional": true + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -16206,6 +17316,13 @@ "node": ">= 0.10" } }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT", + "optional": true + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -16284,7 +17401,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16365,6 +17482,20 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -16378,6 +17509,20 @@ "dev": true, "license": "MIT" }, + "node_modules/phin": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz", + "integrity": "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true, + "dependencies": { + "centra": "^2.7.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16414,6 +17559,29 @@ "node": ">= 6" } }, + "node_modules/pixelmatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz", + "integrity": "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==", + "license": "ISC", + "optional": true, + "dependencies": { + "pngjs": "^3.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -16495,6 +17663,16 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -16746,6 +17924,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -17298,6 +18486,65 @@ "node": ">= 6" } }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "optional": true, + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "optional": true, + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -17525,6 +18772,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -17869,9 +19123,8 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -17883,9 +19136,8 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -17896,9 +19148,8 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -17918,9 +19169,8 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -18188,7 +19438,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -18203,6 +19453,22 @@ "loose-envify": "^1.1.0" } }, + "node_modules/screenshot-desktop": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/screenshot-desktop/-/screenshot-desktop-1.15.4.tgz", + "integrity": "sha512-nvYzQH+pzA9cJVKxZzde4mX1ymZoPETUp3vT5vl72460782i+ZctSbV71vu+YU/9ktAEa9nONLRLpgliXgB5LA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/bencevans" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "temp": "^0.9.4" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -19473,6 +20739,16 @@ "node": ">=0.10.0" } }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -19495,6 +20771,24 @@ "node": ">=0.10.0" } }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/style-mod": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", @@ -19780,9 +21074,8 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -19861,6 +21154,13 @@ "node": ">=0.8" } }, + "node_modules/timm": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz", + "integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==", + "license": "MIT", + "optional": true + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -19881,6 +21181,13 @@ "semver": "bin/semver" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT", + "optional": true + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -19980,6 +21287,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -21252,6 +22577,16 @@ "dev": true, "license": "(WTFPL OR MIT)" }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "license": "MIT", + "optional": true, + "dependencies": { + "pako": "^1.0.11" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -21530,6 +22865,13 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT", + "optional": true + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -21822,6 +23164,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xhr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz", + "integrity": "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "global": "~4.4.0", + "is-function": "^1.0.1", + "parse-headers": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "license": "MIT", + "optional": true + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "optional": true, + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 0c5e8064..215e0f74 100644 --- a/package.json +++ b/package.json @@ -227,5 +227,9 @@ "lint-staged": { "src/**/*.{ts,tsx,js,jsx}": "eslint", "server/**/*.{js,ts}": "eslint" + }, + "optionalDependencies": { + "@nut-tree-fork/nut-js": "^4.2.6", + "screenshot-desktop": "^1.15.4" } } diff --git a/server/computer-use-agent.ts b/server/computer-use-agent.ts new file mode 100644 index 00000000..e39fd0dc --- /dev/null +++ b/server/computer-use-agent.ts @@ -0,0 +1,260 @@ +#!/usr/bin/env node +/** + * CloudCLI Computer Use — Desktop Agent. + * + * Standalone executor for the cloud relay. The Electron desktop app spawns this + * process (via ELECTRON_RUN_AS_NODE) whenever Computer Use is enabled and the + * user has running cloud environments. It opens an outbound websocket to each + * environment's `/desktop-agent` endpoint and executes the `computer_*` actions + * the hosted server relays, returning a fresh screenshot each time. + * + * It is fully self-contained: it reuses the shared nut-js executor module and + * does NOT depend on the local CloudCLI server. Consent is enforced here (the + * controlled machine is the authority): in `ask` mode the agent asks the parent + * Electron process for a per-session decision before the first action runs. + */ +import readline from 'node:readline'; + +import { WebSocket } from 'ws'; + +import { + executor, + captureScreenshot, + getRuntimeReadiness, + type ExecutorTarget, + type Point, + type ClickButton, + type ScrollDirection, +} from './modules/computer-use/computer-executor.js'; + +type ConsentMode = 'ask' | 'auto'; + +type RelayMessage = { + kind?: string; + type?: string; + id?: string; + params?: Record; +}; + +const IPC_PREFIX = '@@CUAGENT@@'; +const RECONNECT_BASE_MS = 2000; +const RECONNECT_MAX_MS = 30_000; + +const consentMode: ConsentMode = process.env.CLOUDCLI_COMPUTER_USE_CONSENT_MODE === 'auto' ? 'auto' : 'ask'; +const agentLabel = process.env.CLOUDCLI_DESKTOP_AGENT_LABEL || 'cloudcli-desktop'; + +function parseTargets(): string[] { + const raw = + process.env.CLOUDCLI_DESKTOP_AGENT_URLS || + process.env.CLOUDCLI_DESKTOP_AGENT_URL || + ''; + return raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +// --- Parent (Electron) IPC over stdout/stdin ------------------------------- + +function emitToParent(message: Record): void { + process.stdout.write(`${IPC_PREFIX} ${JSON.stringify(message)}\n`); +} + +/** Per-session consent decisions, and resolvers awaiting a parent reply. */ +const sessionConsent = new Map(); +const pendingConsent = new Map void>>(); + +const stdinReader = readline.createInterface({ input: process.stdin }); +stdinReader.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed.startsWith(IPC_PREFIX)) { + return; + } + try { + const payload = JSON.parse(trimmed.slice(IPC_PREFIX.length).trim()) as Record; + if (payload.type === 'consent-response' && typeof payload.sessionId === 'string') { + const allow = payload.allow === true; + sessionConsent.set(payload.sessionId, allow ? 'granted' : 'denied'); + const waiters = pendingConsent.get(payload.sessionId) || []; + pendingConsent.delete(payload.sessionId); + for (const resolve of waiters) { + resolve(allow); + } + } else if (payload.type === 'revoke-session' && typeof payload.sessionId === 'string') { + sessionConsent.delete(payload.sessionId); + } + } catch { + // ignore malformed control lines + } +}); + +async function ensureConsent(sessionId: string): Promise { + if (consentMode === 'auto') { + return true; + } + const existing = sessionConsent.get(sessionId); + if (existing === 'granted') return true; + if (existing === 'denied') return false; + + // Ask the parent (Electron) to prompt the user, and wait for the decision. + return new Promise((resolve) => { + const waiters = pendingConsent.get(sessionId) || []; + waiters.push(resolve); + pendingConsent.set(sessionId, waiters); + emitToParent({ type: 'consent-request', sessionId }); + }); +} + +// --- Action execution ------------------------------------------------------ + +function asPoint(value: unknown): Point | undefined { + if (value && typeof value === 'object') { + const point = value as Record; + if (typeof point.x === 'number' && typeof point.y === 'number') { + return { x: point.x, y: point.y }; + } + } + return undefined; +} + +async function snapshot(target: ExecutorTarget) { + const { dataUrl, size } = await captureScreenshot(); + return { screenshotDataUrl: dataUrl, displaySize: size || target.displaySize }; +} + +async function runAction(type: string, params: Record): Promise> { + const readiness = getRuntimeReadiness(); + if (!readiness.nutInstalled || !readiness.screenshotInstalled) { + throw new Error('Computer Use runtime is not installed on the desktop agent.'); + } + + const target: ExecutorTarget = { + displaySize: (params.displaySize as ExecutorTarget['displaySize']) ?? null, + }; + const point = asPoint(params.point); + + switch (type) { + case 'screenshot': + return snapshot(target); + case 'cursor_position': { + const position = await executor.cursorPosition(target); + return { ...(await snapshot(target)), position, cursor: position }; + } + case 'mouse_move': + await executor.moveTo(target, point as Point); + return { ...(await snapshot(target)), cursor: point }; + case 'click': + await executor.click(target, (params.button as ClickButton) || 'left', point, params.double === true); + return { ...(await snapshot(target)), cursor: point ?? null }; + case 'drag': + await executor.drag(target, asPoint(params.from) as Point, asPoint(params.to) as Point, (params.button as ClickButton) || 'left'); + return { ...(await snapshot(target)), cursor: asPoint(params.to) ?? null }; + case 'type': + await executor.type(String(params.text ?? '')); + return snapshot(target); + case 'key': + await executor.pressChord(String(params.key ?? '')); + return snapshot(target); + case 'scroll': + await executor.scroll( + target, + (params.direction as ScrollDirection) || 'down', + typeof params.amount === 'number' ? params.amount : 3, + point, + ); + return { ...(await snapshot(target)), cursor: point ?? null }; + case 'wait': + await new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.min(Number(params.ms) || 1000, 10_000)))); + return snapshot(target); + default: + throw new Error(`Unsupported computer action: ${type}`); + } +} + +// --- Relay connection ------------------------------------------------------ + +function connect(url: string): void { + let reconnectMs = RECONNECT_BASE_MS; + let socket: WebSocket | null = null; + + const open = () => { + socket = new WebSocket(url, { + headers: process.env.CLOUDCLI_DESKTOP_AGENT_TOKEN + ? { 'x-cloudcli-agent-token': process.env.CLOUDCLI_DESKTOP_AGENT_TOKEN } + : undefined, + }); + + socket.on('open', () => { + reconnectMs = RECONNECT_BASE_MS; + emitToParent({ type: 'connected', url }); + socket?.send(JSON.stringify({ kind: 'register', label: agentLabel, consentMode })); + }); + + socket.on('message', async (raw) => { + let message: RelayMessage; + try { + message = JSON.parse(String(raw)) as RelayMessage; + } catch { + return; + } + const kind = message.kind || message.type; + if (kind !== 'computer_relay' || typeof message.id !== 'string') { + return; + } + + const id = message.id; + const type = String(message.type || (message.params?.type as string) || ''); + const params = message.params || {}; + const sessionId = typeof params.sessionId === 'string' ? params.sessionId : 'default'; + + if (type === 'stop_session') { + sessionConsent.delete(sessionId); + socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, result: { ok: true } })); + return; + } + + try { + const allowed = await ensureConsent(sessionId); + if (!allowed) { + socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, error: 'The user denied desktop control for this session.' })); + return; + } + const result = await runAction(type, params); + socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, result })); + } catch (error) { + socket?.send(JSON.stringify({ + kind: 'computer_relay_result', + id, + error: error instanceof Error ? error.message : 'Desktop agent action failed.', + })); + } + }); + + const scheduleReconnect = () => { + emitToParent({ type: 'disconnected', url }); + setTimeout(open, reconnectMs); + reconnectMs = Math.min(reconnectMs * 2, RECONNECT_MAX_MS); + }; + + socket.on('close', scheduleReconnect); + socket.on('error', () => { + try { socket?.close(); } catch { /* noop */ } + }); + }; + + open(); +} + +function main(): void { + const targets = parseTargets(); + if (targets.length === 0) { + emitToParent({ type: 'error', message: 'No desktop-agent target URLs provided.' }); + return; + } + emitToParent({ type: 'starting', targets, consentMode }); + for (const url of targets) { + connect(url); + } +} + +main(); diff --git a/server/computer-use-mcp.ts b/server/computer-use-mcp.ts new file mode 100644 index 00000000..807c8c16 --- /dev/null +++ b/server/computer-use-mcp.ts @@ -0,0 +1,388 @@ +#!/usr/bin/env node +import './load-env.js'; + +type JsonRpcRequest = { + jsonrpc: '2.0'; + id?: string | number | null; + method: string; + params?: Record; +}; + +type ToolDefinition = { + name: string; + description: string; + inputSchema: Record; +}; + +const readString = (value: unknown, name: string): string => { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${name} is required.`); + } + return value.trim(); +}; + +const readNumber = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined; + +const apiUrl = (process.env.CLOUDCLI_COMPUTER_USE_API_URL || 'http://127.0.0.1:3001/api/computer-use-mcp').replace(/\/$/, ''); +const apiToken = process.env.CLOUDCLI_COMPUTER_USE_MCP_TOKEN || ''; + +async function callComputerUseApi(toolName: string, input: Record) { + if (!apiToken) { + throw new Error('CLOUDCLI_COMPUTER_USE_MCP_TOKEN is not configured.'); + } + + const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + const data = await response.json() as { success?: boolean; data?: unknown; error?: string }; + if (!response.ok || data.success === false) { + throw new Error(data.error || `Computer Use API request failed (${response.status})`); + } + return data.data; +} + +/** Pulls the most recent screenshot data URL out of an API result, if present. */ +function findScreenshot(value: unknown): string | null { + if (!value || typeof value !== 'object') { + return null; + } + const record = value as Record; + if (typeof record.screenshotDataUrl === 'string') { + return record.screenshotDataUrl; + } + if (record.session && typeof record.session === 'object') { + const session = record.session as Record; + if (typeof session.screenshotDataUrl === 'string') { + return session.screenshotDataUrl; + } + } + return null; +} + +/** Removes the large data URL from JSON so the text block stays small. */ +function stripScreenshot(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stripScreenshot); + } + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + if (key === 'screenshotDataUrl' && typeof val === 'string') { + out.screenshot = '[returned as image]'; + continue; + } + out[key] = stripScreenshot(val); + } + return out; + } + return value; +} + +/** + * Builds an MCP tool result. Screenshots are returned as an `image` content block so + * vision-capable models actually see the desktop — a JSON data-URL string would not work. + */ +function toolResult(value: unknown) { + const content: Array> = [ + { type: 'text', text: JSON.stringify(stripScreenshot(value), null, 2) }, + ]; + + const screenshot = findScreenshot(value); + const match = screenshot ? /^data:(image\/[a-z]+);base64,(.+)$/i.exec(screenshot) : null; + if (match) { + content.push({ type: 'image', data: match[2], mimeType: match[1] }); + } + + return { content }; +} + +const sessionIdSchema = { + type: 'object', + properties: { + sessionId: { type: 'string', description: 'Computer Use session id.' }, + }, + required: ['sessionId'], +}; + +const pointSchema = { + type: 'object', + properties: { + sessionId: { type: 'string' }, + x: { type: 'number', description: 'X coordinate in screenshot pixel space.' }, + y: { type: 'number', description: 'Y coordinate in screenshot pixel space.' }, + }, + required: ['sessionId'], +}; + +const tools: ToolDefinition[] = [ + { + name: 'computer_create_session', + description: 'Create a Computer Use session that controls the user desktop. The session starts WITHOUT control: the user must grant control in the Computer panel before any action will work. Returns a screenshot once available.', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'computer_list_sessions', + description: 'List Computer Use sessions and whether the user has granted control.', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'computer_screenshot', + description: 'Capture the current desktop screenshot. Returns the image plus the display size to use for coordinates.', + inputSchema: sessionIdSchema, + }, + { + name: 'computer_cursor_position', + description: 'Get the current mouse cursor position in screenshot pixel space.', + inputSchema: sessionIdSchema, + }, + { + name: 'computer_mouse_move', + description: 'Move the mouse cursor to x/y (screenshot pixel space).', + inputSchema: { + type: 'object', + properties: { sessionId: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' } }, + required: ['sessionId', 'x', 'y'], + }, + }, + { + name: 'computer_left_click', + description: 'Left-click. Optionally provide x/y to move there first.', + inputSchema: pointSchema, + }, + { + name: 'computer_right_click', + description: 'Right-click. Optionally provide x/y to move there first.', + inputSchema: pointSchema, + }, + { + name: 'computer_middle_click', + description: 'Middle-click. Optionally provide x/y to move there first.', + inputSchema: pointSchema, + }, + { + name: 'computer_double_click', + description: 'Double-click. Optionally provide x/y to move there first.', + inputSchema: pointSchema, + }, + { + name: 'computer_left_click_drag', + description: 'Press the left button at start coordinates and release at end coordinates (drag).', + inputSchema: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + startX: { type: 'number' }, startY: { type: 'number' }, + endX: { type: 'number' }, endY: { type: 'number' }, + }, + required: ['sessionId', 'startX', 'startY', 'endX', 'endY'], + }, + }, + { + name: 'computer_type', + description: 'Type a string of text at the current focus.', + inputSchema: { + type: 'object', + properties: { sessionId: { type: 'string' }, text: { type: 'string' } }, + required: ['sessionId', 'text'], + }, + }, + { + name: 'computer_key', + description: 'Press a key or key chord using xdotool-style names, e.g. "Return", "Escape", "ctrl+a", "Page_Down".', + inputSchema: { + type: 'object', + properties: { sessionId: { type: 'string' }, key: { type: 'string' } }, + required: ['sessionId', 'key'], + }, + }, + { + name: 'computer_scroll', + description: 'Scroll the mouse wheel. direction is up/down/left/right; amount is the number of steps. Optionally provide x/y to move there first.', + inputSchema: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] }, + amount: { type: 'number' }, + x: { type: 'number' }, + y: { type: 'number' }, + }, + required: ['sessionId', 'direction'], + }, + }, + { + name: 'computer_wait', + description: 'Wait for a short period (milliseconds, max 10000) then return a fresh screenshot.', + inputSchema: { + type: 'object', + properties: { sessionId: { type: 'string' }, timeoutMs: { type: 'number' } }, + required: ['sessionId'], + }, + }, + { + name: 'computer_close_session', + description: 'Stop a Computer Use session and revoke control.', + inputSchema: sessionIdSchema, + }, +]; + +async function callTool(name: string, args: Record) { + switch (name) { + case 'computer_create_session': + return toolResult(await callComputerUseApi(name, {})); + case 'computer_list_sessions': + return toolResult(await callComputerUseApi(name, {})); + case 'computer_screenshot': + case 'computer_cursor_position': + case 'computer_close_session': + return toolResult(await callComputerUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') })); + case 'computer_mouse_move': + return toolResult(await callComputerUseApi(name, { + sessionId: readString(args.sessionId, 'sessionId'), + x: readNumber(args.x), + y: readNumber(args.y), + })); + case 'computer_left_click': + case 'computer_right_click': + case 'computer_middle_click': + case 'computer_double_click': + return toolResult(await callComputerUseApi(name, { + sessionId: readString(args.sessionId, 'sessionId'), + x: readNumber(args.x), + y: readNumber(args.y), + })); + case 'computer_left_click_drag': + return toolResult(await callComputerUseApi(name, { + sessionId: readString(args.sessionId, 'sessionId'), + startX: readNumber(args.startX), + startY: readNumber(args.startY), + endX: readNumber(args.endX), + endY: readNumber(args.endY), + })); + case 'computer_type': + return toolResult(await callComputerUseApi(name, { + sessionId: readString(args.sessionId, 'sessionId'), + text: readString(args.text, 'text'), + })); + case 'computer_key': + return toolResult(await callComputerUseApi(name, { + sessionId: readString(args.sessionId, 'sessionId'), + key: readString(args.key, 'key'), + })); + case 'computer_scroll': + return toolResult(await callComputerUseApi(name, { + sessionId: readString(args.sessionId, 'sessionId'), + direction: typeof args.direction === 'string' ? args.direction : 'up', + amount: readNumber(args.amount), + x: readNumber(args.x), + y: readNumber(args.y), + })); + case 'computer_wait': + return toolResult(await callComputerUseApi(name, { + sessionId: readString(args.sessionId, 'sessionId'), + timeoutMs: readNumber(args.timeoutMs), + })); + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +async function handleMessage(message: JsonRpcRequest) { + if (message.method === 'initialize') { + return { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'cloudcli-computer-use', version: '1.0.0' }, + }; + } + + if (message.method === 'tools/list') { + return { tools }; + } + + if (message.method === 'tools/call') { + const params = message.params || {}; + const name = readString(params.name, 'name'); + const args = (params.arguments && typeof params.arguments === 'object' + ? params.arguments + : {}) as Record; + return callTool(name, args); + } + + if (message.method.startsWith('notifications/')) { + return undefined; + } + + throw new Error(`Unsupported method: ${message.method}`); +} + +function writeMessage(message: Record) { + const payload = JSON.stringify(message); + process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`); +} + +function sendResult(id: string | number | null | undefined, result: unknown) { + if (id === undefined) { + return; + } + writeMessage({ jsonrpc: '2.0', id, result }); +} + +function sendError(id: string | number | null | undefined, error: unknown) { + if (id === undefined) { + return; + } + writeMessage({ + jsonrpc: '2.0', + id, + error: { + code: -32000, + message: error instanceof Error ? error.message : String(error), + }, + }); +} + +let buffer = Buffer.alloc(0); + +process.stdin.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } + + const header = buffer.slice(0, headerEnd).toString('utf8'); + const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header); + if (!lengthMatch) { + buffer = buffer.slice(headerEnd + 4); + continue; + } + + const length = Number.parseInt(lengthMatch[1], 10); + const messageStart = headerEnd + 4; + const messageEnd = messageStart + length; + if (buffer.length < messageEnd) { + return; + } + + const rawMessage = buffer.slice(messageStart, messageEnd).toString('utf8'); + buffer = buffer.slice(messageEnd); + + void (async () => { + const request = JSON.parse(rawMessage) as JsonRpcRequest; + try { + const result = await handleMessage(request); + sendResult(request.id, result); + } catch (error) { + sendError(request.id, error); + } + })(); + } +}); diff --git a/server/index.js b/server/index.js index cbe88ec5..28705cf5 100755 --- a/server/index.js +++ b/server/index.js @@ -65,6 +65,8 @@ import browserUseRoutes from './modules/browser-use/browser-use.routes.js'; import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js'; import { browserUseService } from './modules/browser-use/browser-use.service.js'; import computerUseRoutes from './modules/computer-use/computer-use.routes.js'; +import computerUseMcpRoutes from './modules/computer-use/computer-use-mcp.routes.js'; +import { computerUseService } from './modules/computer-use/computer-use.service.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js'; import { configureWebPush } from './services/vapid-keys.js'; @@ -203,6 +205,9 @@ app.use('/api/browser-use-mcp', browserUseMcpRoutes); // Browser Use API Routes (protected) app.use('/api/browser-use', authenticateToken, browserUseRoutes); +// Computer Use MCP bridge API (local token protected) +app.use('/api/computer-use-mcp', computerUseMcpRoutes); + // Computer Use API Routes (protected) app.use('/api/computer-use', authenticateToken, computerUseRoutes); @@ -1760,6 +1765,11 @@ async function startServer() { } catch (err) { console.error('[Browser Use] Error stopping sessions during shutdown:', err?.message || err); } + try { + await computerUseService.stopAllSessions(); + } catch (err) { + console.error('[Computer Use] Error stopping sessions during shutdown:', err?.message || err); + } try { await stopAllPlugins(); } catch (err) { diff --git a/server/modules/computer-use/computer-executor.ts b/server/modules/computer-use/computer-executor.ts new file mode 100644 index 00000000..441c4e04 --- /dev/null +++ b/server/modules/computer-use/computer-executor.ts @@ -0,0 +1,242 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +export type Point = { x: number; y: number }; +export type ClickButton = 'left' | 'right' | 'middle'; +export type ScrollDirection = 'up' | 'down' | 'left' | 'right'; +export type DisplaySize = { width: number; height: number }; + +export type RuntimeReadiness = { + nut: any | null; + screenshot: any | null; + nutInstalled: boolean; + screenshotInstalled: boolean; +}; + +/** + * Coordinate space the executor reports/accepts. The screenshot pixel space is + * the canonical space agents and users address; it is mapped to the nut-js + * logical mouse space before any action runs. + */ +export type ExecutorTarget = { + displaySize: DisplaySize | null; +}; + +export function getNut(): any | null { + try { + return require('@nut-tree-fork/nut-js'); + } catch { + return null; + } +} + +export function getScreenshot(): any | null { + try { + const mod = require('screenshot-desktop'); + return mod?.default || mod; + } catch { + return null; + } +} + +export function getRuntimeReadiness(): RuntimeReadiness { + const nut = getNut(); + const screenshot = getScreenshot(); + return { + nut, + screenshot, + nutInstalled: Boolean(nut), + screenshotInstalled: typeof screenshot === 'function', + }; +} + +/** Reads the pixel dimensions from a PNG/JPEG buffer header without decoding it. */ +export function readImageSize(buffer: Buffer): DisplaySize | null { + // PNG: 8-byte signature, then IHDR chunk with width/height as big-endian uint32. + if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50) { + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; + } + // JPEG: scan for a Start-Of-Frame marker (0xFFC0..0xFFCF, excluding C4/C8/CC). + if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) { + let offset = 2; + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = buffer[offset + 1]; + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }; + } + offset += 2 + buffer.readUInt16BE(offset + 2); + } + } + return null; +} + +export async function captureScreenshot(): Promise<{ dataUrl: string; size: DisplaySize | null }> { + const screenshot = getScreenshot(); + if (typeof screenshot !== 'function') { + throw new Error('Computer Use runtime is not available.'); + } + const buffer: Buffer = await screenshot({ format: 'png' }); + return { + dataUrl: `data:image/png;base64,${buffer.toString('base64')}`, + size: readImageSize(buffer), + }; +} + +/** Returns the mouse coordinate space size (logical screen pixels). */ +export async function getMouseSpaceSize(): Promise { + const nut = getNut(); + if (!nut) { + throw new Error('Computer Use runtime is not available.'); + } + const width = await nut.screen.width(); + const height = await nut.screen.height(); + return { width, height }; +} + +/** Maps a point from screenshot/image space to the mouse coordinate space. */ +export async function toMouseSpace(target: ExecutorTarget, point: Point): Promise { + const mouseSize = await getMouseSpaceSize(); + const image = target.displaySize || mouseSize; + const scaleX = image.width ? mouseSize.width / image.width : 1; + const scaleY = image.height ? mouseSize.height / image.height : 1; + return { + x: Math.round(point.x * scaleX), + y: Math.round(point.y * scaleY), + }; +} + +/** Maps a point from the mouse coordinate space back to screenshot/image space. */ +export function toImageSpace(target: ExecutorTarget, point: Point, mouseSize: DisplaySize): Point { + const image = target.displaySize || mouseSize; + const scaleX = mouseSize.width ? image.width / mouseSize.width : 1; + const scaleY = mouseSize.height ? image.height / mouseSize.height : 1; + return { + x: Math.round(point.x * scaleX), + y: Math.round(point.y * scaleY), + }; +} + +function nutButton(nut: any, button: ClickButton) { + if (button === 'right') return nut.Button.RIGHT; + if (button === 'middle') return nut.Button.MIDDLE; + return nut.Button.LEFT; +} + +/** Maps a key name (xdotool-style, as Anthropic's computer tool emits) to a nut-js Key. */ +function nutKey(nut: any, token: string): any { + const map: Record = { + return: 'Enter', enter: 'Enter', esc: 'Escape', escape: 'Escape', tab: 'Tab', + space: 'Space', backspace: 'Backspace', delete: 'Delete', del: 'Delete', insert: 'Insert', + up: 'Up', down: 'Down', left: 'Left', right: 'Right', + home: 'Home', end: 'End', pageup: 'PageUp', page_up: 'PageUp', pagedown: 'PageDown', page_down: 'PageDown', + ctrl: 'LeftControl', control: 'LeftControl', alt: 'LeftAlt', shift: 'LeftShift', + meta: 'LeftSuper', super: 'LeftSuper', cmd: 'LeftSuper', win: 'LeftSuper', + capslock: 'CapsLock', + }; + const lower = token.toLowerCase(); + if (map[lower]) { + return nut.Key[map[lower]]; + } + if (/^f([1-9]|1[0-9]|2[0-4])$/.test(lower)) { + return nut.Key[`F${lower.slice(1)}`]; + } + if (token.length === 1) { + const upper = token.toUpperCase(); + if (nut.Key[upper] !== undefined) { + return nut.Key[upper]; + } + if (nut.Key[`Num${token}`] !== undefined && /[0-9]/.test(token)) { + return nut.Key[`Num${token}`]; + } + } + throw new Error(`Unsupported key: ${token}`); +} + +/** + * The cross-platform OS executor. It is intentionally free of any server, + * database, or session dependencies so it can run both inside the local server + * process (OSS mode) and inside the standalone desktop agent (cloud relay). + */ +export const executor = { + async configure() { + const nut = getNut(); + if (nut) { + // Make actions responsive; the agent loop already paces itself with screenshots. + nut.mouse.config.autoDelayMs = 2; + nut.keyboard.config.autoDelayMs = 2; + } + return nut; + }, + + async cursorPosition(target: ExecutorTarget): Promise { + const nut = await this.configure(); + const mouseSize = await getMouseSpaceSize(); + const pos = await nut.mouse.getPosition(); + return toImageSpace(target, { x: pos.x, y: pos.y }, mouseSize); + }, + + async moveTo(target: ExecutorTarget, point: Point): Promise { + const nut = await this.configure(); + const dest = await toMouseSpace(target, point); + await nut.mouse.setPosition(new nut.Point(dest.x, dest.y)); + }, + + async click(target: ExecutorTarget, button: ClickButton, point?: Point, doubleClick = false): Promise { + const nut = await this.configure(); + if (point) { + await this.moveTo(target, point); + } + if (doubleClick) { + await nut.mouse.doubleClick(nutButton(nut, button)); + } else { + await nut.mouse.click(nutButton(nut, button)); + } + }, + + async drag(target: ExecutorTarget, from: Point, to: Point, button: ClickButton = 'left'): Promise { + const nut = await this.configure(); + const start = await toMouseSpace(target, from); + const end = await toMouseSpace(target, to); + await nut.mouse.setPosition(new nut.Point(start.x, start.y)); + await nut.mouse.pressButton(nutButton(nut, button)); + await nut.mouse.setPosition(new nut.Point(end.x, end.y)); + await nut.mouse.releaseButton(nutButton(nut, button)); + }, + + async type(text: string): Promise { + const nut = await this.configure(); + await nut.keyboard.type(text); + }, + + async pressChord(chord: string): Promise { + const nut = await this.configure(); + const tokens = chord.split('+').map((token) => token.trim()).filter(Boolean); + if (tokens.length === 0) { + return; + } + const keys = tokens.map((token) => nutKey(nut, token)); + for (const key of keys) { + await nut.keyboard.pressKey(key); + } + for (const key of [...keys].reverse()) { + await nut.keyboard.releaseKey(key); + } + }, + + async scroll(target: ExecutorTarget, direction: ScrollDirection, amount: number, point?: Point): Promise { + const nut = await this.configure(); + if (point) { + await this.moveTo(target, point); + } + const steps = Math.max(1, Math.round(amount)); + if (direction === 'up') await nut.mouse.scrollUp(steps); + else if (direction === 'down') await nut.mouse.scrollDown(steps); + else if (direction === 'left') await nut.mouse.scrollLeft(steps); + else await nut.mouse.scrollRight(steps); + }, +}; diff --git a/server/modules/computer-use/computer-use-mcp.routes.ts b/server/modules/computer-use/computer-use-mcp.routes.ts new file mode 100644 index 00000000..0840a415 --- /dev/null +++ b/server/modules/computer-use/computer-use-mcp.routes.ts @@ -0,0 +1,118 @@ +import express from 'express'; + +import { computerUseService } from '@/modules/computer-use/computer-use.service.js'; + +const router = express.Router(); + +function readBearerToken(header: unknown): string | null { + if (typeof header !== 'string') { + return null; + } + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match?.[1] || null; +} + +function toButton(value: unknown): 'left' | 'right' | 'middle' { + return value === 'right' || value === 'middle' ? value : 'left'; +} + +function toScrollDirection(value: unknown): 'up' | 'down' | 'left' | 'right' { + return value === 'down' || value === 'left' || value === 'right' ? value : 'up'; +} + +function point(input: Record): { x: number; y: number } | undefined { + return typeof input.x === 'number' && typeof input.y === 'number' + ? { x: input.x, y: input.y } + : undefined; +} + +router.use((req, res, next) => { + const expected = computerUseService.getMcpToken(); + const token = readBearerToken(req.headers.authorization) || String(req.headers['x-computer-use-mcp-token'] || ''); + if (!token || token !== expected) { + res.status(401).json({ success: false, error: 'Invalid Computer Use MCP token.' }); + return; + } + next(); +}); + +router.post('/tools/:toolName', async (req, res) => { + try { + const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record; + const sessionId = typeof input.sessionId === 'string' ? input.sessionId : ''; + const toolName = req.params.toolName; + let result: unknown; + + switch (toolName) { + case 'computer_create_session': + result = await computerUseService.createAgentSession(); + break; + case 'computer_list_sessions': + result = await computerUseService.listAgentSessions(); + break; + case 'computer_screenshot': + result = await computerUseService.agentScreenshot(sessionId); + break; + case 'computer_cursor_position': + result = await computerUseService.agentCursorPosition(sessionId); + break; + case 'computer_mouse_move': + result = await computerUseService.agentMouseMove(sessionId, point(input) || { x: 0, y: 0 }); + break; + case 'computer_left_click': + result = await computerUseService.agentClick(sessionId, 'left', point(input)); + break; + case 'computer_right_click': + result = await computerUseService.agentClick(sessionId, 'right', point(input)); + break; + case 'computer_middle_click': + result = await computerUseService.agentClick(sessionId, 'middle', point(input)); + break; + case 'computer_double_click': + result = await computerUseService.agentClick(sessionId, toButton(input.button), point(input), true); + break; + case 'computer_left_click_drag': { + const from = typeof input.startX === 'number' && typeof input.startY === 'number' + ? { x: input.startX, y: input.startY } + : { x: 0, y: 0 }; + const to = typeof input.endX === 'number' && typeof input.endY === 'number' + ? { x: input.endX, y: input.endY } + : { x: 0, y: 0 }; + result = await computerUseService.agentDrag(sessionId, from, to, 'left'); + break; + } + case 'computer_type': + result = await computerUseService.agentType(sessionId, String(input.text || '')); + break; + case 'computer_key': + result = await computerUseService.agentKey(sessionId, String(input.key || '')); + break; + case 'computer_scroll': + result = await computerUseService.agentScroll(sessionId, { + direction: toScrollDirection(input.direction), + amount: typeof input.amount === 'number' ? input.amount : undefined, + x: typeof input.x === 'number' ? input.x : undefined, + y: typeof input.y === 'number' ? input.y : undefined, + }); + break; + case 'computer_wait': + result = await computerUseService.agentWait(sessionId, typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined); + break; + case 'computer_close_session': + result = await computerUseService.agentStopSession(sessionId); + break; + default: + res.status(404).json({ success: false, error: `Unknown Computer Use MCP tool "${toolName}".` }); + return; + } + + res.json({ success: true, data: result }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Computer Use MCP tool failed.', + }); + } +}); + +export default router; diff --git a/server/modules/computer-use/computer-use.routes.ts b/server/modules/computer-use/computer-use.routes.ts index 76aa35ca..b9745fd3 100644 --- a/server/modules/computer-use/computer-use.routes.ts +++ b/server/modules/computer-use/computer-use.routes.ts @@ -4,16 +4,212 @@ import { computerUseService } from '@/modules/computer-use/computer-use.service. const router = express.Router(); -router.get('/status', (_req, res) => { - res.json({ success: true, data: computerUseService.getStatus() }); +type AuthenticatedRequest = express.Request & { + user?: { + id?: string | number; + }; +}; + +function requireUser(req: AuthenticatedRequest): { id: string | number } { + const userId = req.user?.id; + if (userId === undefined || userId === null) { + throw new Error('Authenticated user is required.'); + } + return { id: userId }; +} + +function readParam(value: string | string[] | undefined): string { + return Array.isArray(value) ? value[0] || '' : value || ''; +} + +function toButton(value: unknown): 'left' | 'right' | 'middle' { + return value === 'right' || value === 'middle' ? value : 'left'; +} + +router.get('/status', async (_req, res) => { + try { + res.json({ success: true, data: await computerUseService.getStatus() }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to load Computer Use status.', + }); + } }); -router.post('/sessions', (_req, res) => { - res.status(409).json({ - success: false, - error: 'Computer Use is not enabled until a local CloudCLI Desktop Agent is connected and approved by the user.', - data: computerUseService.getStatus(), - }); +router.get('/settings', async (_req, res) => { + try { + res.json({ success: true, data: { settings: await computerUseService.getSettings() } }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to load Computer Use settings.', + }); + } +}); + +router.put('/settings', async (req, res) => { + try { + const settings = await computerUseService.updateSettings(req.body || {}); + res.json({ success: true, data: { settings } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to save Computer Use settings.', + }); + } +}); + +router.post('/agent-tools/register', async (_req, res) => { + try { + const result = await computerUseService.registerAgentMcp(); + res.status(201).json({ success: true, data: result }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to register Computer Use MCP.', + }); + } +}); + +router.post('/runtime/install', async (_req, res) => { + try { + const result = await computerUseService.installRuntime(); + res.status(result.success ? 200 : 500).json({ + success: result.success, + data: result, + error: result.success ? undefined : result.message, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to install Computer Use runtime.', + }); + } +}); + +router.get('/sessions', async (req: AuthenticatedRequest, res) => { + try { + res.json({ success: true, data: { sessions: await computerUseService.listSessions(requireUser(req)) } }); + } catch (error) { + res.status(401).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Computer Use sessions.', + }); + } +}); + +router.post('/sessions', async (req: AuthenticatedRequest, res) => { + try { + const session = await computerUseService.createSession(requireUser(req)); + res.status(session.status === 'unavailable' ? 202 : 201).json({ success: true, data: { session } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to create Computer Use session.', + }); + } +}); + +router.post('/sessions/:sessionId/screenshot', async (req: AuthenticatedRequest, res) => { + try { + const session = await computerUseService.userScreenshot(requireUser(req), readParam(req.params.sessionId)); + res.json({ success: true, data: { session } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to capture the screen.', + }); + } +}); + +router.post('/sessions/:sessionId/click', async (req: AuthenticatedRequest, res) => { + try { + const session = await computerUseService.userClick(requireUser(req), readParam(req.params.sessionId), { + x: Number(req.body?.x), + y: Number(req.body?.y), + button: toButton(req.body?.button), + double: req.body?.double === true, + }); + res.json({ success: true, data: { session } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to click.', + }); + } +}); + +router.post('/sessions/:sessionId/type', async (req: AuthenticatedRequest, res) => { + try { + const session = await computerUseService.userType(requireUser(req), readParam(req.params.sessionId), String(req.body?.text || '')); + res.json({ success: true, data: { session } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to type text.', + }); + } +}); + +router.post('/sessions/:sessionId/press-key', async (req: AuthenticatedRequest, res) => { + try { + const session = await computerUseService.userPressKey(requireUser(req), readParam(req.params.sessionId), String(req.body?.key || '')); + res.json({ success: true, data: { session } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to send key input.', + }); + } +}); + +router.post('/sessions/:sessionId/consent/grant', async (req: AuthenticatedRequest, res) => { + try { + const session = await computerUseService.grantAgentAccess(requireUser(req), readParam(req.params.sessionId)); + res.json({ success: true, data: { session } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to grant control.', + }); + } +}); + +router.post('/sessions/:sessionId/consent/revoke', async (req: AuthenticatedRequest, res) => { + try { + const session = await computerUseService.revokeAgentAccess(requireUser(req), readParam(req.params.sessionId)); + res.json({ success: true, data: { session } }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to revoke control.', + }); + } +}); + +router.post('/sessions/:sessionId/stop', async (req: AuthenticatedRequest, res) => { + try { + const result = await computerUseService.stopSession(requireUser(req), readParam(req.params.sessionId)); + res.json({ success: true, data: result }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to stop Computer Use session.', + }); + } +}); + +router.delete('/sessions/:sessionId', async (req: AuthenticatedRequest, res) => { + try { + const result = await computerUseService.deleteSession(requireUser(req), readParam(req.params.sessionId)); + res.json({ success: true, data: result }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to delete Computer Use session.', + }); + } }); export default router; diff --git a/server/modules/computer-use/computer-use.service.ts b/server/modules/computer-use/computer-use.service.ts index 63c1aa38..937c7d8c 100644 --- a/server/modules/computer-use/computer-use.service.ts +++ b/server/modules/computer-use/computer-use.service.ts @@ -1,22 +1,883 @@ +import { createRequire } from 'node:module'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { appConfigDb } from '@/modules/database/repositories/app-config.js'; +import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; +import { getModuleDir } from '@/utils/runtime-paths.js'; +import { + executor, + captureScreenshot as captureScreenshotRuntime, + getRuntimeReadiness as getExecutorReadiness, + type Point, + type ClickButton, + type ScrollDirection, +} from '@/modules/computer-use/computer-executor.js'; +import { desktopAgentRelay } from '@/modules/computer-use/desktop-agent-relay.service.js'; + +const __dirname = getModuleDir(import.meta.url); const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; +const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_SESSIONS_PER_OWNER || '1', 10); +const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10); +const COMPUTER_USE_SETTINGS_KEY = 'computer_use_settings'; +const COMPUTER_USE_MCP_TOKEN_KEY = 'computer_use_mcp_token'; + +type ComputerUseRuntime = 'cloud' | 'local'; +type ComputerUseSessionStatus = 'ready' | 'stopped' | 'unavailable'; + +type ComputerUseSession = { + id: string; + ownerId: string; + createdBy: 'user' | 'agent'; + runtime: ComputerUseRuntime; + status: ComputerUseSessionStatus; + screenshotDataUrl: string | null; + createdAt: string; + updatedAt: string; + lastAction: string | null; + message: string | null; + /** Per-session consent: agents may act only while this is true. */ + agentAccessEnabled: boolean; + /** Size of the captured screenshot in pixels — the coordinate space agents/users use. */ + displaySize: { + width: number; + height: number; + } | null; + cursor: { + x: number; + y: number; + actor: 'agent' | 'user'; + } | null; +}; + +type PublicComputerUseSession = Omit; + +type ComputerUseOwner = { + id: string | number; +}; + +type ComputerUseSettings = { + enabled: boolean; + agentToolsEnabled: boolean; +}; + +type RuntimeReadiness = { + nut: any | null; + screenshot: any | null; + nutInstalled: boolean; + screenshotInstalled: boolean; + installInProgress: boolean; + installMessage: string | null; +}; + +const sessions = new Map(); +let installPromise: Promise<{ success: boolean; message: string }> | null = null; +let lastInstallMessage: string | null = null; + +const DEFAULT_SETTINGS: ComputerUseSettings = { + enabled: false, + agentToolsEnabled: false, +}; +const AGENT_OWNER_ID = 'agent'; +const MCP_SERVER_NAME = 'cloudcli-computer-use'; +const MCP_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'opencode']; + +function getRuntime(): ComputerUseRuntime { + return IS_PLATFORM ? 'cloud' : 'local'; +} + +function readSettings(): ComputerUseSettings { + try { + const raw = appConfigDb.get(COMPUTER_USE_SETTINGS_KEY); + if (!raw) { + return DEFAULT_SETTINGS; + } + + const parsed = JSON.parse(raw) as Partial; + return { + enabled: parsed.enabled === true, + agentToolsEnabled: parsed.agentToolsEnabled === true, + }; + } catch (error: any) { + console.warn('[Computer Use] Failed to read settings:', error?.message || error); + return DEFAULT_SETTINGS; + } +} + +function writeSettings(settings: ComputerUseSettings): ComputerUseSettings { + const normalized = { + enabled: settings.enabled === true, + agentToolsEnabled: settings.agentToolsEnabled === true, + }; + + appConfigDb.set(COMPUTER_USE_SETTINGS_KEY, JSON.stringify(normalized)); + return normalized; +} + +function getOrCreateMcpToken(): string { + const existing = appConfigDb.get(COMPUTER_USE_MCP_TOKEN_KEY); + if (existing) { + return existing; + } + const token = randomBytes(32).toString('hex'); + appConfigDb.set(COMPUTER_USE_MCP_TOKEN_KEY, token); + return token; +} + +function getSetupMessage(settings: ComputerUseSettings, readiness: RuntimeReadiness): string { + if (getRuntime() === 'cloud') { + return 'Cloud Computer Use requires a linked CloudCLI Desktop Agent on the user machine.'; + } + if (!settings.enabled) { + return 'Computer Use is disabled in settings.'; + } + if (!readiness.nutInstalled || !readiness.screenshotInstalled) { + return 'Install the desktop control runtime to capture the screen and drive the mouse and keyboard.'; + } + return readiness.installMessage || 'Computer Use runtime is not ready.'; +} + +function getMcpCommand(): { command: string; args: string[] } { + const serverDir = path.resolve(__dirname, '..', '..'); + const mcpScriptPath = path.join(serverDir, 'computer-use-mcp.js'); + if (fs.existsSync(mcpScriptPath)) { + return { + command: process.execPath, + args: [mcpScriptPath], + }; + } + + return { + command: 'cloudcli', + args: ['computer-use-mcp'], + }; +} + +function getMcpApiUrl(): string { + const port = process.env.SERVER_PORT || process.env.PORT || '3001'; + return `http://127.0.0.1:${port}/api/computer-use-mcp`; +} + +function getRuntimeReadiness(): RuntimeReadiness { + const base = getExecutorReadiness(); + return { + ...base, + installInProgress: Boolean(installPromise), + installMessage: lastInstallMessage, + }; +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: process.cwd(), + env: process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const output: string[] = []; + + child.stdout.on('data', (chunk) => output.push(String(chunk))); + child.stderr.on('data', (chunk) => output.push(String(chunk))); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`)); + }); + }); +} + +function formatInstallError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (process.platform === 'linux' && /libxtst|x11|xtst|libpng|imagemagick|scrot/i.test(message)) { + return [ + 'Installing the desktop control runtime needs system packages.', + 'On Debian/Ubuntu run: sudo apt-get install -y libxtst-dev libpng-dev imagemagick', + 'then try again.', + ].join(' '); + } + return message || 'Failed to install the Computer Use runtime.'; +} + +function isPackagedElectronNodeRuntime(): boolean { + return process.env.ELECTRON_RUN_AS_NODE === '1' && Boolean(process.versions.electron); +} + +async function installRuntime(): Promise<{ success: boolean; message: string }> { + if (installPromise) { + return installPromise; + } + + const readiness = getExecutorReadiness(); + if (readiness.nutInstalled && readiness.screenshotInstalled) { + lastInstallMessage = 'Computer Use runtime is available.'; + return { success: true, message: lastInstallMessage }; + } + + if (isPackagedElectronNodeRuntime()) { + lastInstallMessage = 'Computer Use runtime was not bundled with this desktop build.'; + return { success: false, message: lastInstallMessage }; + } + + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + installPromise = (async () => { + try { + lastInstallMessage = 'Installing desktop control runtime…'; + await runCommand(npmCommand, [ + 'install', + '--no-save', + '--no-package-lock', + '@nut-tree-fork/nut-js', + 'screenshot-desktop', + ]); + + lastInstallMessage = 'Computer Use runtime installed.'; + return { success: true, message: lastInstallMessage }; + } catch (error) { + lastInstallMessage = formatInstallError(error); + return { success: false, message: lastInstallMessage }; + } + })(); + + try { + return await installPromise; + } finally { + installPromise = null; + } +} + +function getOwnerId(owner: ComputerUseOwner): string { + if (owner.id === undefined || owner.id === null || String(owner.id).trim() === '') { + throw new Error('Authenticated user is required.'); + } + + return String(owner.id); +} + +function publicSession(session: ComputerUseSession): PublicComputerUseSession { + const { ownerId: _ownerId, ...publicFields } = session; + return publicFields; +} + +function ownerSessions(ownerId: string): ComputerUseSession[] { + return [...sessions.values()].filter((session) => session.ownerId === ownerId); +} + +function canAccessSession(ownerId: string, session: ComputerUseSession): boolean { + return session.ownerId === ownerId || session.ownerId === AGENT_OWNER_ID; +} + +async function expireStaleSessions(now = Date.now()): Promise { + for (const session of sessions.values()) { + if (session.status !== 'ready') { + continue; + } + + const updatedAt = Date.parse(session.updatedAt); + if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) { + continue; + } + + session.status = 'stopped'; + session.agentAccessEnabled = false; + session.updatedAt = new Date(now).toISOString(); + session.lastAction = 'expire'; + session.message = 'Computer Use session expired after inactivity.'; + } +} + +// --- Action layer: local executor (OSS) or cloud relay to the desktop agent -- +// +// Every desktop interaction goes through `performAction` / `getCursorPosition`. +// In local mode it drives the in-process nut-js executor (computer-executor.ts); +// in cloud mode it forwards the action to the linked desktop agent over +// `desktopAgentRelay` and applies the returned screenshot. The local server +// itself never touches the OS in cloud mode. + +/** One desktop interaction expressed in screenshot-pixel coordinate space. */ +export type ComputerAction = + | { type: 'screenshot' } + | { type: 'mouse_move'; point: Point } + | { type: 'click'; button: ClickButton; point?: Point; double?: boolean } + | { type: 'drag'; from: Point; to: Point; button?: ClickButton } + | { type: 'type'; text: string } + | { type: 'key'; key: string } + | { type: 'scroll'; direction: ScrollDirection; amount?: number; point?: Point } + | { type: 'wait'; ms?: number }; + +/** Shape the desktop agent returns for any relayed action. */ +type RelayResult = { + screenshotDataUrl?: string | null; + displaySize?: { width: number; height: number } | null; + cursor?: { x: number; y: number } | null; + position?: Point | null; +}; + +function applyRelayResult(session: ComputerUseSession, result: RelayResult): void { + if (typeof result.screenshotDataUrl === 'string') { + session.screenshotDataUrl = result.screenshotDataUrl; + } + if (result.displaySize) { + session.displaySize = result.displaySize; + } + if (result.cursor) { + session.cursor = { x: result.cursor.x, y: result.cursor.y, actor: session.cursor?.actor ?? 'agent' }; + } + session.updatedAt = new Date().toISOString(); +} + +async function refreshScreenshot(session: ComputerUseSession): Promise { + if (getRuntime() === 'cloud') { + const result = (await desktopAgentRelay.relay('screenshot', { sessionId: session.id })) as RelayResult; + applyRelayResult(session, result); + return; + } + const { dataUrl, size } = await captureScreenshotRuntime(); + session.screenshotDataUrl = dataUrl; + if (size) { + session.displaySize = size; + } + session.updatedAt = new Date().toISOString(); +} + +/** Runs one action and refreshes the session screenshot afterwards. */ +async function performAction(session: ComputerUseSession, action: ComputerAction): Promise { + if (getRuntime() === 'cloud') { + const result = (await desktopAgentRelay.relay(action.type, { + ...action, + sessionId: session.id, + displaySize: session.displaySize, + })) as RelayResult; + applyRelayResult(session, result); + return; + } + + switch (action.type) { + case 'screenshot': + break; + case 'mouse_move': + await executor.moveTo(session, action.point); + break; + case 'click': + await executor.click(session, action.button, action.point, action.double === true); + break; + case 'drag': + await executor.drag(session, action.from, action.to, action.button ?? 'left'); + break; + case 'type': + await executor.type(action.text); + break; + case 'key': + await executor.pressChord(action.key); + break; + case 'scroll': + await executor.scroll(session, action.direction, action.amount ?? 3, action.point); + break; + case 'wait': + await new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.min(action.ms ?? 1000, 10_000)))); + break; + } + await refreshScreenshot(session); +} + +/** Reads the current cursor position in screenshot-pixel space. */ +async function getCursorPosition(session: ComputerUseSession): Promise { + if (getRuntime() === 'cloud') { + const result = (await desktopAgentRelay.relay('cursor_position', { + sessionId: session.id, + displaySize: session.displaySize, + })) as RelayResult; + applyRelayResult(session, result); + if (result.position) { + return result.position; + } + return session.cursor ? { x: session.cursor.x, y: session.cursor.y } : { x: 0, y: 0 }; + } + return executor.cursorPosition(session); +} + +function assertReady(session: ComputerUseSession): void { + if (session.status !== 'ready') { + throw new Error(session.message || 'Computer Use session is not available.'); + } +} + +/** + * Whether agent tools may operate right now. Cloud mode depends purely on a + * connected desktop agent; local mode depends on the two opt-in settings. + */ +function agentToolsAvailable(): boolean { + if (getRuntime() === 'cloud') { + return desktopAgentRelay.isConnected(); + } + const settings = readSettings(); + return settings.enabled && settings.agentToolsEnabled; +} + +function assertAgentToolsAvailable(): void { + if (agentToolsAvailable()) { + return; + } + throw new Error( + getRuntime() === 'cloud' + ? 'No desktop agent is connected. Open the CloudCLI desktop app with Computer Use enabled.' + : 'Computer Use agent tools are disabled.' + ); +} export const computerUseService = { - getStatus() { + async getSettings() { + return readSettings(); + }, + + async updateSettings(settings: Partial) { + const current = readSettings(); + const nextSettings = { + ...current, + enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled, + agentToolsEnabled: typeof settings.agentToolsEnabled === 'boolean' + ? settings.agentToolsEnabled + : current.agentToolsEnabled, + }; + if (!nextSettings.enabled) { + nextSettings.agentToolsEnabled = false; + } + + const next = writeSettings(nextSettings); + if (next.agentToolsEnabled) { + await this.registerAgentMcp(); + } else if (current.agentToolsEnabled) { + await this.unregisterAgentMcp(); + } + return next; + }, + + async getStatus() { + const settings = readSettings(); + const readiness = getRuntimeReadiness(); + const isCloud = getRuntime() === 'cloud'; + const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled; + // Cloud availability is purely a function of a connected desktop agent; the + // hosted server has no screen of its own. Local availability needs the + // in-process nut-js runtime installed and the feature enabled. + const desktopAgentConnected = desktopAgentRelay.isConnected(); + const available = isCloud + ? desktopAgentConnected + : settings.enabled && runtimeReady; + return { - available: false, - bridgeConnected: false, - runtime: IS_PLATFORM ? 'cloud' : 'local', - requiresDesktopBridge: true, - message: IS_PLATFORM - ? 'Cloud Computer Use requires a linked CloudCLI Desktop Agent on the user machine.' - : 'Local Computer Use requires a desktop bridge with screen recording and accessibility permissions.', - capabilities: { - screenshots: false, - mouse: false, - keyboard: false, - clipboard: false, - stopControl: false, - }, + enabled: isCloud ? true : settings.enabled, + runtime: getRuntime(), + available, + requiresDesktopBridge: isCloud, + desktopAgentConnected, + nutInstalled: readiness.nutInstalled, + screenshotInstalled: readiness.screenshotInstalled, + installInProgress: readiness.installInProgress, + sessionCount: sessions.size, + agentToolsEnabled: isCloud ? desktopAgentConnected : settings.agentToolsEnabled, + mcpRecommended: !settings.agentToolsEnabled, + message: available ? 'Computer Use runtime is available.' : getSetupMessage(settings, readiness), }; }, + + async registerAgentMcp() { + const { command, args } = getMcpCommand(); + const results = await providerMcpService.addMcpServerToAllProviders({ + name: MCP_SERVER_NAME, + scope: 'user', + transport: 'stdio', + command, + args, + env: { + CLOUDCLI_COMPUTER_USE_MCP_TOKEN: getOrCreateMcpToken(), + CLOUDCLI_COMPUTER_USE_API_URL: getMcpApiUrl(), + }, + }); + return { name: MCP_SERVER_NAME, command, args, results }; + }, + + getMcpToken() { + return getOrCreateMcpToken(); + }, + + async unregisterAgentMcp() { + const results = await Promise.all(MCP_PROVIDERS.map(async (provider) => { + try { + const result = await providerMcpService.removeProviderMcpServer(provider, { + name: MCP_SERVER_NAME, + scope: 'user', + }); + return { provider, removed: result.removed }; + } catch (error) { + return { + provider, + removed: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + })); + return { name: MCP_SERVER_NAME, results }; + }, + + async installRuntime() { + const result = await installRuntime(); + return { + ...result, + status: await this.getStatus(), + }; + }, + + async listSessions(owner: ComputerUseOwner) { + const ownerId = getOwnerId(owner); + await expireStaleSessions(); + return [...sessions.values()] + .filter((session) => canAccessSession(ownerId, session)) + .map(publicSession); + }, + + async createSession(owner: ComputerUseOwner, options?: { createdBy?: 'user' | 'agent' }) { + const ownerId = getOwnerId(owner); + await expireStaleSessions(); + const createdBy = options?.createdBy ?? 'user'; + + const now = new Date().toISOString(); + const session: ComputerUseSession = { + id: randomUUID(), + ownerId, + createdBy, + runtime: getRuntime(), + status: 'unavailable', + screenshotDataUrl: null, + createdAt: now, + updatedAt: now, + lastAction: 'create', + // Consent is always OFF at creation — the user must explicitly grant control, + // even for agent-initiated sessions controlling the full desktop. + agentAccessEnabled: false, + displaySize: null, + message: null, + cursor: null, + }; + + const activeOwnerSessions = ownerSessions(ownerId).filter((item) => item.status === 'ready'); + if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) { + throw new Error(`Computer Use is limited to ${MAX_SESSIONS_PER_OWNER} active session(s).`); + } + + const settings = readSettings(); + const readiness = getRuntimeReadiness(); + const isCloud = getRuntime() === 'cloud'; + const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled; + const ready = isCloud + ? desktopAgentRelay.isConnected() + : settings.enabled && runtimeReady; + + if (!ready) { + session.message = getSetupMessage(settings, readiness); + sessions.set(session.id, session); + return publicSession(session); + } + + // In cloud mode the linked desktop agent is the consent authority and prompts + // the user per its own consent mode, so the relay is allowed to act. In local + // mode the user must still grant control from the panel. + if (isCloud) { + session.agentAccessEnabled = true; + } + + session.status = 'ready'; + session.message = isCloud + ? 'Computer Use session is ready on the linked desktop.' + : 'Computer Use session is ready. Grant control to let agents act.'; + sessions.set(session.id, session); + try { + await refreshScreenshot(session); + } catch (error) { + session.status = 'unavailable'; + session.message = error instanceof Error ? error.message : 'Failed to capture the screen.'; + } + return publicSession(session); + }, + + async grantAgentAccess(owner: ComputerUseOwner, sessionId: string) { + const ownerId = getOwnerId(owner); + const session = sessions.get(sessionId); + if (!session || !canAccessSession(ownerId, session)) { + throw new Error('Computer Use session not found.'); + } + session.agentAccessEnabled = true; + session.updatedAt = new Date().toISOString(); + session.lastAction = 'consent:grant'; + return publicSession(session); + }, + + async revokeAgentAccess(owner: ComputerUseOwner, sessionId: string) { + const ownerId = getOwnerId(owner); + const session = sessions.get(sessionId); + if (!session || !canAccessSession(ownerId, session)) { + throw new Error('Computer Use session not found.'); + } + session.agentAccessEnabled = false; + session.updatedAt = new Date().toISOString(); + session.lastAction = 'consent:revoke'; + return publicSession(session); + }, + + async stopSession(owner: ComputerUseOwner, sessionId: string) { + const ownerId = getOwnerId(owner); + const session = sessions.get(sessionId); + if (!session || !canAccessSession(ownerId, session)) { + return { stopped: false }; + } + + session.status = 'stopped'; + session.agentAccessEnabled = false; + session.updatedAt = new Date().toISOString(); + session.lastAction = 'stop'; + session.message = 'Computer Use session stopped. Agent control is revoked.'; + if (getRuntime() === 'cloud' && desktopAgentRelay.isConnected()) { + // Best-effort: tell the desktop agent to forget this session's consent. + void desktopAgentRelay.relay('stop_session', { sessionId }).catch(() => undefined); + } + return { stopped: true, session: publicSession(session) }; + }, + + async deleteSession(owner: ComputerUseOwner, sessionId: string) { + const ownerId = getOwnerId(owner); + const session = sessions.get(sessionId); + if (!session || !canAccessSession(ownerId, session)) { + return { deleted: false }; + } + + sessions.delete(sessionId); + return { deleted: true, sessionId }; + }, + + // --- User-initiated actions (from the panel) ------------------------------- + + async userScreenshot(owner: ComputerUseOwner, sessionId: string) { + const ownerId = getOwnerId(owner); + const session = sessions.get(sessionId); + if (!session || !canAccessSession(ownerId, session)) { + throw new Error('Computer Use session not found.'); + } + assertReady(session); + await refreshScreenshot(session); + session.lastAction = 'screenshot'; + return publicSession(session); + }, + + async userClick(owner: ComputerUseOwner, sessionId: string, input: { x: number; y: number; button?: ClickButton; double?: boolean }) { + const ownerId = getOwnerId(owner); + const session = sessions.get(sessionId); + if (!session || !canAccessSession(ownerId, session)) { + throw new Error('Computer Use session not found.'); + } + assertReady(session); + await performAction(session, { + type: 'click', + button: input.button || 'left', + point: { x: input.x, y: input.y }, + double: input.double === true, + }); + session.cursor = { x: input.x, y: input.y, actor: 'user' }; + session.lastAction = input.double ? 'double_click' : 'click'; + return publicSession(session); + }, + + async userType(owner: ComputerUseOwner, sessionId: string, text: string) { + const ownerId = getOwnerId(owner); + const session = sessions.get(sessionId); + if (!session || !canAccessSession(ownerId, session)) { + throw new Error('Computer Use session not found.'); + } + assertReady(session); + await performAction(session, { type: 'type', text }); + session.lastAction = 'type'; + return publicSession(session); + }, + + async userPressKey(owner: ComputerUseOwner, sessionId: string, key: string) { + const ownerId = getOwnerId(owner); + const session = sessions.get(sessionId); + if (!session || !canAccessSession(ownerId, session)) { + throw new Error('Computer Use session not found.'); + } + assertReady(session); + await performAction(session, { type: 'key', key }); + session.lastAction = `key:${key}`; + return publicSession(session); + }, + + // --- Agent-initiated actions (via MCP) ------------------------------------ + + async createAgentSession() { + assertAgentToolsAvailable(); + return this.createSession({ id: AGENT_OWNER_ID }, { createdBy: 'agent' }); + }, + + async listAgentSessions() { + if (!agentToolsAvailable()) { + return []; + } + await expireStaleSessions(); + return [...sessions.values()].map(publicSession); + }, + + /** + * Resolves a session the agent is allowed to act on. In local mode this + * enforces the in-process per-session consent flag. In cloud mode the linked + * desktop agent is the consent authority (it prompts the user per its own + * consent mode), so this only requires the relay to be connected. + */ + async getConsentedSession(sessionId: string): Promise { + assertAgentToolsAvailable(); + const session = sessions.get(sessionId); + if (!session) { + throw new Error('Computer Use session not found.'); + } + if (getRuntime() !== 'cloud' && !session.agentAccessEnabled) { + throw new Error('Computer Use session is awaiting user consent. Ask the user to grant control in the Computer panel.'); + } + assertReady(session); + return session; + }, + + async agentScreenshot(sessionId: string) { + const session = await this.getConsentedSession(sessionId); + await refreshScreenshot(session); + session.lastAction = 'screenshot'; + return publicSession(session); + }, + + async agentCursorPosition(sessionId: string) { + const session = await this.getConsentedSession(sessionId); + const point = await getCursorPosition(session); + session.cursor = { ...point, actor: 'agent' }; + session.lastAction = 'cursor_position'; + return { session: publicSession(session), position: point }; + }, + + async agentMouseMove(sessionId: string, point: Point) { + const session = await this.getConsentedSession(sessionId); + await performAction(session, { type: 'mouse_move', point }); + session.cursor = { ...point, actor: 'agent' }; + session.lastAction = 'mouse_move'; + return publicSession(session); + }, + + async agentClick(sessionId: string, button: ClickButton, point?: Point, doubleClick = false) { + const session = await this.getConsentedSession(sessionId); + await performAction(session, { type: 'click', button, point, double: doubleClick }); + if (point) { + session.cursor = { ...point, actor: 'agent' }; + } + session.lastAction = doubleClick ? 'double_click' : `${button}_click`; + return publicSession(session); + }, + + async agentDrag(sessionId: string, from: Point, to: Point, button: ClickButton = 'left') { + const session = await this.getConsentedSession(sessionId); + await performAction(session, { type: 'drag', from, to, button }); + session.cursor = { ...to, actor: 'agent' }; + session.lastAction = 'left_click_drag'; + return publicSession(session); + }, + + async agentType(sessionId: string, text: string) { + const session = await this.getConsentedSession(sessionId); + await performAction(session, { type: 'type', text }); + session.lastAction = 'type'; + return publicSession(session); + }, + + async agentKey(sessionId: string, key: string) { + const session = await this.getConsentedSession(sessionId); + await performAction(session, { type: 'key', key }); + session.lastAction = `key:${key}`; + return publicSession(session); + }, + + async agentScroll(sessionId: string, input: { direction: ScrollDirection; amount?: number; x?: number; y?: number }) { + const session = await this.getConsentedSession(sessionId); + const point = typeof input.x === 'number' && typeof input.y === 'number' ? { x: input.x, y: input.y } : undefined; + await performAction(session, { type: 'scroll', direction: input.direction, amount: input.amount, point }); + if (point) { + session.cursor = { ...point, actor: 'agent' }; + } + session.lastAction = `scroll:${input.direction}`; + return publicSession(session); + }, + + async agentWait(sessionId: string, timeoutMs?: number) { + const session = await this.getConsentedSession(sessionId); + await performAction(session, { type: 'wait', ms: timeoutMs }); + session.lastAction = 'wait'; + return publicSession(session); + }, + + async agentStopSession(sessionId: string) { + assertAgentToolsAvailable(); + return this.stopSession({ id: AGENT_OWNER_ID }, sessionId); + }, + + /** + * Cloud only: when a desktop agent links to this hosted environment, expose + * the computer_* MCP tools to every provider so the running agent can use + * them. Mirrors `registerAgentMcp` but is driven by relay connectivity rather + * than a settings toggle. + */ + async onDesktopAgentConnected() { + if (getRuntime() !== 'cloud') { + return; + } + try { + await this.registerAgentMcp(); + } catch (error) { + console.warn('[Computer Use] Failed to register MCP for linked desktop agent:', error instanceof Error ? error.message : error); + } + }, + + /** Cloud only: tear down sessions when the last desktop agent disconnects. */ + async onDesktopAgentDisconnected() { + if (getRuntime() !== 'cloud' || desktopAgentRelay.isConnected()) { + return; + } + for (const session of sessions.values()) { + if (session.status === 'ready') { + session.status = 'stopped'; + session.agentAccessEnabled = false; + session.updatedAt = new Date().toISOString(); + session.lastAction = 'agent-disconnected'; + session.message = 'The linked desktop agent disconnected.'; + } + } + }, + + async stopAllSessions() { + for (const session of sessions.values()) { + session.status = 'stopped'; + session.agentAccessEnabled = false; + session.updatedAt = new Date().toISOString(); + session.lastAction = 'shutdown'; + session.message = 'Computer Use session stopped during server shutdown.'; + } + }, }; + +// Drive cloud MCP exposure + session teardown off desktop-agent connectivity. +desktopAgentRelay.setHooks({ + onFirstConnect: () => computerUseService.onDesktopAgentConnected(), + onLastDisconnect: () => computerUseService.onDesktopAgentDisconnected(), +}); + +process.once('beforeExit', () => { + void computerUseService.stopAllSessions(); +}); diff --git a/server/modules/computer-use/desktop-agent-relay.service.ts b/server/modules/computer-use/desktop-agent-relay.service.ts new file mode 100644 index 00000000..54303e4b --- /dev/null +++ b/server/modules/computer-use/desktop-agent-relay.service.ts @@ -0,0 +1,129 @@ +import { randomUUID } from 'node:crypto'; + +import type { WebSocket } from 'ws'; + +const RELAY_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_RELAY_TIMEOUT_MS || '60000', 10); +const WS_OPEN = 1; + +type PendingRelay = { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: ReturnType; +}; + +type ConnectedAgent = { + ws: WebSocket; + label: string; + registeredAt: string; +}; + +type RelayLifecycleHooks = { + onFirstConnect?: () => void | Promise; + onLastDisconnect?: () => void | Promise; +}; + +const agents = new Map(); +const pending = new Map(); +let hooks: RelayLifecycleHooks = {}; + +function rejectAllPending(reason: string): void { + for (const [callId, call] of pending.entries()) { + clearTimeout(call.timer); + call.reject(new Error(reason)); + pending.delete(callId); + } +} + +function pickAgent(): ConnectedAgent | undefined { + for (const agent of agents.values()) { + if (agent.ws.readyState === WS_OPEN) { + return agent; + } + } + return undefined; +} + +/** + * Cloud-side registry of linked desktop agents and the request/response relay + * used to drive the user's real desktop. The hosted server never touches the OS + * itself — it only forwards `computer_*` actions to a connected desktop agent + * and awaits the screenshot it returns. + */ +export const desktopAgentRelay = { + setHooks(next: RelayLifecycleHooks): void { + hooks = next; + }, + + register(ws: WebSocket, label = 'desktop-agent'): void { + const wasEmpty = pickAgent() === undefined; + agents.set(ws, { ws, label, registeredAt: new Date().toISOString() }); + console.log(`[DesktopAgent] Registered (${label}); ${agents.size} connected.`); + + ws.on('close', () => { + agents.delete(ws); + console.log(`[DesktopAgent] Disconnected (${label}); ${agents.size} remain.`); + if (pickAgent() === undefined) { + rejectAllPending('Desktop agent disconnected.'); + void hooks.onLastDisconnect?.(); + } + }); + + if (wasEmpty) { + void hooks.onFirstConnect?.(); + } + }, + + /** Resolves a pending relay call with the desktop agent's reply. */ + handleResult(id: string, result: unknown, error?: string): void { + const call = pending.get(id); + if (!call) { + return; + } + clearTimeout(call.timer); + pending.delete(id); + if (error) { + call.reject(new Error(error)); + } else { + call.resolve(result); + } + }, + + isConnected(): boolean { + return pickAgent() !== undefined; + }, + + connectedCount(): number { + let count = 0; + for (const agent of agents.values()) { + if (agent.ws.readyState === WS_OPEN) { + count++; + } + } + return count; + }, + + async relay(type: string, params: Record): Promise { + const agent = pickAgent(); + if (!agent) { + throw new Error( + 'No desktop agent connected. Open the CloudCLI desktop app with Computer Use enabled to control this machine.' + ); + } + + const id = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error('Desktop agent did not respond in time.')); + }, RELAY_TIMEOUT_MS); + pending.set(id, { resolve, reject, timer }); + try { + agent.ws.send(JSON.stringify({ kind: 'computer_relay', id, type, params })); + } catch (error) { + clearTimeout(timer); + pending.delete(id); + reject(error instanceof Error ? error : new Error('Failed to send to desktop agent.')); + } + }); + }, +}; diff --git a/server/modules/computer-use/index.ts b/server/modules/computer-use/index.ts new file mode 100644 index 00000000..d971479c --- /dev/null +++ b/server/modules/computer-use/index.ts @@ -0,0 +1,2 @@ +export { computerUseService } from '@/modules/computer-use/computer-use.service.js'; +export { desktopAgentRelay } from '@/modules/computer-use/desktop-agent-relay.service.js'; diff --git a/server/modules/websocket/services/desktop-agent-websocket.service.ts b/server/modules/websocket/services/desktop-agent-websocket.service.ts new file mode 100644 index 00000000..3f9a7614 --- /dev/null +++ b/server/modules/websocket/services/desktop-agent-websocket.service.ts @@ -0,0 +1,42 @@ +import type { WebSocket } from 'ws'; + +import { desktopAgentRelay } from '@/modules/computer-use/index.js'; +import type { AuthenticatedWebSocketRequest } from '@/shared/types.js'; +import { parseIncomingJsonObject } from '@/shared/utils.js'; + +/** + * Handles the `/desktop-agent` websocket — the inbound side of the cloud + * Computer Use relay. A linked CloudCLI desktop app connects here and registers + * itself as the executor for this hosted environment. The server then forwards + * `computer_*` actions via `desktopAgentRelay`, and the agent returns results as + * `computer_relay_result` frames correlated by `id`. + */ +export function handleDesktopAgentConnection( + ws: WebSocket, + request: AuthenticatedWebSocketRequest +): void { + const label = request.user?.username ? `desktop:${request.user.username}` : 'desktop-agent'; + console.log('[INFO] Desktop agent websocket connected:', label); + desktopAgentRelay.register(ws, label); + + ws.on('message', (rawMessage) => { + const data = parseIncomingJsonObject(rawMessage); + if (!data) { + return; + } + const kind = typeof data.kind === 'string' ? data.kind : typeof data.type === 'string' ? data.type : ''; + if (kind === 'computer_relay_result' && typeof data.id === 'string') { + desktopAgentRelay.handleResult( + data.id, + (data as Record).result, + typeof (data as Record).error === 'string' + ? ((data as Record).error as string) + : undefined + ); + } + }); + + ws.on('close', () => { + console.log('[INFO] Desktop agent websocket disconnected:', label); + }); +} diff --git a/server/modules/websocket/services/websocket-server.service.ts b/server/modules/websocket/services/websocket-server.service.ts index 2ba2ec6e..ec9aa229 100644 --- a/server/modules/websocket/services/websocket-server.service.ts +++ b/server/modules/websocket/services/websocket-server.service.ts @@ -6,6 +6,7 @@ import { handleChatConnection } from '@/modules/websocket/services/chat-websocke import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js'; import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js'; import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js'; +import { handleDesktopAgentConnection } from '@/modules/websocket/services/desktop-agent-websocket.service.js'; import type { AuthenticatedWebSocketRequest } from '@/shared/types.js'; type WebSocketServerDependencies = { @@ -63,6 +64,11 @@ export function createWebSocketServer( return; } + if (pathname === '/desktop-agent') { + handleDesktopAgentConnection(ws, incomingRequest); + return; + } + if (pathname.startsWith('/plugin-ws/')) { handlePluginWsProxy(ws, pathname, dependencies.getPluginPort); return; diff --git a/src/components/computer-use/view/ComputerUsePanel.tsx b/src/components/computer-use/view/ComputerUsePanel.tsx index e7c58ce9..589cb28f 100644 --- a/src/components/computer-use/view/ComputerUsePanel.tsx +++ b/src/components/computer-use/view/ComputerUsePanel.tsx @@ -1,63 +1,263 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Cable, MonitorCog, RefreshCw, ShieldCheck } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'; +import { Bot, Camera, Download, Expand, Loader2, MonitorCog, RefreshCw, ShieldCheck, Square, Trash2, X } from 'lucide-react'; import { Badge, Button } from '../../../shared/view/ui'; import { authenticatedFetch } from '../../../utils/api'; type ComputerUseStatus = { - available: boolean; - bridgeConnected: boolean; + enabled: boolean; runtime: 'cloud' | 'local'; + available: boolean; requiresDesktopBridge: boolean; + nutInstalled: boolean; + screenshotInstalled: boolean; + installInProgress: boolean; + sessionCount: number; + agentToolsEnabled: boolean; + mcpRecommended: boolean; message: string; - capabilities: { - screenshots: boolean; - mouse: boolean; - keyboard: boolean; - clipboard: boolean; - stopControl: boolean; - }; +}; + +type ComputerUseSession = { + id: string; + status: 'ready' | 'stopped' | 'unavailable'; + screenshotDataUrl: string | null; + createdAt: string; + updatedAt: string; + lastAction: string | null; + message: string | null; + agentAccessEnabled: boolean; + createdBy: 'user' | 'agent'; + displaySize: { + width: number; + height: number; + } | null; + cursor: { + x: number; + y: number; + actor: 'agent' | 'user'; + } | null; }; type ComputerUsePanelProps = { isVisible: boolean; }; -async function readStatus(response: Response): Promise { +async function readJson(response: Response): Promise { const data = await response.json(); if (!response.ok || data.success === false) { - throw new Error(data.error || `Request failed (${response.status})`); + throw new Error(data.error || data.details || `Request failed (${response.status})`); } - return data.data; + return data as T; } export default function ComputerUsePanel({ isVisible }: ComputerUsePanelProps) { const [status, setStatus] = useState(null); + const [sessions, setSessions] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState(null); + const [isBusy, setIsBusy] = useState(false); + const [isInstalling, setIsInstalling] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); const [error, setError] = useState(null); + const viewerRef = useRef(null); + + const selectedSession = useMemo( + () => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null, + [selectedSessionId, sessions], + ); const refresh = useCallback(async () => { - setError(null); - try { - const response = await authenticatedFetch('/api/computer-use/status'); - setStatus(await readStatus(response)); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load Computer Use status'); - } + const [statusResponse, sessionsResponse] = await Promise.all([ + authenticatedFetch('/api/computer-use/status'), + authenticatedFetch('/api/computer-use/sessions'), + ]); + const statusData = await readJson<{ data: ComputerUseStatus }>(statusResponse); + const sessionsData = await readJson<{ data: { sessions: ComputerUseSession[] } }>(sessionsResponse); + setStatus(statusData.data); + setSessions(sessionsData.data.sessions); + setSelectedSessionId((current) => ( + current && sessionsData.data.sessions.some((session) => session.id === current) + ? current + : sessionsData.data.sessions[0]?.id || null + )); }, []); useEffect(() => { - if (isVisible) { - void refresh(); - } + if (!isVisible) return; + void refresh().catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Computer Use')); }, [isVisible, refresh]); - const capabilities = status?.capabilities || { - screenshots: false, - mouse: false, - keyboard: false, - clipboard: false, - stopControl: false, - }; + // Poll while an active session exists so agent-driven changes show up live. + useEffect(() => { + if (!isVisible || !selectedSession || selectedSession.status !== 'ready') return; + const timer = window.setInterval(() => { + void refresh().catch(() => undefined); + }, 1500); + return () => window.clearInterval(timer); + }, [isVisible, selectedSession, refresh]); + + const runAction = useCallback(async (action: () => Promise) => { + setIsBusy(true); + setError(null); + try { + await action(); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Computer Use action failed'); + } finally { + setIsBusy(false); + } + }, [refresh]); + + const createSession = () => runAction(async () => { + const response = await authenticatedFetch('/api/computer-use/sessions', { method: 'POST' }); + const data = await readJson<{ data: { session: ComputerUseSession } }>(response); + setSelectedSessionId(data.data.session.id); + }); + + const captureScreenshot = () => runAction(async () => { + if (!selectedSession) return; + const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/screenshot`, { method: 'POST' }); + await readJson(response); + }); + + const stopSession = () => runAction(async () => { + if (!selectedSession) return; + const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/stop`, { method: 'POST' }); + await readJson(response); + }); + + const deleteSession = () => runAction(async () => { + if (!selectedSession) return; + const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}`, { method: 'DELETE' }); + await readJson(response); + setIsFullscreen(false); + }); + + const grantControl = () => runAction(async () => { + if (!selectedSession) return; + const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/consent/grant`, { method: 'POST' }); + await readJson(response); + }); + + const revokeControl = () => runAction(async () => { + if (!selectedSession) return; + const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/consent/revoke`, { method: 'POST' }); + await readJson(response); + }); + + const installRuntime = () => runAction(async () => { + setIsInstalling(true); + try { + const response = await authenticatedFetch('/api/computer-use/runtime/install', { method: 'POST' }); + await readJson(response); + } finally { + setIsInstalling(false); + } + }); + + const clickViewer = useCallback((event: MouseEvent) => { + if (!selectedSession || selectedSession.status !== 'ready' || !selectedSession.displaySize) { + return; + } + viewerRef.current?.focus(); + + const bounds = event.currentTarget.getBoundingClientRect(); + const scaleX = selectedSession.displaySize.width / bounds.width; + const scaleY = selectedSession.displaySize.height / bounds.height; + const x = Math.round((event.clientX - bounds.left) * scaleX); + const y = Math.round((event.clientY - bounds.top) * scaleY); + + void runAction(async () => { + const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/click`, { + method: 'POST', + body: JSON.stringify({ x, y, double: event.detail === 2 }), + }); + await readJson(response); + }); + }, [runAction, selectedSession]); + + const keyForEvent = useCallback((event: KeyboardEvent) => { + if (event.key === ' ') return 'Space'; + const parts: string[] = []; + if (event.ctrlKey) parts.push('ctrl'); + if (event.altKey) parts.push('alt'); + if (event.shiftKey && event.key.length > 1) parts.push('shift'); + if (event.metaKey) parts.push('meta'); + parts.push(event.key); + return parts.join('+'); + }, []); + + const pressViewerKey = useCallback((event: KeyboardEvent) => { + if (!selectedSession || selectedSession.status !== 'ready') { + return; + } + + const ignoredKeys = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']); + if (ignoredKeys.has(event.key)) { + return; + } + + event.preventDefault(); + const key = keyForEvent(event); + void runAction(async () => { + const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/press-key`, { + method: 'POST', + body: JSON.stringify({ key }), + }); + await readJson(response); + }); + }, [keyForEvent, runAction, selectedSession]); + + const needsRuntime = Boolean(status?.enabled && status.runtime === 'local' && (!status.nutInstalled || !status.screenshotInstalled)); + const isCloud = status?.runtime === 'cloud'; + + const cursorStyle = selectedSession?.cursor && selectedSession.displaySize + ? { + left: `${(selectedSession.cursor.x / selectedSession.displaySize.width) * 100}%`, + top: `${(selectedSession.cursor.y / selectedSession.displaySize.height) * 100}%`, + } + : null; + + const renderSurface = (fullscreen = false) => ( +
+ {selectedSession?.screenshotDataUrl ? ( +
+ Desktop screenshot + {cursorStyle && ( +
+
+
+ )} +
+ ) : ( +
+ +
+ {selectedSession?.message || 'Start a Computer Use session to capture your desktop.'} +
+

+ {isCloud + ? 'Cloud Computer Use requires a linked local CloudCLI Desktop Agent.' + : 'Install the desktop control runtime from this panel or enable Computer Use from Settings.'} +

+
+ )} +
+ ); return (
@@ -69,64 +269,186 @@ export default function ComputerUsePanel({ isVisible }: ComputerUsePanelProps) { {status && {status.runtime}}

- Local desktop control through a user-approved CloudCLI Desktop Agent. + Capture your desktop and let agents drive the mouse and keyboard — only while you grant control.

- +
+ + +
- {error && ( -
- {error} -
- )} - -
-
-
-
- -
-
-
-

Desktop bridge

- - {status?.bridgeConnected ? 'connected' : 'not connected'} - -
-

- {status?.message || 'Loading Computer Use status...'} +

+
+ )} - + +
+
+ + {selectedSession?.agentAccessEnabled ? ( + + ) : ( + + )} + + + +
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ + + {selectedSession?.displaySize + ? `${selectedSession.displaySize.width}×${selectedSession.displaySize.height}` + : 'No screen captured'} + + {selectedSession?.agentAccessEnabled && ( + + + Agent control active + + )} +
+ {renderSurface()} +
+

+ Click the screenshot to click the real desktop. Focus the view and type to send keystrokes. +

+
+
+ {isFullscreen && selectedSession && ( +
+
+
+
Desktop session
+ +
+ {renderSurface(true)} +
+
+ )} ); } diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index 6ae16ec8..4f9586e1 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -65,6 +65,7 @@ export type MainContentHeaderProps = { selectedSession: ProjectSession | null; shouldShowTasksTab: boolean; shouldShowBrowserTab: boolean; + shouldShowComputerTab: boolean; isMobile: boolean; onMenuClick: () => void; }; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index b8930e30..3af7a1a8 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -59,9 +59,11 @@ function MainContent({ const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue; const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue; const [browserUseEnabled, setBrowserUseEnabled] = useState(false); + const [computerUseEnabled, setComputerUseEnabled] = useState(false); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); const shouldShowBrowserTab = browserUseEnabled; + const shouldShowComputerTab = computerUseEnabled; const { editingFile, @@ -117,6 +119,28 @@ function MainContent({ } }, [shouldShowBrowserTab, activeTab, setActiveTab]); + const loadComputerUseSettings = useCallback(async () => { + try { + const response = await authenticatedFetch('/api/computer-use/settings'); + const data = await response.json(); + setComputerUseEnabled(Boolean(response.ok && data?.success !== false && data?.data?.settings?.enabled)); + } catch { + setComputerUseEnabled(false); + } + }, []); + + useEffect(() => { + void loadComputerUseSettings(); + window.addEventListener('computerUseSettingsChanged', loadComputerUseSettings); + return () => window.removeEventListener('computerUseSettingsChanged', loadComputerUseSettings); + }, [loadComputerUseSettings]); + + useEffect(() => { + if (!shouldShowComputerTab && activeTab === 'computer') { + setActiveTab('chat'); + } + }, [shouldShowComputerTab, activeTab, setActiveTab]); + usePaletteOpsRegister({ openFile: (filePath: string) => { setActiveTab('files'); @@ -141,6 +165,7 @@ function MainContent({ selectedSession={selectedSession} shouldShowTasksTab={shouldShowTasksTab} shouldShowBrowserTab={shouldShowBrowserTab} + shouldShowComputerTab={shouldShowComputerTab} isMobile={isMobile} onMenuClick={onMenuClick} /> @@ -205,7 +230,7 @@ function MainContent({ )} - {activeTab === 'computer' && ( + {shouldShowComputerTab && activeTab === 'computer' && (
diff --git a/src/components/main-content/view/subcomponents/MainContentHeader.tsx b/src/components/main-content/view/subcomponents/MainContentHeader.tsx index f75013ce..8dc27d05 100644 --- a/src/components/main-content/view/subcomponents/MainContentHeader.tsx +++ b/src/components/main-content/view/subcomponents/MainContentHeader.tsx @@ -11,6 +11,7 @@ export default function MainContentHeader({ selectedSession, shouldShowTasksTab, shouldShowBrowserTab, + shouldShowComputerTab, isMobile, onMenuClick, }: MainContentHeaderProps) { @@ -61,6 +62,7 @@ export default function MainContentHeader({ setActiveTab={setActiveTab} shouldShowTasksTab={shouldShowTasksTab} shouldShowBrowserTab={shouldShowBrowserTab} + shouldShowComputerTab={shouldShowComputerTab} /> {canScrollRight && ( diff --git a/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx b/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx index d8d23940..0cb22fa2 100644 --- a/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx +++ b/src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx @@ -12,6 +12,7 @@ type MainContentTabSwitcherProps = { setActiveTab: Dispatch>; shouldShowTasksTab: boolean; shouldShowBrowserTab: boolean; + shouldShowComputerTab: boolean; }; type BuiltInTab = { @@ -36,7 +37,6 @@ const BASE_TABS: BuiltInTab[] = [ { kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal }, { kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder }, { kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch }, - { kind: 'builtin', id: 'computer', labelKey: 'tabs.computer', icon: MonitorCog }, ]; const BROWSER_TAB: BuiltInTab = { @@ -46,6 +46,13 @@ const BROWSER_TAB: BuiltInTab = { icon: MonitorPlay, }; +const COMPUTER_TAB: BuiltInTab = { + kind: 'builtin', + id: 'computer', + labelKey: 'tabs.computer', + icon: MonitorCog, +}; + const TASKS_TAB: BuiltInTab = { kind: 'builtin', id: 'tasks', @@ -58,6 +65,7 @@ export default function MainContentTabSwitcher({ setActiveTab, shouldShowTasksTab, shouldShowBrowserTab, + shouldShowComputerTab, }: MainContentTabSwitcherProps) { const { t } = useTranslation(); const { plugins } = usePlugins(); @@ -65,6 +73,7 @@ export default function MainContentTabSwitcher({ const builtInTabs: BuiltInTab[] = [ ...BASE_TABS, ...(shouldShowBrowserTab ? [BROWSER_TAB] : []), + ...(shouldShowComputerTab ? [COMPUTER_TAB] : []), ...(shouldShowTasksTab ? [TASKS_TAB] : []), ]; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index a172b831..e5325e28 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -54,7 +54,7 @@ type NotificationPreferencesResponse = { type ActiveLoginProvider = AgentProvider | ''; -const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'notifications', 'plugins', 'about']; +const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'computer', 'notifications', 'plugins', 'about']; const normalizeMainTab = (tab: string): SettingsMainTab => { // Keep backwards compatibility with older callers that still pass "tools". diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index 672be1ee..8b7e84d4 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { LLMProvider } from '../../../types/app'; import type { ProviderAuthStatus } from '../../provider-auth/types'; -export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about'; +export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'computer' | 'notifications' | 'plugins' | 'about'; export type AgentProvider = LLMProvider; export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type ProjectSortOrder = 'name' | 'date'; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 800440e0..34b316b4 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -8,6 +8,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab'; import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab'; import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab'; import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab'; +import ComputerUseSettingsTab from '../view/tabs/computer-use-settings/ComputerUseSettingsTab'; import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab'; import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab'; import PluginSettingsTab from '../../plugins/view/PluginSettingsTab'; @@ -142,6 +143,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set {activeTab === 'browser' && } + {activeTab === 'computer' && } + {activeTab === 'notifications' && ( (response: Response): Promise { + const data = await response.json(); + if (!response.ok || data.success === false) { + throw new Error(data.error || data.details || `Request failed (${response.status})`); + } + return data as T; +} + +export default function ComputerUseSettingsTab() { + const [settings, setSettings] = useState({ enabled: false, agentToolsEnabled: false }); + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isInstalling, setIsInstalling] = useState(false); + const [error, setError] = useState(null); + + const loadState = useCallback(async () => { + setError(null); + const [settingsResponse, statusResponse] = await Promise.all([ + authenticatedFetch('/api/computer-use/settings'), + authenticatedFetch('/api/computer-use/status'), + ]); + const settingsData = await readJson<{ data: { settings: ComputerUseSettings } }>(settingsResponse); + const statusData = await readJson<{ data: ComputerUseStatus }>(statusResponse); + setSettings(settingsData.data.settings); + setStatus(statusData.data); + }, []); + + useEffect(() => { + setIsLoading(true); + void loadState() + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Computer Use settings')) + .finally(() => setIsLoading(false)); + }, [loadState]); + + const updateSettings = async (nextSettings: Partial) => { + setIsSaving(true); + setError(null); + try { + const response = await authenticatedFetch('/api/computer-use/settings', { + method: 'PUT', + body: JSON.stringify(nextSettings), + }); + const data = await readJson<{ data: { settings: ComputerUseSettings } }>(response); + setSettings(data.data.settings); + window.dispatchEvent(new Event('computerUseSettingsChanged')); + await loadState(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save Computer Use settings'); + } finally { + setIsSaving(false); + } + }; + + const installRuntime = async () => { + setIsInstalling(true); + setError(null); + try { + const response = await authenticatedFetch('/api/computer-use/runtime/install', { method: 'POST' }); + await readJson(response); + await loadState(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to install Computer Use runtime'); + } finally { + setIsInstalling(false); + } + }; + + const isCloud = status?.runtime === 'cloud'; + const needsRuntime = Boolean(settings.enabled && !isCloud && status && (!status.nutInstalled || !status.screenshotInstalled)); + + return ( +
+ + +
+
+ Computer Use can control your entire desktop. Agents act only while you grant control from the + Computer panel, and any action stops the moment you press Stop. +
+
+ + + void updateSettings({ enabled: value })} + ariaLabel="Enable Computer Use" + disabled={isLoading || isSaving} + /> + + + + void updateSettings({ agentToolsEnabled: value })} + ariaLabel="Enable Computer Tools for Agents" + disabled={isLoading || isSaving || !settings.enabled} + /> + + + {(needsRuntime || isCloud || error) && ( +
+ {isCloud && ( +
+ {status?.message || 'Cloud Computer Use requires a linked CloudCLI Desktop Agent on the user machine.'} +
+ )} + + {needsRuntime && ( +
+
+
Desktop runtime required
+

+ {status?.message || 'Install the desktop control runtime needed to capture the screen and drive input.'} +

+
+ + Control lib: {status?.nutInstalled ? 'installed' : 'missing'} + + + Screen capture: {status?.screenshotInstalled ? 'installed' : 'missing'} + +
+
+ + +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index bae8db89..41865212 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -95,6 +95,7 @@ "apiTokens": "API & Tokens", "tasks": "Tasks", "browser": "Browser Use", + "computer": "Computer Use", "notifications": "Notifications", "plugins": "Plugins", "about": "About"