mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-19 07:12:03 +08:00
Compare commits
46 Commits
fix/codex-
...
electron-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2333e7d93 | ||
|
|
f75ae385dd | ||
|
|
7786763dd1 | ||
|
|
1dbf545fd9 | ||
|
|
ac37213269 | ||
|
|
65fdc38f2e | ||
|
|
6c2652aee6 | ||
|
|
bf50d29c20 | ||
|
|
e88539170e | ||
|
|
ffc0cd7501 | ||
|
|
59194d1502 | ||
|
|
7e6028b113 | ||
|
|
9881e5e366 | ||
|
|
496a895e8a | ||
|
|
086df034b4 | ||
|
|
fc71fc7d2b | ||
|
|
a0d56429a7 | ||
|
|
6af4afe6f2 | ||
|
|
c03ddb25fe | ||
|
|
d7a38a567a | ||
|
|
fec91d3deb | ||
|
|
c6c153e7f2 | ||
|
|
4758ccf36e | ||
|
|
e23e6af06a | ||
|
|
56b2e14059 | ||
|
|
39b0473e38 | ||
|
|
7aeca52669 | ||
|
|
56532af33a | ||
|
|
9438a365f2 | ||
|
|
e5c6e5e596 | ||
|
|
0426522406 | ||
|
|
6e7e2ff4c1 | ||
|
|
e6263dbd1f | ||
|
|
260070bae0 | ||
|
|
daac6e3fd3 | ||
|
|
861cfecbaa | ||
|
|
a182765e10 | ||
|
|
828d1a2302 | ||
|
|
f319d2cf8d | ||
|
|
d427004bd7 | ||
|
|
243e6cecd5 | ||
|
|
86f64797b0 | ||
|
|
21b0f14e7a | ||
|
|
f12af8a61b | ||
|
|
f549bd99e7 | ||
|
|
bc34085af9 |
87
.github/workflows/desktop-macos-branch-build.yml
vendored
Normal file
87
.github/workflows/desktop-macos-branch-build.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Desktop macOS Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- electron-app
|
||||
|
||||
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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- 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: Build local server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- 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)"
|
||||
test -n "$(find release/server-bundles -maxdepth 1 -name 'cloudcli-server-*.tar.gz' -print -quit)"
|
||||
shasum -a 256 release/*.{dmg,zip} release/server-bundles/* > release/SHASUMS256.txt
|
||||
cat release/SHASUMS256.txt
|
||||
|
||||
- name: Upload branch build artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
release/server-bundles/*
|
||||
release/SHASUMS256.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
109
.github/workflows/desktop-macos-release.yml
vendored
Normal file
109
.github/workflows/desktop-macos-release.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
name: Desktop macOS Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to create or update (defaults to v<package version>)'
|
||||
required: false
|
||||
type: string
|
||||
release_name:
|
||||
description: 'Release name (defaults to "CloudCLI Desktop macOS <tag>")'
|
||||
required: false
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Mark the GitHub release as a prerelease'
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
name: Build signed macOS desktop app
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
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 release metadata
|
||||
id: release
|
||||
run: |
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
TAG="${{ inputs.tag }}"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="v${VERSION}"
|
||||
fi
|
||||
|
||||
RELEASE_NAME="${{ inputs.release_name }}"
|
||||
if [ -z "$RELEASE_NAME" ]; then
|
||||
RELEASE_NAME="CloudCLI Desktop macOS ${TAG}"
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "release_name=$RELEASE_NAME" >> "$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: Build local server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- 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)"
|
||||
test -n "$(find release/server-bundles -maxdepth 1 -name 'cloudcli-server-*.tar.gz' -print -quit)"
|
||||
shasum -a 256 release/*.{dmg,zip} release/server-bundles/* > release/SHASUMS256.txt
|
||||
cat release/SHASUMS256.txt
|
||||
|
||||
- name: Publish GitHub release assets
|
||||
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
|
||||
files: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
release/server-bundles/*
|
||||
release/SHASUMS256.txt
|
||||
71
.github/workflows/desktop-windows-branch-build.yml
vendored
Normal file
71
.github/workflows/desktop-windows-branch-build.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Desktop Windows Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- electron-app
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build unsigned Windows desktop artifact
|
||||
runs-on: windows-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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Resolve artifact metadata
|
||||
id: artifact
|
||||
shell: bash
|
||||
run: |
|
||||
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
echo "name=CloudCLI-windows-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build unsigned Windows artifacts
|
||||
run: npm run desktop:dist:win -- --publish never
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
|
||||
- name: Build local server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Verify Windows artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
test -n "$(find release -maxdepth 1 -name '*.exe' -print -quit)"
|
||||
test -n "$(find release -maxdepth 1 -name '*.zip' -print -quit)"
|
||||
test -n "$(find release/server-bundles -maxdepth 1 -name 'cloudcli-server-*.tar.gz' -print -quit)"
|
||||
sha256sum release/*.{exe,zip} release/server-bundles/* > release/SHASUMS256.txt
|
||||
cat release/SHASUMS256.txt
|
||||
|
||||
- name: Upload branch build artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: |
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
release/server-bundles/*
|
||||
release/SHASUMS256.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -134,6 +134,7 @@ tasks/
|
||||
|
||||
# Translations
|
||||
!src/i18n/locales/en/tasks.json
|
||||
!src/i18n/locales/fr/tasks.json
|
||||
!src/i18n/locales/ja/tasks.json
|
||||
!src/i18n/locales/ru/tasks.json
|
||||
!src/i18n/locales/de/tasks.json
|
||||
@@ -142,3 +143,10 @@ tasks/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Local desktop packaging artifacts
|
||||
/.desktop-build/
|
||||
/release/
|
||||
cloudcli-sidebar-app-source.tar.gz
|
||||
cloudcli-sidebar.html
|
||||
electron/*.tar.gz
|
||||
|
||||
@@ -164,6 +164,14 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
|
||||
| Plugin | Beschreibung |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
|
||||
|
||||
### Eigenes Plugin erstellen
|
||||
|
||||
|
||||
@@ -158,6 +158,14 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
|
||||
| プラグイン | 説明 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | エージェントタスクを表示、フィルタリング、起動するためのタスクキューダッシュボード |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues 用の Kanban ボード。TaskMaster との双方向同期と /github-task CLI スキルの自動インストールに対応 |
|
||||
|
||||
### 自作する
|
||||
|
||||
|
||||
@@ -158,6 +158,14 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
|
||||
| 플러그인 | 설명 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 에이전트 작업을 보고, 필터링하고, 실행하는 작업 큐 대시보드 |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues용 Kanban 보드. TaskMaster 양방향 동기화와 /github-task CLI 스킬 자동 설치 지원 |
|
||||
|
||||
### 직접 만들기
|
||||
|
||||
|
||||
17
README.md
17
README.md
@@ -59,6 +59,7 @@
|
||||
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
|
||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Browser Use** - Open browser sessions for web research, testing, and agent-driven browser tasks
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
@@ -73,6 +74,11 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
||||
|
||||
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
||||
|
||||
### Desktop App
|
||||
|
||||
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
|
||||
|
||||
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
||||
|
||||
### Self-Hosted (Open source)
|
||||
|
||||
@@ -163,8 +169,15 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
|
||||
| Plugin | Description |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI|
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task queue dashboard to view, filter, and launch agent tasks |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install |
|
||||
|
||||
### Build Your Own
|
||||
|
||||
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
|
||||
|
||||
@@ -164,6 +164,14 @@ CloudCLI UI — это open source UI-слой, на котором постро
|
||||
| Плагин | Описание |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Дашборд очереди задач для просмотра, фильтрации и запуска агентских задач |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-доска для GitHub Issues с двусторонней синхронизацией TaskMaster и автоустановкой CLI-навыка /github-task |
|
||||
|
||||
### Создать свой
|
||||
|
||||
|
||||
@@ -164,6 +164,13 @@ CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel s
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Ajan görevlerini görüntülemek, filtrelemek ve başlatmak için görev kuyruğu paneli |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues için Kanban panosu; çift yönlü TaskMaster senkronizasyonu ve /github-task CLI becerisi otomatik kurulumu içerir |
|
||||
|
||||
### Kendi Eklentini Yaz
|
||||
|
||||
|
||||
@@ -158,6 +158,14 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
|
||||
| 插件 | 描述 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用于查看、筛选和启动代理任务的任务队列仪表板 |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用于 GitHub Issues 的看板,支持 TaskMaster 双向同步和 /github-task CLI 技能自动安装 |
|
||||
|
||||
### 自行构建
|
||||
|
||||
|
||||
@@ -158,6 +158,14 @@ CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Nod
|
||||
| 外掛 | 描述 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 或 Gemini CLI 等本機 CLI 執行 |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 Claude Code 工作階段 |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用於檢視、篩選和啟動代理任務的任務佇列儀表板 |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用於 GitHub Issues 的看板,支援 TaskMaster 雙向同步和 /github-task CLI 技能自動安裝 |
|
||||
|
||||
### 自行建構
|
||||
|
||||
|
||||
BIN
electron/assets/logo-macos.icns
Normal file
BIN
electron/assets/logo-macos.icns
Normal file
Binary file not shown.
BIN
electron/assets/logo-macos.png
Normal file
BIN
electron/assets/logo-macos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
240
electron/cloud.js
Normal file
240
electron/cloud.js
Normal file
@@ -0,0 +1,240 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { safeStorage } from 'electron';
|
||||
|
||||
function encryptSecret(secret) {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { encrypted: false, value: secret };
|
||||
}
|
||||
|
||||
return {
|
||||
encrypted: true,
|
||||
value: safeStorage.encryptString(secret).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decryptSecret(record) {
|
||||
if (!record?.value) return null;
|
||||
if (!record.encrypted) return record.value;
|
||||
return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
|
||||
}
|
||||
|
||||
export class CloudController {
|
||||
constructor({ storePath, controlPlaneUrl, callbackUrl, onChange }) {
|
||||
this.storePath = storePath;
|
||||
this.controlPlaneUrl = controlPlaneUrl;
|
||||
this.callbackUrl = callbackUrl;
|
||||
this.onChange = onChange;
|
||||
this.cloudAccount = null;
|
||||
this.cloudEnvironments = [];
|
||||
this.authState = 'logged_out';
|
||||
}
|
||||
|
||||
getAccount() {
|
||||
return this.cloudAccount;
|
||||
}
|
||||
|
||||
getAuthState() {
|
||||
return this.authState;
|
||||
}
|
||||
|
||||
getEnvironments() {
|
||||
return this.cloudEnvironments;
|
||||
}
|
||||
|
||||
getEnvironmentUrl(environment) {
|
||||
return environment.access_url || `https://${environment.subdomain}.cloudcli.ai`;
|
||||
}
|
||||
|
||||
async getEnvironmentLaunchUrl(environment) {
|
||||
if (!environment?.id) {
|
||||
return this.getEnvironmentUrl(environment);
|
||||
}
|
||||
|
||||
const data = await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/launch`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return data.launch_url || data.environment_url || this.getEnvironmentUrl(environment);
|
||||
}
|
||||
|
||||
findEnvironment(environmentId) {
|
||||
return this.cloudEnvironments.find((item) => item.id === environmentId) || null;
|
||||
}
|
||||
|
||||
async loadCloudAccount() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.storePath, 'utf8');
|
||||
const stored = JSON.parse(raw);
|
||||
const apiKey = decryptSecret(stored.apiKey);
|
||||
this.cloudAccount = {
|
||||
deviceId: stored.deviceId || crypto.randomUUID(),
|
||||
email: stored.email || null,
|
||||
apiKey: apiKey || null,
|
||||
};
|
||||
this.authState = apiKey ? 'connected' : (stored.email ? 'expired' : 'logged_out');
|
||||
return this.cloudAccount;
|
||||
} catch {
|
||||
this.cloudAccount = {
|
||||
deviceId: crypto.randomUUID(),
|
||||
email: null,
|
||||
apiKey: null,
|
||||
};
|
||||
this.authState = 'logged_out';
|
||||
return this.cloudAccount;
|
||||
}
|
||||
}
|
||||
|
||||
async saveCloudAccount(account) {
|
||||
const payload = {
|
||||
deviceId: account.deviceId || crypto.randomUUID(),
|
||||
email: account.email || null,
|
||||
apiKey: account.apiKey ? encryptSecret(account.apiKey) : null,
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
this.cloudAccount = {
|
||||
deviceId: payload.deviceId,
|
||||
email: payload.email,
|
||||
apiKey: account.apiKey || null,
|
||||
};
|
||||
this.authState = account.apiKey ? 'connected' : 'logged_out';
|
||||
this.onChange?.();
|
||||
return this.cloudAccount;
|
||||
}
|
||||
|
||||
async clearCloudAccount() {
|
||||
this.cloudAccount = {
|
||||
deviceId: crypto.randomUUID(),
|
||||
email: null,
|
||||
apiKey: null,
|
||||
};
|
||||
this.cloudEnvironments = [];
|
||||
this.authState = 'logged_out';
|
||||
await fs.rm(this.storePath, { force: true });
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
async invalidateCloudAccount() {
|
||||
this.cloudEnvironments = [];
|
||||
if (!this.cloudAccount) {
|
||||
this.cloudAccount = {
|
||||
deviceId: crypto.randomUUID(),
|
||||
email: null,
|
||||
apiKey: null,
|
||||
};
|
||||
} else {
|
||||
this.cloudAccount = {
|
||||
...this.cloudAccount,
|
||||
apiKey: null,
|
||||
};
|
||||
}
|
||||
this.authState = this.cloudAccount.email ? 'expired' : 'logged_out';
|
||||
const payload = {
|
||||
deviceId: this.cloudAccount.deviceId,
|
||||
email: this.cloudAccount.email || null,
|
||||
apiKey: null,
|
||||
};
|
||||
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
async cloudApi(pathname, options = {}) {
|
||||
if (!this.cloudAccount?.apiKey) {
|
||||
throw new Error('Connect your CloudCLI account first.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.cloudAccount.apiKey,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
await this.invalidateCloudAccount();
|
||||
}
|
||||
throw new Error(body.error || `CloudCLI API request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async refreshCloudEnvironments() {
|
||||
if (!this.cloudAccount?.apiKey) {
|
||||
this.cloudEnvironments = [];
|
||||
this.onChange?.();
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await this.cloudApi('/api/v1/environments');
|
||||
this.cloudEnvironments = data.environments || [];
|
||||
this.onChange?.();
|
||||
return this.cloudEnvironments;
|
||||
}
|
||||
|
||||
async startEnvironment(environment) {
|
||||
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async stopEnvironment(environment) {
|
||||
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async getEnvironmentCredentials(environment) {
|
||||
return this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/credentials`);
|
||||
}
|
||||
|
||||
async startEnvironmentAndWait(environment, timeoutMs) {
|
||||
await this.startEnvironment(environment);
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const environments = await this.refreshCloudEnvironments();
|
||||
const current = environments.find((env) => env.id === environment.id);
|
||||
if (current?.status === 'running') {
|
||||
return current;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
throw new Error(`${environment.name} did not become ready in time.`);
|
||||
}
|
||||
|
||||
buildConnectUrl() {
|
||||
if (!this.cloudAccount?.deviceId) {
|
||||
this.cloudAccount = {
|
||||
deviceId: crypto.randomUUID(),
|
||||
email: null,
|
||||
apiKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
const connectUrl = new URL('/auth/app-connect', this.controlPlaneUrl);
|
||||
connectUrl.searchParams.set('device_id', this.cloudAccount.deviceId);
|
||||
connectUrl.searchParams.set('callback_url', this.callbackUrl);
|
||||
connectUrl.searchParams.set('app_surface', 'cloudcli_desktop');
|
||||
connectUrl.searchParams.set('client_platform', 'desktop');
|
||||
return connectUrl.toString();
|
||||
}
|
||||
|
||||
async saveFromCallback({ apiKey, email }) {
|
||||
await this.saveCloudAccount({
|
||||
deviceId: this.cloudAccount?.deviceId || crypto.randomUUID(),
|
||||
email,
|
||||
apiKey,
|
||||
});
|
||||
return this.cloudAccount;
|
||||
}
|
||||
}
|
||||
221
electron/computerAgent.js
Normal file
221
electron/computerAgent.js
Normal file
@@ -0,0 +1,221 @@
|
||||
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: {} };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a Computer Use desktop agent connected to running cloud environments
|
||||
* while desktop access is enabled.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
781
electron/desktopWindow.js
Normal file
781
electron/desktopWindow.js
Normal file
@@ -0,0 +1,781 @@
|
||||
import { BrowserView, BrowserWindow, Menu, Tray, nativeImage, nativeTheme, session } from 'electron';
|
||||
|
||||
const TITLEBAR_HEIGHT = 44;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildPlaceholderHtml(title, message, logs = []) {
|
||||
const logHtml = logs.length
|
||||
? `<pre>${logs.map(escapeHtml).join('\n')}</pre>`
|
||||
: '<pre>Waiting for process output...</pre>';
|
||||
return [
|
||||
'<!doctype html><meta charset="utf-8">',
|
||||
'<style>',
|
||||
'html,body{margin:0;height:100%;background:#0a0a0a;color:#fafafa;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}',
|
||||
'body{padding:28px;overflow:hidden}',
|
||||
'.shell{height:100%;display:flex;flex-direction:column;gap:16px}',
|
||||
'.box{display:flex;align-items:center;gap:10px;color:#d4d4d4;flex:0 0 auto}',
|
||||
'.dot{width:8px;height:8px;border-radius:50%;background:#0b60ea;box-shadow:0 0 0 6px rgba(11,96,234,.15)}',
|
||||
'pre{margin:0;flex:1;overflow:auto;border:1px solid #262626;border-radius:10px;background:#050505;color:#d4d4d4;padding:14px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:pre-wrap;user-select:text}',
|
||||
'</style>',
|
||||
'<div class="shell">',
|
||||
`<div class="box"><span class="dot"></span><span>${escapeHtml(message || `Opening ${title}...`)}</span></div>`,
|
||||
logHtml,
|
||||
'</div>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function isHttpUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
|
||||
try {
|
||||
const source = new URL(sourceUrl);
|
||||
if ((source.hostname === '127.0.0.1' || source.hostname === 'localhost') && source.protocol === 'http:') {
|
||||
return true;
|
||||
}
|
||||
if (source.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
const controlPlane = new URL(controlPlaneUrl);
|
||||
return source.origin === controlPlane.origin || source.hostname.endsWith('.cloudcli.ai');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class DesktopWindowManager {
|
||||
constructor({
|
||||
appName,
|
||||
getWindowIconPath,
|
||||
getLauncherPath,
|
||||
getPreloadPath,
|
||||
openExternalUrl,
|
||||
getDesktopState,
|
||||
getDisplayTargetName,
|
||||
getRemoteEnvironmentMenuItems,
|
||||
getCloudState,
|
||||
getLocalState,
|
||||
actions,
|
||||
tabs,
|
||||
}) {
|
||||
this.appName = appName;
|
||||
this.getWindowIconPath = getWindowIconPath;
|
||||
this.getLauncherPath = getLauncherPath;
|
||||
this.getPreloadPath = getPreloadPath;
|
||||
this.openExternalUrl = openExternalUrl;
|
||||
this.getDesktopState = getDesktopState;
|
||||
this.getDisplayTargetName = getDisplayTargetName;
|
||||
this.getRemoteEnvironmentMenuItems = getRemoteEnvironmentMenuItems;
|
||||
this.getCloudState = getCloudState;
|
||||
this.getLocalState = getLocalState;
|
||||
this.actions = actions;
|
||||
this.tabs = tabs;
|
||||
|
||||
this.mainWindow = null;
|
||||
this.settingsWindow = null;
|
||||
this.tray = null;
|
||||
this.launcherLoaded = false;
|
||||
this.activeContentView = null;
|
||||
this.tabViews = new Map();
|
||||
}
|
||||
|
||||
getMainWindow() {
|
||||
return this.mainWindow;
|
||||
}
|
||||
|
||||
getTrayImage() {
|
||||
const image = nativeImage.createFromPath(this.getWindowIconPath());
|
||||
return image.resize({ width: 18, height: 18 });
|
||||
}
|
||||
|
||||
getContentViewBounds() {
|
||||
if (!this.mainWindow) return { x: 0, y: TITLEBAR_HEIGHT, width: 0, height: 0 };
|
||||
const [width, height] = this.mainWindow.getContentSize();
|
||||
return {
|
||||
x: 0,
|
||||
y: TITLEBAR_HEIGHT,
|
||||
width,
|
||||
height: Math.max(0, height - TITLEBAR_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
configureChildWebContents(webContents) {
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
detachActiveContentView() {
|
||||
if (!this.mainWindow || this.mainWindow.isDestroyed() || !this.activeContentView) return;
|
||||
try {
|
||||
if (this.mainWindow.getBrowserViews().includes(this.activeContentView)) {
|
||||
this.mainWindow.removeBrowserView(this.activeContentView);
|
||||
}
|
||||
} catch {
|
||||
// BrowserViews may already be gone during BrowserWindow teardown.
|
||||
}
|
||||
this.activeContentView = null;
|
||||
}
|
||||
|
||||
getOrCreateTabView(tabId) {
|
||||
let view = this.tabViews.get(tabId);
|
||||
if (view) return view;
|
||||
|
||||
view = new BrowserView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: this.getPreloadPath(),
|
||||
},
|
||||
});
|
||||
this.configureChildWebContents(view.webContents);
|
||||
this.tabViews.set(tabId, view);
|
||||
return view;
|
||||
}
|
||||
|
||||
attachContentView(view) {
|
||||
if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
|
||||
if (this.activeContentView && this.activeContentView !== view) {
|
||||
this.detachActiveContentView();
|
||||
}
|
||||
this.activeContentView = view;
|
||||
try {
|
||||
if (!this.mainWindow.getBrowserViews().includes(view)) {
|
||||
this.mainWindow.addBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
view.setBounds(this.getContentViewBounds());
|
||||
view.setAutoResize({ width: true, height: true });
|
||||
}
|
||||
|
||||
async showTabPlaceholder(target, message) {
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
this.attachContentView(view);
|
||||
const html = buildPlaceholderHtml(target.name || this.appName, message);
|
||||
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
view.__cloudcliStartupHtml = html;
|
||||
view.__cloudcliLoadedUrl = null;
|
||||
}
|
||||
|
||||
async showLocalStartupTarget(target, logs) {
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
if (view.__cloudcliLoadingUrl) return;
|
||||
this.attachContentView(view);
|
||||
const html = buildPlaceholderHtml(target.name || this.appName, 'Starting Local CloudCLI...', logs);
|
||||
if (view.__cloudcliStartupHtml === html) return;
|
||||
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
view.__cloudcliStartupHtml = html;
|
||||
view.__cloudcliLoadedUrl = null;
|
||||
}
|
||||
|
||||
async showContentTarget(target) {
|
||||
if (!isHttpUrl(target.url)) {
|
||||
throw new Error(`Refusing to load unsupported app URL: ${target.url}`);
|
||||
}
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
this.attachContentView(view);
|
||||
if (view.__cloudcliLoadedUrl !== target.url) {
|
||||
view.__cloudcliLoadingUrl = target.url;
|
||||
try {
|
||||
await view.webContents.loadURL(target.url);
|
||||
view.__cloudcliLoadedUrl = target.url;
|
||||
view.__cloudcliStartupHtml = null;
|
||||
} finally {
|
||||
if (view.__cloudcliLoadingUrl === target.url) {
|
||||
view.__cloudcliLoadingUrl = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroyTabView(tabId) {
|
||||
const view = this.tabViews.get(tabId);
|
||||
if (!view) return;
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
try {
|
||||
if (this.mainWindow.getBrowserViews().includes(view)) {
|
||||
this.mainWindow.removeBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
// Ignore teardown races; Electron owns final destruction during quit.
|
||||
}
|
||||
}
|
||||
if (this.activeContentView === view) {
|
||||
this.activeContentView = null;
|
||||
}
|
||||
try {
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
view.webContents.destroy();
|
||||
}
|
||||
} catch {
|
||||
// The view may already be destroyed by its parent BrowserWindow.
|
||||
}
|
||||
this.tabViews.delete(tabId);
|
||||
}
|
||||
|
||||
emitDesktopState() {
|
||||
const state = this.getDesktopState();
|
||||
if (this.mainWindow && !this.mainWindow.webContents.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('cloudcli-desktop:state-updated', state);
|
||||
}
|
||||
if (this.settingsWindow && !this.settingsWindow.webContents.isDestroyed()) {
|
||||
this.settingsWindow.webContents.send('cloudcli-desktop:state-updated', state);
|
||||
}
|
||||
}
|
||||
|
||||
emitLauncherCommand(command) {
|
||||
if (!this.mainWindow || this.mainWindow.webContents.isDestroyed()) return;
|
||||
this.mainWindow.webContents.send('cloudcli-desktop:launcher-command', command);
|
||||
}
|
||||
|
||||
emitSettingsCommand(command) {
|
||||
if (!this.settingsWindow || this.settingsWindow.webContents.isDestroyed()) return;
|
||||
this.settingsWindow.webContents.send('cloudcli-desktop:launcher-command', command);
|
||||
}
|
||||
|
||||
syncSettingsWindowBounds() {
|
||||
if (!this.mainWindow || !this.settingsWindow || this.settingsWindow.isDestroyed()) return;
|
||||
this.settingsWindow.setBounds(this.mainWindow.getBounds());
|
||||
}
|
||||
|
||||
async ensureSettingsWindow(sheet = 'desktop-settings') {
|
||||
if (!this.mainWindow) return null;
|
||||
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.syncSettingsWindowBounds();
|
||||
this.emitSettingsCommand({ type: 'open-sheet', sheet });
|
||||
this.settingsWindow.focus();
|
||||
return this.settingsWindow;
|
||||
}
|
||||
|
||||
this.settingsWindow = new BrowserWindow({
|
||||
parent: this.mainWindow,
|
||||
modal: true,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
movable: false,
|
||||
skipTaskbar: true,
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: this.getPreloadPath(),
|
||||
},
|
||||
});
|
||||
this.syncSettingsWindowBounds();
|
||||
this.configureChildWebContents(this.settingsWindow.webContents);
|
||||
this.settingsWindow.once('ready-to-show', () => this.settingsWindow?.show());
|
||||
this.settingsWindow.on('closed', () => {
|
||||
this.settingsWindow = null;
|
||||
});
|
||||
await this.settingsWindow.loadFile(this.getLauncherPath(), {
|
||||
query: { modal: '1', sheet },
|
||||
});
|
||||
return this.settingsWindow;
|
||||
}
|
||||
|
||||
closeSettingsWindow() {
|
||||
if (!this.settingsWindow || this.settingsWindow.isDestroyed()) return;
|
||||
this.settingsWindow.close();
|
||||
}
|
||||
|
||||
async showTarget(target, { trackTab = true } = {}) {
|
||||
if (!this.mainWindow) return;
|
||||
if (trackTab) {
|
||||
this.tabs.upsertTarget(target);
|
||||
}
|
||||
this.actions.setActiveTarget(target);
|
||||
this.buildAppMenu();
|
||||
this.mainWindow.setTitle(`${this.appName} - ${target.name}`);
|
||||
await this.showContentTarget(target);
|
||||
this.emitDesktopState();
|
||||
}
|
||||
|
||||
async showLauncher() {
|
||||
if (!this.mainWindow) return;
|
||||
const target = { kind: 'launcher', name: this.appName, url: null };
|
||||
this.tabs.upsertTarget(target);
|
||||
this.actions.setActiveTarget(target);
|
||||
this.detachActiveContentView();
|
||||
this.buildAppMenu();
|
||||
this.mainWindow.setTitle(this.appName);
|
||||
if (!this.launcherLoaded) {
|
||||
await this.mainWindow.loadFile(this.getLauncherPath());
|
||||
this.launcherLoaded = true;
|
||||
} else {
|
||||
this.emitDesktopState();
|
||||
}
|
||||
}
|
||||
|
||||
async switchDesktopTab(tabId) {
|
||||
const tab = this.tabs.activate(tabId);
|
||||
if (!tab || !this.mainWindow) return this.getDesktopState();
|
||||
|
||||
if (tab.id === 'home' || tab.kind === 'launcher') {
|
||||
await this.showLauncher();
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
if (!tab.target?.url) {
|
||||
throw new Error('This tab does not have a target URL.');
|
||||
}
|
||||
|
||||
await this.showTarget(tab.target, { trackTab: false });
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async closeDesktopTab(tabId) {
|
||||
const tab = this.tabs.remove(tabId);
|
||||
if (!tab) return this.getDesktopState();
|
||||
this.destroyTabView(tabId);
|
||||
if (this.tabs.activeTabId === 'home') {
|
||||
await this.showLauncher();
|
||||
} else {
|
||||
this.emitDesktopState();
|
||||
}
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
buildEnvironmentActionsSubmenu(environment) {
|
||||
const items = [];
|
||||
const statusSuffix = environment.status === 'running' ? '' : ` (${environment.status})`;
|
||||
items.push({
|
||||
label: 'Open Environment',
|
||||
click: () => void this.actions.openEnvironmentInDesktop(environment)
|
||||
.catch((error) => this.actions.showError(`Could not open ${environment.name || environment.subdomain}${statusSuffix}`, error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Open in Browser',
|
||||
click: () => void this.actions.openEnvironmentInBrowser(environment)
|
||||
.catch((error) => this.actions.showError('Could not open environment in browser', error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Open in VS Code',
|
||||
click: () => void this.actions.openEnvironmentInIde(environment, 'vscode')
|
||||
.catch((error) => this.actions.showError('Could not open environment in VS Code', error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Open in Cursor',
|
||||
click: () => void this.actions.openEnvironmentInIde(environment, 'cursor')
|
||||
.catch((error) => this.actions.showError('Could not open environment in Cursor', error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Open SSH Terminal',
|
||||
click: () => void this.actions.openEnvironmentInSsh(environment)
|
||||
.catch((error) => this.actions.showError('Could not open SSH terminal', error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Copy Mobile/Web URL',
|
||||
click: () => this.actions.copyText(this.actions.getEnvironmentUrl(environment)),
|
||||
});
|
||||
if (environment.status !== 'running') {
|
||||
items.unshift({
|
||||
label: environment.status === 'paused' ? 'Resume' : 'Start',
|
||||
click: () => void this.actions.startEnvironment(environment)
|
||||
.catch((error) => this.actions.showError('Could not start environment', error)),
|
||||
});
|
||||
}
|
||||
if (environment.status === 'running') {
|
||||
items.push({
|
||||
label: 'Stop',
|
||||
click: () => void this.actions.stopEnvironment(environment)
|
||||
.catch((error) => this.actions.showError('Could not stop environment', error)),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
buildTrayEnvironmentSection() {
|
||||
const cloudState = this.getCloudState();
|
||||
if (!cloudState.account?.apiKey) {
|
||||
return [
|
||||
{
|
||||
label: cloudState.account?.email ? `Reconnect ${cloudState.account.email}` : 'Login',
|
||||
click: () => void this.actions.connectCloudAccount()
|
||||
.catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!cloudState.environments.length) {
|
||||
return [{ label: 'No environments found', enabled: false }];
|
||||
}
|
||||
|
||||
return cloudState.environments.map((environment) => ({
|
||||
label: `${environment.name || environment.subdomain} - ${environment.status}`,
|
||||
submenu: this.buildEnvironmentActionsSubmenu(environment),
|
||||
}));
|
||||
}
|
||||
|
||||
buildAppMenu() {
|
||||
if (!this.mainWindow) return;
|
||||
const cloudState = this.getCloudState();
|
||||
const localState = this.getLocalState();
|
||||
const remoteItems = this.getRemoteEnvironmentMenuItems();
|
||||
const cloudAccountLabel = cloudState.account?.apiKey
|
||||
? (cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'CloudCLI Connected')
|
||||
: (cloudState.account?.email ? `Reconnect: ${cloudState.account.email}` : 'Connect CloudCLI Account...');
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: this.appName,
|
||||
submenu: [
|
||||
{ label: `About ${this.appName}`, role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Show Launcher',
|
||||
accelerator: 'CmdOrCtrl+Shift+L',
|
||||
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
|
||||
},
|
||||
{
|
||||
label: 'Switch Environment',
|
||||
accelerator: 'CmdOrCtrl+Shift+E',
|
||||
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Services',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Computer Use',
|
||||
click: () => void this.showDesktopSettings(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Diagnostics',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Copy Diagnostics',
|
||||
click: () => void this.actions.copyDiagnostics(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: process.platform === 'darwin' ? `Hide ${this.appName}` : 'Hide',
|
||||
role: 'hide',
|
||||
visible: process.platform === 'darwin',
|
||||
},
|
||||
{ label: 'Hide Others', role: 'hideOthers', visible: process.platform === 'darwin' },
|
||||
{ label: 'Show All', role: 'unhide', visible: process.platform === 'darwin' },
|
||||
{ type: 'separator', visible: process.platform === 'darwin' },
|
||||
{ label: `Quit ${this.appName}`, accelerator: 'CmdOrCtrl+Q', role: 'quit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Environment',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Show Launcher',
|
||||
accelerator: 'CmdOrCtrl+Shift+L',
|
||||
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
|
||||
},
|
||||
{
|
||||
label: 'Switch Environment',
|
||||
accelerator: 'CmdOrCtrl+Shift+E',
|
||||
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Open Local CloudCLI',
|
||||
accelerator: 'CmdOrCtrl+L',
|
||||
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
|
||||
},
|
||||
{
|
||||
label: 'Open Local Web UI in Browser',
|
||||
accelerator: 'CmdOrCtrl+Shift+W',
|
||||
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
|
||||
},
|
||||
{
|
||||
label: 'Copy Local Web URL',
|
||||
accelerator: 'CmdOrCtrl+Shift+U',
|
||||
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Keep Local Server Running After Quit',
|
||||
type: 'checkbox',
|
||||
checked: localState.desktopSettings.keepLocalServerRunning,
|
||||
click: (menuItem) => void this.actions.updateDesktopSetting('keepLocalServerRunning', menuItem.checked)
|
||||
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
|
||||
},
|
||||
{
|
||||
label: 'Allow LAN Access to Local Server',
|
||||
type: 'checkbox',
|
||||
checked: localState.desktopSettings.exposeLocalServerOnNetwork,
|
||||
click: (menuItem) => void this.actions.updateDesktopSetting('exposeLocalServerOnNetwork', menuItem.checked)
|
||||
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cloud',
|
||||
submenu: [
|
||||
{
|
||||
label: cloudAccountLabel,
|
||||
accelerator: 'CmdOrCtrl+Shift+C',
|
||||
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||
},
|
||||
{
|
||||
label: 'Refresh Cloud Environments',
|
||||
click: () => void this.actions.refreshCloudEnvironments().catch((error) => this.actions.showError('Could not load CloudCLI environments', error)),
|
||||
enabled: Boolean(cloudState.account?.apiKey),
|
||||
},
|
||||
{
|
||||
label: 'Disconnect Cloud Account',
|
||||
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not disconnect cloud account', error)),
|
||||
enabled: Boolean(cloudState.account?.apiKey),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Remote Environments',
|
||||
submenu: remoteItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'selectAll' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(process.platform === 'darwin' ? [{ type: 'separator' }, { role: 'front' }] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open cloudcli.ai',
|
||||
click: () => void this.actions.openCloudDashboard(),
|
||||
},
|
||||
{
|
||||
label: 'Copy Diagnostics',
|
||||
click: () => void this.actions.copyDiagnostics(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
this.buildTrayMenu();
|
||||
}
|
||||
|
||||
buildTrayMenu() {
|
||||
if (!this.tray) return;
|
||||
const cloudState = this.getCloudState();
|
||||
const localState = this.getLocalState();
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: 'Local',
|
||||
submenu: [
|
||||
{
|
||||
label: localState.localServerRunning ? 'Open Local in CloudCLI' : 'Start Local in CloudCLI',
|
||||
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
|
||||
},
|
||||
{
|
||||
label: 'Open Local in Browser',
|
||||
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
|
||||
},
|
||||
{
|
||||
label: 'Copy Local URL',
|
||||
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cloud Environments',
|
||||
submenu: this.buildTrayEnvironmentSection(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'Login',
|
||||
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||
},
|
||||
{
|
||||
label: 'Disconnect Cloud Account',
|
||||
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not disconnect cloud account', error)),
|
||||
enabled: Boolean(cloudState.account?.apiKey),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: `Quit ${this.appName}`,
|
||||
role: 'quit',
|
||||
},
|
||||
];
|
||||
|
||||
this.tray.setToolTip(`${this.appName}${this.actions.getActiveTarget()?.name ? ` - ${this.actions.getActiveTarget().name}` : ''}`);
|
||||
this.tray.setContextMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
async showDesktopSettings() {
|
||||
if (!this.mainWindow) return this.getDesktopState();
|
||||
await this.ensureSettingsWindow('desktop-settings');
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async showLocalSettings() {
|
||||
if (!this.mainWindow) return this.getDesktopState();
|
||||
await this.ensureSettingsWindow('local-settings');
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async showActiveEnvironmentActionsMenu() {
|
||||
if (!this.mainWindow) return this.getDesktopState();
|
||||
const activeTarget = this.actions.getActiveTarget();
|
||||
if (activeTarget?.kind !== 'remote') return this.getDesktopState();
|
||||
|
||||
const environment = this.getCloudState().environments.find((item) => item.id === activeTarget.id);
|
||||
if (!environment) return this.getDesktopState();
|
||||
|
||||
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
|
||||
menu.popup({ window: this.mainWindow });
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async showEnvironmentActionsMenu(environmentId) {
|
||||
if (!this.mainWindow) return this.getDesktopState();
|
||||
const environment = this.getCloudState().environments.find((item) => item.id === environmentId);
|
||||
if (!environment) return this.getDesktopState();
|
||||
|
||||
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
|
||||
menu.popup({ window: this.mainWindow });
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
configurePermissions() {
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
const sourceUrl = webContents.getURL();
|
||||
const allowedPermissions = new Set(['clipboard-read', 'media']);
|
||||
callback(isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission));
|
||||
});
|
||||
}
|
||||
|
||||
createTray() {
|
||||
if (this.tray) return;
|
||||
this.tray = new Tray(this.getTrayImage());
|
||||
this.tray.on('click', () => {
|
||||
if (!this.mainWindow) return;
|
||||
if (this.mainWindow.isVisible()) {
|
||||
this.mainWindow.focus();
|
||||
} else {
|
||||
this.mainWindow.show();
|
||||
}
|
||||
});
|
||||
this.buildTrayMenu();
|
||||
}
|
||||
|
||||
async createWindow() {
|
||||
this.mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 960,
|
||||
minWidth: 1024,
|
||||
minHeight: 720,
|
||||
show: false,
|
||||
backgroundColor: '#0f172a',
|
||||
title: this.appName,
|
||||
icon: this.getWindowIconPath(),
|
||||
titleBarStyle: 'hidden',
|
||||
...(process.platform === 'darwin'
|
||||
? { trafficLightPosition: { x: 18, y: 14 } }
|
||||
: {
|
||||
titleBarOverlay: {
|
||||
color: nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f8fa',
|
||||
symbolColor: nativeTheme.shouldUseDarkColors ? '#a1a1a1' : '#5b6470',
|
||||
height: 44,
|
||||
},
|
||||
}),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: this.getPreloadPath(),
|
||||
},
|
||||
});
|
||||
|
||||
this.mainWindow.once('ready-to-show', () => {
|
||||
this.mainWindow?.show();
|
||||
});
|
||||
|
||||
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
this.mainWindow.on('resize', () => {
|
||||
if (this.activeContentView) {
|
||||
this.activeContentView.setBounds(this.getContentViewBounds());
|
||||
}
|
||||
this.syncSettingsWindowBounds();
|
||||
});
|
||||
|
||||
this.mainWindow.on('move', () => {
|
||||
this.syncSettingsWindowBounds();
|
||||
});
|
||||
|
||||
this.mainWindow.on('closed', () => {
|
||||
this.tabViews.clear();
|
||||
this.activeContentView = null;
|
||||
this.settingsWindow = null;
|
||||
this.mainWindow = null;
|
||||
this.launcherLoaded = false;
|
||||
});
|
||||
|
||||
this.buildAppMenu();
|
||||
await this.showLauncher();
|
||||
}
|
||||
}
|
||||
14
electron/launcher/index.html
Normal file
14
electron/launcher/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:; connect-src *; img-src 'self' data:" />
|
||||
<title>CloudCLI Desktop</title>
|
||||
<link rel="stylesheet" href="./launcher.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./launcher.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
758
electron/launcher/launcher.css
Normal file
758
electron/launcher/launcher.css
Normal file
@@ -0,0 +1,758 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html.cc-modal-window,
|
||||
body.cc-modal-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #111315;
|
||||
--s1: #171a1d;
|
||||
--s2: #1e2328;
|
||||
--s3: #262d34;
|
||||
--b-subtle: #28303a;
|
||||
--b: #313b46;
|
||||
--b-strong: #42505f;
|
||||
--tx: #f5f7fa;
|
||||
--tx2: #adb8c5;
|
||||
--tx3: #7f8b98;
|
||||
--brand: #0a66d9;
|
||||
--brand-2: #5fa5ff;
|
||||
--brand-faint: rgba(10, 102, 217, 0.14);
|
||||
--ok: #2aa775;
|
||||
--warn: #d48b20;
|
||||
--err: #d65252;
|
||||
--tab-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
--tab-active-bg: rgba(255, 255, 255, 0.14);
|
||||
--mono: "SF Mono", "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f3f5f8;
|
||||
--s1: #ffffff;
|
||||
--s2: #f7f9fb;
|
||||
--s3: #edf1f5;
|
||||
--b-subtle: #e5eaf0;
|
||||
--b: #d8dee6;
|
||||
--b-strong: #c3ccd6;
|
||||
--tx: #11151a;
|
||||
--tx2: #566171;
|
||||
--tx3: #7f8b98;
|
||||
--brand: #0a66d9;
|
||||
--brand-2: #0f5fc6;
|
||||
--brand-faint: rgba(10, 102, 217, 0.09);
|
||||
--ok: #1f8e61;
|
||||
--warn: #b67515;
|
||||
--err: #c24747;
|
||||
--tab-hover-bg: rgba(15, 23, 42, 0.05);
|
||||
--tab-active-bg: rgba(15, 23, 42, 0.08);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--tx);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--b);
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--tx3);
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--b-subtle);
|
||||
background: color-mix(in srgb, var(--s1) 90%, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.titlebar button,
|
||||
.titlebar input,
|
||||
.titlebar .no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand .mk {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 13px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--b);
|
||||
background: var(--s1);
|
||||
color: var(--tx);
|
||||
font-weight: 500;
|
||||
transition: border-color 0.12s, background 0.12s, filter 0.12s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--b-strong);
|
||||
background: var(--s2);
|
||||
}
|
||||
|
||||
.btn.pri {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.pri:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn.sm {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--tx2);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--s2);
|
||||
border-color: var(--b);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: var(--s2);
|
||||
color: var(--tx2);
|
||||
border: 1px solid var(--b-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge.idle {
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.cc-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 27px;
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--b-subtle);
|
||||
background: var(--s1);
|
||||
font-size: 11px;
|
||||
color: var(--tx2);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.statusbar .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.status-msg.progress {
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.status-msg.error {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.cc-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 8, 11, 0.46);
|
||||
backdrop-filter: blur(16px);
|
||||
display: none;
|
||||
z-index: 50;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.cc-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cc-sheet {
|
||||
width: 620px;
|
||||
max-width: min(92vw, 620px);
|
||||
max-height: 86vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--b);
|
||||
background: color-mix(in srgb, var(--s1) 94%, transparent);
|
||||
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.cc-sheet-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 20px 18px;
|
||||
border-bottom: 1px solid var(--b-subtle);
|
||||
}
|
||||
|
||||
.cc-sheet-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cc-sheet-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cc-sheet-subtitle {
|
||||
margin-top: 6px;
|
||||
color: var(--tx2);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cc-sheet-close {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cc-sheet-body {
|
||||
overflow: auto;
|
||||
padding: 16px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cc-sheet-footer {
|
||||
padding: 14px 20px 18px;
|
||||
border-top: 1px solid var(--b-subtle);
|
||||
}
|
||||
|
||||
.cc-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cc-section-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cc-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.cc-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cc-surface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--b-subtle);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--s2) 86%, transparent), var(--s1));
|
||||
}
|
||||
|
||||
.cc-row2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cc-meta {
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cc-toggle,
|
||||
.cc-choice {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
color: var(--tx2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cc-toggle input,
|
||||
.cc-choice input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.cc-toggle b,
|
||||
.cc-choice b {
|
||||
color: var(--tx);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-choice-group {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cc-kv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
color: var(--tx2);
|
||||
}
|
||||
|
||||
.cc-kv span:last-child {
|
||||
color: var(--tx);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cc-actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.cc-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--b-subtle);
|
||||
background: var(--s2);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-status-badge.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.cc-status-badge.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.cc-status-badge.idle {
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.v-sidebar {
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 12px;
|
||||
border-right: 1px solid var(--b-subtle);
|
||||
background: var(--s1);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sb-grp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.sb-grp .lbl {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.sb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
color: var(--tx2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sb-item > span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sb-item .sb-meta {
|
||||
font-size: 11px;
|
||||
color: var(--tx3);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.sb-item:hover {
|
||||
background: var(--s2);
|
||||
}
|
||||
|
||||
.sb-item.active {
|
||||
background: var(--brand-faint);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.sb-item.active svg {
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.sb-main {
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pane-h {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pane-sub {
|
||||
margin: 4px 0 0;
|
||||
color: var(--tx2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--b);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--s1) 94%, transparent);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 620px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-t {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
margin-top: 4px;
|
||||
color: var(--tx2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.env {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--b);
|
||||
border-radius: 12px;
|
||||
background: var(--s1);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.env:hover {
|
||||
border-color: var(--b-strong);
|
||||
}
|
||||
|
||||
.env-i {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.env-n {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.env-u {
|
||||
font-size: 12px;
|
||||
color: var(--tx3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.env-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--tx2);
|
||||
background: var(--s2);
|
||||
border: 1px solid var(--b-subtle);
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
border: 1px dashed var(--b);
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
color: var(--tx2);
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
body.mac .titlebar {
|
||||
padding-left: 92px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
body.win .titlebar {
|
||||
padding-right: 150px;
|
||||
}
|
||||
|
||||
.titlebar .brand {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.tb-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tb-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 112px;
|
||||
max-width: 232px;
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 0 7px 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.tb-tab:hover {
|
||||
background: var(--tab-hover-bg);
|
||||
}
|
||||
|
||||
.tb-tab.active {
|
||||
background: var(--tab-active-bg);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.tb-tab span:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 20ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tb-close {
|
||||
display: grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 8px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: var(--tx3);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tb-close:hover {
|
||||
background: var(--tab-hover-bg);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.tb-action {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.v-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sb {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.env-tags {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cc-sheet {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cc-row2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
727
electron/launcher/launcher.js
Normal file
727
electron/launcher/launcher.js
Normal file
@@ -0,0 +1,727 @@
|
||||
window.__APP_VERSION__ = '1.34.0';
|
||||
window.__MOCK_STATE__ = {
|
||||
account: { connected: true, email: 'you@cloudcli.ai' },
|
||||
activeTarget: { kind: 'launcher', name: 'Launcher', url: null },
|
||||
cloudLoading: false,
|
||||
desktopSettings: { keepLocalServerRunning: false, exposeLocalServerOnNetwork: false, themeMode: 'system' },
|
||||
localWebUrl: 'http://localhost:3001',
|
||||
shareableWebUrl: 'http://localhost:3001',
|
||||
localServerRunning: false,
|
||||
localStartupLogs: [],
|
||||
computerUse: { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 },
|
||||
environments: [
|
||||
{ id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
|
||||
{ id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
|
||||
{ id: 'env-data', name: 'data-pipeline', subdomain: 'data-pipeline', access_url: 'https://data-pipeline.cloudcli.ai', status: 'stopped', region: 'fra1', agent: 'Cursor' },
|
||||
{ id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'Gemini' },
|
||||
],
|
||||
};
|
||||
|
||||
(function cloudCliLauncher() {
|
||||
var MOCK = window.__MOCK_STATE__ || {};
|
||||
var VERSION = window.__APP_VERSION__ || '';
|
||||
var LOGO_URL = new URL('../../public/logo-32.png', window.location.href).toString();
|
||||
var SEARCH = new URLSearchParams(window.location.search || '');
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
var mockState = clone(MOCK);
|
||||
var mockBridge = {
|
||||
getState: function () { return Promise.resolve(clone(mockState)); },
|
||||
openLocal: function () {
|
||||
mockState.localServerRunning = true;
|
||||
mockState.activeTarget = { kind: 'local', name: 'Local CloudCLI', url: mockState.localWebUrl };
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
openLocalWebUi: function () {
|
||||
mockState.localServerRunning = true;
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
copyLocalWebUrl: function () { return Promise.resolve(clone(mockState)); },
|
||||
connectCloud: function () {
|
||||
mockState.account = { connected: true, email: 'you@cloudcli.ai' };
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
|
||||
copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
|
||||
showComputerAccess: function () { return Promise.resolve(clone(mockState)); },
|
||||
showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
|
||||
showLauncher: function () { return Promise.resolve(clone(mockState)); },
|
||||
showLocalSettings: function () { return Promise.resolve(clone(mockState)); },
|
||||
showDesktopSettings: function () { return Promise.resolve(clone(mockState)); },
|
||||
closeSettingsWindow: function () { return Promise.resolve(clone(mockState)); },
|
||||
showActiveEnvironmentActionsMenu: function () { return Promise.resolve(clone(mockState)); },
|
||||
openCloudDashboard: function () { return Promise.resolve(clone(mockState)); },
|
||||
runActiveEnvironmentAction: function () { return Promise.resolve(clone(mockState)); },
|
||||
switchTab: function (id) { mockState.activeTabId = id; return Promise.resolve(clone(mockState)); },
|
||||
closeTab: function (id) {
|
||||
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.id === 'home' || tab.id !== id; });
|
||||
if (mockState.activeTabId === id) mockState.activeTabId = 'home';
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
updateSetting: function (key, value) {
|
||||
mockState.desktopSettings = mockState.desktopSettings || {};
|
||||
mockState.desktopSettings[key] = key === 'themeMode' ? value : !!value;
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
updateComputerUse: function (settings) {
|
||||
mockState.computerUse = mockState.computerUse || { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 };
|
||||
if (typeof settings.enabled === 'boolean') mockState.computerUse.enabled = settings.enabled;
|
||||
if (settings.consentMode === 'auto' || settings.consentMode === 'ask') mockState.computerUse.consentMode = settings.consentMode;
|
||||
mockState.computerUse.running = mockState.computerUse.enabled;
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
openEnvironment: function (id) {
|
||||
var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
|
||||
if (env) {
|
||||
env.status = 'starting';
|
||||
setTimeout(function () {
|
||||
env.status = 'running';
|
||||
mockState.activeTarget = { kind: 'remote', id: id, name: env.name, url: env.access_url };
|
||||
}, 1700);
|
||||
}
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
};
|
||||
|
||||
var bridge = window.cloudcliDesktop || mockBridge;
|
||||
|
||||
var ICONS = {
|
||||
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||
cloud: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/>',
|
||||
refresh: '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
settings: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
||||
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6l-.03.08a2 2 0 1 1-3.94 0L10 20a1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1l-.08-.03a2 2 0 1 1 0-3.94L4 10a1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6l.03-.08a2 2 0 1 1 3.94 0L14 4a1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.2.36.4.7.6 1l.08.03a2 2 0 1 1 0 3.94L20 14a1.7 1.7 0 0 0-.6 1z"/>',
|
||||
play: '<polygon points="6 4 20 12 6 20 6 4"/>',
|
||||
arrow: '<line x1="7" y1="17" x2="17" y2="7"/><polyline points="8 7 17 7 17 16"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
cloudPlus: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/><line x1="12" y1="9" x2="12" y2="15"/><line x1="9" y1="12" x2="15" y2="12"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
phone: '<rect x="7" y="2" width="10" height="20" rx="2"/><line x1="11" y1="18" x2="13" y2="18"/>',
|
||||
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
};
|
||||
var FILLED = { play: true };
|
||||
|
||||
function icon(name, size) {
|
||||
size = size || 16;
|
||||
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="' + (FILLED[name] ? 'currentColor' : 'none') + '" stroke="' + (FILLED[name] ? 'none' : 'currentColor') + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
|
||||
}
|
||||
|
||||
function esc(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function statusMeta(status) {
|
||||
var map = {
|
||||
running: { label: 'Running', cls: 'ok', dot: '#10b981', verb: 'Opening', open: 'Open' },
|
||||
starting: { label: 'Starting', cls: 'warn', dot: '#f59e0b', verb: 'Starting', open: 'Open', busy: true },
|
||||
stopped: { label: 'Stopped', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' },
|
||||
paused: { label: 'Paused', cls: 'warn', dot: '#f59e0b', verb: 'Resuming', open: 'Resume' },
|
||||
};
|
||||
return map[status] || { label: status || 'Unknown', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' };
|
||||
}
|
||||
|
||||
function connected(state) {
|
||||
return !!(state && state.account && state.account.connected);
|
||||
}
|
||||
|
||||
function authState(state) {
|
||||
return state && state.account ? (state.account.authState || (state.account.connected ? 'connected' : 'logged_out')) : 'logged_out';
|
||||
}
|
||||
|
||||
function accountLabel(state) {
|
||||
if (authState(state) === 'expired') return 'Reconnect';
|
||||
if (state && state.account && state.account.email) return state.account.email;
|
||||
if (connected(state)) return 'Connected';
|
||||
return 'Log in';
|
||||
}
|
||||
|
||||
function localUrl(state) {
|
||||
return (state && (state.shareableWebUrl || state.localWebUrl)) || '';
|
||||
}
|
||||
|
||||
function envCount(state) {
|
||||
var count = state && state.environments ? state.environments.length : 0;
|
||||
return count + ' environment' + (count === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
function errMsg(error) {
|
||||
return error && error.message ? error.message : String(error);
|
||||
}
|
||||
|
||||
function resolveTheme(state) {
|
||||
var settings = state && state.desktopSettings ? state.desktopSettings : {};
|
||||
var mode = settings.themeMode || 'system';
|
||||
if (mode === 'light' || mode === 'dark') return mode;
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function computerUseStatus(state) {
|
||||
var computerUse = state && state.computerUse ? state.computerUse : {};
|
||||
if (!computerUse.enabled) {
|
||||
return { label: 'Disabled', tone: 'idle', detail: 'CloudCLI cannot use this computer.' };
|
||||
}
|
||||
if (computerUse.consentMode === 'auto') {
|
||||
return { label: 'Unattended access', tone: 'warn', detail: 'Trusted agents can use this computer without a local approval prompt.' };
|
||||
}
|
||||
return { label: 'Ask before each session', tone: 'ok', detail: 'Agents need approval before control starts.' };
|
||||
}
|
||||
|
||||
var CC = {
|
||||
icon: icon,
|
||||
esc: esc,
|
||||
statusMeta: statusMeta,
|
||||
connected: connected,
|
||||
authState: authState,
|
||||
accountLabel: accountLabel,
|
||||
localUrl: localUrl,
|
||||
envCount: envCount,
|
||||
computerUseStatus: computerUseStatus,
|
||||
version: VERSION,
|
||||
logoUrl: LOGO_URL,
|
||||
platform: 'win',
|
||||
state: clone(MOCK),
|
||||
ui: {},
|
||||
_busyEnv: null,
|
||||
_status: { msg: '', tone: '' },
|
||||
_reg: {},
|
||||
_wired: false,
|
||||
_poll: null,
|
||||
modalMode: SEARCH.get('modal') === '1',
|
||||
};
|
||||
|
||||
window.CC = CC;
|
||||
|
||||
var app;
|
||||
var overlay;
|
||||
|
||||
CC.setState = function (state) {
|
||||
var currentSheet = CC.ui.openSheet || (CC.modalMode ? (CC.ui.initialSheet || 'desktop-settings') : null);
|
||||
var sheetBody = overlay ? overlay.querySelector('.cc-sheet-body') : null;
|
||||
var scrollTop = sheetBody ? sheetBody.scrollTop : 0;
|
||||
if (state && typeof state === 'object') CC.state = state;
|
||||
CC.applyTheme(CC.state);
|
||||
CC.render(CC.state);
|
||||
if (currentSheet) {
|
||||
CC.openSheet(currentSheet, { scrollTop: scrollTop });
|
||||
}
|
||||
};
|
||||
|
||||
CC.applyTheme = function (state) {
|
||||
var settings = state && state.desktopSettings ? state.desktopSettings : {};
|
||||
var themeMode = settings.themeMode || 'system';
|
||||
var resolvedTheme = resolveTheme(state);
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
document.documentElement.setAttribute('data-theme-mode', themeMode);
|
||||
};
|
||||
|
||||
CC.refresh = function () {
|
||||
return Promise.resolve(bridge.getState()).then(function (state) {
|
||||
CC.setState(state);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
CC.run = function (label, fn) {
|
||||
CC._status = { msg: label, tone: 'progress' };
|
||||
CC.render(CC.state);
|
||||
return Promise.resolve()
|
||||
.then(fn)
|
||||
.then(function (state) {
|
||||
if (state && state.environments) CC.state = state;
|
||||
return CC.refresh();
|
||||
})
|
||||
.then(function () {
|
||||
CC._status = { msg: '', tone: '' };
|
||||
CC.render(CC.state);
|
||||
})
|
||||
.catch(function (error) {
|
||||
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||
CC.render(CC.state);
|
||||
});
|
||||
};
|
||||
|
||||
CC.startPolling = function () {
|
||||
if (CC._poll) return;
|
||||
var ticks = 0;
|
||||
CC._poll = setInterval(function () {
|
||||
ticks += 1;
|
||||
Promise.resolve(bridge.getState()).then(function (state) {
|
||||
CC.setState(state);
|
||||
var anyStarting = (state.environments || []).some(function (environment) { return environment.status === 'starting'; });
|
||||
if (!anyStarting || ticks > 16) {
|
||||
clearInterval(CC._poll);
|
||||
CC._poll = null;
|
||||
if (!anyStarting) {
|
||||
CC._status = { msg: '', tone: '' };
|
||||
CC.render(CC.state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
CC.openEnv = function (id) {
|
||||
var env = (CC.state.environments || []).filter(function (environment) { return environment.id === id; })[0];
|
||||
var meta = statusMeta(env ? env.status : '');
|
||||
CC._busyEnv = id;
|
||||
CC._status = { msg: (meta.verb || 'Opening') + ' ' + ((env && (env.name || env.subdomain)) || 'environment') + '...', tone: 'progress' };
|
||||
if (env) {
|
||||
var tabId = 'remote:' + env.id;
|
||||
var tabs = CC.state.tabs && CC.state.tabs.length ? CC.state.tabs : [{ id: 'home', title: 'Home', kind: 'launcher', closable: false }];
|
||||
tabs = tabs.map(function (tab) {
|
||||
tab.active = false;
|
||||
return tab;
|
||||
});
|
||||
var existing = tabs.filter(function (tab) { return tab.id === tabId; })[0];
|
||||
if (existing) {
|
||||
existing.active = true;
|
||||
existing.title = env.name || env.subdomain;
|
||||
} else {
|
||||
tabs.push({ id: tabId, title: env.name || env.subdomain, kind: 'remote', closable: true, active: true });
|
||||
}
|
||||
CC.state.tabs = tabs;
|
||||
CC.state.activeTabId = tabId;
|
||||
}
|
||||
if (env && env.status !== 'running') env.status = 'starting';
|
||||
CC.render(CC.state);
|
||||
return Promise.resolve(bridge.openEnvironment(id)).then(function (state) {
|
||||
if (state && state.environments) CC.setState(state);
|
||||
CC.startPolling();
|
||||
}).catch(function (error) {
|
||||
CC._busyEnv = null;
|
||||
if (env) env.status = 'stopped';
|
||||
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||
CC.render(CC.state);
|
||||
});
|
||||
};
|
||||
|
||||
CC.act = function (name, node) {
|
||||
switch (name) {
|
||||
case 'local':
|
||||
return CC.run('Starting Local CloudCLI...', function () { return bridge.openLocal(); });
|
||||
case 'connect':
|
||||
return CC.run('Opening cloudcli.ai to connect your account...', function () { return bridge.connectCloud(); });
|
||||
case 'open-web':
|
||||
return CC.run('Opening local web UI in your browser...', function () { return bridge.openLocalWebUi(); });
|
||||
case 'copy-web':
|
||||
return CC.run('Copied local URL to clipboard', function () { return bridge.copyLocalWebUrl(); });
|
||||
case 'diagnostics':
|
||||
return CC.run('Copied diagnostics to clipboard', function () { return bridge.copyDiagnostics(); });
|
||||
case 'set-setting':
|
||||
return CC.run('Saved', function () { return bridge.updateSetting(node.key, node.value); });
|
||||
case 'set-theme-mode':
|
||||
return CC.run('Saved', function () { return bridge.updateSetting('themeMode', node.value); });
|
||||
case 'set-computer-mode':
|
||||
return CC.run('Saved', function () {
|
||||
return bridge.updateComputerUse({
|
||||
enabled: true,
|
||||
consentMode: node.value,
|
||||
});
|
||||
});
|
||||
case 'set-computer-enabled':
|
||||
return CC.run('Saved', function () {
|
||||
var current = (CC.state && CC.state.computerUse) || { consentMode: 'ask' };
|
||||
return bridge.updateComputerUse({
|
||||
enabled: !!node.value,
|
||||
consentMode: current.consentMode === 'auto' ? 'auto' : 'ask',
|
||||
});
|
||||
});
|
||||
case 'settings-toggle':
|
||||
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||
case 'desktop-settings-toggle':
|
||||
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||
case 'local-settings-toggle':
|
||||
return CC.run('Opening local settings...', function () { return bridge.showLocalSettings(); });
|
||||
case 'computer-settings-toggle':
|
||||
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||
case 'settings-close':
|
||||
return CC.closeSheet();
|
||||
case 'dashboard':
|
||||
return CC.run('Opening CloudCLI dashboard...', function () { return bridge.openCloudDashboard(); });
|
||||
case 'env-action':
|
||||
return CC.run('Opening environment...', function () { return bridge.runActiveEnvironmentAction(node.getAttribute('data-cc-env-action')); });
|
||||
case 'env-menu':
|
||||
return CC.run('Opening environment actions...', function () { return bridge.showActiveEnvironmentActionsMenu(); });
|
||||
case 'env-row-menu':
|
||||
return CC.run('Opening environment actions...', function () { return bridge.showEnvironmentActionsMenu(node.getAttribute('data-cc-environment-id')); });
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
function renderTabs(state) {
|
||||
var tabs = state.tabs && state.tabs.length ? state.tabs : [{ id: 'home', title: 'Home', closable: false, active: true }];
|
||||
return tabs.map(function (tab) {
|
||||
var title = tab.title || '';
|
||||
var visibleChars = Math.min(title.length, 20);
|
||||
var tabWidth = Math.max(112, Math.min(232, (visibleChars * 8) + (tab.closable ? 56 : 38)));
|
||||
return '<button class="tb-tab no-drag' + (tab.active ? ' active' : '') + '" data-cc-tab="' + esc(tab.id) + '" title="' + esc(title) + '" style="width:' + tabWidth + 'px;flex-basis:' + tabWidth + 'px">' +
|
||||
'<span>' + esc(title) + '</span>' +
|
||||
(tab.closable ? '<span class="tb-close" data-cc-close-tab="' + esc(tab.id) + '" title="Close tab">×</span>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
CC.titlebar = function (state) {
|
||||
var conn = connected(state);
|
||||
var activeRemote = state.activeTarget && state.activeTarget.kind === 'remote';
|
||||
var envActions = activeRemote ? '<button class="btn sm tb-action no-drag" data-cc-action="env-menu" title="Open environment actions">Open environment in...</button>' : '';
|
||||
return '<div class="titlebar">' +
|
||||
'<div class="brand"><img class="mk" src="' + esc(LOGO_URL) + '" alt=""><span>CloudCLI</span></div>' +
|
||||
'<div class="tb-tabs no-drag">' + renderTabs(state) + '</div>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
envActions +
|
||||
'<button class="btn sm tb-action no-drag" data-cc-action="connect" title="' + esc(authState(state) === 'expired' ? 'Reconnect your CloudCLI account' : accountLabel(state)) + '"><span class="dot" style="background:' + (conn ? 'var(--ok)' : (authState(state) === 'expired' ? 'var(--warn)' : 'var(--tx3)')) + '"></span>' + esc(accountLabel(state)) + '</button>' +
|
||||
'<button class="icon-btn tb-action no-drag" data-cc-action="settings-toggle" title="Settings">' + icon('settings', 16) + '</button>' +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
CC.statusbar = function (state) {
|
||||
var status = CC._status || {};
|
||||
var running = !!state.localServerRunning;
|
||||
return '<div class="statusbar">' +
|
||||
'<span><span class="dot" style="width:7px;height:7px;background:' + (running ? 'var(--ok)' : 'var(--tx3)') + '"></span> local ' + (running ? 'running · ' + esc(localUrl(state)) : 'idle') + '</span>' +
|
||||
'<span class="sep">·</span><span>' + esc(envCount(state)) + '</span>' +
|
||||
'<span class="sep">·</span><span>' + (authState(state) === 'expired' ? 'session expired' : (connected(state) ? esc(accountLabel(state)) : 'not connected')) + '</span>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
(status.msg ? '<span class="status-msg ' + esc(status.tone) + '">' + esc(status.msg) + '</span><span class="sep">·</span>' : '') +
|
||||
'<span>v' + esc(VERSION) + '</span>' +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
CC.renderSheet = function (title, subtitle, sections, footer) {
|
||||
overlay.innerHTML =
|
||||
'<div class="cc-sheet cc-modal">' +
|
||||
'<div class="cc-sheet-header">' +
|
||||
'<div class="cc-sheet-copy"><div class="cc-sheet-title">' + esc(title) + '</div><div class="cc-sheet-subtitle">' + esc(subtitle || '') + '</div></div>' +
|
||||
'<button class="icon-btn cc-sheet-close" data-cc-action="settings-close" title="Close">' + icon('x', 16) + '</button>' +
|
||||
'</div>' +
|
||||
'<div class="cc-sheet-body">' + sections.join('') + '</div>' +
|
||||
(footer ? '<div class="cc-sheet-footer">' + footer + '</div>' : '') +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
CC.renderSection = function (eyebrow, title, body) {
|
||||
return '<section class="cc-section">' +
|
||||
'<div class="cc-section-head">' +
|
||||
'<div class="lbl">' + esc(eyebrow) + '</div>' +
|
||||
'<div class="cc-section-title">' + esc(title) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="cc-section-body">' + body + '</div>' +
|
||||
'</section>';
|
||||
};
|
||||
|
||||
CC.renderRadioOption = function (name, value, checked, title, description) {
|
||||
return '<label class="cc-choice">' +
|
||||
'<input type="radio" name="' + esc(name) + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + '>' +
|
||||
'<span><b>' + esc(title) + '</b><br>' + esc(description) + '</span>' +
|
||||
'</label>';
|
||||
};
|
||||
|
||||
CC.openSheet = function (sheet, options) {
|
||||
options = options || {};
|
||||
if (sheet === 'desktop-settings') {
|
||||
CC.renderDesktopSettings();
|
||||
} else {
|
||||
CC.renderLocalSettings();
|
||||
}
|
||||
CC.ui.openSheet = sheet;
|
||||
overlay.classList.add('open');
|
||||
if (typeof options.scrollTop === 'number') {
|
||||
var body = overlay.querySelector('.cc-sheet-body');
|
||||
if (body) body.scrollTop = options.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
CC.closeSheet = function () {
|
||||
if (CC.modalMode && bridge.closeSettingsWindow) {
|
||||
CC.ui.openSheet = null;
|
||||
return bridge.closeSettingsWindow();
|
||||
}
|
||||
CC.ui.openSheet = null;
|
||||
overlay.classList.remove('open');
|
||||
};
|
||||
|
||||
CC.buildLocalServerSection = function (state, options) {
|
||||
options = options || {};
|
||||
var settings = state.desktopSettings || {};
|
||||
var url = localUrl(state) || 'starts on demand';
|
||||
var body = '<div class="cc-surface">' +
|
||||
'<div class="cc-meta mono">' + esc(url) + '</div>' +
|
||||
'<div class="cc-row2"><button class="btn sm" data-cc-action="open-web">' + icon('arrow', 14) + 'Open in browser</button><button class="btn sm" data-cc-action="copy-web">' + icon('copy', 14) + 'Copy URL</button></div>';
|
||||
if (options.includePreferences) {
|
||||
body +=
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + (settings.keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + (settings.exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>';
|
||||
}
|
||||
body += '</div>';
|
||||
return CC.renderSection(
|
||||
options.eyebrow || 'LOCAL SERVER',
|
||||
options.title || 'Run Local CloudCLI on this machine',
|
||||
body
|
||||
);
|
||||
};
|
||||
|
||||
CC.buildThemeSection = function (state) {
|
||||
var settings = state.desktopSettings || {};
|
||||
return CC.renderSection('APPEARANCE', 'Desktop theme', '' +
|
||||
'<div class="cc-surface cc-choice-group">' +
|
||||
CC.renderRadioOption('desktop-theme', 'system', settings.themeMode === 'system', 'System', 'Follow the operating system appearance.') +
|
||||
CC.renderRadioOption('desktop-theme', 'light', settings.themeMode === 'light', 'Light', 'Use the light interface appearance.') +
|
||||
CC.renderRadioOption('desktop-theme', 'dark', settings.themeMode === 'dark', 'Dark', 'Use the dark interface appearance.') +
|
||||
'</div>'
|
||||
);
|
||||
};
|
||||
|
||||
CC.buildComputerUseSection = function (state) {
|
||||
var computerUse = state.computerUse || {};
|
||||
var body =
|
||||
'<div class="cc-surface">' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-computer-enabled="true"' + (computerUse.enabled ? ' checked' : '') + '><span><b>Enable Computer Use</b><br>Let CloudCLI use the computer. Agents cannot act until you approve a session.</span></label>';
|
||||
if (computerUse.enabled) {
|
||||
body += '<div class="cc-choice-group">' +
|
||||
CC.renderRadioOption('computer-access-mode', 'ask', computerUse.consentMode !== 'auto', 'Ask before each session', 'Agents can request control, but you approve every session.') +
|
||||
CC.renderRadioOption('computer-access-mode', 'auto', computerUse.consentMode === 'auto', 'Unattended access', 'Trusted agents can use this computer without a local approval prompt.') +
|
||||
'</div>';
|
||||
}
|
||||
body += '</div>';
|
||||
return CC.renderSection('COMPUTER USE', 'Control how agents can use this computer', body);
|
||||
};
|
||||
|
||||
CC.renderLocalSettings = function () {
|
||||
var state = CC.state || {};
|
||||
var sections = [
|
||||
CC.buildLocalServerSection(state, { includePreferences: false }),
|
||||
CC.renderSection('PREFERENCES', 'How the local service behaves', '' +
|
||||
'<div class="cc-surface">' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + ((state.desktopSettings || {}).keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + ((state.desktopSettings || {}).exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>' +
|
||||
'</div>'
|
||||
),
|
||||
];
|
||||
CC.renderSheet('Local Settings', 'Manage how Local CloudCLI runs on this computer.', sections);
|
||||
};
|
||||
|
||||
CC.renderDesktopSettings = function () {
|
||||
var state = CC.state || {};
|
||||
var sections = [
|
||||
CC.buildThemeSection(state),
|
||||
CC.buildComputerUseSection(state),
|
||||
];
|
||||
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance and Computer Use behavior.', sections);
|
||||
};
|
||||
|
||||
CC.render = function (state) {
|
||||
state = state || CC.state;
|
||||
var titlebar = (CC._reg.titlebar || CC.titlebar)(state);
|
||||
var statusbar = (CC._reg.statusbar || CC.statusbar)(state);
|
||||
var body = CC._reg.renderBody ? CC._reg.renderBody(state) : '';
|
||||
if (CC.modalMode) {
|
||||
app.innerHTML = '';
|
||||
} else {
|
||||
app.innerHTML = titlebar + '<div class="cc-body ' + (CC._reg.bodyClass || '') + '">' + body + '</div>' + statusbar;
|
||||
}
|
||||
if (CC._reg.afterRender) CC._reg.afterRender(state);
|
||||
};
|
||||
|
||||
function wireEvents() {
|
||||
if (CC._wired) return;
|
||||
CC._wired = true;
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (CC._reg.onClick && CC._reg.onClick(event)) return;
|
||||
var closeTab = event.target.closest('[data-cc-close-tab]');
|
||||
if (closeTab) {
|
||||
event.stopPropagation();
|
||||
CC.run('Closing tab...', function () { return bridge.closeTab(closeTab.getAttribute('data-cc-close-tab')); });
|
||||
return;
|
||||
}
|
||||
var tab = event.target.closest('[data-cc-tab]');
|
||||
if (tab) {
|
||||
CC.run('Switching tab...', function () { return bridge.switchTab(tab.getAttribute('data-cc-tab')); });
|
||||
return;
|
||||
}
|
||||
var action = event.target.closest('[data-cc-action]');
|
||||
if (action) {
|
||||
CC.act(action.getAttribute('data-cc-action'), action);
|
||||
return;
|
||||
}
|
||||
var env = event.target.closest('[data-cc-env]');
|
||||
if (env) {
|
||||
CC.openEnv(env.getAttribute('data-cc-env'));
|
||||
return;
|
||||
}
|
||||
if (overlay.classList.contains('open') && !event.target.closest('.cc-sheet')) {
|
||||
CC.closeSheet();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var setting = event.target.closest('[data-cc-setting]');
|
||||
if (setting) {
|
||||
CC.act('set-setting', {
|
||||
key: setting.getAttribute('data-cc-setting'),
|
||||
value: setting.checked,
|
||||
});
|
||||
return;
|
||||
}
|
||||
var theme = event.target.closest('[name="desktop-theme"]');
|
||||
if (theme) {
|
||||
CC.act('set-theme-mode', { value: theme.value });
|
||||
return;
|
||||
}
|
||||
var computerMode = event.target.closest('[name="computer-access-mode"]');
|
||||
if (computerMode) {
|
||||
CC.act('set-computer-mode', { value: computerMode.value });
|
||||
return;
|
||||
}
|
||||
var computerEnabled = event.target.closest('[data-cc-computer-enabled]');
|
||||
if (computerEnabled) {
|
||||
CC.act('set-computer-enabled', { value: computerEnabled.checked });
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && overlay.classList.contains('open')) {
|
||||
CC.closeSheet();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
|
||||
event.preventDefault();
|
||||
CC.act('settings-toggle');
|
||||
return;
|
||||
}
|
||||
if (overlay.classList.contains('open')) return;
|
||||
if (CC._reg.onKey) CC._reg.onKey(event, CC.state);
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
app = document.getElementById('app');
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'cc-overlay';
|
||||
overlay.className = 'cc-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var isMac = /Mac/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent);
|
||||
var isWin = /Win/i.test(navigator.platform);
|
||||
CC.platform = isMac ? 'mac' : (isWin ? 'win' : 'linux');
|
||||
document.body.classList.add(CC.platform);
|
||||
CC.ui.initialSheet = SEARCH.get('sheet') || 'desktop-settings';
|
||||
if (CC.modalMode) {
|
||||
document.documentElement.classList.add('cc-modal-window');
|
||||
document.body.classList.add('cc-modal-window');
|
||||
}
|
||||
|
||||
wireEvents();
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||
CC.applyTheme(CC.state);
|
||||
});
|
||||
}
|
||||
if (bridge.onStateUpdated) {
|
||||
bridge.onStateUpdated(function (state) { CC.setState(state); });
|
||||
}
|
||||
if (bridge.onLauncherCommand) {
|
||||
bridge.onLauncherCommand(function (command) {
|
||||
if (command && command.type === 'open-sheet') {
|
||||
CC.ui.initialSheet = command.sheet || CC.ui.initialSheet || 'desktop-settings';
|
||||
CC.openSheet(command.sheet);
|
||||
}
|
||||
});
|
||||
}
|
||||
CC.refresh().catch(function (error) {
|
||||
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||
CC.render(CC.state);
|
||||
});
|
||||
}
|
||||
|
||||
CC.register = function (registry) {
|
||||
CC._reg = registry || {};
|
||||
};
|
||||
|
||||
CC.start = function () {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(function sidebarApp() {
|
||||
var CC = window.CC;
|
||||
|
||||
function navItem(id, iconName, label, meta, selected) {
|
||||
return '<button class="sb-item' + (selected === id ? ' active' : '') + '" data-cc-nav="' + id + '">' +
|
||||
CC.icon(iconName, 16) + '<span>' + label + '</span><span class="sb-meta">' + CC.esc(meta) + '</span></button>';
|
||||
}
|
||||
|
||||
function localPane(state) {
|
||||
return '<div class="pane-h"><div><h2 class="pane-title">Local CloudCLI</h2><p class="pane-sub">Run the open-source app on this machine. No account required.</p></div></div>' +
|
||||
'<div class="card"><div class="card-head"><div><div class="card-t">Local server</div><div class="card-sub mono">' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '</div></div><div class="card-tools"><span class="dot" style="background:' + (state.localServerRunning ? 'var(--ok)' : 'var(--tx3)') + '"></span><button class="icon-btn" data-cc-action="local-settings-toggle" title="Local settings">' + CC.icon('gear', 16) + '</button></div></div>' +
|
||||
'<div class="card-actions"><button class="btn pri" data-cc-action="local">' + CC.icon('play', 15) + 'Open Local CloudCLI</button><button class="btn" data-cc-action="open-web">' + CC.icon('arrow', 14) + 'Open in browser</button><button class="btn" data-cc-action="copy-web">' + CC.icon('copy', 14) + 'Copy URL</button></div></div>' +
|
||||
'<div class="card"><div class="card-head"><div><div class="card-t">Computer Use</div><div class="card-sub">' + CC.esc(computerUseStatus(state).detail) + '</div></div><div class="card-tools"><span class="badge ' + CC.esc(computerUseStatus(state).tone) + '">' + CC.esc(computerUseStatus(state).label) + '</span><button class="icon-btn" data-cc-action="computer-settings-toggle" title="Computer Use settings">' + CC.icon('monitor', 16) + '</button></div></div>' +
|
||||
'<div class="card-actions"><button class="btn" data-cc-action="computer-settings-toggle">' + CC.icon('settings', 14) + 'Open settings</button></div></div>';
|
||||
}
|
||||
|
||||
function envRow(environment) {
|
||||
var meta = CC.statusMeta(environment.status);
|
||||
var tags = (environment.agent ? '<span class="tag">' + CC.esc(environment.agent) + '</span>' : '') + (environment.region ? '<span class="tag">' + CC.esc(environment.region) + '</span>' : '');
|
||||
return '<div class="env" data-cc-env="' + environment.id + '"><span class="dot" style="background:' + meta.dot + '"></span>' +
|
||||
'<div class="env-i"><div class="env-n">' + CC.esc(environment.name || environment.subdomain) + '</div><div class="env-u mono">' + CC.esc(environment.access_url || '') + '</div></div>' +
|
||||
'<div class="env-tags">' + tags + '</div>' +
|
||||
'<span class="badge ' + meta.cls + '">' + meta.label + '</span>' +
|
||||
'<button class="btn sm" data-cc-action="env-row-menu" data-cc-environment-id="' + environment.id + '">Open environment in...</button>' +
|
||||
'<button class="btn sm ' + (environment.status === 'running' ? 'pri' : '') + '">' + CC.icon(meta.busy ? 'refresh' : (environment.status === 'running' ? 'arrow' : 'play'), 14) + meta.open + '</button></div>';
|
||||
}
|
||||
|
||||
function cloudPane(state) {
|
||||
var header = '<div class="pane-h"><div><h2 class="pane-title">Environments</h2><p class="pane-sub">' + CC.esc(CC.envCount(state)) + '</p></div><button class="btn sm" data-cc-action="dashboard">' + CC.icon('arrow', 14) + 'Dashboard</button></div>';
|
||||
if (CC.authState(state) === 'expired') {
|
||||
return header + '<div class="empty">Your CloudCLI session expired.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Reconnect account</button></div></div>';
|
||||
}
|
||||
if (!CC.connected(state)) {
|
||||
return header + '<div class="empty">Connect your CloudCLI account to list hosted environments.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Connect account</button></div></div>';
|
||||
}
|
||||
if (state.cloudLoading && !(state.environments || []).length) {
|
||||
return header + '<div class="empty">Loading your CloudCLI environments...</div>';
|
||||
}
|
||||
|
||||
var list = (state.environments || []).map(envRow).join('');
|
||||
if (!list) list = '<div class="empty">No hosted environments yet.</div>';
|
||||
return header + list;
|
||||
}
|
||||
|
||||
function renderBody(state) {
|
||||
var section = CC.ui.section || ((CC.connected(state) || CC.authState(state) === 'expired') ? 'cloud' : 'local');
|
||||
CC.ui.section = section;
|
||||
var nav = '<div class="sb"><div class="sb-grp"><div class="lbl">Workspace</div>' +
|
||||
navItem('local', 'terminal', 'Local', state.localServerRunning ? 'on' : 'idle', section) +
|
||||
navItem('cloud', 'cloud', 'Cloud', (state.environments || []).length, section) +
|
||||
'</div></div>';
|
||||
return nav + '<div class="sb-main">' + (section === 'local' ? localPane(state) : cloudPane(state)) + '</div>';
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
var nav = event.target.closest('[data-cc-nav]');
|
||||
if (!nav) return false;
|
||||
CC.ui.section = nav.getAttribute('data-cc-nav');
|
||||
CC.render(CC.state);
|
||||
return true;
|
||||
}
|
||||
|
||||
CC.register({
|
||||
bodyClass: 'v-sidebar',
|
||||
renderBody: renderBody,
|
||||
onClick: onClick,
|
||||
});
|
||||
CC.start();
|
||||
})();
|
||||
533
electron/localServer.js
Normal file
533
electron/localServer.js
Normal file
@@ -0,0 +1,533 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ServerInstaller } from './serverInstaller.js';
|
||||
|
||||
const DEFAULT_PORT = 3001;
|
||||
const HOST = '127.0.0.1';
|
||||
const DISPLAY_HOST = 'localhost';
|
||||
const HEALTH_TIMEOUT_MS = 1000;
|
||||
const SERVER_START_TIMEOUT_MS = 30000;
|
||||
const MAX_STARTUP_LOG_LINES = 300;
|
||||
const SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
|
||||
const LOCAL_SERVER_URL_ENV_KEYS = [
|
||||
'CLOUDCLI_DESKTOP_LOCAL_SERVER_URL',
|
||||
'CLOUDCLI_LOCAL_SERVER_URL',
|
||||
'ELECTRON_LOCAL_SERVER_URL',
|
||||
];
|
||||
const LOCAL_SERVER_PORT_ENV_KEYS = [
|
||||
'CLOUDCLI_DESKTOP_LOCAL_SERVER_PORT',
|
||||
'CLOUDCLI_SERVER_PORT',
|
||||
'SERVER_PORT',
|
||||
'PORT',
|
||||
];
|
||||
|
||||
function requestJson(url, timeoutMs = HEALTH_TIMEOUT_MS) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
||||
let body = '';
|
||||
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
json: JSON.parse(body),
|
||||
});
|
||||
} catch {
|
||||
resolve({ ok: false, json: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({ ok: false, json: null });
|
||||
});
|
||||
req.on('error', () => resolve({ ok: false, json: null }));
|
||||
});
|
||||
}
|
||||
|
||||
async function isCloudCliServer(baseUrl) {
|
||||
const response = await requestJson(`${baseUrl}/health`);
|
||||
return response.ok
|
||||
&& response.json?.status === 'ok'
|
||||
&& typeof response.json?.installMode === 'string';
|
||||
}
|
||||
|
||||
function isPortAvailable(port, host = HOST) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', reject);
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : DEFAULT_PORT;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.listen(0, HOST);
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseServerPort(host) {
|
||||
if (await isPortAvailable(DEFAULT_PORT, host)) {
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
|
||||
return getFreePort();
|
||||
}
|
||||
|
||||
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(usePackagedElectronRuntime) {
|
||||
if (process.env.ELECTRON_NODE_PATH) {
|
||||
return { command: process.env.ELECTRON_NODE_PATH, env: {}, label: 'ELECTRON_NODE_PATH' };
|
||||
}
|
||||
|
||||
if (usePackagedElectronRuntime && process.versions.electron) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
label: `Electron ${process.versions.electron} Node ${process.versions.node}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.npm_node_execpath) {
|
||||
return { command: process.env.npm_node_execpath, env: {}, label: 'npm_node_execpath' };
|
||||
}
|
||||
|
||||
return { command: 'node', env: {}, label: 'PATH node' };
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
return value.endsWith('/') ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
function addCandidateUrl(urls, rawUrl) {
|
||||
if (!rawUrl) return;
|
||||
try {
|
||||
const parsed = new URL(String(rawUrl));
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
const normalized = stripTrailingSlash(parsed.toString());
|
||||
if (!urls.includes(normalized)) urls.push(normalized);
|
||||
} catch {
|
||||
// Ignore invalid user-provided discovery values.
|
||||
}
|
||||
}
|
||||
|
||||
function addCandidatePort(urls, rawPort) {
|
||||
const port = Number.parseInt(String(rawPort || ''), 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
|
||||
addCandidateUrl(urls, `http://${HOST}:${port}`);
|
||||
}
|
||||
|
||||
function getPortFromUrl(baseUrl) {
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.port) return Number.parseInt(parsed.port, 10);
|
||||
return parsed.protocol === 'https:' ? 443 : 80;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayUrl(baseUrl) {
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.hostname === HOST) {
|
||||
parsed.hostname = DISPLAY_HOST;
|
||||
}
|
||||
return stripTrailingSlash(parsed.toString());
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getServerCwd(appRoot, serverEntry) {
|
||||
const normalizedEntry = path.resolve(serverEntry);
|
||||
const bundledEntry = path.resolve(appRoot, 'dist-server', 'server', 'index.js');
|
||||
if (normalizedEntry === bundledEntry) {
|
||||
return appRoot;
|
||||
}
|
||||
|
||||
// Installed server entries are laid out as <root>/dist-server/server/index.js.
|
||||
return path.resolve(path.dirname(normalizedEntry), '..', '..');
|
||||
}
|
||||
|
||||
async function readServerMarkerUrl() {
|
||||
try {
|
||||
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
|
||||
const marker = JSON.parse(raw);
|
||||
return marker.url || (marker.port ? `http://${marker.host || HOST}:${marker.port}` : null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getExistingServerCandidateUrls(defaultUrl) {
|
||||
const urls = [];
|
||||
|
||||
for (const key of LOCAL_SERVER_URL_ENV_KEYS) {
|
||||
addCandidateUrl(urls, process.env[key]);
|
||||
}
|
||||
|
||||
addCandidateUrl(urls, await readServerMarkerUrl());
|
||||
|
||||
for (const key of LOCAL_SERVER_PORT_ENV_KEYS) {
|
||||
addCandidatePort(urls, process.env[key]);
|
||||
}
|
||||
|
||||
addCandidateUrl(urls, defaultUrl);
|
||||
return urls;
|
||||
}
|
||||
|
||||
async function waitForCloudCliServer(baseUrl, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isCloudCliServer(baseUrl)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export class LocalServerController {
|
||||
constructor({ appRoot, settingsPath, isPackaged = false, appVersion, onChange }) {
|
||||
this.appRoot = appRoot;
|
||||
this.settingsPath = settingsPath;
|
||||
this.isPackaged = isPackaged;
|
||||
this.appVersion = appVersion;
|
||||
this.onChange = onChange;
|
||||
this.localServerUrl = null;
|
||||
this.localServerPort = null;
|
||||
this.ownedServerProcess = null;
|
||||
this.startupLogs = [];
|
||||
this.desktopSettings = {
|
||||
keepLocalServerRunning: false,
|
||||
exposeLocalServerOnNetwork: false,
|
||||
themeMode: 'system',
|
||||
};
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
return this.desktopSettings;
|
||||
}
|
||||
|
||||
getLocalServerUrl() {
|
||||
return this.localServerUrl;
|
||||
}
|
||||
|
||||
getHealthCheckUrl() {
|
||||
if (!this.localServerPort) return this.localServerUrl;
|
||||
return `http://${HOST}:${this.localServerPort}`;
|
||||
}
|
||||
|
||||
appendStartupLog(line) {
|
||||
const text = String(line || '').trimEnd();
|
||||
if (!text) return;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
this.startupLogs.push(`[${timestamp}] ${text}`);
|
||||
if (this.startupLogs.length > MAX_STARTUP_LOG_LINES) {
|
||||
this.startupLogs.splice(0, this.startupLogs.length - MAX_STARTUP_LOG_LINES);
|
||||
}
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
getStartupLogs() {
|
||||
return [...this.startupLogs];
|
||||
}
|
||||
|
||||
getPendingTarget() {
|
||||
return {
|
||||
kind: 'local',
|
||||
name: 'Local CloudCLI',
|
||||
url: this.localServerUrl || `http://${DISPLAY_HOST}:${this.localServerPort || DEFAULT_PORT}`,
|
||||
};
|
||||
}
|
||||
|
||||
getLanAddress() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
for (const entry of entries || []) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) {
|
||||
return entry.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getShareableWebUrl() {
|
||||
if (!this.localServerUrl || !this.localServerPort) return null;
|
||||
if (this.desktopSettings.exposeLocalServerOnNetwork) {
|
||||
const lanAddress = this.getLanAddress();
|
||||
if (lanAddress) {
|
||||
return `http://${lanAddress}:${this.localServerPort}`;
|
||||
}
|
||||
}
|
||||
return this.getLocalServerUrl();
|
||||
}
|
||||
|
||||
getServerBindHost() {
|
||||
return this.desktopSettings.exposeLocalServerOnNetwork ? '0.0.0.0' : HOST;
|
||||
}
|
||||
|
||||
async loadDesktopSettings() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.settingsPath, 'utf8');
|
||||
const stored = JSON.parse(raw);
|
||||
this.desktopSettings = {
|
||||
keepLocalServerRunning: Boolean(stored.keepLocalServerRunning),
|
||||
exposeLocalServerOnNetwork: Boolean(stored.exposeLocalServerOnNetwork),
|
||||
themeMode: stored.themeMode === 'light' || stored.themeMode === 'dark' ? stored.themeMode : 'system',
|
||||
};
|
||||
} catch {
|
||||
this.desktopSettings = {
|
||||
keepLocalServerRunning: false,
|
||||
exposeLocalServerOnNetwork: false,
|
||||
themeMode: 'system',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async saveDesktopSettings(nextSettings = this.desktopSettings) {
|
||||
this.desktopSettings = {
|
||||
keepLocalServerRunning: Boolean(nextSettings.keepLocalServerRunning),
|
||||
exposeLocalServerOnNetwork: Boolean(nextSettings.exposeLocalServerOnNetwork),
|
||||
themeMode: nextSettings.themeMode === 'light' || nextSettings.themeMode === 'dark' ? nextSettings.themeMode : 'system',
|
||||
};
|
||||
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
|
||||
await fs.writeFile(this.settingsPath, JSON.stringify(this.desktopSettings, null, 2), 'utf8');
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
async updateDesktopSetting(key, value) {
|
||||
if (!Object.prototype.hasOwnProperty.call(this.desktopSettings, key)) {
|
||||
throw new Error(`Unknown desktop setting: ${key}`);
|
||||
}
|
||||
|
||||
const wasExposeSetting = key === 'exposeLocalServerOnNetwork';
|
||||
const wasLocalRunning = Boolean(this.localServerUrl);
|
||||
const nextValue = key === 'themeMode' ? value : Boolean(value);
|
||||
await this.saveDesktopSettings({ ...this.desktopSettings, [key]: nextValue });
|
||||
|
||||
return {
|
||||
desktopSettings: this.desktopSettings,
|
||||
requiresRestartNotice: wasExposeSetting && wasLocalRunning,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves the local server entry, installing the matching runtime if needed. */
|
||||
async resolveServerEntry() {
|
||||
if (process.env.ELECTRON_SERVER_ENTRY) {
|
||||
return process.env.ELECTRON_SERVER_ENTRY;
|
||||
}
|
||||
|
||||
const bundledEntry = path.join(this.appRoot, 'dist-server', 'server', 'index.js');
|
||||
if (process.env.CLOUDCLI_USE_INSTALLED_SERVER !== '1' && await pathExists(bundledEntry)) {
|
||||
return bundledEntry;
|
||||
}
|
||||
|
||||
if (!this.appVersion) {
|
||||
throw new Error('Cannot install local server: app version is unknown.');
|
||||
}
|
||||
const installer = new ServerInstaller({
|
||||
version: this.appVersion,
|
||||
onLog: (line) => this.appendStartupLog(line),
|
||||
});
|
||||
return installer.ensureInstalled();
|
||||
}
|
||||
|
||||
startBundledServer(port, serverEntry) {
|
||||
const bindHost = this.getServerBindHost();
|
||||
const runtime = getNodeRuntime(this.isPackaged);
|
||||
const serverCwd = getServerCwd(this.appRoot, serverEntry);
|
||||
|
||||
const command = `${runtime.command} ${serverEntry}`;
|
||||
this.appendStartupLog(`$ ${command}`);
|
||||
this.appendStartupLog(`runtime: ${runtime.label}`);
|
||||
this.appendStartupLog(`cwd: ${serverCwd}`);
|
||||
this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
|
||||
|
||||
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
|
||||
cwd: serverCwd,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...runtime.env,
|
||||
HOST: bindHost,
|
||||
SERVER_PORT: String(port),
|
||||
NODE_ENV: 'production',
|
||||
PATH: getDesktopPath(),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.ownedServerProcess.once('error', (error) => {
|
||||
this.appendStartupLog(`failed to start process: ${error.message}`);
|
||||
this.ownedServerProcess = null;
|
||||
});
|
||||
|
||||
this.ownedServerProcess.stdout?.on('data', (chunk) => {
|
||||
for (const line of String(chunk).split(/\r?\n/)) {
|
||||
this.appendStartupLog(line);
|
||||
}
|
||||
});
|
||||
|
||||
this.ownedServerProcess.stderr?.on('data', (chunk) => {
|
||||
for (const line of String(chunk).split(/\r?\n/)) {
|
||||
this.appendStartupLog(`stderr: ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.ownedServerProcess.once('exit', (code, signal) => {
|
||||
this.appendStartupLog(`process exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
|
||||
if (this.ownedServerProcess) {
|
||||
console.error(`CloudCLI desktop server exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
|
||||
}
|
||||
this.ownedServerProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
async resolveLocalServerUrl() {
|
||||
const defaultUrl = `http://${HOST}:${DEFAULT_PORT}`;
|
||||
const defaultDisplayUrl = `http://${DISPLAY_HOST}:${DEFAULT_PORT}`;
|
||||
const devUrl = process.env.ELECTRON_DEV_URL;
|
||||
const forceOwnServer = process.env.ELECTRON_FORCE_OWN_SERVER === '1';
|
||||
|
||||
if (devUrl) {
|
||||
const ready = await waitForCloudCliServer(defaultUrl, SERVER_START_TIMEOUT_MS);
|
||||
if (!ready) {
|
||||
throw new Error(`Development backend did not become ready at ${defaultDisplayUrl}`);
|
||||
}
|
||||
this.localServerPort = DEFAULT_PORT;
|
||||
return devUrl;
|
||||
}
|
||||
|
||||
if (!forceOwnServer) {
|
||||
const candidateUrls = await getExistingServerCandidateUrls(defaultUrl);
|
||||
for (const candidateUrl of candidateUrls) {
|
||||
if (await isCloudCliServer(candidateUrl)) {
|
||||
const displayUrl = getDisplayUrl(candidateUrl);
|
||||
this.localServerPort = getPortFromUrl(candidateUrl);
|
||||
this.appendStartupLog(`Using existing Local CloudCLI at ${displayUrl}`);
|
||||
return displayUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serverEntry = await this.resolveServerEntry();
|
||||
|
||||
const port = await chooseServerPort(this.getServerBindHost());
|
||||
const serverUrl = `http://${HOST}:${port}`;
|
||||
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
|
||||
this.localServerPort = port;
|
||||
this.startBundledServer(port, serverEntry);
|
||||
|
||||
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
|
||||
if (!ready) {
|
||||
const recentLogs = this.getStartupLogs().slice(-20).join('\n');
|
||||
this.localServerPort = null;
|
||||
throw new Error([
|
||||
`Bundled backend did not become ready at ${displayUrl}.`,
|
||||
recentLogs ? `Recent startup output:\n${recentLogs}` : 'No startup output was captured.',
|
||||
].join('\n\n'));
|
||||
}
|
||||
|
||||
this.appendStartupLog(`Local CloudCLI ready at ${displayUrl}`);
|
||||
this.localServerUrl = displayUrl;
|
||||
return displayUrl;
|
||||
}
|
||||
|
||||
async ensureLocalServer() {
|
||||
if (!this.localServerUrl) {
|
||||
this.localServerUrl = await this.resolveLocalServerUrl();
|
||||
}
|
||||
return this.localServerUrl;
|
||||
}
|
||||
|
||||
async getResolvedTarget() {
|
||||
await this.ensureLocalServer();
|
||||
return {
|
||||
kind: 'local',
|
||||
name: 'Local CloudCLI',
|
||||
url: this.localServerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async loadLocalTarget() {
|
||||
return {
|
||||
pendingTarget: this.getPendingTarget(),
|
||||
target: await this.getResolvedTarget(),
|
||||
};
|
||||
}
|
||||
|
||||
hasOwnedServer() {
|
||||
return Boolean(this.ownedServerProcess);
|
||||
}
|
||||
|
||||
detachOwnedServer() {
|
||||
if (!this.ownedServerProcess) return;
|
||||
this.ownedServerProcess.unref();
|
||||
this.ownedServerProcess = null;
|
||||
}
|
||||
|
||||
async shutdownOwnedServer() {
|
||||
if (!this.ownedServerProcess) return;
|
||||
|
||||
const child = this.ownedServerProcess;
|
||||
this.ownedServerProcess = null;
|
||||
child.kill('SIGTERM');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 3000);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_PORT, HOST };
|
||||
859
electron/main.js
Normal file
859
electron/main.js
Normal file
@@ -0,0 +1,859 @@
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } 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';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const APP_NAME = 'CloudCLI';
|
||||
const CALLBACK_PROTOCOL = 'cloudcli';
|
||||
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
|
||||
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
|
||||
const REMOTE_START_TIMEOUT_MS = 30000;
|
||||
const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const tabs = new TabsController();
|
||||
|
||||
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;
|
||||
let pendingCloudConnectStartedAt = 0;
|
||||
|
||||
function getAppRoot() {
|
||||
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..');
|
||||
}
|
||||
|
||||
function getLauncherPath() {
|
||||
return path.join(__dirname, 'launcher', 'index.html');
|
||||
}
|
||||
|
||||
function getPreloadPath() {
|
||||
return path.join(__dirname, 'preload.cjs');
|
||||
}
|
||||
|
||||
function getWindowIconPath() {
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(getAppRoot(), 'electron', 'assets', 'logo-macos.png');
|
||||
}
|
||||
return path.join(getAppRoot(), 'public', 'logo-512.png');
|
||||
}
|
||||
|
||||
function getStorePath() {
|
||||
return path.join(app.getPath('userData'), 'cloud-account.json');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getCloudState() {
|
||||
return {
|
||||
account: cloud.getAccount(),
|
||||
environments: cloud.getEnvironments(),
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
function getLocalState() {
|
||||
return {
|
||||
desktopSettings: localServer.getSettings(),
|
||||
localServerRunning: Boolean(localServer.getLocalServerUrl()),
|
||||
localWebUrl: localServer.getLocalServerUrl(),
|
||||
shareableWebUrl: localServer.getShareableWebUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeEnvironment(environment) {
|
||||
return {
|
||||
id: environment.id,
|
||||
name: environment.name,
|
||||
subdomain: environment.subdomain,
|
||||
access_url: cloud.getEnvironmentUrl(environment),
|
||||
status: environment.status,
|
||||
created_at: environment.created_at,
|
||||
github_url: environment.github_url || null,
|
||||
region: environment.region || null,
|
||||
agent: environment.agent || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getDesktopState() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const localState = getLocalState();
|
||||
const authState = cloud.getAuthState();
|
||||
return {
|
||||
account: {
|
||||
connected: authState === 'connected',
|
||||
email: cloudAccount?.email || null,
|
||||
authState,
|
||||
requiresReconnect: authState === 'expired',
|
||||
},
|
||||
activeTarget,
|
||||
desktopSettings: localState.desktopSettings,
|
||||
localWebUrl: localState.localWebUrl,
|
||||
shareableWebUrl: localState.shareableWebUrl,
|
||||
localServerRunning: localState.localServerRunning,
|
||||
localStartupLogs: localServer.getStartupLogs(),
|
||||
cloudLoading: isRefreshingCloud,
|
||||
tabs: tabs.getSerializableTabs(),
|
||||
activeTabId: tabs.activeTabId,
|
||||
environments: cloud.getEnvironments().map(serializeEnvironment),
|
||||
computerUse: computerAgent?.getState() || { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async function openExternalUrl(url) {
|
||||
if (String(url).startsWith(`${CALLBACK_PROTOCOL}://`)) {
|
||||
await handleDeepLink(url);
|
||||
return;
|
||||
}
|
||||
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
|
||||
async function showError(title, error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`${title}: ${message}`);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'error',
|
||||
title,
|
||||
message: title,
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
|
||||
function isExpectedNavigationAbort(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return error?.code === 'ERR_ABORTED' || message.includes('ERR_ABORTED') || message.includes('(-3)');
|
||||
}
|
||||
|
||||
function syncDesktopState() {
|
||||
if (!desktopWindow) return;
|
||||
desktopWindow.buildAppMenu();
|
||||
desktopWindow.emitDesktopState();
|
||||
if (activeTarget?.kind === 'local' && !localServer?.getLocalServerUrl()) {
|
||||
void desktopWindow.showLocalStartupTarget(localServer.getPendingTarget(), localServer.getStartupLogs())
|
||||
.catch((error) => {
|
||||
if (isExpectedNavigationAbort(error)) return;
|
||||
void showError('Could not update local startup log', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveTarget(target) {
|
||||
activeTarget = target;
|
||||
}
|
||||
|
||||
function getEnvironmentTarget(environment) {
|
||||
return {
|
||||
kind: 'remote',
|
||||
id: environment.id,
|
||||
name: environment.name || environment.subdomain,
|
||||
url: cloud.getEnvironmentUrl(environment),
|
||||
};
|
||||
}
|
||||
|
||||
async function getEnvironmentLaunchTarget(environment) {
|
||||
return {
|
||||
...getEnvironmentTarget(environment),
|
||||
url: await cloud.getEnvironmentLaunchUrl(environment),
|
||||
};
|
||||
}
|
||||
|
||||
function getDiagnosticsText() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const localState = getLocalState();
|
||||
return JSON.stringify({
|
||||
app: APP_NAME,
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
appPath: getAppRoot(),
|
||||
userDataPath: app.getPath('userData'),
|
||||
activeTarget,
|
||||
localServerUrl: localState.localWebUrl,
|
||||
localServerPort: localServer.localServerPort,
|
||||
localWebUrl: localState.localWebUrl,
|
||||
shareableWebUrl: localState.shareableWebUrl,
|
||||
desktopSettings: localState.desktopSettings,
|
||||
cloudConnected: Boolean(cloudAccount?.apiKey),
|
||||
cloudEmail: cloudAccount?.email || null,
|
||||
cloudEnvironmentCount: cloud.getEnvironments().length,
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
async function copyDiagnostics() {
|
||||
clipboard.writeText(getDiagnosticsText());
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Diagnostics copied',
|
||||
message: 'CloudCLI desktop diagnostics were copied to the clipboard.',
|
||||
});
|
||||
}
|
||||
|
||||
async function showComputerAccess() {
|
||||
await desktopWindow?.showDesktopSettings();
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function updateComputerUse(settings) {
|
||||
const current = computerAgent?.getSettings() || { enabled: false, consentMode: 'ask' };
|
||||
const next = {
|
||||
enabled: typeof settings?.enabled === 'boolean' ? settings.enabled : current.enabled,
|
||||
consentMode: settings?.consentMode === 'auto' ? 'auto' : 'ask',
|
||||
};
|
||||
await computerAgent?.saveSettings(next);
|
||||
syncDesktopState();
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
||||
isRefreshingCloud = true;
|
||||
syncDesktopState();
|
||||
try {
|
||||
return await cloud.refreshCloudEnvironments();
|
||||
} catch (error) {
|
||||
const authState = cloud.getAuthState();
|
||||
if (authState === 'expired') {
|
||||
const expiredError = new Error('Your CloudCLI session expired. Reconnect your account.');
|
||||
if (showErrors) {
|
||||
await showError('CloudCLI login required', expiredError);
|
||||
return [];
|
||||
}
|
||||
throw expiredError;
|
||||
}
|
||||
if (showErrors) {
|
||||
await showError('Could not load CloudCLI environments', error);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
isRefreshingCloud = false;
|
||||
void computerAgent?.sync().catch((error) => console.error('[ComputerAgent] sync failed:', error?.message || error));
|
||||
syncDesktopState();
|
||||
}
|
||||
}
|
||||
|
||||
async function connectCloudAccount() {
|
||||
const connectUrl = cloud.buildConnectUrl();
|
||||
pendingCloudConnectStartedAt = Date.now();
|
||||
clipboard.writeText(connectUrl);
|
||||
await openExternalUrl(connectUrl);
|
||||
return connectUrl;
|
||||
}
|
||||
|
||||
async function handleDeepLink(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== `${CALLBACK_PROTOCOL}:` || parsed.hostname !== 'auth') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingCloudConnectStartedAt || Date.now() - pendingCloudConnectStartedAt > AUTH_CALLBACK_TTL_MS) {
|
||||
await showError('CloudCLI account connection failed', new Error('No recent CloudCLI account connection was started from this app.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = parsed.searchParams.get('api_key');
|
||||
if (!apiKey) {
|
||||
await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.'));
|
||||
return;
|
||||
}
|
||||
|
||||
await cloud.saveFromCallback({
|
||||
apiKey,
|
||||
email: parsed.searchParams.get('email'),
|
||||
});
|
||||
pendingCloudConnectStartedAt = 0;
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
|
||||
dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'CloudCLI account connected',
|
||||
message: cloud.getAccount()?.email ? `Connected as ${cloud.getAccount().email}.` : 'CloudCLI account connected.',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function copyLocalWebUrl() {
|
||||
await localServer.ensureLocalServer();
|
||||
const shareableUrl = localServer.getShareableWebUrl();
|
||||
const localUrl = localServer.getLocalServerUrl();
|
||||
|
||||
if (!shareableUrl) {
|
||||
throw new Error('Local CloudCLI URL is not available yet.');
|
||||
}
|
||||
|
||||
clipboard.writeText(shareableUrl);
|
||||
const isLanUrl = shareableUrl !== localUrl;
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Web URL copied',
|
||||
message: isLanUrl ? 'LAN web URL copied.' : 'Local web URL copied.',
|
||||
detail: isLanUrl
|
||||
? `${shareableUrl}\n\nUse this URL from another device on the same network.`
|
||||
: `${shareableUrl}\n\nThis URL works on this computer. Enable LAN access before starting Local CloudCLI to copy a phone-accessible URL.`,
|
||||
});
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openLocalWebUi() {
|
||||
await localServer.ensureLocalServer();
|
||||
const url = localServer.getShareableWebUrl() || localServer.getLocalServerUrl();
|
||||
if (!url) {
|
||||
throw new Error('Local CloudCLI URL is not available yet.');
|
||||
}
|
||||
|
||||
await openExternalUrl(url);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function updateDesktopSetting(key, value) {
|
||||
const result = await localServer.updateDesktopSetting(key, value);
|
||||
syncDesktopState();
|
||||
|
||||
if (result.requiresRestartNotice) {
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Restart local server to apply',
|
||||
message: 'LAN access changes apply the next time the local server starts.',
|
||||
detail: 'Quit CloudCLI and stop the local server, then open Local CloudCLI again.',
|
||||
});
|
||||
}
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function showEnvironmentPicker() {
|
||||
const environments = await refreshCloudEnvironments({ showErrors: true });
|
||||
const choices = ['Local CloudCLI', ...environments.map((environment) => {
|
||||
const status = environment.status === 'running' ? '' : ` (${environment.status})`;
|
||||
return `${environment.name || environment.subdomain}${status}`;
|
||||
})];
|
||||
|
||||
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
|
||||
type: 'question',
|
||||
buttons: [...choices, 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: choices.length,
|
||||
title: 'Switch CloudCLI Environment',
|
||||
message: 'Choose where this desktop window should connect.',
|
||||
});
|
||||
|
||||
if (response.response === choices.length) return getDesktopState();
|
||||
if (response.response === 0) return openLocalInDesktop();
|
||||
return openEnvironmentInDesktop(environments[response.response - 1]);
|
||||
}
|
||||
|
||||
async function startEnvironment(environment) {
|
||||
await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function stopEnvironment(environment) {
|
||||
await cloud.stopEnvironment(environment);
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInBrowser(environment) {
|
||||
await openExternalUrl(await cloud.getEnvironmentLaunchUrl(environment));
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getProjectFolder(environment) {
|
||||
return String(environment.name || environment.subdomain || 'workspace').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
}
|
||||
|
||||
function getSshTarget(credentials) {
|
||||
if (credentials.ssh_command) {
|
||||
const parts = String(credentials.ssh_command).split(/\s+/);
|
||||
if (parts.length >= 2) return parts[1];
|
||||
}
|
||||
return `${credentials.username}@ssh.cloudcli.ai`;
|
||||
}
|
||||
|
||||
function getSshHost(credentials) {
|
||||
const target = getSshTarget(credentials);
|
||||
const atIndex = target.indexOf('@');
|
||||
return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai';
|
||||
}
|
||||
|
||||
function getSafeSshUsername(credentials) {
|
||||
const username = String(credentials.username || '');
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
||||
throw new Error('Cloud environment returned an invalid SSH username.');
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
function getSafeSshHost(credentials) {
|
||||
const host = getSshHost(credentials);
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
|
||||
throw new Error('Cloud environment returned an invalid SSH host.');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function getEnvironmentCredentials(environment) {
|
||||
const credentials = await cloud.getEnvironmentCredentials(environment);
|
||||
if (credentials.password) {
|
||||
clipboard.writeText(credentials.password);
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function openEnvironmentInIde(environment, ide) {
|
||||
const credentials = await getEnvironmentCredentials(environment);
|
||||
const scheme = ide === 'cursor' ? 'cursor' : 'vscode';
|
||||
const remoteUri = `${scheme}://vscode-remote/ssh-remote+${getSafeSshUsername(credentials)}@${getSafeSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`;
|
||||
await shell.openExternal(remoteUri);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInSsh(environment) {
|
||||
const credentials = await getEnvironmentCredentials(environment);
|
||||
const remoteCommand = `cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l`;
|
||||
const sshCommand = `ssh -t ${shellQuote(getSshTarget(credentials))} ${shellQuote(remoteCommand)}`;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
spawn('osascript', ['-e', `tell application "Terminal" to do script "${escaped}"`], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
}).unref();
|
||||
} else {
|
||||
clipboard.writeText(sshCommand);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'SSH command copied',
|
||||
message: 'The SSH command was copied to the clipboard.',
|
||||
detail: sshCommand,
|
||||
});
|
||||
}
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function copyEnvironmentMobileUrl(environment) {
|
||||
const url = cloud.getEnvironmentUrl(environment);
|
||||
clipboard.writeText(url);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Environment URL copied',
|
||||
message: 'Use this URL from your mobile browser.',
|
||||
detail: url,
|
||||
});
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openCloudDashboard() {
|
||||
await openExternalUrl(CLOUDCLI_CONTROL_PLANE_URL);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getActiveRemoteEnvironment() {
|
||||
if (activeTarget?.kind !== 'remote') return null;
|
||||
return cloud.findEnvironment(activeTarget.id);
|
||||
}
|
||||
|
||||
async function runActiveEnvironmentAction(action) {
|
||||
const environment = getActiveRemoteEnvironment();
|
||||
if (!environment) {
|
||||
throw new Error('Open a cloud environment first.');
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'web':
|
||||
return openEnvironmentInBrowser(environment);
|
||||
case 'vscode':
|
||||
return openEnvironmentInIde(environment, 'vscode');
|
||||
case 'cursor':
|
||||
return openEnvironmentInIde(environment, 'cursor');
|
||||
case 'ssh':
|
||||
return openEnvironmentInSsh(environment);
|
||||
case 'mobile':
|
||||
return copyEnvironmentMobileUrl(environment);
|
||||
default:
|
||||
throw new Error(`Unknown environment action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLocalInDesktop() {
|
||||
const existingTab = tabs.getTab('local');
|
||||
if (existingTab && localServer.getLocalServerUrl()) {
|
||||
await desktopWindow.showTarget(await localServer.getResolvedTarget());
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
const pendingTarget = localServer.getPendingTarget();
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
setActiveTarget(pendingTarget);
|
||||
await desktopWindow.showLocalStartupTarget(pendingTarget, localServer.getStartupLogs());
|
||||
desktopWindow.emitDesktopState();
|
||||
|
||||
const target = await localServer.getResolvedTarget();
|
||||
await desktopWindow.showTarget(target);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInDesktop(environment) {
|
||||
const pendingTarget = getEnvironmentTarget(environment);
|
||||
const tabId = tabs.getTabIdForTarget(pendingTarget);
|
||||
const hadTab = Boolean(tabs.getTab(tabId));
|
||||
const previousTabId = tabs.activeTabId;
|
||||
|
||||
if (!hadTab) {
|
||||
await desktopWindow.showTabPlaceholder(
|
||||
pendingTarget,
|
||||
`${environment.status === 'running' ? 'Opening' : 'Starting'} ${pendingTarget.name}...`,
|
||||
);
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
desktopWindow.emitDesktopState();
|
||||
}
|
||||
|
||||
let nextEnvironment = environment;
|
||||
|
||||
if (environment.status !== 'running') {
|
||||
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
|
||||
type: 'question',
|
||||
buttons: ['Start Environment', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Start environment?',
|
||||
message: `${pendingTarget.name} is ${environment.status}.`,
|
||||
detail: 'CloudCLI can start it before opening the remote app.',
|
||||
});
|
||||
|
||||
if (response.response !== 0) {
|
||||
if (!hadTab) {
|
||||
tabs.remove(tabId);
|
||||
desktopWindow.destroyTabView(tabId);
|
||||
if (previousTabId && previousTabId !== tabId) {
|
||||
await desktopWindow.switchDesktopTab(previousTabId);
|
||||
} else {
|
||||
await desktopWindow.showLauncher();
|
||||
}
|
||||
}
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
if (hadTab) {
|
||||
await desktopWindow.showTabPlaceholder(pendingTarget, `Starting ${pendingTarget.name}...`);
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
desktopWindow.emitDesktopState();
|
||||
}
|
||||
|
||||
nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
const target = await getEnvironmentLaunchTarget(nextEnvironment);
|
||||
await desktopWindow.showTarget(target);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function clearCloudAccount() {
|
||||
await cloud.clearCloudAccount();
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getRemoteEnvironmentMenuItems() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const environments = cloud.getEnvironments();
|
||||
|
||||
if (!cloudAccount?.apiKey) {
|
||||
return [{ label: 'Connect CloudCLI Account...', click: () => void connectCloudAccount() }];
|
||||
}
|
||||
|
||||
if (!environments.length) {
|
||||
return [{ label: 'No environments found', enabled: false }];
|
||||
}
|
||||
|
||||
return environments.map((environment) => ({
|
||||
label: `${environment.name || environment.subdomain}${environment.status === 'running' ? '' : ` (${environment.status})`}`,
|
||||
click: () => void openEnvironmentInDesktop(environment)
|
||||
.catch((error) => showError('Could not open environment', error)),
|
||||
}));
|
||||
}
|
||||
|
||||
function registerProtocolHandler() {
|
||||
const appEntry = path.join(getAppRoot(), 'electron', 'main.js');
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL, process.execPath, [appEntry]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL);
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpcHandlers() {
|
||||
ipcMain.handle('cloudcli-desktop:connect-cloud', async () => ({
|
||||
...getDesktopState(),
|
||||
connectUrl: await connectCloudAccount(),
|
||||
}));
|
||||
|
||||
ipcMain.handle('cloudcli-desktop:copy-diagnostics', async () => {
|
||||
await copyDiagnostics();
|
||||
return getDesktopState();
|
||||
});
|
||||
|
||||
ipcMain.handle('cloudcli-desktop:copy-local-web-url', async () => copyLocalWebUrl());
|
||||
ipcMain.handle('cloudcli-desktop:get-state', () => getDesktopState());
|
||||
ipcMain.handle('cloudcli-desktop:open-cloud-dashboard', async () => openCloudDashboard());
|
||||
ipcMain.handle('cloudcli-desktop:run-active-environment-action', async (_event, action) => runActiveEnvironmentAction(action));
|
||||
ipcMain.handle('cloudcli-desktop:open-environment', async (_event, environmentId) => {
|
||||
const environment = cloud.findEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error('Environment not found. Refresh and try again.');
|
||||
}
|
||||
return openEnvironmentInDesktop(environment);
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:open-local', async () => openLocalInDesktop());
|
||||
ipcMain.handle('cloudcli-desktop:open-local-web-ui', async () => openLocalWebUi());
|
||||
ipcMain.handle('cloudcli-desktop:refresh-environments', async () => {
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:show-environment-picker', async () => showEnvironmentPicker());
|
||||
ipcMain.handle('cloudcli-desktop:show-launcher', async () => {
|
||||
await desktopWindow.showLauncher();
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:show-computer-access', async () => {
|
||||
await showComputerAccess();
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:update-computer-use', async (_event, settings) => updateComputerUse(settings));
|
||||
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
|
||||
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
|
||||
ipcMain.handle('cloudcli-desktop:close-settings-window', async () => {
|
||||
desktopWindow.closeSettingsWindow();
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:show-active-environment-actions-menu', async () => desktopWindow.showActiveEnvironmentActionsMenu());
|
||||
ipcMain.handle('cloudcli-desktop:show-environment-actions-menu', async (_event, environmentId) => desktopWindow.showEnvironmentActionsMenu(environmentId));
|
||||
ipcMain.handle('cloudcli-desktop:switch-tab', async (_event, tabId) => desktopWindow.switchDesktopTab(tabId));
|
||||
ipcMain.handle('cloudcli-desktop:close-tab', async (_event, tabId) => desktopWindow.closeDesktopTab(tabId));
|
||||
ipcMain.handle('cloudcli-desktop:update-setting', async (_event, key, value) => updateDesktopSetting(key, value));
|
||||
}
|
||||
|
||||
function registerAppEvents() {
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
void handleDeepLink(url);
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
if (desktopWindow) {
|
||||
void desktopWindow.createWindow();
|
||||
} else {
|
||||
void createDesktopWindow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const window = desktopWindow?.getMainWindow();
|
||||
if (window) {
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
computerAgent?.stop();
|
||||
});
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (isQuitting || !localServer?.hasOwnedServer()) return;
|
||||
if (localServer.getSettings().keepLocalServerRunning) {
|
||||
localServer.detachOwnedServer();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
isQuitting = true;
|
||||
void localServer.shutdownOwnedServer().finally(() => app.quit());
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createDesktopWindow() {
|
||||
desktopWindow = new DesktopWindowManager({
|
||||
appName: APP_NAME,
|
||||
getWindowIconPath,
|
||||
getLauncherPath,
|
||||
getPreloadPath,
|
||||
openExternalUrl,
|
||||
getDesktopState,
|
||||
getDisplayTargetName,
|
||||
getRemoteEnvironmentMenuItems,
|
||||
getCloudState,
|
||||
getLocalState,
|
||||
tabs,
|
||||
actions: {
|
||||
copyDiagnostics,
|
||||
copyText: (text) => clipboard.writeText(text),
|
||||
clearCloudAccount,
|
||||
connectCloudAccount,
|
||||
getActiveTarget: () => activeTarget,
|
||||
getEnvironmentUrl: (environment) => cloud.getEnvironmentUrl(environment),
|
||||
openEnvironmentInBrowser,
|
||||
openEnvironmentInDesktop,
|
||||
openEnvironmentInIde,
|
||||
openEnvironmentInSsh,
|
||||
openLocalInDesktop,
|
||||
openLocalWebUi,
|
||||
openCloudDashboard,
|
||||
refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
|
||||
setActiveTarget,
|
||||
showComputerAccess,
|
||||
showEnvironmentPicker,
|
||||
showError,
|
||||
startEnvironment,
|
||||
stopEnvironment,
|
||||
updateDesktopSetting,
|
||||
copyLocalWebUrl,
|
||||
},
|
||||
});
|
||||
|
||||
desktopWindow.createTray();
|
||||
desktopWindow.configurePermissions();
|
||||
await desktopWindow.createWindow();
|
||||
}
|
||||
|
||||
function registerSingleInstance() {
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
return false;
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
const deepLink = argv.find((arg) => arg.startsWith(`${CALLBACK_PROTOCOL}://`));
|
||||
if (deepLink) {
|
||||
void handleDeepLink(deepLink);
|
||||
}
|
||||
|
||||
const window = desktopWindow?.getMainWindow();
|
||||
if (window) {
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
app.name = APP_NAME;
|
||||
app.setName(APP_NAME);
|
||||
process.title = APP_NAME;
|
||||
|
||||
await app.whenReady();
|
||||
app.setName(APP_NAME);
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: APP_NAME,
|
||||
applicationVersion: app.getVersion(),
|
||||
copyright: 'CloudCLI',
|
||||
});
|
||||
|
||||
localServer = new LocalServerController({
|
||||
appRoot: getAppRoot(),
|
||||
settingsPath: getSettingsPath(),
|
||||
isPackaged: app.isPackaged,
|
||||
appVersion: app.getVersion(),
|
||||
onChange: syncDesktopState,
|
||||
});
|
||||
cloud = new CloudController({
|
||||
storePath: getStorePath(),
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
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();
|
||||
registerAppEvents();
|
||||
await createDesktopWindow();
|
||||
void refreshCloudEnvironments({ showErrors: false });
|
||||
}
|
||||
|
||||
if (registerSingleInstance()) {
|
||||
bootstrap().catch(async (error) => {
|
||||
await showError('CloudCLI failed to start', error);
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
34
electron/preload.cjs
Normal file
34
electron/preload.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
if (window.location.protocol === 'file:') {
|
||||
contextBridge.exposeInMainWorld('cloudcliDesktop', {
|
||||
connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
|
||||
copyDiagnostics: () => ipcRenderer.invoke('cloudcli-desktop:copy-diagnostics'),
|
||||
copyLocalWebUrl: () => ipcRenderer.invoke('cloudcli-desktop:copy-local-web-url'),
|
||||
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
|
||||
openCloudDashboard: () => ipcRenderer.invoke('cloudcli-desktop:open-cloud-dashboard'),
|
||||
openEnvironment: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:open-environment', environmentId),
|
||||
runActiveEnvironmentAction: (action) => ipcRenderer.invoke('cloudcli-desktop:run-active-environment-action', action),
|
||||
openLocal: () => ipcRenderer.invoke('cloudcli-desktop:open-local'),
|
||||
openLocalWebUi: () => ipcRenderer.invoke('cloudcli-desktop:open-local-web-ui'),
|
||||
refreshEnvironments: () => ipcRenderer.invoke('cloudcli-desktop:refresh-environments'),
|
||||
showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
|
||||
showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
|
||||
showComputerAccess: () => ipcRenderer.invoke('cloudcli-desktop:show-computer-access'),
|
||||
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
|
||||
updateComputerUse: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-computer-use', settings),
|
||||
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
|
||||
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
|
||||
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
|
||||
showEnvironmentActionsMenu: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:show-environment-actions-menu', environmentId),
|
||||
switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
|
||||
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
|
||||
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
|
||||
onStateUpdated: (callback) => {
|
||||
ipcRenderer.on('cloudcli-desktop:state-updated', (_event, state) => callback(state));
|
||||
},
|
||||
onLauncherCommand: (callback) => {
|
||||
ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command));
|
||||
},
|
||||
});
|
||||
}
|
||||
62
electron/scripts/generate-macos-icon.js
Normal file
62
electron/scripts/generate-macos-icon.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const size = 1024;
|
||||
const assetsDir = 'electron/assets';
|
||||
const iconPath = 'electron/assets/logo-macos.png';
|
||||
const icnsPath = 'electron/assets/logo-macos.icns';
|
||||
|
||||
function renderSvg(entrySize) {
|
||||
const scale = entrySize / 32;
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${entrySize}" height="${entrySize}" viewBox="0 0 ${entrySize} ${entrySize}">
|
||||
<rect width="${entrySize}" height="${entrySize}" fill="#2563eb"/>
|
||||
<path
|
||||
d="M${8 * scale} ${9 * scale}C${8 * scale} ${8.44772 * scale} ${8.44772 * scale} ${8 * scale} ${9 * scale} ${8 * scale}H${23 * scale}C${23.5523 * scale} ${8 * scale} ${24 * scale} ${8.44772 * scale} ${24 * scale} ${9 * scale}V${18 * scale}C${24 * scale} ${18.5523 * scale} ${23.5523 * scale} ${19 * scale} ${23 * scale} ${19 * scale}H${12 * scale}L${8 * scale} ${23 * scale}V${9 * scale}Z"
|
||||
stroke="white"
|
||||
stroke-width="${2 * scale}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
async function renderPng(entrySize) {
|
||||
return sharp(Buffer.from(renderSvg(entrySize)))
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
await fs.writeFile(iconPath, await renderPng(size));
|
||||
|
||||
const icnsEntries = [
|
||||
['icp4', 16],
|
||||
['icp5', 32],
|
||||
['icp6', 64],
|
||||
['ic07', 128],
|
||||
['ic08', 256],
|
||||
['ic09', 512],
|
||||
['ic10', 1024],
|
||||
['ic11', 32],
|
||||
['ic12', 64],
|
||||
['ic13', 256],
|
||||
['ic14', 512],
|
||||
];
|
||||
|
||||
const blocks = await Promise.all(icnsEntries.map(async ([type, entrySize]) => {
|
||||
const png = await renderPng(entrySize);
|
||||
const block = Buffer.alloc(8 + png.length);
|
||||
block.write(type, 0, 4, 'ascii');
|
||||
block.writeUInt32BE(block.length, 4);
|
||||
png.copy(block, 8);
|
||||
return block;
|
||||
}));
|
||||
|
||||
const totalLength = 8 + blocks.reduce((sum, block) => sum + block.length, 0);
|
||||
const header = Buffer.alloc(8);
|
||||
header.write('icns', 0, 4, 'ascii');
|
||||
header.writeUInt32BE(totalLength, 4);
|
||||
|
||||
await fs.writeFile(icnsPath, Buffer.concat([header, ...blocks], totalLength));
|
||||
275
electron/serverInstaller.js
Normal file
275
electron/serverInstaller.js
Normal file
@@ -0,0 +1,275 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Installs the versioned local server runtime used by CloudCLI Desktop.
|
||||
*
|
||||
* Server bundles are cached under:
|
||||
* ~/.cloudcli/server/<version>/dist-server/server/index.js
|
||||
*/
|
||||
|
||||
const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.cloudcli', 'server');
|
||||
const DEFAULT_BUNDLE_BASE_URL = 'https://github.com/siteboon/claudecodeui/releases/download';
|
||||
const MAX_REDIRECTS = 5;
|
||||
const LOCAL_DOWNLOAD_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
|
||||
function mapArch(arch = process.arch) {
|
||||
return arch === 'arm64' ? 'arm64' : 'x64';
|
||||
}
|
||||
|
||||
function mapPlatform(platform = process.platform) {
|
||||
if (platform === 'darwin') return 'mac';
|
||||
if (platform === 'win32') return 'win';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
export class ServerInstaller {
|
||||
constructor({
|
||||
version,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
installRoot = process.env.CLOUDCLI_SERVER_DIR || DEFAULT_INSTALL_ROOT,
|
||||
bundleBaseUrl = process.env.CLOUDCLI_SERVER_BUNDLE_URL || DEFAULT_BUNDLE_BASE_URL,
|
||||
onLog,
|
||||
} = {}) {
|
||||
if (!version) throw new Error('ServerInstaller requires the app version');
|
||||
this.version = version;
|
||||
this.platform = mapPlatform(platform);
|
||||
this.arch = mapArch(arch);
|
||||
this.installRoot = installRoot;
|
||||
this.bundleBaseUrl = bundleBaseUrl.replace(/\/+$/, '');
|
||||
this.onLog = typeof onLog === 'function' ? onLog : () => {};
|
||||
}
|
||||
|
||||
/** Directory the current version's server is (or will be) installed in. */
|
||||
getVersionDir() {
|
||||
return path.join(this.installRoot, this.version);
|
||||
}
|
||||
|
||||
/** Absolute path to the server entry once installed. */
|
||||
getServerEntry() {
|
||||
return path.join(this.getVersionDir(), 'dist-server', 'server', 'index.js');
|
||||
}
|
||||
|
||||
getBundleName() {
|
||||
return `cloudcli-server-${this.version}-${this.platform}-${this.arch}.tar.gz`;
|
||||
}
|
||||
|
||||
getBundleUrl() {
|
||||
const url = new URL(`${this.bundleBaseUrl}/v${this.version}/${this.getBundleName()}`);
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_DOWNLOAD_HOSTS.has(url.hostname))) {
|
||||
throw new Error(`Refusing unsupported server bundle URL: ${url.toString()}`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
log(line) {
|
||||
this.onLog(String(line));
|
||||
}
|
||||
|
||||
async isInstalled() {
|
||||
try {
|
||||
const marker = JSON.parse(
|
||||
await fs.readFile(path.join(this.getVersionDir(), '.installed.json'), 'utf8'),
|
||||
);
|
||||
if (marker.version !== this.version) return false;
|
||||
await fs.access(this.getServerEntry());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the server for this version is installed, downloading + extracting
|
||||
* it if needed. Returns the resolved server entry path.
|
||||
*/
|
||||
async ensureInstalled() {
|
||||
if (await this.isInstalled()) {
|
||||
this.log(`Local server ${this.version} already installed.`);
|
||||
return this.getServerEntry();
|
||||
}
|
||||
|
||||
const versionDir = this.getVersionDir();
|
||||
const tmpDir = path.join(this.installRoot, `.tmp-${this.version}-${process.pid}`);
|
||||
const archivePath = path.join(tmpDir, this.getBundleName());
|
||||
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
try {
|
||||
const url = this.getBundleUrl();
|
||||
this.log(`Downloading local server bundle…`);
|
||||
this.log(url);
|
||||
await this.#download(url, archivePath);
|
||||
await this.#verifyChecksum(url, archivePath);
|
||||
|
||||
this.log('Extracting local server…');
|
||||
await fs.rm(versionDir, { recursive: true, force: true });
|
||||
await fs.mkdir(versionDir, { recursive: true });
|
||||
await this.#validateArchive(archivePath);
|
||||
await this.#extract(archivePath, versionDir);
|
||||
|
||||
const entry = this.getServerEntry();
|
||||
await fs.access(entry);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(versionDir, '.installed.json'),
|
||||
JSON.stringify({ version: this.version, installedAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
this.log(`Local server ${this.version} installed.`);
|
||||
return entry;
|
||||
} catch (error) {
|
||||
await fs.rm(versionDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw new Error(`Failed to install local server: ${error.message}`);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
#download(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, (res) => {
|
||||
const { statusCode, headers } = res;
|
||||
|
||||
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
||||
res.resume();
|
||||
if (redirectsLeft <= 0) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const next = new URL(headers.location, url).toString();
|
||||
resolve(this.#download(next, destPath, redirectsLeft - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`Download failed with HTTP ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const total = Number(headers['content-length']) || 0;
|
||||
let received = 0;
|
||||
let lastPct = -1;
|
||||
const out = createWriteStream(destPath);
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
received += chunk.length;
|
||||
if (total) {
|
||||
const pct = Math.floor((received / total) * 100);
|
||||
if (pct !== lastPct && pct % 10 === 0) {
|
||||
lastPct = pct;
|
||||
this.log(`Downloading… ${pct}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
res.pipe(out);
|
||||
out.on('finish', () => out.close(resolve));
|
||||
out.on('error', reject);
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async #verifyChecksum(url, archivePath) {
|
||||
let expected;
|
||||
try {
|
||||
expected = (await this.#fetchText(`${url}.sha256`)).trim().split(/\s+/)[0];
|
||||
} catch (error) {
|
||||
throw new Error(`Could not verify server bundle checksum: ${error.message}`);
|
||||
}
|
||||
const actual = await this.#sha256(archivePath);
|
||||
if (expected.toLowerCase() !== actual.toLowerCase()) {
|
||||
throw new Error('Checksum mismatch — refusing to install');
|
||||
}
|
||||
this.log('Checksum verified.');
|
||||
}
|
||||
|
||||
#fetchText(url, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, (res) => {
|
||||
const { statusCode, headers } = res;
|
||||
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
||||
res.resume();
|
||||
if (redirectsLeft <= 0) return reject(new Error('Too many redirects'));
|
||||
return resolve(this.#fetchText(new URL(headers.location, url).toString(), redirectsLeft - 1));
|
||||
}
|
||||
if (statusCode !== 200) {
|
||||
res.resume();
|
||||
return reject(new Error(`HTTP ${statusCode}`));
|
||||
}
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (c) => (body += c));
|
||||
res.on('end', () => resolve(body));
|
||||
res.on('error', reject);
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
#sha256(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (c) => hash.update(c));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
#extract(archivePath, destDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('tar', ['-xzf', archivePath, '-C', destDir], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (c) => (stderr += c));
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tar exited with code ${code}: ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#validateArchive(archivePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('tar', ['-tzf', archivePath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (c) => { stdout += c; });
|
||||
child.stderr?.on('data', (c) => { stderr += c; });
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`tar list exited with code ${code}: ${stderr.trim()}`));
|
||||
return;
|
||||
}
|
||||
for (const entry of stdout.split(/\r?\n/).filter(Boolean)) {
|
||||
const normalized = entry.replace(/\\/g, '/');
|
||||
if (
|
||||
path.isAbsolute(normalized)
|
||||
|| /^[a-zA-Z]:\//.test(normalized)
|
||||
|| normalized.split('/').includes('..')
|
||||
) {
|
||||
reject(new Error(`Refusing unsafe archive entry: ${entry}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
71
electron/tabs.js
Normal file
71
electron/tabs.js
Normal file
@@ -0,0 +1,71 @@
|
||||
export class TabsController {
|
||||
constructor() {
|
||||
this.activeTabId = 'home';
|
||||
this.tabs = [
|
||||
{
|
||||
id: 'home',
|
||||
title: 'Home',
|
||||
kind: 'launcher',
|
||||
closable: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getTabIdForTarget(target) {
|
||||
if (target.kind === 'launcher') return 'home';
|
||||
if (target.kind === 'remote' && target.id) return `remote:${target.id}`;
|
||||
return target.kind;
|
||||
}
|
||||
|
||||
upsertTarget(target) {
|
||||
const tabId = this.getTabIdForTarget(target);
|
||||
const existingTab = this.tabs.find((tab) => tab.id === tabId);
|
||||
const nextTab = {
|
||||
id: tabId,
|
||||
title: target.kind === 'launcher' ? 'Home' : target.name,
|
||||
kind: target.kind,
|
||||
target,
|
||||
closable: tabId !== 'home',
|
||||
};
|
||||
|
||||
if (existingTab) {
|
||||
Object.assign(existingTab, nextTab);
|
||||
} else {
|
||||
this.tabs.push(nextTab);
|
||||
}
|
||||
|
||||
this.activeTabId = tabId;
|
||||
return nextTab;
|
||||
}
|
||||
|
||||
activate(tabId) {
|
||||
const tab = this.tabs.find((item) => item.id === tabId);
|
||||
if (!tab) return null;
|
||||
this.activeTabId = tab.id;
|
||||
return tab;
|
||||
}
|
||||
|
||||
remove(tabId) {
|
||||
const tab = this.tabs.find((item) => item.id === tabId);
|
||||
if (!tab || !tab.closable) return null;
|
||||
this.tabs = this.tabs.filter((item) => item.id !== tabId);
|
||||
if (this.activeTabId === tabId) {
|
||||
this.activeTabId = 'home';
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
getTab(tabId) {
|
||||
return this.tabs.find((item) => item.id === tabId) || null;
|
||||
}
|
||||
|
||||
getSerializableTabs() {
|
||||
return this.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
kind: tab.kind,
|
||||
closable: tab.closable,
|
||||
active: tab.id === this.activeTabId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -161,8 +161,6 @@ export default tseslint.config(
|
||||
"server/shared/utils.{js,ts}",
|
||||
"server/shared/frontmatter.ts",
|
||||
"server/shared/claude-cli-path.ts",
|
||||
"server/shared/cli-runtime-env.ts",
|
||||
"server/shared/codex-cli-runtime.ts",
|
||||
], // classify shared utility files so modules can depend on them explicitly
|
||||
mode: "file",
|
||||
},
|
||||
|
||||
4386
package-lock.json
generated
4386
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
74
package.json
74
package.json
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.34.0",
|
||||
"productName": "CloudCLI",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
@@ -8,6 +9,7 @@
|
||||
"cloudcli": "dist-server/server/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"electron/",
|
||||
"server/",
|
||||
"shared/",
|
||||
"public/api-docs.html",
|
||||
@@ -30,6 +32,14 @@
|
||||
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
|
||||
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
|
||||
"client": "vite",
|
||||
"desktop": "electron electron/main.js",
|
||||
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
||||
"desktop:stage": "node scripts/release/prepare-desktop-app.js",
|
||||
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
|
||||
"desktop:dist:mac": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --mac dmg zip",
|
||||
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis zip",
|
||||
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
|
||||
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
|
||||
"build": "npm run build:client && npm run build:server",
|
||||
"build:client": "vite build",
|
||||
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
||||
@@ -45,6 +55,63 @@
|
||||
"prepare": "husky",
|
||||
"update:platform": "./update-platform.sh"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.cloudcli.desktop",
|
||||
"productName": "CloudCLI",
|
||||
"asar": false,
|
||||
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.js"
|
||||
},
|
||||
"files": [
|
||||
"electron/",
|
||||
"public/",
|
||||
"dist/",
|
||||
"dist-server/",
|
||||
"shared/",
|
||||
"server/",
|
||||
"package.json",
|
||||
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
|
||||
],
|
||||
"protocols": [
|
||||
{
|
||||
"name": "CloudCLI",
|
||||
"schemes": [
|
||||
"cloudcli"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "electron/assets/logo-macos.icns",
|
||||
"notarize": true,
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"extendInfo": {
|
||||
"CFBundleName": "CloudCLI",
|
||||
"CFBundleDisplayName": "CloudCLI",
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLName": "CloudCLI",
|
||||
"CFBundleURLSchemes": [
|
||||
"cloudcli"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
]
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"claude code",
|
||||
"claude-code",
|
||||
@@ -141,6 +208,9 @@
|
||||
"auto-changelog": "^2.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^6.0.2",
|
||||
@@ -167,5 +237,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"
|
||||
}
|
||||
}
|
||||
|
||||
175
scripts/release/build-server-bundle.js
Normal file
175
scripts/release/build-server-bundle.js
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from 'node:crypto';
|
||||
import { createReadStream, readFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..', '..');
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
|
||||
);
|
||||
|
||||
function getElectronVersion() {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
|
||||
).version;
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
|
||||
).packages['node_modules/electron'].version;
|
||||
} catch {
|
||||
throw new Error('Could not resolve an exact Electron version for server native rebuild.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapArch(arch = process.arch) {
|
||||
return arch === 'arm64' ? 'arm64' : 'x64';
|
||||
}
|
||||
|
||||
function mapPlatform(platform = process.platform) {
|
||||
if (platform === 'darwin') return 'mac';
|
||||
if (platform === 'win32') return 'win';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
...options,
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRequired(stageDir, relativePath) {
|
||||
const from = path.join(rootDir, relativePath);
|
||||
if (!(await pathExists(from))) {
|
||||
throw new Error(`Required server bundle input is missing: ${relativePath}`);
|
||||
}
|
||||
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||
}
|
||||
|
||||
async function copyIfExists(stageDir, relativePath) {
|
||||
const from = path.join(rootDir, relativePath);
|
||||
if (!(await pathExists(from))) return;
|
||||
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||
}
|
||||
|
||||
async function writeServerPackageJson(stageDir) {
|
||||
const stagedPackageJson = {
|
||||
...packageJson,
|
||||
scripts: {
|
||||
...(packageJson.scripts || {}),
|
||||
},
|
||||
};
|
||||
// The bundle stage is not a git checkout with dev dependencies, so lifecycle
|
||||
// scripts such as Husky prepare must not run there. Dependency install scripts
|
||||
// still run; native modules need them before the Electron ABI rebuild below.
|
||||
delete stagedPackageJson.scripts.prepare;
|
||||
delete stagedPackageJson.scripts.prepublishOnly;
|
||||
await fs.writeFile(
|
||||
path.join(stageDir, 'package.json'),
|
||||
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function sha256(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const platform = mapPlatform(process.env.CLOUDCLI_BUNDLE_PLATFORM || process.platform);
|
||||
const arch = mapArch(process.env.CLOUDCLI_BUNDLE_ARCH || process.arch);
|
||||
const version = packageJson.version;
|
||||
const bundleName = `cloudcli-server-${version}-${platform}-${arch}.tar.gz`;
|
||||
const bundleRoot = path.join(rootDir, 'release', 'server-bundles');
|
||||
const stageDir = path.join(bundleRoot, `.stage-${version}-${platform}-${arch}`);
|
||||
const archivePath = path.join(bundleRoot, bundleName);
|
||||
|
||||
await fs.rm(stageDir, { recursive: true, force: true });
|
||||
await fs.mkdir(stageDir, { recursive: true });
|
||||
await fs.mkdir(bundleRoot, { recursive: true });
|
||||
|
||||
await copyRequired(stageDir, 'dist');
|
||||
await copyRequired(stageDir, 'dist-server');
|
||||
await copyRequired(stageDir, 'public');
|
||||
await copyRequired(stageDir, 'shared');
|
||||
await copyRequired(stageDir, 'package-lock.json');
|
||||
await copyIfExists(stageDir, 'scripts/fix-node-pty.js');
|
||||
await writeServerPackageJson(stageDir);
|
||||
|
||||
console.log('Installing production server dependencies into bundle stage...');
|
||||
await run('npm', ['ci', '--omit=dev'], {
|
||||
cwd: stageDir,
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_audit: 'false',
|
||||
npm_config_fund: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
const electronVersion = getElectronVersion();
|
||||
const electronRebuild = process.platform === 'win32'
|
||||
? path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild.cmd')
|
||||
: path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild');
|
||||
console.log(`Rebuilding native server dependencies for Electron ${electronVersion} (${arch})...`);
|
||||
await run(electronRebuild, ['--version', electronVersion, '--module-dir', stageDir, '--arch', arch, '--force'], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_audit: 'false',
|
||||
npm_config_fund: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
if (await pathExists(path.join(stageDir, 'scripts', 'fix-node-pty.js'))) {
|
||||
await run(process.execPath, ['scripts/fix-node-pty.js'], { cwd: stageDir });
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(stageDir, '.installed.json'),
|
||||
JSON.stringify({ version, platform, arch, builtAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.rm(archivePath, { force: true });
|
||||
const tarArgs = process.platform === 'win32'
|
||||
? ['-czf', archivePath, '-C', stageDir, '.']
|
||||
: ['-czf', archivePath, '-C', stageDir, '.'];
|
||||
await run('tar', tarArgs);
|
||||
|
||||
const digest = await sha256(archivePath);
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
await fs.writeFile(checksumPath, `${digest} ${bundleName}\n`, 'utf8');
|
||||
await fs.rm(stageDir, { recursive: true, force: true });
|
||||
|
||||
const size = (await fs.stat(archivePath)).size / 1024 / 1024;
|
||||
console.log(`Wrote ${path.relative(rootDir, archivePath)} (${size.toFixed(1)} MB)`);
|
||||
console.log(`Wrote ${path.relative(rootDir, checksumPath)}`);
|
||||
146
scripts/release/prepare-desktop-app.js
Normal file
146
scripts/release/prepare-desktop-app.js
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..', '..');
|
||||
const stageDir = path.join(rootDir, '.desktop-build', 'desktop-app');
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
|
||||
);
|
||||
|
||||
function getElectronVersion() {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
|
||||
).version;
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
|
||||
).packages['node_modules/electron'].version;
|
||||
} catch {
|
||||
throw new Error('Could not resolve an exact Electron version for desktop packaging.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRequired(relativePath) {
|
||||
const from = path.join(rootDir, relativePath);
|
||||
const to = path.join(stageDir, relativePath);
|
||||
if (!(await pathExists(from))) {
|
||||
throw new Error(`Required desktop build input is missing: ${relativePath}`);
|
||||
}
|
||||
await fs.cp(from, to, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyIfExists(relativePath) {
|
||||
const from = path.join(rootDir, relativePath);
|
||||
if (!(await pathExists(from))) return false;
|
||||
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function copyNodeModule(packageName) {
|
||||
const parts = packageName.split('/');
|
||||
const source = path.join(rootDir, 'node_modules', ...parts);
|
||||
if (!(await pathExists(source))) return false;
|
||||
|
||||
const target = path.join(stageDir, 'node_modules', ...parts);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.cp(source, target, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildDesktopPackageJson(copiedOptionalDependencies) {
|
||||
return {
|
||||
name: `${packageJson.name}-desktop`,
|
||||
version: packageJson.version,
|
||||
productName: packageJson.productName,
|
||||
description: `${packageJson.productName} desktop shell`,
|
||||
author: packageJson.author,
|
||||
license: packageJson.license,
|
||||
type: 'module',
|
||||
main: 'electron/main.js',
|
||||
dependencies: {
|
||||
ws: packageJson.dependencies.ws,
|
||||
},
|
||||
optionalDependencies: copiedOptionalDependencies,
|
||||
build: {
|
||||
appId: packageJson.build.appId,
|
||||
productName: packageJson.build.productName,
|
||||
asar: packageJson.build.asar,
|
||||
artifactName: packageJson.build.artifactName,
|
||||
electronVersion: getElectronVersion(),
|
||||
directories: {
|
||||
output: '../../release',
|
||||
},
|
||||
extraMetadata: {
|
||||
main: 'electron/main.js',
|
||||
},
|
||||
files: [
|
||||
'electron/**',
|
||||
'public/**',
|
||||
'dist/**',
|
||||
'dist-server/**',
|
||||
'node_modules/**',
|
||||
'package.json',
|
||||
],
|
||||
protocols: packageJson.build.protocols,
|
||||
mac: packageJson.build.mac,
|
||||
win: packageJson.build.win,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await fs.rm(stageDir, { recursive: true, force: true });
|
||||
await fs.mkdir(stageDir, { recursive: true });
|
||||
|
||||
await copyRequired('electron');
|
||||
await copyRequired('dist');
|
||||
await copyRequired('public');
|
||||
|
||||
// The desktop app still ships the standalone Computer Use desktop agent, but
|
||||
// not the full local server. Local CloudCLI is downloaded on demand.
|
||||
await copyRequired('dist-server/server/computer-use-agent.js');
|
||||
await copyIfExists('dist-server/server/computer-use-agent.js.map');
|
||||
await copyRequired('dist-server/server/modules/computer-use/computer-executor.js');
|
||||
await copyIfExists('dist-server/server/modules/computer-use/computer-executor.js.map');
|
||||
|
||||
const copiedRuntimeDependencies = [];
|
||||
if (await copyNodeModule('ws')) {
|
||||
copiedRuntimeDependencies.push('ws');
|
||||
} else {
|
||||
throw new Error('Required desktop dependency is missing from node_modules: ws');
|
||||
}
|
||||
|
||||
const copiedOptionalDependencies = {};
|
||||
for (const [name, version] of Object.entries(packageJson.optionalDependencies || {})) {
|
||||
if (await copyNodeModule(name)) {
|
||||
copiedOptionalDependencies[name] = version;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(stageDir, 'package.json'),
|
||||
`${JSON.stringify(buildDesktopPackageJson(copiedOptionalDependencies), null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
console.log(`Prepared thin desktop app at ${path.relative(rootDir, stageDir)}`);
|
||||
console.log(`Runtime dependencies: ${copiedRuntimeDependencies.join(', ')}`);
|
||||
if (Object.keys(copiedOptionalDependencies).length) {
|
||||
console.log(`Optional dependencies: ${Object.keys(copiedOptionalDependencies).join(', ')}`);
|
||||
}
|
||||
384
server/browser-use-mcp.ts
Normal file
384
server/browser-use-mcp.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env node
|
||||
import './load-env.js';
|
||||
|
||||
type JsonRpcRequest = {
|
||||
jsonrpc: '2.0';
|
||||
id?: string | number | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ToolDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const textResponse = (text: string) => ({
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
|
||||
const jsonResponse = (value: unknown) => textResponse(JSON.stringify(value, null, 2));
|
||||
|
||||
const readString = (value: unknown, name: string): string => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const readOptionalString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
|
||||
const readNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
const apiUrl = (process.env.CLOUDCLI_BROWSER_USE_API_URL || 'http://127.0.0.1:3001/api/browser-use-mcp').replace(/\/$/, '');
|
||||
const apiToken = process.env.CLOUDCLI_BROWSER_USE_MCP_TOKEN || '';
|
||||
const API_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_API_TIMEOUT_MS || '60000', 10);
|
||||
|
||||
async function callBrowserUseApi(toolName: string, input: Record<string, unknown>) {
|
||||
if (!apiToken) {
|
||||
throw new Error('CLOUDCLI_BROWSER_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),
|
||||
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
||||
});
|
||||
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `Browser API request failed (${response.status})`);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
const sessionIdSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session id.' },
|
||||
},
|
||||
required: ['sessionId'],
|
||||
};
|
||||
|
||||
const tools: ToolDefinition[] = [
|
||||
{
|
||||
name: 'browser_create_session',
|
||||
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profileName: { type: 'string', description: 'Optional background profile name for persistent browser storage.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_list_sessions',
|
||||
description: 'List Browser sessions currently available to agents.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'browser_snapshot',
|
||||
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser session.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
{
|
||||
name: 'browser_take_screenshot',
|
||||
description: 'Capture the latest screenshot for a Browser session.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
{
|
||||
name: 'browser_navigate',
|
||||
description: 'Navigate a Browser session to an HTTP or HTTPS URL.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
required: ['sessionId', 'url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_click',
|
||||
description: 'Click an element by CSS selector, visible text, or x/y coordinates.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
selector: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
},
|
||||
required: ['sessionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_type',
|
||||
description: 'Type text into the focused page or fill a CSS selector. Set submit to press Enter after typing.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
selector: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
submit: { type: 'boolean' },
|
||||
},
|
||||
required: ['sessionId', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_fill_form',
|
||||
description: 'Fill multiple form fields using CSS selectors.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
selector: { type: 'string' },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['selector', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['sessionId', 'fields'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_press_key',
|
||||
description: 'Press a keyboard key, for example Enter, Escape, Tab, or Control+A.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
},
|
||||
required: ['sessionId', 'key'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_select_option',
|
||||
description: 'Select option values in a select element found by CSS selector.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
selector: { type: 'string' },
|
||||
values: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['sessionId', 'selector', 'values'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_wait_for',
|
||||
description: 'Wait for visible text, a URL pattern, or a short timeout.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
timeoutMs: { type: 'number' },
|
||||
},
|
||||
required: ['sessionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_tabs',
|
||||
description: 'List, open, select, or close tabs in a Browser session.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
action: { type: 'string', enum: ['list', 'new', 'select', 'close'] },
|
||||
index: { type: 'number' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
required: ['sessionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_close_session',
|
||||
description: 'Stop a Browser session controlled by agents.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
];
|
||||
|
||||
async function callTool(name: string, args: Record<string, unknown>) {
|
||||
switch (name) {
|
||||
case 'browser_create_session':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
profileName: readOptionalString(args.profileName),
|
||||
}));
|
||||
case 'browser_list_sessions':
|
||||
return jsonResponse(await callBrowserUseApi(name, {}));
|
||||
case 'browser_snapshot':
|
||||
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||
case 'browser_take_screenshot': {
|
||||
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||
}
|
||||
case 'browser_navigate':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
url: readString(args.url, 'url'),
|
||||
}));
|
||||
case 'browser_click':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
selector: readOptionalString(args.selector),
|
||||
text: readOptionalString(args.text),
|
||||
x: readNumber(args.x),
|
||||
y: readNumber(args.y),
|
||||
}));
|
||||
case 'browser_type':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
selector: readOptionalString(args.selector),
|
||||
text: readString(args.text, 'text'),
|
||||
submit: args.submit === true,
|
||||
}));
|
||||
case 'browser_fill_form': {
|
||||
const fields = Array.isArray(args.fields)
|
||||
? args.fields.map((field) => {
|
||||
const record = field as Record<string, unknown>;
|
||||
return {
|
||||
selector: readString(record.selector, 'field.selector'),
|
||||
value: readString(record.value, 'field.value'),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
fields,
|
||||
}));
|
||||
}
|
||||
case 'browser_press_key':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
key: readString(args.key, 'key'),
|
||||
}));
|
||||
case 'browser_select_option':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
selector: readString(args.selector, 'selector'),
|
||||
values: Array.isArray(args.values) ? args.values.filter((value): value is string => typeof value === 'string') : [],
|
||||
}));
|
||||
case 'browser_wait_for':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
text: readOptionalString(args.text),
|
||||
url: readOptionalString(args.url),
|
||||
timeoutMs: readNumber(args.timeoutMs),
|
||||
}));
|
||||
case 'browser_tabs':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
action: args.action === 'new' || args.action === 'select' || args.action === 'close' || args.action === 'list'
|
||||
? args.action
|
||||
: undefined,
|
||||
index: readNumber(args.index),
|
||||
url: readOptionalString(args.url),
|
||||
}));
|
||||
case 'browser_close_session':
|
||||
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||
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-browser', 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<string, unknown>;
|
||||
return callTool(name, args);
|
||||
}
|
||||
|
||||
if (message.method.startsWith('notifications/')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported method: ${message.method}`);
|
||||
}
|
||||
|
||||
function writeMessage(message: Record<string, unknown>) {
|
||||
// MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
|
||||
// no embedded newlines). This is NOT the LSP Content-Length framing.
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
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 = '';
|
||||
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||
const rawMessage = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!rawMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
let request: JsonRpcRequest;
|
||||
try {
|
||||
request = JSON.parse(rawMessage) as JsonRpcRequest;
|
||||
} catch (error) {
|
||||
sendError(null, error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await handleMessage(request);
|
||||
sendResult(request.id, result);
|
||||
} catch (error) {
|
||||
sendError(request.id, error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
* (no args) - Start the server (default)
|
||||
* start - Start the server
|
||||
* sandbox - Manage Docker sandbox environments
|
||||
* browser-use-mcp - Run Browser MCP stdio server
|
||||
* status - Show configuration and data locations
|
||||
* help - Show help information
|
||||
* version - Show version information
|
||||
@@ -154,12 +155,13 @@ Usage:
|
||||
cloudcli [command] [options]
|
||||
|
||||
Commands:
|
||||
start Start the CloudCLI server (default)
|
||||
sandbox Manage Docker sandbox environments
|
||||
status Show configuration and data locations
|
||||
update Update to the latest version
|
||||
help Show this help information
|
||||
version Show version information
|
||||
start Start the CloudCLI server (default)
|
||||
sandbox Manage Docker sandbox environments
|
||||
browser-use-mcp Run the Browser MCP stdio server
|
||||
status Show configuration and data locations
|
||||
update Update to the latest version
|
||||
help Show this help information
|
||||
version Show version information
|
||||
|
||||
Options:
|
||||
-p, --port <port> Set server port (default: 3001)
|
||||
@@ -605,6 +607,10 @@ async function startServer() {
|
||||
await import('./index.js');
|
||||
}
|
||||
|
||||
async function startBrowserUseMcp() {
|
||||
await import('./browser-use-mcp.js');
|
||||
}
|
||||
|
||||
// Parse CLI arguments
|
||||
function parseArgs(args) {
|
||||
const parsed = { command: 'start', options: {} };
|
||||
@@ -658,6 +664,9 @@ async function main() {
|
||||
case 'sandbox':
|
||||
await sandboxCommand(remainingArgs || []);
|
||||
break;
|
||||
case 'browser-use-mcp':
|
||||
await startBrowserUseMcp();
|
||||
break;
|
||||
case 'status':
|
||||
case 'info':
|
||||
showStatus();
|
||||
|
||||
260
server/computer-use-agent.ts
Normal file
260
server/computer-use-agent.ts
Normal file
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>): void {
|
||||
process.stdout.write(`${IPC_PREFIX} ${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
/** Per-session consent decisions, and resolvers awaiting a parent reply. */
|
||||
const sessionConsent = new Map<string, 'granted' | 'denied'>();
|
||||
const pendingConsent = new Map<string, Array<(allow: boolean) => 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<string, unknown>;
|
||||
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<boolean> {
|
||||
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<boolean>((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<string, unknown>;
|
||||
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<string, unknown>): Promise<Record<string, unknown>> {
|
||||
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();
|
||||
388
server/computer-use-mcp.ts
Normal file
388
server/computer-use-mcp.ts
Normal file
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
type ToolDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>;
|
||||
if (typeof record.screenshotDataUrl === 'string') {
|
||||
return record.screenshotDataUrl;
|
||||
}
|
||||
if (record.session && typeof record.session === 'object') {
|
||||
const session = record.session as Record<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<Record<string, unknown>> = [
|
||||
{ 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<string, unknown>) {
|
||||
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<string, unknown>;
|
||||
return callTool(name, args);
|
||||
}
|
||||
|
||||
if (message.method.startsWith('notifications/')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported method: ${message.method}`);
|
||||
}
|
||||
|
||||
function writeMessage(message: Record<string, unknown>) {
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
@@ -61,6 +61,12 @@ import userRoutes from './routes/user.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import pluginsRoutes from './routes/plugins.js';
|
||||
import providerRoutes from './modules/providers/provider.routes.js';
|
||||
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';
|
||||
@@ -72,6 +78,7 @@ const __dirname = getModuleDir(import.meta.url);
|
||||
// The server source runs from /server, while the compiled output runs from /dist-server/server.
|
||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||
const APP_ROOT = findAppRoot(__dirname);
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8'));
|
||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||
@@ -152,6 +159,7 @@ app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
version: packageJson.version,
|
||||
timestamp: new Date().toISOString(),
|
||||
installMode
|
||||
});
|
||||
@@ -193,6 +201,18 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
|
||||
// Plugins API Routes (protected)
|
||||
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||
|
||||
// Browser MCP bridge API (local token protected)
|
||||
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
|
||||
|
||||
// Browser 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);
|
||||
|
||||
// Unified provider MCP routes (protected)
|
||||
app.use('/api/providers', authenticateToken, providerRoutes);
|
||||
|
||||
@@ -1656,6 +1676,40 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DISPLAY_HOST = getConnectableHost(HOST);
|
||||
const VITE_PORT = process.env.VITE_PORT || 5173;
|
||||
const LOCAL_SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
|
||||
|
||||
async function writeLocalServerMarker() {
|
||||
const marker = {
|
||||
pid: process.pid,
|
||||
host: HOST,
|
||||
port: Number.parseInt(String(SERVER_PORT), 10),
|
||||
url: `http://${DISPLAY_HOST}:${SERVER_PORT}`,
|
||||
installMode,
|
||||
appRoot: APP_ROOT,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fsPromises.mkdir(path.dirname(LOCAL_SERVER_MARKER_PATH), { recursive: true });
|
||||
await fsPromises.writeFile(LOCAL_SERVER_MARKER_PATH, JSON.stringify(marker, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function removeLocalServerMarker() {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(LOCAL_SERVER_MARKER_PATH, 'utf8');
|
||||
const marker = JSON.parse(raw);
|
||||
if (marker.pid && marker.pid !== process.pid) return;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsPromises.unlink(LOCAL_SERVER_MARKER_PATH);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.warn('[WARN] Could not remove local server marker:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
@@ -1682,6 +1736,9 @@ async function startServer() {
|
||||
|
||||
server.listen(SERVER_PORT, HOST, async () => {
|
||||
const appInstallPath = APP_ROOT;
|
||||
await writeLocalServerMarker().catch((error) => {
|
||||
console.warn('[WARN] Could not write local server marker:', error.message);
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(c.dim('═'.repeat(63)));
|
||||
@@ -1704,12 +1761,31 @@ async function startServer() {
|
||||
|
||||
await closeSessionsWatcher();
|
||||
// Clean up plugin processes on shutdown
|
||||
const shutdownPlugins = async () => {
|
||||
await stopAllPlugins();
|
||||
const shutdownRuntimeServices = async () => {
|
||||
try {
|
||||
await browserUseService.stopAllSessions();
|
||||
} catch (err) {
|
||||
console.error('[Browser] 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) {
|
||||
console.error('[Plugins] Error stopping plugins during shutdown:', err?.message || err);
|
||||
}
|
||||
try {
|
||||
await removeLocalServerMarker();
|
||||
} catch (err) {
|
||||
console.error('[Local Server] Error removing server marker during shutdown:', err?.message || err);
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => void shutdownPlugins());
|
||||
process.on('SIGINT', () => void shutdownPlugins());
|
||||
process.on('SIGTERM', () => void shutdownRuntimeServices());
|
||||
process.on('SIGINT', () => void shutdownRuntimeServices());
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to start server:', error);
|
||||
process.exit(1);
|
||||
|
||||
120
server/modules/browser-use/browser-use-mcp.routes.ts
Normal file
120
server/modules/browser-use/browser-use-mcp.routes.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import express from 'express';
|
||||
|
||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function readBearerToken(header: unknown): string | null {
|
||||
if (typeof header !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = /^Bearer\s+(\S.*)$/i.exec(header.trim());
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const expected = browserUseService.getMcpToken();
|
||||
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
|
||||
if (!token || token !== expected) {
|
||||
res.status(401).json({ success: false, error: 'Invalid Browser MCP token.' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.post('/tools/:toolName', async (req, res) => {
|
||||
try {
|
||||
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
|
||||
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : '';
|
||||
const toolName = req.params.toolName;
|
||||
let result: unknown;
|
||||
|
||||
switch (toolName) {
|
||||
case 'browser_create_session':
|
||||
result = await browserUseService.createAgentSession({
|
||||
profileName: typeof input.profileName === 'string' ? input.profileName : null,
|
||||
});
|
||||
break;
|
||||
case 'browser_list_sessions':
|
||||
result = await browserUseService.listAgentSessions();
|
||||
break;
|
||||
case 'browser_snapshot':
|
||||
case 'browser_take_screenshot':
|
||||
result = await browserUseService.agentSnapshot(sessionId);
|
||||
break;
|
||||
case 'browser_navigate':
|
||||
result = await browserUseService.agentNavigate(sessionId, String(input.url || ''));
|
||||
break;
|
||||
case 'browser_click':
|
||||
result = await browserUseService.agentClick(sessionId, {
|
||||
selector: typeof input.selector === 'string' ? input.selector : undefined,
|
||||
text: typeof input.text === 'string' ? input.text : undefined,
|
||||
x: typeof input.x === 'number' ? input.x : undefined,
|
||||
y: typeof input.y === 'number' ? input.y : undefined,
|
||||
});
|
||||
break;
|
||||
case 'browser_type':
|
||||
result = await browserUseService.agentType(sessionId, {
|
||||
selector: typeof input.selector === 'string' ? input.selector : undefined,
|
||||
text: String(input.text || ''),
|
||||
submit: input.submit === true,
|
||||
});
|
||||
break;
|
||||
case 'browser_fill_form':
|
||||
result = await browserUseService.agentFillForm(
|
||||
sessionId,
|
||||
Array.isArray(input.fields)
|
||||
? input.fields.map((field) => {
|
||||
const record = field as Record<string, unknown>;
|
||||
return {
|
||||
selector: String(record.selector || ''),
|
||||
value: String(record.value || ''),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
);
|
||||
break;
|
||||
case 'browser_press_key':
|
||||
result = await browserUseService.agentPressKey(sessionId, String(input.key || ''));
|
||||
break;
|
||||
case 'browser_select_option':
|
||||
result = await browserUseService.agentSelectOption(
|
||||
sessionId,
|
||||
String(input.selector || ''),
|
||||
Array.isArray(input.values) ? input.values.filter((value): value is string => typeof value === 'string') : [],
|
||||
);
|
||||
break;
|
||||
case 'browser_wait_for':
|
||||
result = await browserUseService.agentWaitFor(sessionId, {
|
||||
text: typeof input.text === 'string' ? input.text : undefined,
|
||||
url: typeof input.url === 'string' ? input.url : undefined,
|
||||
timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
|
||||
});
|
||||
break;
|
||||
case 'browser_tabs':
|
||||
result = await browserUseService.agentTabs(sessionId, {
|
||||
action: input.action === 'new' || input.action === 'select' || input.action === 'close' || input.action === 'list'
|
||||
? input.action
|
||||
: undefined,
|
||||
index: typeof input.index === 'number' ? input.index : undefined,
|
||||
url: typeof input.url === 'string' ? input.url : undefined,
|
||||
});
|
||||
break;
|
||||
case 'browser_close_session':
|
||||
result = await browserUseService.agentStopSession(sessionId);
|
||||
break;
|
||||
default:
|
||||
res.status(404).json({ success: false, error: `Unknown Browser MCP tool "${toolName}".` });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Browser MCP tool failed.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
96
server/modules/browser-use/browser-use.routes.ts
Normal file
96
server/modules/browser-use/browser-use.routes.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import express from 'express';
|
||||
|
||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function readParam(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value[0] || '' : value || '';
|
||||
}
|
||||
|
||||
router.get('/status', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await browserUseService.getStatus() });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load Browser status.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/settings', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: { settings: await browserUseService.getSettings() } });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load Browser settings.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/settings', async (req, res) => {
|
||||
try {
|
||||
const settings = await browserUseService.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 Browser settings.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/runtime/install', async (_req, res) => {
|
||||
try {
|
||||
const result = await browserUseService.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 Browser runtime.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sessions/:sessionId/stop', async (req, res) => {
|
||||
try {
|
||||
const result = await browserUseService.stopSession(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 browser session.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const result = await browserUseService.deleteSession(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 browser session.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
836
server/modules/browser-use/browser-use.service.ts
Normal file
836
server/modules/browser-use/browser-use.service.ts
Normal file
@@ -0,0 +1,836 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { appConfigDb } from '@/modules/database/index.js';
|
||||
import { providerMcpService } from '@/modules/providers/index.js';
|
||||
import { getModuleDir } from '@/utils/runtime-paths.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
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_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
|
||||
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
||||
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
|
||||
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
|
||||
|
||||
type BrowserUseRuntime = 'cloud' | 'local';
|
||||
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
||||
|
||||
type BrowserUseSession = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
createdBy: 'agent';
|
||||
runtime: BrowserUseRuntime;
|
||||
status: BrowserUseSessionStatus;
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
screenshotDataUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastAction: string | null;
|
||||
message: string | null;
|
||||
profileName: string | null;
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
cursor: {
|
||||
x: number;
|
||||
y: number;
|
||||
actor: 'agent';
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
|
||||
|
||||
type RuntimeHandle = {
|
||||
browser?: any;
|
||||
context?: any;
|
||||
page?: any;
|
||||
};
|
||||
|
||||
type BrowserUseSettings = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type RuntimeReadiness = {
|
||||
playwright: any | null;
|
||||
playwrightInstalled: boolean;
|
||||
chromiumInstalled: boolean;
|
||||
chromiumExecutablePath: string | null;
|
||||
installInProgress: boolean;
|
||||
installMessage: string | null;
|
||||
};
|
||||
|
||||
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
|
||||
|
||||
const sessions = new Map<string, BrowserUseSession>();
|
||||
const handles = new Map<string, RuntimeHandle>();
|
||||
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
||||
let lastInstallMessage: string | null = null;
|
||||
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
|
||||
|
||||
const DEFAULT_SETTINGS: BrowserUseSettings = {
|
||||
enabled: false,
|
||||
};
|
||||
const AGENT_OWNER_ID = 'agent';
|
||||
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
|
||||
const MCP_SERVER_NAME = 'cloudcli-browser';
|
||||
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
|
||||
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
|
||||
|
||||
function getRuntime(): BrowserUseRuntime {
|
||||
return IS_PLATFORM ? 'cloud' : 'local';
|
||||
}
|
||||
|
||||
function readSettings(): BrowserUseSettings {
|
||||
try {
|
||||
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
|
||||
return {
|
||||
enabled: parsed.enabled === true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.warn('[Browser] Failed to read settings:', error?.message || error);
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
|
||||
const normalized = {
|
||||
enabled: settings.enabled === true,
|
||||
};
|
||||
|
||||
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getOrCreateMcpToken(): string {
|
||||
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const token = randomBytes(32).toString('hex');
|
||||
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
|
||||
if (!settings.enabled) {
|
||||
return 'Browser is disabled in settings.';
|
||||
}
|
||||
|
||||
if (!readiness.playwrightInstalled) {
|
||||
return 'Install Playwright and Chromium to use browser sessions.';
|
||||
}
|
||||
|
||||
if (!readiness.chromiumInstalled) {
|
||||
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
|
||||
}
|
||||
|
||||
return readiness.installMessage || 'Browser runtime is not ready.';
|
||||
}
|
||||
|
||||
function getPlaywright(): any | null {
|
||||
try {
|
||||
return require('playwright');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMcpCommand(): { command: string; args: string[] } {
|
||||
const serverDir = path.resolve(__dirname, '..', '..');
|
||||
const mcpScriptPath = path.join(serverDir, 'browser-use-mcp.js');
|
||||
if (fs.existsSync(mcpScriptPath)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [mcpScriptPath],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'cloudcli',
|
||||
args: ['browser-use-mcp'],
|
||||
};
|
||||
}
|
||||
|
||||
function getMcpApiUrl(): string {
|
||||
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
|
||||
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
|
||||
}
|
||||
|
||||
async function removeMcpServerFromAllProviders(name: string) {
|
||||
const results = await providerMcpService.removeMcpServerFromAllProviders({
|
||||
name,
|
||||
scope: 'user',
|
||||
});
|
||||
return results.map((result) => ({ ...result, name }));
|
||||
}
|
||||
|
||||
function normalizeProfileName(profileName?: string | null): string | null {
|
||||
const normalized = String(profileName || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.slice(0, 80);
|
||||
}
|
||||
|
||||
function getProfilePath(profileName: string): string {
|
||||
const safeName = profileName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'default';
|
||||
return path.join(PROFILE_ROOT, safeName);
|
||||
}
|
||||
|
||||
function probeRuntime(): RuntimeProbe {
|
||||
const playwright = getPlaywright();
|
||||
const readiness: RuntimeProbe = {
|
||||
playwright,
|
||||
playwrightInstalled: Boolean(playwright),
|
||||
chromiumInstalled: false,
|
||||
chromiumExecutablePath: null,
|
||||
};
|
||||
|
||||
if (!playwright) {
|
||||
return readiness;
|
||||
}
|
||||
|
||||
try {
|
||||
const executablePath = playwright.chromium.executablePath();
|
||||
readiness.chromiumExecutablePath = executablePath;
|
||||
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
|
||||
} catch {
|
||||
readiness.chromiumInstalled = false;
|
||||
}
|
||||
|
||||
return readiness;
|
||||
}
|
||||
|
||||
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
|
||||
const now = Date.now();
|
||||
const cachedProbe = runtimeProbeCache;
|
||||
const canUseCache = !options.force
|
||||
&& !installPromise
|
||||
&& cachedProbe
|
||||
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
|
||||
const probe = canUseCache ? cachedProbe.value : probeRuntime();
|
||||
|
||||
if (!canUseCache && !installPromise) {
|
||||
runtimeProbeCache = { value: probe, updatedAt: now };
|
||||
}
|
||||
|
||||
return {
|
||||
...probe,
|
||||
installInProgress: Boolean(installPromise),
|
||||
installMessage: lastInstallMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
|
||||
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
|
||||
10,
|
||||
);
|
||||
|
||||
function runCommand(command: string, args: string[]): Promise<void> {
|
||||
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[] = [];
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
finish(() => reject(new Error(
|
||||
`${command} ${args.join(' ')} timed out after ${INSTALL_COMMAND_TIMEOUT_MS}ms.`,
|
||||
)));
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.stderr.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.on('error', (error) => finish(() => reject(error)));
|
||||
child.on('close', (code) => finish(() => {
|
||||
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 (message.includes('sudo') && message.includes('password')) {
|
||||
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
|
||||
}
|
||||
return message || 'Failed to install Browser runtime.';
|
||||
}
|
||||
|
||||
async function installRuntime(): Promise<{ success: boolean; message: string }> {
|
||||
if (installPromise) {
|
||||
return installPromise;
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
runtimeProbeCache = null;
|
||||
installPromise = (async () => {
|
||||
try {
|
||||
lastInstallMessage = 'Installing Playwright package...';
|
||||
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
lastInstallMessage = 'Installing Chromium system dependencies...';
|
||||
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install-deps', 'chromium']);
|
||||
}
|
||||
|
||||
lastInstallMessage = 'Installing Chromium runtime...';
|
||||
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
|
||||
|
||||
lastInstallMessage = 'Browser runtime installed.';
|
||||
return { success: true, message: lastInstallMessage };
|
||||
} catch (error) {
|
||||
lastInstallMessage = formatInstallError(error);
|
||||
return { success: false, message: lastInstallMessage };
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await installPromise;
|
||||
} finally {
|
||||
installPromise = null;
|
||||
runtimeProbeCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('URL is required.');
|
||||
}
|
||||
|
||||
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
const parsed = new URL(withProtocol);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new Error('Only http and https URLs are supported.');
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
|
||||
const { ownerId: _ownerId, ...publicFields } = session;
|
||||
return publicFields;
|
||||
}
|
||||
|
||||
function ownerSessions(ownerId: string): BrowserUseSession[] {
|
||||
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
||||
}
|
||||
|
||||
async function closeHandle(sessionId: string): Promise<void> {
|
||||
const handle = handles.get(sessionId);
|
||||
handles.delete(sessionId);
|
||||
await handle?.context?.close?.().catch(() => undefined);
|
||||
await handle?.browser?.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
||||
await Promise.all([...sessions.values()].map(async (session) => {
|
||||
if (session.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedAt = Date.parse(session.updatedAt);
|
||||
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
await closeHandle(session.id);
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date(now).toISOString();
|
||||
session.lastAction = 'expire';
|
||||
session.message = 'Browser session expired after inactivity.';
|
||||
}));
|
||||
}
|
||||
|
||||
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
|
||||
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
|
||||
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
|
||||
session.title = await page.title().catch(() => null);
|
||||
session.url = page.url() || session.url;
|
||||
session.viewport = page.viewportSize?.() || session.viewport;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
async function getActionPoint(page: any, input: { selector?: string; text?: string; x?: number; y?: number }) {
|
||||
if (typeof input.x === 'number' && typeof input.y === 'number') {
|
||||
return { x: input.x, y: input.y };
|
||||
}
|
||||
|
||||
const locator = input.selector
|
||||
? page.locator(input.selector).first()
|
||||
: input.text
|
||||
? page.getByText(input.text, { exact: false }).first()
|
||||
: null;
|
||||
|
||||
if (!locator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const box = await locator.boundingBox().catch(() => null);
|
||||
if (!box) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(box.x + box.width / 2),
|
||||
y: Math.round(box.y + box.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
export const browserUseService = {
|
||||
async getSettings() {
|
||||
return readSettings();
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<BrowserUseSettings>) {
|
||||
const current = readSettings();
|
||||
const nextSettings = {
|
||||
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
|
||||
};
|
||||
|
||||
const next = writeSettings(nextSettings);
|
||||
if (next.enabled) {
|
||||
await this.registerAgentMcp();
|
||||
} else if (current.enabled) {
|
||||
await this.unregisterAgentMcp();
|
||||
await this.stopAllSessions();
|
||||
}
|
||||
return next;
|
||||
},
|
||||
|
||||
async getStatus() {
|
||||
const settings = readSettings();
|
||||
const readiness = getRuntimeReadiness();
|
||||
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
|
||||
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
runtime: getRuntime(),
|
||||
available,
|
||||
playwrightInstalled: readiness.playwrightInstalled,
|
||||
chromiumInstalled: readiness.chromiumInstalled,
|
||||
installInProgress: readiness.installInProgress,
|
||||
sessionCount: sessions.size,
|
||||
message: available
|
||||
? 'Browser runtime is available.'
|
||||
: getSetupMessage(settings, readiness),
|
||||
};
|
||||
},
|
||||
|
||||
async registerAgentMcp() {
|
||||
const { command, args } = getMcpCommand();
|
||||
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
|
||||
const results = await providerMcpService.addMcpServerToAllProviders({
|
||||
name: MCP_SERVER_NAME,
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command,
|
||||
args,
|
||||
env: {
|
||||
CLOUDCLI_BROWSER_USE_MCP_TOKEN: getOrCreateMcpToken(),
|
||||
CLOUDCLI_BROWSER_USE_API_URL: getMcpApiUrl(),
|
||||
},
|
||||
});
|
||||
return { name: MCP_SERVER_NAME, command, args, results };
|
||||
},
|
||||
|
||||
getMcpToken() {
|
||||
return getOrCreateMcpToken();
|
||||
},
|
||||
|
||||
async unregisterAgentMcp() {
|
||||
const results = (await Promise.all(
|
||||
[MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
|
||||
)).flat();
|
||||
return { name: MCP_SERVER_NAME, results };
|
||||
},
|
||||
|
||||
async installRuntime() {
|
||||
const result = await installRuntime();
|
||||
return {
|
||||
...result,
|
||||
status: await this.getStatus(),
|
||||
};
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
await expireStaleSessions();
|
||||
return [...sessions.values()]
|
||||
.filter((session) => session.ownerId === AGENT_OWNER_ID)
|
||||
.map(publicSession);
|
||||
},
|
||||
|
||||
async createAgentSession(options?: { profileName?: string | null }) {
|
||||
const settings = readSettings();
|
||||
if (!settings.enabled) {
|
||||
throw new Error('Browser agent tools are disabled.');
|
||||
}
|
||||
|
||||
await expireStaleSessions();
|
||||
const profileName = normalizeProfileName(options?.profileName);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const session: BrowserUseSession = {
|
||||
id: randomUUID(),
|
||||
ownerId: AGENT_OWNER_ID,
|
||||
createdBy: 'agent',
|
||||
runtime: getRuntime(),
|
||||
status: 'unavailable',
|
||||
url: null,
|
||||
title: null,
|
||||
screenshotDataUrl: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastAction: 'create',
|
||||
message: null,
|
||||
profileName,
|
||||
viewport: { width: 1440, height: 900 },
|
||||
cursor: null,
|
||||
};
|
||||
|
||||
const activeOwnerSessions = ownerSessions(AGENT_OWNER_ID).filter((item) => item.status === 'ready');
|
||||
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
|
||||
throw new Error(`Browser is limited to ${MAX_SESSIONS_PER_OWNER} active agent sessions.`);
|
||||
}
|
||||
|
||||
const readiness = getRuntimeReadiness();
|
||||
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
|
||||
session.message = getSetupMessage(settings, readiness);
|
||||
sessions.set(session.id, session);
|
||||
return publicSession(session);
|
||||
}
|
||||
|
||||
let browser: any | undefined;
|
||||
let context: any | undefined;
|
||||
let page: any;
|
||||
const launchOptions = {
|
||||
headless: true,
|
||||
args: ['--disable-dev-shm-usage'],
|
||||
};
|
||||
const contextOptions = {
|
||||
viewport: { width: 1440, height: 900 },
|
||||
serviceWorkers: 'block',
|
||||
};
|
||||
|
||||
if (profileName) {
|
||||
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
|
||||
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
|
||||
...launchOptions,
|
||||
...contextOptions,
|
||||
});
|
||||
page = context.pages()[0] || await context.newPage();
|
||||
} else {
|
||||
browser = await readiness.playwright.chromium.launch(launchOptions);
|
||||
context = await browser.newContext(contextOptions);
|
||||
page = await context.newPage();
|
||||
}
|
||||
session.status = 'ready';
|
||||
session.message = 'Browser session is ready.';
|
||||
sessions.set(session.id, session);
|
||||
handles.set(session.id, { browser, context, page });
|
||||
await captureSession(session, page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async listAgentSessions() {
|
||||
const settings = readSettings();
|
||||
if (!settings.enabled) {
|
||||
return [];
|
||||
}
|
||||
await expireStaleSessions();
|
||||
return [...sessions.values()]
|
||||
.filter((session) => session.ownerId === AGENT_OWNER_ID)
|
||||
.map(publicSession);
|
||||
},
|
||||
|
||||
async getAgentSession(sessionId: string) {
|
||||
const settings = readSettings();
|
||||
if (!settings.enabled) {
|
||||
throw new Error('Browser agent tools are disabled.');
|
||||
}
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||
throw new Error('Browser session not found.');
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
async agentNavigate(sessionId: string, rawUrl: string) {
|
||||
await this.getAgentSession(sessionId);
|
||||
await expireStaleSessions();
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||
throw new Error('Browser session not found.');
|
||||
}
|
||||
|
||||
if (session.status !== 'ready') {
|
||||
throw new Error(session.message || 'Browser session is not available.');
|
||||
}
|
||||
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
|
||||
const url = normalizeUrl(rawUrl);
|
||||
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
session.lastAction = `navigate:${url}`;
|
||||
session.cursor = null;
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentSnapshot(sessionId: string) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
await captureSession(session, handle.page);
|
||||
const text = await handle.page.locator('body').innerText({ timeout: 5_000 }).catch(() => '');
|
||||
return {
|
||||
session: publicSession(session),
|
||||
text: text.slice(0, 30_000),
|
||||
};
|
||||
},
|
||||
|
||||
async agentClick(sessionId: string, input: { selector?: string; text?: string; x?: number; y?: number }) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
const point = await getActionPoint(handle.page, input);
|
||||
|
||||
if (input.selector) {
|
||||
await handle.page.locator(input.selector).first().click({ timeout: 10_000 });
|
||||
} else if (input.text) {
|
||||
await handle.page.getByText(input.text, { exact: false }).first().click({ timeout: 10_000 });
|
||||
} else if (typeof input.x === 'number' && typeof input.y === 'number') {
|
||||
await handle.page.mouse.click(input.x, input.y);
|
||||
} else {
|
||||
throw new Error('Provide selector, text, or x/y coordinates.');
|
||||
}
|
||||
|
||||
session.lastAction = 'click';
|
||||
session.cursor = point ? { ...point, actor: 'agent' } : null;
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentType(sessionId: string, input: { selector?: string; text: string; submit?: boolean }) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
|
||||
if (input.selector) {
|
||||
await handle.page.locator(input.selector).first().fill(input.text, { timeout: 10_000 });
|
||||
session.cursor = await getActionPoint(handle.page, input).then((point) => (
|
||||
point ? { ...point, actor: 'agent' as const } : null
|
||||
));
|
||||
} else {
|
||||
await handle.page.keyboard.type(input.text);
|
||||
}
|
||||
if (input.submit) {
|
||||
await handle.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
session.lastAction = 'type';
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentFillForm(sessionId: string, fields: Array<{ selector: string; value: string }>) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
for (const field of fields) {
|
||||
await handle.page.locator(field.selector).first().fill(field.value, { timeout: 10_000 });
|
||||
}
|
||||
session.lastAction = 'fill_form';
|
||||
if (fields[0]) {
|
||||
session.cursor = await getActionPoint(handle.page, { selector: fields[0].selector }).then((point) => (
|
||||
point ? { ...point, actor: 'agent' as const } : null
|
||||
));
|
||||
}
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentPressKey(sessionId: string, key: string) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
await handle.page.keyboard.press(key);
|
||||
session.lastAction = `press_key:${key}`;
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentSelectOption(sessionId: string, selector: string, values: string[]) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
await handle.page.locator(selector).first().selectOption(values, { timeout: 10_000 });
|
||||
session.lastAction = 'select_option';
|
||||
session.cursor = await getActionPoint(handle.page, { selector }).then((point) => (
|
||||
point ? { ...point, actor: 'agent' as const } : null
|
||||
));
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentWaitFor(sessionId: string, input: { text?: string; url?: string; timeoutMs?: number }) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
const timeout = Math.max(250, Math.min(input.timeoutMs || 5_000, 30_000));
|
||||
if (input.text) {
|
||||
await handle.page.getByText(input.text, { exact: false }).first().waitFor({ timeout });
|
||||
} else if (input.url) {
|
||||
await handle.page.waitForURL(input.url, { timeout });
|
||||
} else {
|
||||
await handle.page.waitForTimeout(timeout);
|
||||
}
|
||||
session.lastAction = 'wait_for';
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentTabs(sessionId: string, input: { action?: 'list' | 'new' | 'select' | 'close'; index?: number; url?: string }) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.context || !handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
const action = input.action || 'list';
|
||||
if (action === 'new') {
|
||||
const page = await handle.context.newPage();
|
||||
handles.set(sessionId, { ...handle, page });
|
||||
if (input.url) {
|
||||
await this.agentNavigate(sessionId, input.url);
|
||||
}
|
||||
} else if (action === 'select') {
|
||||
const page = handle.context.pages()[input.index || 0];
|
||||
if (!page) {
|
||||
throw new Error('Tab not found.');
|
||||
}
|
||||
handles.set(sessionId, { ...handle, page });
|
||||
} else if (action === 'close') {
|
||||
const pages = handle.context.pages();
|
||||
const page = pages[input.index ?? pages.indexOf(handle.page)];
|
||||
if (!page) {
|
||||
throw new Error('Tab not found.');
|
||||
}
|
||||
await page.close();
|
||||
handles.set(sessionId, { ...handle, page: handle.context.pages()[0] || await handle.context.newPage() });
|
||||
}
|
||||
const updatedHandle = handles.get(sessionId);
|
||||
await captureSession(session, updatedHandle?.page || handle.page);
|
||||
return {
|
||||
session: publicSession(session),
|
||||
tabs: handle.context.pages().map((page: any, index: number) => ({
|
||||
index,
|
||||
url: page.url(),
|
||||
active: page === (updatedHandle?.page || handle.page),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async stopSession(sessionId: string) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||
return { stopped: false };
|
||||
}
|
||||
|
||||
await closeHandle(sessionId);
|
||||
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
session.lastAction = 'stop';
|
||||
session.message = 'Browser session stopped. Create a new session to continue browsing.';
|
||||
return { stopped: true, session: publicSession(session) };
|
||||
},
|
||||
|
||||
async deleteSession(sessionId: string) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||
return { deleted: false };
|
||||
}
|
||||
|
||||
await closeHandle(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
return { deleted: true, sessionId };
|
||||
},
|
||||
|
||||
async agentStopSession(sessionId: string) {
|
||||
await this.getAgentSession(sessionId);
|
||||
return this.stopSession(sessionId);
|
||||
},
|
||||
|
||||
async stopAllSessions() {
|
||||
await Promise.all([...sessions.keys()].map(async (sessionId) => {
|
||||
await closeHandle(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
session.lastAction = 'shutdown';
|
||||
session.message = 'Browser session stopped during server shutdown.';
|
||||
}
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
process.once('beforeExit', () => {
|
||||
void browserUseService.stopAllSessions();
|
||||
});
|
||||
10
server/modules/browser-use/tests/browser-use.service.test.ts
Normal file
10
server/modules/browser-use/tests/browser-use.service.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||
|
||||
test('browser monitor list starts empty without agent sessions', async () => {
|
||||
const sessions = await browserUseService.listSessions();
|
||||
|
||||
assert.deepEqual(sessions, []);
|
||||
});
|
||||
242
server/modules/computer-use/computer-executor.ts
Normal file
242
server/modules/computer-use/computer-executor.ts
Normal file
@@ -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<DisplaySize> {
|
||||
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<Point> {
|
||||
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<string, string> = {
|
||||
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<Point> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const nut = await this.configure();
|
||||
await nut.keyboard.type(text);
|
||||
},
|
||||
|
||||
async pressChord(chord: string): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
118
server/modules/computer-use/computer-use-mcp.routes.ts
Normal file
118
server/modules/computer-use/computer-use-mcp.routes.ts
Normal file
@@ -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<string, unknown>): { 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<string, unknown>;
|
||||
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;
|
||||
215
server/modules/computer-use/computer-use.routes.ts
Normal file
215
server/modules/computer-use/computer-use.routes.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import express from 'express';
|
||||
|
||||
import { computerUseService } from '@/modules/computer-use/computer-use.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
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.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;
|
||||
883
server/modules/computer-use/computer-use.service.ts
Normal file
883
server/modules/computer-use/computer-use.service.ts
Normal file
@@ -0,0 +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<ComputerUseSession, 'ownerId'>;
|
||||
|
||||
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<string, ComputerUseSession>();
|
||||
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<ComputerUseSettings>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Point> {
|
||||
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 = {
|
||||
async getSettings() {
|
||||
return readSettings();
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<ComputerUseSettings>) {
|
||||
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 {
|
||||
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<ComputerUseSession> {
|
||||
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();
|
||||
});
|
||||
129
server/modules/computer-use/desktop-agent-relay.service.ts
Normal file
129
server/modules/computer-use/desktop-agent-relay.service.ts
Normal file
@@ -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<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type ConnectedAgent = {
|
||||
ws: WebSocket;
|
||||
label: string;
|
||||
registeredAt: string;
|
||||
};
|
||||
|
||||
type RelayLifecycleHooks = {
|
||||
onFirstConnect?: () => void | Promise<void>;
|
||||
onLastDisconnect?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
const agents = new Map<WebSocket, ConnectedAgent>();
|
||||
const pending = new Map<string, PendingRelay>();
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<unknown>((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.'));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
2
server/modules/computer-use/index.ts
Normal file
2
server/modules/computer-use/index.ts
Normal file
@@ -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';
|
||||
@@ -1,5 +1,6 @@
|
||||
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
|
||||
export { providerSkillsService } from './services/skills.service.js';
|
||||
export { providerMcpService } from './services/mcp.service.js';
|
||||
|
||||
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
|
||||
@@ -6,7 +6,6 @@ import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from '@/shared/codex-cli-runtime.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type CodexCredentialsStatus = {
|
||||
@@ -22,12 +21,8 @@ export class CodexProviderAuth implements IProviderAuth {
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
try {
|
||||
const result = spawn.sync(resolveCodexExecutablePath(), ['--version'], {
|
||||
env: createCodexRuntimeEnv(),
|
||||
stdio: 'ignore',
|
||||
timeout: 5000,
|
||||
});
|
||||
return !result.error && result.status === 0;
|
||||
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -80,4 +80,30 @@ export const providerMcpService = {
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes one MCP server from every provider. Mirrors `addMcpServerToAllProviders`
|
||||
* by iterating the live provider registry, so callers stay in sync with which
|
||||
* providers exist instead of maintaining their own provider list.
|
||||
*/
|
||||
async removeMcpServerFromAllProviders(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<Array<{ provider: LLMProvider; removed: boolean; error?: string }>> {
|
||||
const results: Array<{ provider: LLMProvider; removed: boolean; error?: string }> = [];
|
||||
const providers = providerRegistry.listProviders();
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const result = await provider.mcp.removeServer(input);
|
||||
results.push({ provider: provider.id, removed: result.removed });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
provider: provider.id,
|
||||
removed: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>).result,
|
||||
typeof (data as Record<string, unknown>).error === 'string'
|
||||
? ((data as Record<string, unknown>).error as string)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[INFO] Desktop agent websocket disconnected:', label);
|
||||
});
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import path from 'node:path';
|
||||
import pty, { type IPty } from 'node-pty';
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
|
||||
import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js';
|
||||
import { getCodexShellCommand } from '@/shared/codex-cli-runtime.js';
|
||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
type ShellIncomingMessage = {
|
||||
@@ -139,14 +137,13 @@ function buildShellCommand(
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
const codexCommand = getCodexShellCommand();
|
||||
if (resumeSessionId) {
|
||||
if (os.platform() === 'win32') {
|
||||
return `${codexCommand} resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { ${codexCommand} }`;
|
||||
return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
}
|
||||
return `${codexCommand} resume "${resumeSessionId}" || ${codexCommand}`;
|
||||
return `codex resume "${resumeSessionId}" || codex`;
|
||||
}
|
||||
return codexCommand;
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
@@ -287,10 +284,6 @@ export function handleShellConnection(
|
||||
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||
const termCols = readNumber(data.cols, 80);
|
||||
const termRows = readNumber(data.rows, 24);
|
||||
// Plain terminals inherit the server process PATH, which npm can prefix with
|
||||
// /opt/claudecodeui/node_modules/.bin. Put user CLI bins first so shell
|
||||
// commands resolve like the user's login shell instead of the app install.
|
||||
const ptyEnv = createUserShellRuntimeEnv();
|
||||
|
||||
shellProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
@@ -298,7 +291,7 @@ export function handleShellConnection(
|
||||
rows: termRows,
|
||||
cwd: resolvedProjectPath,
|
||||
env: {
|
||||
...ptyEnv,
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,12 +14,10 @@
|
||||
*/
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from './shared/codex-cli-runtime.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Track active sessions
|
||||
@@ -250,11 +248,8 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
const abortController = new AbortController();
|
||||
|
||||
try {
|
||||
// Initialize Codex SDK against the same user/global Codex runtime used by shell terminals.
|
||||
codex = new Codex({
|
||||
codexPathOverride: resolveCodexExecutablePath(),
|
||||
env: createCodexRuntimeEnv(),
|
||||
});
|
||||
// Initialize Codex SDK
|
||||
codex = new Codex();
|
||||
|
||||
// Thread options with sandbox and approval settings
|
||||
const threadOptions = {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js';
|
||||
|
||||
const POSIX_PATH_DELIMITER = ':';
|
||||
|
||||
test('createUserShellRuntimeEnv prepends user CLI bins before app-local npm bins', () => {
|
||||
const runtimeEnv = createUserShellRuntimeEnv(
|
||||
{
|
||||
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||
},
|
||||
{
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
runtimeEnv.PATH,
|
||||
[
|
||||
'/home/devuser/.npm-global/bin',
|
||||
'/home/devuser/.local/bin',
|
||||
'/opt/claudecodeui/node_modules/.bin',
|
||||
'/usr/bin',
|
||||
].join(POSIX_PATH_DELIMITER)
|
||||
);
|
||||
});
|
||||
|
||||
test('createUserShellRuntimeEnv does not duplicate existing user CLI path entries', () => {
|
||||
const runtimeEnv = createUserShellRuntimeEnv(
|
||||
{
|
||||
PATH: [
|
||||
'/home/devuser/.npm-global/bin',
|
||||
'/opt/claudecodeui/node_modules/.bin',
|
||||
'/usr/bin',
|
||||
].join(POSIX_PATH_DELIMITER),
|
||||
},
|
||||
{
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
runtimeEnv.PATH,
|
||||
[
|
||||
'/home/devuser/.local/bin',
|
||||
'/home/devuser/.npm-global/bin',
|
||||
'/opt/claudecodeui/node_modules/.bin',
|
||||
'/usr/bin',
|
||||
].join(POSIX_PATH_DELIMITER)
|
||||
);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export type EnvRecord = Record<string, string | undefined>;
|
||||
|
||||
export type CliRuntimeEnvDependencies = {
|
||||
env?: EnvRecord;
|
||||
homedir?: typeof os.homedir;
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path implementation that matches the target runtime platform.
|
||||
*/
|
||||
function getPathApi(platform: NodeJS.Platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PATH delimiter used by the target runtime platform.
|
||||
*/
|
||||
function getPathDelimiter(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? ';' : ':';
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the environment key that represents PATH, preserving Windows case variants.
|
||||
*/
|
||||
function getPathEnvKey(env: EnvRecord, platform: NodeJS.Platform): string {
|
||||
if (platform !== 'win32') {
|
||||
return 'PATH';
|
||||
}
|
||||
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates non-empty string values while preserving their original order.
|
||||
*/
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a process-style environment object into a string-only environment for child processes.
|
||||
*/
|
||||
export function toStringEnv(env: EnvRecord): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds user/global CLI bin directories that should rank ahead of app-local npm bins.
|
||||
*/
|
||||
export function getPreferredUserCliBinDirectories(
|
||||
dependencies: Required<CliRuntimeEnvDependencies>
|
||||
): string[] {
|
||||
const pathApi = getPathApi(dependencies.platform);
|
||||
const homeDir = dependencies.homedir();
|
||||
const candidates: string[] = [];
|
||||
const npmPrefix = dependencies.env.NPM_CONFIG_PREFIX?.trim();
|
||||
|
||||
if (npmPrefix) {
|
||||
candidates.push(pathApi.join(npmPrefix, dependencies.platform === 'win32' ? '' : 'bin'));
|
||||
}
|
||||
|
||||
if (dependencies.platform === 'win32') {
|
||||
const appData = dependencies.env.APPDATA?.trim();
|
||||
if (appData) {
|
||||
candidates.push(appData, pathApi.join(appData, 'npm'));
|
||||
}
|
||||
candidates.push(pathApi.join(homeDir, 'AppData', 'Roaming', 'npm'));
|
||||
} else {
|
||||
candidates.push(
|
||||
pathApi.join(homeDir, '.npm-global', 'bin'),
|
||||
pathApi.join(homeDir, '.local', 'bin'),
|
||||
);
|
||||
}
|
||||
|
||||
return unique(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a provider-neutral shell environment that prefers user/global CLI bins over app-local bins.
|
||||
*/
|
||||
export function createUserShellRuntimeEnv(
|
||||
env: EnvRecord = process.env,
|
||||
dependencies: CliRuntimeEnvDependencies = {}
|
||||
): Record<string, string> {
|
||||
const deps: Required<CliRuntimeEnvDependencies> = {
|
||||
env,
|
||||
homedir: dependencies.homedir ?? os.homedir,
|
||||
platform: dependencies.platform ?? process.platform,
|
||||
};
|
||||
const pathKey = getPathEnvKey(env, deps.platform);
|
||||
const delimiter = getPathDelimiter(deps.platform);
|
||||
const currentPathEntries = (env[pathKey] ?? '').split(delimiter).filter(Boolean);
|
||||
const preferredEntries = getPreferredUserCliBinDirectories(deps).filter(
|
||||
(entry) => !currentPathEntries.includes(entry)
|
||||
);
|
||||
const nextEnv: EnvRecord = { ...env };
|
||||
|
||||
if (preferredEntries.length > 0) {
|
||||
nextEnv[pathKey] = [...preferredEntries, ...currentPathEntries].join(delimiter);
|
||||
}
|
||||
|
||||
return toStringEnv(nextEnv);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createCodexRuntimeEnv,
|
||||
getCodexShellCommand,
|
||||
resolveCodexExecutablePath,
|
||||
type ResolveCodexExecutablePathDependencies,
|
||||
} from '@/shared/codex-cli-runtime.js';
|
||||
|
||||
const POSIX_PATH_DELIMITER = ':';
|
||||
|
||||
function createExistsSync(paths: string[]): ResolveCodexExecutablePathDependencies['existsSync'] {
|
||||
const existing = new Set(paths);
|
||||
return ((candidate: string) => existing.has(candidate)) as ResolveCodexExecutablePathDependencies['existsSync'];
|
||||
}
|
||||
|
||||
test('resolveCodexExecutablePath prefers the user npm-global install over app-local PATH entries', () => {
|
||||
const globalCodexPath = '/home/devuser/.npm-global/bin/codex';
|
||||
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
|
||||
|
||||
const resolved = resolveCodexExecutablePath(undefined, {
|
||||
env: {
|
||||
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||
},
|
||||
existsSync: createExistsSync([globalCodexPath, localCodexPath]),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(resolved, globalCodexPath);
|
||||
});
|
||||
|
||||
test('resolveCodexExecutablePath skips node_modules bin when a non-local PATH codex exists', () => {
|
||||
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
|
||||
const pathCodexPath = '/usr/local/bin/codex';
|
||||
|
||||
const resolved = resolveCodexExecutablePath(undefined, {
|
||||
env: {
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/local/bin`,
|
||||
},
|
||||
existsSync: createExistsSync([localCodexPath, pathCodexPath]),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(resolved, pathCodexPath);
|
||||
});
|
||||
|
||||
test('resolveCodexExecutablePath falls back to app-local codex when it is the only install', () => {
|
||||
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
|
||||
|
||||
const resolved = resolveCodexExecutablePath(undefined, {
|
||||
env: {
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||
},
|
||||
existsSync: createExistsSync([localCodexPath]),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(resolved, localCodexPath);
|
||||
});
|
||||
|
||||
test('createCodexRuntimeEnv prepends the selected Codex directory to PATH', () => {
|
||||
const runtimeEnv = createCodexRuntimeEnv(
|
||||
{
|
||||
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||
},
|
||||
{
|
||||
existsSync: createExistsSync(['/home/devuser/.npm-global/bin/codex']),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
runtimeEnv.PATH,
|
||||
`/home/devuser/.npm-global/bin${POSIX_PATH_DELIMITER}/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`
|
||||
);
|
||||
});
|
||||
|
||||
test('getCodexShellCommand quotes explicit executable paths for shell launches', () => {
|
||||
const command = getCodexShellCommand({
|
||||
env: {
|
||||
CODEX_CLI_PATH: "/home/devuser/bin/codex with space",
|
||||
},
|
||||
existsSync: createExistsSync([]),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(command, "'/home/devuser/bin/codex with space'");
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_CODEX_COMMAND = 'codex';
|
||||
const CODEX_CLI_PATH_ENV_KEYS = ['CODEX_CLI_PATH', 'CLOUDCLI_CODEX_CLI_PATH'] as const;
|
||||
|
||||
/**
|
||||
* Codex runtime precedence:
|
||||
* 1. Explicit CODEX_CLI_PATH or CLOUDCLI_CODEX_CLI_PATH.
|
||||
* 2. User/global installs such as NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, or ~/.local/bin.
|
||||
* 3. Non-local PATH entries.
|
||||
* 4. App-local node_modules/.bin as the final fallback.
|
||||
*/
|
||||
type EnvRecord = Record<string, string | undefined>;
|
||||
|
||||
export type ResolveCodexExecutablePathDependencies = {
|
||||
env?: EnvRecord;
|
||||
existsSync?: typeof fs.existsSync;
|
||||
homedir?: typeof os.homedir;
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path implementation that matches the target runtime platform.
|
||||
*/
|
||||
function getPathApi(platform: NodeJS.Platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PATH delimiter used by the target runtime platform.
|
||||
*/
|
||||
function getPathDelimiter(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? ';' : ':';
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one matching pair of surrounding quotes from a configured path value.
|
||||
*/
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a command value looks like a filesystem path instead of a bare command name.
|
||||
*/
|
||||
function isPathLike(value: string, platform: NodeJS.Platform): boolean {
|
||||
return value.includes('/') || value.includes('\\') || getPathApi(platform).isAbsolute(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the environment key that represents PATH, preserving Windows case variants.
|
||||
*/
|
||||
function getPathEnvKey(env: EnvRecord, platform: NodeJS.Platform): string {
|
||||
if (platform !== 'win32') {
|
||||
return 'PATH';
|
||||
}
|
||||
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Codex executable filenames to probe for the target platform.
|
||||
*/
|
||||
function getExecutableNames(platform: NodeJS.Platform): string[] {
|
||||
if (platform !== 'win32') {
|
||||
return [DEFAULT_CODEX_COMMAND];
|
||||
}
|
||||
|
||||
return ['codex.exe', 'codex.cmd', 'codex.bat', 'codex.ps1', DEFAULT_CODEX_COMMAND];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates non-empty string values while preserving their original order.
|
||||
*/
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects app-local npm bin directories so they can be treated as a fallback.
|
||||
*/
|
||||
function isNodeModulesBinPath(directoryPath: string, platform: NodeJS.Platform): boolean {
|
||||
const pathApi = getPathApi(platform);
|
||||
const normalized = directoryPath.replace(/[\\/]+$/, '');
|
||||
return (
|
||||
pathApi.basename(normalized).toLowerCase() === '.bin' &&
|
||||
pathApi.basename(pathApi.dirname(normalized)).toLowerCase() === 'node_modules'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the first Codex executable that exists inside one directory.
|
||||
*/
|
||||
function resolveExecutableInDirectory(
|
||||
directoryPath: string,
|
||||
deps: Required<ResolveCodexExecutablePathDependencies>
|
||||
): string | null {
|
||||
const pathApi = getPathApi(deps.platform);
|
||||
for (const executableName of getExecutableNames(deps.platform)) {
|
||||
const candidate = pathApi.join(directoryPath, executableName);
|
||||
if (deps.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an explicit Codex executable override from supported environment variables.
|
||||
*/
|
||||
function getConfiguredCodexPath(env: EnvRecord): string | null {
|
||||
for (const key of CODEX_CLI_PATH_ENV_KEYS) {
|
||||
const value = env[key]?.trim();
|
||||
if (value) {
|
||||
return stripWrappingQuotes(value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds user/global Codex install candidates that rank ahead of PATH and app-local installs:
|
||||
* NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, ~/.local/bin, or the Windows npm user folders.
|
||||
*/
|
||||
function getPreferredUserInstallCandidates(
|
||||
deps: Required<ResolveCodexExecutablePathDependencies>
|
||||
): string[] {
|
||||
const pathApi = getPathApi(deps.platform);
|
||||
const homeDir = deps.homedir();
|
||||
const candidates: string[] = [];
|
||||
const npmPrefix = deps.env.NPM_CONFIG_PREFIX?.trim();
|
||||
|
||||
if (npmPrefix) {
|
||||
candidates.push(pathApi.join(npmPrefix, deps.platform === 'win32' ? '' : 'bin'));
|
||||
}
|
||||
|
||||
if (deps.platform === 'win32') {
|
||||
const appData = deps.env.APPDATA?.trim();
|
||||
if (appData) {
|
||||
candidates.push(appData, pathApi.join(appData, 'npm'));
|
||||
}
|
||||
candidates.push(pathApi.join(homeDir, 'AppData', 'Roaming', 'npm'));
|
||||
} else {
|
||||
candidates.push(
|
||||
pathApi.join(homeDir, '.npm-global', 'bin'),
|
||||
pathApi.join(homeDir, '.local', 'bin'),
|
||||
);
|
||||
}
|
||||
|
||||
return unique(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches PATH for Codex after user/global candidates, keeping node_modules/.bin as the last fallback.
|
||||
*/
|
||||
function resolveFromPath(
|
||||
deps: Required<ResolveCodexExecutablePathDependencies>
|
||||
): string | null {
|
||||
const pathKey = getPathEnvKey(deps.env, deps.platform);
|
||||
const pathValue = deps.env[pathKey] ?? '';
|
||||
const directories = unique(pathValue.split(getPathDelimiter(deps.platform)).filter(Boolean));
|
||||
let nodeModulesFallback: string | null = null;
|
||||
|
||||
for (const directory of directories) {
|
||||
const candidate = resolveExecutableInDirectory(directory, deps);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNodeModulesBinPath(directory, deps.platform)) {
|
||||
nodeModulesFallback ??= candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return nodeModulesFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a process-style environment object into a string-only environment for child processes.
|
||||
*/
|
||||
function toStringEnv(env: EnvRecord): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Codex executable path for all backend entry points in this order:
|
||||
* explicit CODEX_CLI_PATH/CLOUDCLI_CODEX_CLI_PATH, user/global installs, non-local PATH, app-local PATH.
|
||||
*/
|
||||
export function resolveCodexExecutablePath(
|
||||
configuredPath: string | undefined = undefined,
|
||||
dependencies: ResolveCodexExecutablePathDependencies = {}
|
||||
): string {
|
||||
const deps: Required<ResolveCodexExecutablePathDependencies> = {
|
||||
env: dependencies.env ?? process.env,
|
||||
existsSync: dependencies.existsSync ?? fs.existsSync,
|
||||
homedir: dependencies.homedir ?? os.homedir,
|
||||
platform: dependencies.platform ?? process.platform,
|
||||
};
|
||||
|
||||
const normalizedConfiguredPath = stripWrappingQuotes(
|
||||
configuredPath?.trim() || getConfiguredCodexPath(deps.env) || ''
|
||||
);
|
||||
if (normalizedConfiguredPath) {
|
||||
if (!isPathLike(normalizedConfiguredPath, deps.platform)) {
|
||||
return resolveFromPath(deps) ?? normalizedConfiguredPath;
|
||||
}
|
||||
return normalizedConfiguredPath;
|
||||
}
|
||||
|
||||
for (const candidateDirectory of getPreferredUserInstallCandidates(deps)) {
|
||||
const candidate = resolveExecutableInDirectory(candidateDirectory, deps);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveFromPath(deps) ?? DEFAULT_CODEX_COMMAND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Codex child-process environment with the selected runtime directory first on PATH,
|
||||
* preserving the same source precedence as resolveCodexExecutablePath.
|
||||
*/
|
||||
export function createCodexRuntimeEnv(
|
||||
env: EnvRecord = process.env,
|
||||
dependencies: ResolveCodexExecutablePathDependencies = {}
|
||||
): Record<string, string> {
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
const pathApi = getPathApi(platform);
|
||||
const resolvedCodexPath = resolveCodexExecutablePath(undefined, {
|
||||
...dependencies,
|
||||
env,
|
||||
platform,
|
||||
});
|
||||
const pathKey = getPathEnvKey(env, platform);
|
||||
const currentPath = env[pathKey] ?? '';
|
||||
const resolvedDirectory = isPathLike(resolvedCodexPath, platform)
|
||||
? pathApi.dirname(resolvedCodexPath)
|
||||
: '';
|
||||
const nextEnv: EnvRecord = { ...env };
|
||||
|
||||
if (resolvedDirectory) {
|
||||
const delimiter = getPathDelimiter(platform);
|
||||
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
||||
if (!pathEntries.includes(resolvedDirectory)) {
|
||||
nextEnv[pathKey] = currentPath
|
||||
? `${resolvedDirectory}${delimiter}${currentPath}`
|
||||
: resolvedDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
return toStringEnv(nextEnv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shell-safe Codex command used by interactive PTY launches.
|
||||
*/
|
||||
export function getCodexShellCommand(
|
||||
dependencies: ResolveCodexExecutablePathDependencies = {}
|
||||
): string {
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
const resolvedCodexPath = resolveCodexExecutablePath(undefined, dependencies);
|
||||
if (!isPathLike(resolvedCodexPath, platform)) {
|
||||
return resolvedCodexPath;
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return `& '${resolvedCodexPath.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
return `'${resolvedCodexPath.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
@@ -71,7 +71,6 @@ function AppContentInner() {
|
||||
setActiveTab,
|
||||
setSidebarOpen,
|
||||
setIsInputFocused,
|
||||
setShowSettings,
|
||||
openSettings,
|
||||
refreshProjectsSilently,
|
||||
registerOptimisticSession,
|
||||
@@ -121,16 +120,12 @@ function AppContentInner() {
|
||||
}, [refreshRunningSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (processingSessions.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshRunningSessions();
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [processingSessions.size, refreshRunningSessions]);
|
||||
}, [refreshRunningSessions]);
|
||||
|
||||
usePaletteOpsRegister({
|
||||
openSettings,
|
||||
@@ -251,7 +246,7 @@ function AppContentInner() {
|
||||
onSessionEstablished={(targetSessionId, context) =>
|
||||
registerOptimisticSession({ sessionId: targetSessionId, ...context })
|
||||
}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
onShowSettings={openSettings}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
newSessionTrigger={newSessionTrigger}
|
||||
/>
|
||||
|
||||
1
src/components/browser-use/index.ts
Normal file
1
src/components/browser-use/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BrowserUsePanel } from './view/BrowserUsePanel';
|
||||
536
src/components/browser-use/view/BrowserUsePanel.tsx
Normal file
536
src/components/browser-use/view/BrowserUsePanel.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Clock3,
|
||||
Download,
|
||||
Expand,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MonitorPlay,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Square,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge, Button } from '../../../shared/view/ui';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { SettingsMainTab } from '../../settings/types/types';
|
||||
|
||||
type BrowserUseStatus = {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
playwrightInstalled: boolean;
|
||||
chromiumInstalled: boolean;
|
||||
installInProgress: boolean;
|
||||
sessionCount: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type BrowserUseSession = {
|
||||
id: string;
|
||||
status: 'ready' | 'stopped' | 'unavailable';
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
screenshotDataUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastAction: string | null;
|
||||
message: string | null;
|
||||
createdBy: 'agent';
|
||||
profileName: string | null;
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
cursor: {
|
||||
x: number;
|
||||
y: number;
|
||||
actor: 'agent';
|
||||
} | null;
|
||||
};
|
||||
|
||||
type BrowserUsePanelProps = {
|
||||
isVisible: boolean;
|
||||
onShowSettings?: (tab?: SettingsMainTab) => void;
|
||||
};
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
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;
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | null): string {
|
||||
if (!value) return 'Never';
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return 'Unknown';
|
||||
|
||||
const elapsedSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
||||
if (elapsedSeconds < 10) return 'Just now';
|
||||
if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`;
|
||||
const elapsedMinutes = Math.round(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
|
||||
const elapsedHours = Math.round(elapsedMinutes / 60);
|
||||
if (elapsedHours < 24) return `${elapsedHours}h ago`;
|
||||
return `${Math.round(elapsedHours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function getDomain(url: string | null): string {
|
||||
if (!url) return 'No page loaded';
|
||||
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAction(action: string | null): string {
|
||||
if (!action) return 'Waiting';
|
||||
return action.replace(/_/g, ' ').replace(/:/g, ': ');
|
||||
}
|
||||
|
||||
function getStatusTone(status: BrowserUseSession['status']): string {
|
||||
if (status === 'ready') {
|
||||
return 'border-primary/30 bg-primary/5 text-foreground';
|
||||
}
|
||||
if (status === 'stopped') {
|
||||
return 'border-border bg-muted text-muted-foreground';
|
||||
}
|
||||
return 'border-border bg-background text-muted-foreground';
|
||||
}
|
||||
|
||||
function getRuntimeTone(status: BrowserUseStatus | null, installing: boolean): string {
|
||||
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
|
||||
if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
|
||||
if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
|
||||
return 'border-border bg-background text-muted-foreground';
|
||||
}
|
||||
|
||||
function getStatusDot(status: BrowserUseSession['status']): string {
|
||||
if (status === 'ready') return 'bg-primary';
|
||||
if (status === 'stopped') return 'bg-muted-foreground/50';
|
||||
return 'bg-border';
|
||||
}
|
||||
|
||||
const PROMPTS = [
|
||||
'Use Browser to inspect the checkout flow and report any broken UI states.',
|
||||
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
|
||||
];
|
||||
|
||||
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) {
|
||||
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
||||
const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedSession = useMemo(
|
||||
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
|
||||
[selectedSessionId, sessions],
|
||||
);
|
||||
|
||||
const activeSessions = sessions.filter((session) => session.status === 'ready');
|
||||
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
|
||||
const runtimeLabel = !status?.enabled
|
||||
? 'Disabled'
|
||||
: status.available
|
||||
? 'Ready'
|
||||
: status.installInProgress || isInstalling
|
||||
? 'Installing'
|
||||
: 'Setup required';
|
||||
|
||||
const cursorStyle = selectedSession?.cursor && selectedSession.viewport
|
||||
? {
|
||||
left: `${(selectedSession.cursor.x / selectedSession.viewport.width) * 100}%`,
|
||||
top: `${(selectedSession.cursor.y / selectedSession.viewport.height) * 100}%`,
|
||||
}
|
||||
: null;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const [statusResponse, sessionsResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/browser-use/status'),
|
||||
authenticatedFetch('/api/browser-use/sessions'),
|
||||
]);
|
||||
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
|
||||
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
|
||||
const nextSessions = sessionsData.data.sessions;
|
||||
setStatus(statusData.data);
|
||||
setSessions(nextSessions);
|
||||
setSelectedSessionId((current) => (
|
||||
current && nextSessions.some((session) => session.id === current)
|
||||
? current
|
||||
: nextSessions[0]?.id || null
|
||||
));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load Browser');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
void refresh();
|
||||
}, [isVisible, refresh]);
|
||||
|
||||
const runAction = useCallback(async (action: () => Promise<void>) => {
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Browser action failed');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const stopSession = () => runAction(async () => {
|
||||
if (!selectedSession) return;
|
||||
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
|
||||
await readJson(response);
|
||||
});
|
||||
|
||||
const deleteSession = () => runAction(async () => {
|
||||
if (!selectedSession) return;
|
||||
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}`, { method: 'DELETE' });
|
||||
await readJson(response);
|
||||
setIsFullscreen(false);
|
||||
});
|
||||
|
||||
const installBrowserBinaries = () => runAction(async () => {
|
||||
setIsInstalling(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
|
||||
await readJson(response);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
});
|
||||
|
||||
const renderSessionItem = (session: BrowserUseSession) => {
|
||||
const isSelected = selectedSession?.id === session.id;
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSessionId(session.id)}
|
||||
className={cn(
|
||||
'group w-full rounded-md border px-3 py-2.5 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
||||
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
|
||||
<div className="truncate text-sm font-medium">{session.title || getDomain(session.url)}</div>
|
||||
</div>
|
||||
<div className="mt-1 truncate pl-3.5 text-xs text-muted-foreground">{getDomain(session.url)}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 border-border bg-background text-[10px] text-muted-foreground">
|
||||
{session.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
<span>{formatRelativeTime(session.updatedAt)}</span>
|
||||
<span className="truncate">- {formatAction(session.lastAction)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
|
||||
<div className="w-full max-w-2xl rounded-md border border-border bg-card/40 p-5 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-background">
|
||||
<MonitorPlay className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{status?.enabled ? 'No browser sessions yet' : 'Browser is disabled'}
|
||||
</div>
|
||||
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
|
||||
{status?.enabled
|
||||
? 'Agent browser sessions appear here while an AI task is using Browser.'
|
||||
: 'Enable Browser in settings to let agents open monitored browser sessions.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsBrowserBinaries && (
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3">
|
||||
<div className="text-sm font-medium text-foreground">Runtime setup required</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{status?.message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={installBrowserBinaries}
|
||||
disabled={isBusy || isInstalling || status?.installInProgress}
|
||||
>
|
||||
{isInstalling || status?.installInProgress ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-2">
|
||||
{PROMPTS.map((prompt) => (
|
||||
<div key={prompt} className="rounded-md border border-border/70 bg-background/70 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Prompt
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-foreground">{prompt}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBrowserSurface = (fullscreen = false) => (
|
||||
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
|
||||
{selectedSession?.screenshotDataUrl ? (
|
||||
<div className="relative inline-block max-h-full">
|
||||
<img
|
||||
src={selectedSession.screenshotDataUrl}
|
||||
alt="Browser session screenshot"
|
||||
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[72vh] w-auto max-w-full object-contain'}
|
||||
/>
|
||||
{cursorStyle && (
|
||||
<div
|
||||
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-primary/80 shadow-[0_0_0_6px_hsl(var(--primary)/0.18)]"
|
||||
style={cursorStyle}
|
||||
>
|
||||
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 text-center">
|
||||
<MonitorPlay className="mx-auto h-9 w-9 text-neutral-500" />
|
||||
<div className="mt-3 text-sm font-medium text-neutral-100">{selectedSession?.message || 'Waiting for screenshot'}</div>
|
||||
<p className="mt-1 text-xs text-neutral-400">The next agent browser snapshot will render here.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorPlay className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Browser</h3>
|
||||
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
|
||||
{runtimeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onShowSettings && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onShowSettings('browser')}
|
||||
title="Open Browser settings"
|
||||
aria-label="Open Browser settings"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => void refresh()}
|
||||
disabled={isRefreshing || isBusy}
|
||||
title="Refresh browser sessions"
|
||||
aria-label="Refresh browser sessions"
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<div className="border-b border-border/60 bg-muted/20 px-3 py-2 lg:hidden">
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSessionId(session.id)}
|
||||
className={cn(
|
||||
'flex min-w-[180px] items-center gap-2 rounded-md border px-2.5 py-2 text-left',
|
||||
selectedSession?.id === session.id
|
||||
? 'border-primary/40 bg-primary/5'
|
||||
: 'border-border bg-background',
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||
{session.title || getDomain(session.url)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<main className="flex min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
|
||||
<div className="min-w-0 truncate">
|
||||
{activeSessions.length} active
|
||||
<span className="px-1.5">/</span>
|
||||
{sessions.length} total
|
||||
</div>
|
||||
<div className="min-w-0 truncate">
|
||||
Updated {formatRelativeTime(selectedSession?.updatedAt || null)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
renderEmptyState()
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
|
||||
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<Badge variant="outline" className={selectedSession ? cn('text-[10px]', getStatusTone(selectedSession.status)) : 'text-[10px]'}>
|
||||
{selectedSession?.status || 'empty'}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{selectedSession?.title || getDomain(selectedSession?.url || null)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-xs text-muted-foreground md:block">
|
||||
{formatAction(selectedSession?.lastAction || null)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
|
||||
<Expand className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session">
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{renderBrowserSurface()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="hidden min-h-0 flex-col border-l border-border/60 bg-background lg:flex">
|
||||
<div className="border-b border-border/60 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">Sessions</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{sessions.length} total</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">{activeSessions.length} active</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{sessions.length > 0 ? (
|
||||
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
No agent browser sessions.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 p-3">
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Selected
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Status</span>
|
||||
<span className="font-medium text-foreground">{selectedSession?.status || 'None'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Last action</span>
|
||||
<span className="truncate font-medium text-foreground">{formatAction(selectedSession?.lastAction || null)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Profile</span>
|
||||
<span className="truncate font-medium text-foreground">{selectedSession?.profileName || 'Temporary'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{isFullscreen && selectedSession && (
|
||||
<div className="fixed inset-0 z-50 bg-black/90 p-6">
|
||||
<div className="flex h-full flex-col rounded-md border border-white/10 bg-black">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
|
||||
<div className="min-w-0 truncate">{selectedSession.title || selectedSession.url || 'Browser session'}</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
{renderBrowserSurface(true)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,7 +202,9 @@ export function useChatRealtimeHandlers({
|
||||
// indicator derives from the processing map, so deleting the entry
|
||||
// hides it immediately and atomically.
|
||||
onSessionIdle?.(sid);
|
||||
setPendingPermissionRequests([]);
|
||||
if (sid === activeViewSessionId) {
|
||||
setPendingPermissionRequests([]);
|
||||
}
|
||||
|
||||
if (msg.aborted) {
|
||||
// Abort was requested — the complete event confirms it. No
|
||||
@@ -232,17 +234,19 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
case 'permission_request': {
|
||||
if (!msg.requestId) break;
|
||||
setPendingPermissionRequests((prev) => {
|
||||
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||
return [...prev, {
|
||||
requestId: msg.requestId as string,
|
||||
toolName: (msg.toolName as string) || 'UnknownTool',
|
||||
input: msg.input,
|
||||
context: msg.context,
|
||||
sessionId: sid || null,
|
||||
receivedAt: new Date(),
|
||||
}];
|
||||
});
|
||||
if (sid === activeViewSessionId) {
|
||||
setPendingPermissionRequests((prev) => {
|
||||
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||
return [...prev, {
|
||||
requestId: msg.requestId as string,
|
||||
toolName: (msg.toolName as string) || 'UnknownTool',
|
||||
input: msg.input,
|
||||
context: msg.context,
|
||||
sessionId: sid || null,
|
||||
receivedAt: new Date(),
|
||||
}];
|
||||
});
|
||||
}
|
||||
if (sid) {
|
||||
onSessionProcessing?.(sid);
|
||||
}
|
||||
@@ -250,7 +254,7 @@ export function useChatRealtimeHandlers({
|
||||
}
|
||||
|
||||
case 'permission_cancelled': {
|
||||
if (msg.requestId) {
|
||||
if (msg.requestId && sid === activeViewSessionId) {
|
||||
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -452,14 +452,31 @@ export function useChatSessionState({
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}`;
|
||||
const selectedSessionId = selectedSession.id;
|
||||
const sessionKey = `${selectedSessionId}:${selectedProject.projectId}`;
|
||||
|
||||
const subscribeToSelectedSession = () => {
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusCheckSentAtRef.current.set(selectedSessionId, Date.now());
|
||||
sendMessage({
|
||||
type: 'chat.subscribe',
|
||||
sessions: [{
|
||||
sessionId: selectedSessionId,
|
||||
lastSeq: lastSeqRef.current.get(selectedSessionId) ?? 0,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
// Skip if already loaded and fresh
|
||||
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
|
||||
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSessionId) && !sessionStore.isStale(selectedSessionId)) {
|
||||
subscribeToSelectedSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSessionId;
|
||||
if (sessionChanged) {
|
||||
resetStreamingState();
|
||||
}
|
||||
@@ -482,29 +499,20 @@ export function useChatSessionState({
|
||||
setTokenBudget(null);
|
||||
}
|
||||
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
setCurrentSessionId(selectedSessionId);
|
||||
|
||||
// Subscribe to the session's live run (if any): the ack reconciles the
|
||||
// processing indicator, re-attaches a mid-flight stream to this socket,
|
||||
// and replays any live events missed since `lastSeq`. Recording the send
|
||||
// time lets the ack handler discard idle acks that a newer request has
|
||||
// since outdated.
|
||||
if (ws) {
|
||||
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||
sendMessage({
|
||||
type: 'chat.subscribe',
|
||||
sessions: [{
|
||||
sessionId: selectedSession.id,
|
||||
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
||||
}],
|
||||
});
|
||||
}
|
||||
subscribeToSelectedSession();
|
||||
|
||||
lastLoadedSessionKeyRef.current = sessionKey;
|
||||
|
||||
// Fetch from server → store updates → chatMessages re-derives automatically
|
||||
setIsLoadingSessionMessages(true);
|
||||
sessionStore.fetchFromServer(selectedSession.id, {
|
||||
sessionStore.fetchFromServer(selectedSessionId, {
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
offset: 0,
|
||||
}).then(slot => {
|
||||
|
||||
1
src/components/computer-use/index.ts
Normal file
1
src/components/computer-use/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ComputerUsePanel } from './view/ComputerUsePanel';
|
||||
454
src/components/computer-use/view/ComputerUsePanel.tsx
Normal file
454
src/components/computer-use/view/ComputerUsePanel.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
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 = {
|
||||
enabled: boolean;
|
||||
runtime: 'cloud' | 'local';
|
||||
available: boolean;
|
||||
requiresDesktopBridge: boolean;
|
||||
nutInstalled: boolean;
|
||||
screenshotInstalled: boolean;
|
||||
installInProgress: boolean;
|
||||
sessionCount: number;
|
||||
agentToolsEnabled: boolean;
|
||||
mcpRecommended: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
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 readJson<T>(response: Response): Promise<T> {
|
||||
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 ComputerUsePanel({ isVisible }: ComputerUsePanelProps) {
|
||||
const [status, setStatus] = useState<ComputerUseStatus | null>(null);
|
||||
const [sessions, setSessions] = useState<ComputerUseSession[]>([]);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const viewerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedSession = useMemo(
|
||||
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
|
||||
[selectedSessionId, sessions],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
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) return;
|
||||
void refresh().catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Computer Use'));
|
||||
}, [isVisible, refresh]);
|
||||
|
||||
// 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<void>) => {
|
||||
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<HTMLImageElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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) => (
|
||||
<div
|
||||
ref={viewerRef}
|
||||
tabIndex={selectedSession?.status === 'ready' ? 0 : -1}
|
||||
onKeyDown={pressViewerKey}
|
||||
className={`flex min-h-[360px] flex-1 items-center justify-center bg-neutral-950 outline-none ${fullscreen ? 'min-h-[80vh]' : ''}`}
|
||||
>
|
||||
{selectedSession?.screenshotDataUrl ? (
|
||||
<div className="relative inline-block max-h-full">
|
||||
<img
|
||||
src={selectedSession.screenshotDataUrl}
|
||||
alt="Desktop screenshot"
|
||||
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[70vh] w-auto max-w-full object-contain'}
|
||||
onClick={clickViewer}
|
||||
/>
|
||||
{cursorStyle && (
|
||||
<div
|
||||
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-sky-500/80 shadow-[0_0_0_6px_rgba(14,165,233,0.18)]"
|
||||
style={cursorStyle}
|
||||
>
|
||||
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-md px-6 text-center">
|
||||
<MonitorCog className="mx-auto h-10 w-10 text-neutral-500" />
|
||||
<div className="mt-3 text-sm font-medium text-neutral-100">
|
||||
{selectedSession?.message || 'Start a Computer Use session to capture your desktop.'}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-neutral-400">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorCog className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Computer Use</h3>
|
||||
{status && <Badge variant="outline" className="text-[11px]">{status.runtime}</Badge>}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Capture your desktop and let agents drive the mouse and keyboard — only while you grant control.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh()} disabled={isBusy}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={createSession} disabled={isBusy || !status?.available}>
|
||||
<MonitorCog className="h-4 w-4" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<aside className="border-b border-border/60 p-3 lg:border-b-0 lg:border-r">
|
||||
{needsRuntime && (
|
||||
<div className="rounded-lg border border-border/70 bg-card/40 p-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Desktop runtime required</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{status?.message || 'Install the desktop control runtime to enable Computer Use.'}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-md border border-border px-2 py-1">
|
||||
Control lib: {status?.nutInstalled ? 'installed' : 'missing'}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-1">
|
||||
Screen capture: {status?.screenshotInstalled ? 'installed' : 'missing'}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-3 w-full"
|
||||
onClick={installRuntime}
|
||||
disabled={isBusy || isInstalling || status?.installInProgress}
|
||||
>
|
||||
{isInstalling || status?.installInProgress ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isInstalling || status?.installInProgress ? 'Installing…' : 'Install Runtime'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-lg border border-border/70 bg-muted/30 p-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Safety
|
||||
</div>
|
||||
<p className="mt-1.5">
|
||||
Agents can act on a session only while you have granted control. Use
|
||||
<span className="font-medium text-foreground"> Grant Control </span>
|
||||
to allow agent actions, and
|
||||
<span className="font-medium text-foreground"> Stop </span>
|
||||
to revoke instantly.
|
||||
</p>
|
||||
</div>
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSessionId(session.id)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors ${selectedSession?.id === session.id
|
||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
||||
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{session.createdBy === 'agent' ? 'Agent session' : 'Desktop session'}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px]">{session.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{session.agentAccessEnabled ? (
|
||||
<span className="rounded border border-emerald-500/30 px-1.5 py-0.5 text-[10px] text-emerald-600 dark:text-emerald-300">
|
||||
control granted
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded border border-amber-500/30 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300">
|
||||
awaiting consent
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs">{session.lastAction || session.message || session.id}</div>
|
||||
</button>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
No Computer Use sessions yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 flex-col">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<Button variant="outline" size="sm" onClick={captureScreenshot} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
||||
<Camera className="h-4 w-4" />
|
||||
Screenshot
|
||||
</Button>
|
||||
{selectedSession?.agentAccessEnabled ? (
|
||||
<Button variant="outline" size="sm" onClick={revokeControl} disabled={isBusy || !selectedSession}>
|
||||
<X className="h-4 w-4" />
|
||||
Revoke Control
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={grantControl}
|
||||
disabled={isBusy || !selectedSession || selectedSession.status !== 'ready' || !status?.agentToolsEnabled}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
Grant Control
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl}>
|
||||
<Expand className="h-4 w-4" />
|
||||
Full Screen
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
|
||||
<div className="mx-auto flex min-h-[420px] max-w-6xl flex-col overflow-hidden rounded-lg border border-border bg-background shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground">
|
||||
<MonitorCog className="h-3.5 w-3.5" />
|
||||
<span className="truncate">
|
||||
{selectedSession?.displaySize
|
||||
? `${selectedSession.displaySize.width}×${selectedSession.displaySize.height}`
|
||||
: 'No screen captured'}
|
||||
</span>
|
||||
{selectedSession?.agentAccessEnabled && (
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded border border-emerald-500/30 px-2 py-0.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Agent control active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderSurface()}
|
||||
</div>
|
||||
<p className="mx-auto mt-2 max-w-6xl text-center text-xs text-muted-foreground">
|
||||
Click the screenshot to click the real desktop. Focus the view and type to send keystrokes.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{isFullscreen && selectedSession && (
|
||||
<div className="fixed inset-0 z-50 bg-black/90 p-6">
|
||||
<div className="flex h-full flex-col rounded-lg border border-white/10 bg-black">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
|
||||
<div className="min-w-0 truncate">Desktop session</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
{renderSurface(true)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
SessionActivityMap,
|
||||
} from '../../../hooks/useSessionProtection';
|
||||
import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types';
|
||||
import type { SettingsMainTab } from '../../settings/types/types';
|
||||
|
||||
export type TaskMasterTask = {
|
||||
id: string | number;
|
||||
@@ -53,7 +54,7 @@ export type MainContentProps = {
|
||||
processingSessions: SessionActivityMap;
|
||||
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||
onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||
onShowSettings: () => void;
|
||||
onShowSettings: (tab?: SettingsMainTab) => void;
|
||||
externalMessageUpdate: number;
|
||||
newSessionTrigger: number;
|
||||
};
|
||||
@@ -64,6 +65,8 @@ export type MainContentHeaderProps = {
|
||||
selectedProject: Project;
|
||||
selectedSession: ProjectSession | null;
|
||||
shouldShowTasksTab: boolean;
|
||||
shouldShowBrowserTab: boolean;
|
||||
shouldShowComputerTab: boolean;
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
};
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import ChatInterface from '../../chat/view/ChatInterface';
|
||||
import FileTree from '../../file-tree/view/FileTree';
|
||||
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
||||
import GitPanel from '../../git-panel/view/GitPanel';
|
||||
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
||||
import { BrowserUsePanel } from '../../browser-use';
|
||||
import { ComputerUsePanel } from '../../computer-use';
|
||||
import type { MainContentProps } from '../types/types';
|
||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
||||
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
||||
import type { Project } from '../../../types/app';
|
||||
@@ -55,8 +58,12 @@ 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,
|
||||
@@ -90,6 +97,50 @@ function MainContent({
|
||||
}
|
||||
}, [shouldShowTasksTab, activeTab, setActiveTab]);
|
||||
|
||||
const loadBrowserUseSettings = useCallback(async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/browser-use/settings');
|
||||
const data = await response.json();
|
||||
setBrowserUseEnabled(Boolean(response.ok && data?.success !== false && data?.data?.settings?.enabled));
|
||||
} catch {
|
||||
setBrowserUseEnabled(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBrowserUseSettings();
|
||||
window.addEventListener('browserUseSettingsChanged', loadBrowserUseSettings);
|
||||
return () => window.removeEventListener('browserUseSettingsChanged', loadBrowserUseSettings);
|
||||
}, [loadBrowserUseSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowBrowserTab && activeTab === 'browser') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
}, [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');
|
||||
@@ -113,6 +164,8 @@ function MainContent({
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
shouldShowTasksTab={shouldShowTasksTab}
|
||||
shouldShowBrowserTab={shouldShowBrowserTab}
|
||||
shouldShowComputerTab={shouldShowComputerTab}
|
||||
isMobile={isMobile}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
@@ -171,7 +224,17 @@ function MainContent({
|
||||
|
||||
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
|
||||
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
|
||||
{shouldShowBrowserTab && activeTab === 'browser' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<BrowserUsePanel isVisible={activeTab === 'browser'} onShowSettings={onShowSettings} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowComputerTab && activeTab === 'computer' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<ComputerUsePanel isVisible={activeTab === 'computer'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div className="h-full overflow-hidden">
|
||||
|
||||
@@ -10,6 +10,8 @@ export default function MainContentHeader({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
shouldShowTasksTab,
|
||||
shouldShowBrowserTab,
|
||||
shouldShowComputerTab,
|
||||
isMobile,
|
||||
onMenuClick,
|
||||
}: MainContentHeaderProps) {
|
||||
@@ -59,6 +61,8 @@ export default function MainContentHeader({
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
shouldShowTasksTab={shouldShowTasksTab}
|
||||
shouldShowBrowserTab={shouldShowBrowserTab}
|
||||
shouldShowComputerTab={shouldShowComputerTab}
|
||||
/>
|
||||
</div>
|
||||
{canScrollRight && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
|
||||
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, MonitorCog, MonitorPlay, type LucideIcon } from 'lucide-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui';
|
||||
import type { AppTab } from '../../../../types/app';
|
||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||
@@ -10,6 +11,8 @@ type MainContentTabSwitcherProps = {
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
shouldShowTasksTab: boolean;
|
||||
shouldShowBrowserTab: boolean;
|
||||
shouldShowComputerTab: boolean;
|
||||
};
|
||||
|
||||
type BuiltInTab = {
|
||||
@@ -36,6 +39,20 @@ const BASE_TABS: BuiltInTab[] = [
|
||||
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
||||
];
|
||||
|
||||
const BROWSER_TAB: BuiltInTab = {
|
||||
kind: 'builtin',
|
||||
id: 'browser',
|
||||
labelKey: 'tabs.browser',
|
||||
icon: MonitorPlay,
|
||||
};
|
||||
|
||||
const COMPUTER_TAB: BuiltInTab = {
|
||||
kind: 'builtin',
|
||||
id: 'computer',
|
||||
labelKey: 'tabs.computer',
|
||||
icon: MonitorCog,
|
||||
};
|
||||
|
||||
const TASKS_TAB: BuiltInTab = {
|
||||
kind: 'builtin',
|
||||
id: 'tasks',
|
||||
@@ -47,11 +64,18 @@ export default function MainContentTabSwitcher({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
shouldShowTasksTab,
|
||||
shouldShowBrowserTab,
|
||||
shouldShowComputerTab,
|
||||
}: MainContentTabSwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const { plugins } = usePlugins();
|
||||
|
||||
const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
||||
const builtInTabs: BuiltInTab[] = [
|
||||
...BASE_TABS,
|
||||
...(shouldShowBrowserTab ? [BROWSER_TAB] : []),
|
||||
...(shouldShowComputerTab ? [COMPUTER_TAB] : []),
|
||||
...(shouldShowTasksTab ? [TASKS_TAB] : []),
|
||||
];
|
||||
|
||||
const pluginTabs: PluginTab[] = plugins
|
||||
.filter((p) => p.enabled)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
|
||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||
@@ -27,6 +28,14 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
|
||||
return 'TaskMaster';
|
||||
}
|
||||
|
||||
if (activeTab === 'browser') {
|
||||
return 'Browser';
|
||||
}
|
||||
|
||||
if (activeTab === 'computer') {
|
||||
return 'Computer Use';
|
||||
}
|
||||
|
||||
return 'Project';
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ const getServerKey = (server: ProviderMcpServer): string => (
|
||||
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
|
||||
);
|
||||
|
||||
// Servers prefixed with `cloudcli-` are written and removed automatically by a
|
||||
// CloudCLI feature toggle (e.g. the Browser tab), not added by the user. They are
|
||||
// shown read-only so users don't edit/delete them out of sync with the feature.
|
||||
const isManagedServer = (server: ProviderMcpServer): boolean => server.name.startsWith('cloudcli-');
|
||||
|
||||
function ConfigLine({ label, children }: { label: string; children: string }) {
|
||||
if (!children) {
|
||||
return null;
|
||||
@@ -177,65 +182,92 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
|
||||
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
|
||||
)}
|
||||
|
||||
{servers.map((server) => (
|
||||
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{getTransportIcon(server.transport)}
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.transport || 'stdio'}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getScopeLabel(server.scope)}
|
||||
</Badge>
|
||||
{server.projectDisplayName && (
|
||||
<Badge variant="outline" className="max-w-full truncate text-xs">
|
||||
{server.projectDisplayName}
|
||||
</Badge>
|
||||
)}
|
||||
{servers.map((server) => {
|
||||
const managed = isManagedServer(server);
|
||||
|
||||
return (
|
||||
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{!managed && getTransportIcon(server.transport)}
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
{!managed && (
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.transport || 'stdio'}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getScopeLabel(server.scope)}
|
||||
</Badge>
|
||||
{server.projectDisplayName && (
|
||||
<Badge variant="outline" className="max-w-full truncate text-xs">
|
||||
{server.projectDisplayName}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{managed && (
|
||||
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t('mcpServers.managed.badge', { defaultValue: 'Managed' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
{!managed && (
|
||||
<>
|
||||
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
|
||||
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
|
||||
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
|
||||
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<ConfigLine label={t('mcpServers.config.environment')}>
|
||||
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
||||
</ConfigLine>
|
||||
)}
|
||||
{server.envVars && server.envVars.length > 0 && (
|
||||
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{managed && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('mcpServers.managed.hint', {
|
||||
defaultValue: 'Managed by CloudCLI.',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
|
||||
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
|
||||
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
|
||||
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<ConfigLine label={t('mcpServers.config.environment')}>
|
||||
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
||||
</ConfigLine>
|
||||
)}
|
||||
{server.envVars && server.envVars.length > 0 && (
|
||||
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => openForm(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => deleteServer(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{!managed && (
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => openForm(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => deleteServer(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Calculator,
|
||||
Clock,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Github,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
ListTodo,
|
||||
RefreshCw,
|
||||
ServerCrash,
|
||||
ShieldAlert,
|
||||
@@ -27,6 +30,10 @@ const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-term
|
||||
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
||||
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
||||
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
|
||||
const SESSION_MANAGER_PLUGIN_URL = 'https://github.com/strykereye2/cloudcli-plugin-session-manager';
|
||||
const TOKEN_COST_CALCULATOR_PLUGIN_URL = 'https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator';
|
||||
const TASK_QUEUE_PLUGIN_URL = 'https://github.com/TadMSTR/cloudcli-plugin-task-queue';
|
||||
const GITHUB_ISSUES_BOARD_PLUGIN_URL = 'https://github.com/szmidtpiotr/claude-github-issue';
|
||||
|
||||
type PluginRecommendation = {
|
||||
id: string;
|
||||
@@ -79,8 +86,40 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
||||
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
||||
installedNames: ['prism'],
|
||||
icon: Activity,
|
||||
source: 'unofficial'
|
||||
}
|
||||
source: 'unofficial',
|
||||
},
|
||||
{
|
||||
id: 'session-manager',
|
||||
translationKey: 'sessionManagerPlugin',
|
||||
repoUrl: SESSION_MANAGER_PLUGIN_URL,
|
||||
installedNames: ['session-manager'],
|
||||
icon: Activity,
|
||||
source: 'unofficial',
|
||||
},
|
||||
{
|
||||
id: 'token-cost-calculator',
|
||||
translationKey: 'tokenCostCalculatorPlugin',
|
||||
repoUrl: TOKEN_COST_CALCULATOR_PLUGIN_URL,
|
||||
installedNames: ['token-cost-calculator'],
|
||||
icon: Calculator,
|
||||
source: 'unofficial',
|
||||
},
|
||||
{
|
||||
id: 'task-queue',
|
||||
translationKey: 'taskQueuePlugin',
|
||||
repoUrl: TASK_QUEUE_PLUGIN_URL,
|
||||
installedNames: ['task-queue'],
|
||||
icon: ListTodo,
|
||||
source: 'unofficial',
|
||||
},
|
||||
{
|
||||
id: 'claude-github-issue',
|
||||
translationKey: 'githubIssuesBoardPlugin',
|
||||
repoUrl: GITHUB_ISSUES_BOARD_PLUGIN_URL,
|
||||
installedNames: ['claude-github-issue'],
|
||||
icon: Github,
|
||||
source: 'unofficial',
|
||||
},
|
||||
];
|
||||
|
||||
function repoSlug(repoUrl: string) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Info,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
MonitorPlay,
|
||||
Palette,
|
||||
Plug,
|
||||
} from 'lucide-react';
|
||||
@@ -32,6 +33,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
|
||||
{ id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch },
|
||||
{ id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound },
|
||||
{ id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks },
|
||||
{ id: 'browser', label: 'Browser', keywords: 'browser playwright chromium automation', icon: MonitorPlay },
|
||||
{ id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell },
|
||||
{ id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug },
|
||||
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
|
||||
|
||||
@@ -54,7 +54,7 @@ type NotificationPreferencesResponse = {
|
||||
|
||||
type ActiveLoginProvider = AgentProvider | '';
|
||||
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];
|
||||
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".
|
||||
|
||||
@@ -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' | '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';
|
||||
|
||||
@@ -7,6 +7,8 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
||||
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';
|
||||
@@ -139,17 +141,21 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
|
||||
{activeTab === 'tasks' && <TasksSettingsTab />}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationsSettingsTab
|
||||
notificationPreferences={notificationPreferences}
|
||||
onNotificationPreferencesChange={setNotificationPreferences}
|
||||
pushPermission={pushPermission}
|
||||
isPushSubscribed={isPushSubscribed}
|
||||
isPushLoading={isPushLoading}
|
||||
onEnablePush={handleEnablePush}
|
||||
onDisablePush={handleDisablePush}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'browser' && <BrowserUseSettingsTab />}
|
||||
|
||||
{activeTab === 'computer' && <ComputerUseSettingsTab />}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationsSettingsTab
|
||||
notificationPreferences={notificationPreferences}
|
||||
onNotificationPreferencesChange={setNotificationPreferences}
|
||||
pushPermission={pushPermission}
|
||||
isPushSubscribed={isPushSubscribed}
|
||||
isPushLoading={isPushLoading}
|
||||
onEnablePush={handleEnablePush}
|
||||
onDisablePush={handleDisablePush}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'api' && <CredentialsSettingsTab />}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bell, Bot, GitBranch, Info, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
|
||||
import { Bell, Bot, GitBranch, Info, Key, ListChecks, MonitorCog, MonitorPlay, Palette, Puzzle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { PillBar, Pill } from '../../../shared/view/ui';
|
||||
@@ -21,6 +21,8 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
|
||||
{ id: 'browser', labelKey: 'mainTabs.browser', icon: MonitorPlay },
|
||||
{ id: 'computer', labelKey: 'mainTabs.computer', icon: MonitorCog },
|
||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
|
||||
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../../../../shared/view/ui';
|
||||
import { authenticatedFetch } from '../../../../../utils/api';
|
||||
import SettingsCard from '../../SettingsCard';
|
||||
import SettingsRow from '../../SettingsRow';
|
||||
import SettingsSection from '../../SettingsSection';
|
||||
import SettingsToggle from '../../SettingsToggle';
|
||||
|
||||
type BrowserUseSettings = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type BrowserUseStatus = {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
playwrightInstalled: boolean;
|
||||
chromiumInstalled: boolean;
|
||||
installInProgress: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
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 BrowserUseSettingsTab() {
|
||||
const [settings, setSettings] = useState<BrowserUseSettings | null>(null);
|
||||
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
||||
const [isSettingsLoading, setIsSettingsLoading] = useState(true);
|
||||
const [isStatusLoading, setIsStatusLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
const settingsResponse = await authenticatedFetch('/api/browser-use/settings');
|
||||
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
|
||||
setSettings(settingsData.data.settings);
|
||||
}, []);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
const statusResponse = await authenticatedFetch('/api/browser-use/status');
|
||||
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
|
||||
setStatus(statusData.data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
setIsSettingsLoading(true);
|
||||
setIsStatusLoading(true);
|
||||
|
||||
void loadSettings()
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser settings'))
|
||||
.finally(() => setIsSettingsLoading(false));
|
||||
|
||||
void loadStatus()
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser status'))
|
||||
.finally(() => setIsStatusLoading(false));
|
||||
}, [loadSettings, loadStatus]);
|
||||
|
||||
const updateSettings = async (nextSettings: Partial<BrowserUseSettings>) => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/browser-use/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(nextSettings),
|
||||
});
|
||||
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
|
||||
setSettings(data.data.settings);
|
||||
window.dispatchEvent(new Event('browserUseSettingsChanged'));
|
||||
setIsStatusLoading(true);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save Browser settings');
|
||||
} finally {
|
||||
setIsStatusLoading(false);
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const installBrowserBinaries = async () => {
|
||||
setIsInstalling(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
|
||||
await readJson(response);
|
||||
setIsStatusLoading(true);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to install browser runtime');
|
||||
} finally {
|
||||
setIsStatusLoading(false);
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const browserEnabled = settings?.enabled === true;
|
||||
const needsBrowserBinaries = Boolean(browserEnabled && status && (!status.playwrightInstalled || !status.chromiumInstalled));
|
||||
const runtimeLabel = (installed?: boolean) => {
|
||||
if (isStatusLoading && !status) {
|
||||
return 'checking...';
|
||||
}
|
||||
return installed ? 'installed' : 'missing';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection
|
||||
title="Browser"
|
||||
description="Allow agents to create guarded Playwright browser sessions that you can monitor from the Browser tab."
|
||||
>
|
||||
<SettingsCard divided>
|
||||
<SettingsRow
|
||||
label="Enable Browser"
|
||||
description="Registers Browser for supported agents. Agents can create browser sessions; you can watch, stop, and delete them."
|
||||
>
|
||||
{isSettingsLoading && !settings ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<SettingsToggle
|
||||
checked={browserEnabled}
|
||||
onChange={(value) => void updateSettings({ enabled: value })}
|
||||
ariaLabel="Enable Browser"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-md border border-border px-2 py-1">
|
||||
Playwright: {runtimeLabel(status?.playwrightInstalled)}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-1">
|
||||
Chromium: {runtimeLabel(status?.chromiumInstalled)}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-1">
|
||||
Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{needsBrowserBinaries && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">Browser runtime required</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status?.message || 'Install the browser runtime before agents can create Browser sessions.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void installBrowserBinaries()}
|
||||
disabled={isInstalling || status?.installInProgress}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{isInstalling || status?.installInProgress ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../../../../shared/view/ui';
|
||||
import { authenticatedFetch } from '../../../../../utils/api';
|
||||
import SettingsCard from '../../SettingsCard';
|
||||
import SettingsRow from '../../SettingsRow';
|
||||
import SettingsSection from '../../SettingsSection';
|
||||
import SettingsToggle from '../../SettingsToggle';
|
||||
|
||||
type ComputerUseSettings = {
|
||||
enabled: boolean;
|
||||
agentToolsEnabled: boolean;
|
||||
};
|
||||
|
||||
type ComputerUseStatus = {
|
||||
enabled: boolean;
|
||||
runtime: 'cloud' | 'local';
|
||||
available: boolean;
|
||||
nutInstalled: boolean;
|
||||
screenshotInstalled: boolean;
|
||||
installInProgress: boolean;
|
||||
agentToolsEnabled: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
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<ComputerUseSettings>({ enabled: false, agentToolsEnabled: false });
|
||||
const [status, setStatus] = useState<ComputerUseStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<ComputerUseSettings>) => {
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection
|
||||
title="Computer Use"
|
||||
description="Let agents see your desktop and drive the mouse and keyboard through a guarded, consent-gated control loop."
|
||||
>
|
||||
<SettingsCard divided>
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
<div className="rounded-md border border-amber-300/50 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsRow
|
||||
label="Enable Computer Use"
|
||||
description="Allow CloudCLI to capture the screen and create desktop control sessions on this machine."
|
||||
>
|
||||
<SettingsToggle
|
||||
checked={settings.enabled}
|
||||
onChange={(value) => void updateSettings({ enabled: value })}
|
||||
ariaLabel="Enable Computer Use"
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Enable Computer Tools for Agents"
|
||||
description="Register the Computer Use MCP server for all agent providers. Agents can request desktop control, but actions require your explicit per-session consent."
|
||||
>
|
||||
<SettingsToggle
|
||||
checked={settings.agentToolsEnabled}
|
||||
onChange={(value) => void updateSettings({ agentToolsEnabled: value })}
|
||||
ariaLabel="Enable Computer Tools for Agents"
|
||||
disabled={isLoading || isSaving || !settings.enabled}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{(needsRuntime || isCloud || error) && (
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
{isCloud && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{status?.message || 'Cloud Computer Use requires a linked CloudCLI Desktop Agent on the user machine.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsRuntime && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">Desktop runtime required</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status?.message || 'Install the desktop control runtime needed to capture the screen and drive input.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 pt-1 text-xs text-muted-foreground">
|
||||
<span className="rounded-md border border-border px-2 py-1">
|
||||
Control lib: {status?.nutInstalled ? 'installed' : 'missing'}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-1">
|
||||
Screen capture: {status?.screenshotInstalled ? 'installed' : 'missing'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void installRuntime()}
|
||||
disabled={isInstalling || status?.installInProgress}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{isInstalling || status?.installInProgress ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isInstalling || status?.installInProgress ? 'Installing…' : 'Install Runtime'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default function GitHubStarBadge() {
|
||||
>
|
||||
<GitHubIcon className="h-3.5 w-3.5" />
|
||||
<Star className="h-3 w-3" />
|
||||
<span className="font-medium">Star</span>
|
||||
<span className="font-normal">Star</span>
|
||||
{formattedCount && (
|
||||
<span className="border-l border-border/50 pl-1.5 tabular-nums">{formattedCount}</span>
|
||||
)}
|
||||
|
||||
@@ -264,7 +264,7 @@ export default function SidebarContent({
|
||||
<div key={projectResult.projectName} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{projectResult.projectDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -284,7 +284,7 @@ export default function SidebarContent({
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{session.sessionSummary}
|
||||
</span>
|
||||
{session.provider && session.provider !== 'claude' && (
|
||||
@@ -296,7 +296,7 @@ export default function SidebarContent({
|
||||
<div className="space-y-1 pl-4">
|
||||
{session.matches.map((match, idx) => (
|
||||
<div key={idx} className="flex items-start gap-1">
|
||||
<span className="mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60">
|
||||
<span className="mt-0.5 flex-shrink-0 text-[10px] font-normal uppercase text-muted-foreground/60">
|
||||
{match.role === 'user' ? 'U' : 'A'}
|
||||
</span>
|
||||
<HighlightedSnippet
|
||||
@@ -334,11 +334,11 @@ export default function SidebarContent({
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{t('running.title', 'Running now')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-normal text-emerald-700 dark:text-emerald-300">
|
||||
{runningSessionsCount}
|
||||
</span>
|
||||
</div>
|
||||
@@ -393,7 +393,7 @@ export default function SidebarContent({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
<span className="truncate text-sm font-normal text-foreground">
|
||||
{project.displayName}
|
||||
</span>
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||
@@ -446,7 +446,7 @@ export default function SidebarContent({
|
||||
<SessionProviderLogo provider={session.__provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{(typeof session.summary === 'string' && session.summary.trim().length > 0
|
||||
? session.summary
|
||||
: typeof session.name === 'string' && session.name.trim().length > 0
|
||||
@@ -482,7 +482,7 @@ export default function SidebarContent({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
<span className="truncate text-sm font-normal text-foreground">
|
||||
{group.projectDisplayName}
|
||||
</span>
|
||||
{group.isProjectArchived && (
|
||||
@@ -511,7 +511,7 @@ export default function SidebarContent({
|
||||
<SessionProviderLogo provider={session.provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{session.sessionTitle}
|
||||
</span>
|
||||
{session.lastActivity && (
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function SidebarFooter({
|
||||
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||
<span className="block truncate text-sm font-normal text-blue-600 dark:text-blue-300">
|
||||
{releaseInfo?.title || `v${latestVersion}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-blue-500/70 dark:text-blue-400/60">
|
||||
@@ -69,11 +69,11 @@ export default function SidebarFooter({
|
||||
onClick={onShowVersionModal}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<ArrowUpCircle className="w-4.5 h-4.5 text-blue-500 dark:text-blue-400" />
|
||||
<ArrowUpCircle className="h-4 w-4 text-blue-500 dark:text-blue-400" />
|
||||
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||
<span className="block truncate text-sm font-normal text-blue-600 dark:text-blue-300">
|
||||
{releaseInfo?.title || `v${latestVersion}`}
|
||||
</span>
|
||||
<span className="text-xs text-blue-500/70 dark:text-blue-400/60">
|
||||
@@ -145,12 +145,12 @@ export default function SidebarFooter({
|
||||
href={GITHUB_ISSUES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
||||
className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
|
||||
<Bug className="w-4.5 h-4.5 text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-base font-medium text-foreground">{t('actions.reportIssue')}</span>
|
||||
<span className="text-sm font-normal text-foreground">{t('actions.reportIssue')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -160,25 +160,25 @@ export default function SidebarFooter({
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
||||
className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
|
||||
<DiscordIcon className="w-4.5 h-4.5 text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||
<DiscordIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-base font-medium text-foreground">{t('actions.joinCommunity')}</span>
|
||||
<span className="text-sm font-normal text-foreground">{t('actions.joinCommunity')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile settings */}
|
||||
<div className="px-3 pb-3 pt-2 md:hidden">
|
||||
<button
|
||||
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
||||
className="flex h-10 w-full items-center gap-3 rounded-xl bg-muted/40 px-3.5 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
||||
onClick={onShowSettings}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
|
||||
<Settings className="w-4.5 h-4.5 text-muted-foreground" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-base font-medium text-foreground">{t('actions.settings')}</span>
|
||||
<span className="text-sm font-normal text-foreground">{t('actions.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function SidebarHeader({
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">{t('app.title')}</h1>
|
||||
<h1 className="truncate text-sm font-normal tracking-tight text-foreground">{t('app.title')}</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function SidebarHeader({
|
||||
onClick={() => onSearchModeChange('projects')}
|
||||
aria-pressed={searchMode === 'projects'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'projects'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -151,7 +151,7 @@ export default function SidebarHeader({
|
||||
onClick={() => onSearchModeChange('conversations')}
|
||||
aria-pressed={searchMode === 'conversations'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'conversations'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -167,7 +167,7 @@ export default function SidebarHeader({
|
||||
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||
title={t('search.runningTooltip', 'Running sessions')}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'running'
|
||||
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -190,7 +190,7 @@ export default function SidebarHeader({
|
||||
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'archived'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -278,7 +278,7 @@ export default function SidebarHeader({
|
||||
onClick={() => onSearchModeChange('projects')}
|
||||
aria-pressed={searchMode === 'projects'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'projects'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -291,7 +291,7 @@ export default function SidebarHeader({
|
||||
onClick={() => onSearchModeChange('conversations')}
|
||||
aria-pressed={searchMode === 'conversations'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'conversations'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -307,7 +307,7 @@ export default function SidebarHeader({
|
||||
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||
title={t('search.runningTooltip', 'Running sessions')}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'running'
|
||||
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -331,7 +331,7 @@ export default function SidebarHeader({
|
||||
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'archived'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function SidebarProjectItem({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between">
|
||||
<h3 className="truncate text-sm font-medium text-foreground">{project.displayName}</h3>
|
||||
<h3 className="truncate text-sm font-normal text-foreground">{project.displayName}</h3>
|
||||
{tasksEnabled && (
|
||||
<TaskIndicator
|
||||
status={taskStatus}
|
||||
@@ -318,7 +318,7 @@ export default function SidebarProjectItem({
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="truncate text-sm font-semibold text-foreground" title={project.displayName}>
|
||||
<div className="truncate text-sm font-normal text-foreground" title={project.displayName}>
|
||||
{project.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function SidebarSessionItem({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
|
||||
{isProcessing ? (
|
||||
<span className="ml-auto flex-shrink-0">
|
||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||
@@ -219,7 +219,7 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
|
||||
{isProcessing ? (
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function TaskIndicator({
|
||||
title={indicatorConfig.title}
|
||||
>
|
||||
<Icon className={sizeClassNames[size]} />
|
||||
<span className="font-medium">{indicatorConfig.label}</span>
|
||||
<span className="font-normal">{indicatorConfig.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,4 +91,4 @@ export const ThemeProvider = ({ children }) => {
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -324,7 +324,7 @@ const removeSessionFromProject = (project: Project, sessionIdToDelete: string):
|
||||
return updatedProject;
|
||||
};
|
||||
|
||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
|
||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser', 'computer']);
|
||||
|
||||
const isValidTab = (tab: string): tab is AppTab => {
|
||||
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
|
||||
@@ -776,7 +776,7 @@ export function useProjectsState({
|
||||
(session: ProjectSession) => {
|
||||
setSelectedSession(session);
|
||||
|
||||
if (activeTab === 'tasks' || activeTab === 'preview') {
|
||||
if (activeTab === 'tasks' || activeTab === 'browser' || activeTab === 'computer') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface SessionActivity {
|
||||
canInterrupt: boolean;
|
||||
/**
|
||||
* When this request was first marked as processing (client clock). Drives
|
||||
* the elapsed-time display and the stale `session-status` reply guard.
|
||||
* the elapsed-time display and the stale `chat_subscribed` idle-ack guard.
|
||||
*/
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ export const languages = [
|
||||
label: 'English',
|
||||
nativeName: 'English',
|
||||
},
|
||||
{
|
||||
value: 'fr',
|
||||
label: 'French',
|
||||
nativeName: 'Français',
|
||||
},
|
||||
{
|
||||
value: 'ko',
|
||||
label: 'Korean',
|
||||
@@ -48,6 +53,8 @@ export const languages = [
|
||||
value: 'tr',
|
||||
label: 'Turkish',
|
||||
nativeName: 'Türkçe',
|
||||
},
|
||||
{
|
||||
value: 'it',
|
||||
label: 'Italian',
|
||||
nativeName: 'Italiano',
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
"shell": "Terminal",
|
||||
"files": "Dateien",
|
||||
"git": "Quellcodeverwaltung",
|
||||
"tasks": "Aufgaben"
|
||||
"tasks": "Aufgaben",
|
||||
"browser": "Browser",
|
||||
"computer": "Computer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Lädt...",
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
"shell": "Shell",
|
||||
"files": "Files",
|
||||
"git": "Source Control",
|
||||
"tasks": "Tasks"
|
||||
"tasks": "Tasks",
|
||||
"browser": "Browser",
|
||||
"computer": "Computer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -94,6 +94,8 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API & Tokens",
|
||||
"tasks": "Tasks",
|
||||
"browser": "Browser",
|
||||
"computer": "Computer Use",
|
||||
"notifications": "Notifications",
|
||||
"plugins": "Plugins",
|
||||
"about": "About"
|
||||
@@ -450,6 +452,10 @@
|
||||
"edit": "Edit server",
|
||||
"delete": "Delete server"
|
||||
},
|
||||
"managed": {
|
||||
"badge": "Managed",
|
||||
"hint": "Managed by CloudCLI."
|
||||
},
|
||||
"help": {
|
||||
"title": "About Codex MCP",
|
||||
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
|
||||
@@ -514,6 +520,30 @@
|
||||
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
|
||||
"install": "Install"
|
||||
},
|
||||
"sessionManagerPlugin": {
|
||||
"name": "Sessions",
|
||||
"badge": "unofficial",
|
||||
"description": "View, manage, and kill active Claude Code sessions.",
|
||||
"install": "Install"
|
||||
},
|
||||
"tokenCostCalculatorPlugin": {
|
||||
"name": "Token Cost Calculator",
|
||||
"badge": "unofficial",
|
||||
"description": "Calculate API costs from model prices and token usage, with preset model pricing support.",
|
||||
"install": "Install"
|
||||
},
|
||||
"taskQueuePlugin": {
|
||||
"name": "Task Queue",
|
||||
"badge": "unofficial",
|
||||
"description": "Task queue dashboard to view, filter, and launch agent tasks.",
|
||||
"install": "Install"
|
||||
},
|
||||
"githubIssuesBoardPlugin": {
|
||||
"name": "GitHub Issues Board",
|
||||
"badge": "unofficial",
|
||||
"description": "Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install",
|
||||
"install": "Install"
|
||||
},
|
||||
"morePlugins": "More",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
|
||||
37
src/i18n/locales/fr/auth.json
Normal file
37
src/i18n/locales/fr/auth.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Bon retour",
|
||||
"description": "Connectez-vous à votre compte CloudCLI auto-hébergé",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"submit": "Se connecter",
|
||||
"loading": "Connexion en cours...",
|
||||
"errors": {
|
||||
"invalidCredentials": "Nom d'utilisateur ou mot de passe incorrect",
|
||||
"requiredFields": "Veuillez remplir tous les champs",
|
||||
"networkError": "Erreur réseau. Veuillez réessayer."
|
||||
},
|
||||
"placeholders": {
|
||||
"username": "Entrez votre nom d'utilisateur",
|
||||
"password": "Entrez votre mot de passe"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "Créer un compte",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"submit": "Créer le compte",
|
||||
"loading": "Création du compte...",
|
||||
"errors": {
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"usernameTaken": "Ce nom d'utilisateur est déjà pris",
|
||||
"weakPassword": "Le mot de passe est trop faible"
|
||||
}
|
||||
},
|
||||
"logout": {
|
||||
"title": "Se déconnecter",
|
||||
"confirm": "Êtes-vous sûr de vouloir vous déconnecter ?",
|
||||
"button": "Se déconnecter"
|
||||
}
|
||||
}
|
||||
241
src/i18n/locales/fr/chat.json
Normal file
241
src/i18n/locales/fr/chat.json
Normal file
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"codeBlock": {
|
||||
"copy": "Copier",
|
||||
"copied": "Copié",
|
||||
"copyCode": "Copier le code"
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "Copier le message",
|
||||
"copied": "Message copié",
|
||||
"selectFormat": "Sélectionner le format de copie",
|
||||
"copyAsMarkdown": "Copier en markdown",
|
||||
"copyAsText": "Copier en texte brut"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
"error": "Erreur",
|
||||
"tool": "Outil",
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "Paramètres de l'outil",
|
||||
"error": "Erreur de l'outil",
|
||||
"result": "Résultat de l'outil",
|
||||
"viewParams": "Voir les paramètres d'entrée",
|
||||
"viewRawParams": "Voir les paramètres bruts",
|
||||
"viewDiff": "Voir les différences pour",
|
||||
"creatingFile": "Création du fichier :",
|
||||
"updatingTodo": "Mise à jour de la liste de tâches",
|
||||
"read": "Lire",
|
||||
"readFile": "Lire le fichier",
|
||||
"updateTodo": "Mettre à jour la liste de tâches",
|
||||
"readTodo": "Lire la liste de tâches",
|
||||
"searchResults": "résultats"
|
||||
},
|
||||
"search": {
|
||||
"found": "{{count}} {{type}} trouvé(s)",
|
||||
"file": "fichier",
|
||||
"files": "fichiers",
|
||||
"pattern": "motif :",
|
||||
"in": "dans :"
|
||||
},
|
||||
"fileOperations": {
|
||||
"updated": "Fichier mis à jour avec succès",
|
||||
"created": "Fichier créé avec succès",
|
||||
"written": "Fichier écrit avec succès",
|
||||
"diff": "Diff",
|
||||
"newFile": "Nouveau fichier",
|
||||
"viewContent": "Voir le contenu du fichier",
|
||||
"viewFullOutput": "Voir la sortie complète ({{count}} caractères)",
|
||||
"contentDisplayed": "Le contenu du fichier est affiché dans la vue diff ci-dessus"
|
||||
},
|
||||
"interactive": {
|
||||
"title": "Invite interactive",
|
||||
"waiting": "En attente de votre réponse dans le CLI",
|
||||
"instruction": "Veuillez sélectionner une option dans votre terminal où Claude s'exécute.",
|
||||
"selectedOption": "✓ Claude a sélectionné l'option {{number}}",
|
||||
"instructionDetail": "Dans le CLI, vous sélectionneriez cette option de manière interactive avec les touches fléchées ou en tapant le numéro."
|
||||
},
|
||||
"thinking": {
|
||||
"title": "Réflexion...",
|
||||
"emoji": "💭 Réflexion..."
|
||||
},
|
||||
"json": {
|
||||
"response": "Réponse JSON"
|
||||
},
|
||||
"permissions": {
|
||||
"grant": "Autoriser {{tool}}",
|
||||
"added": "Permission ajoutée",
|
||||
"addTo": "Ajoute {{entry}} aux outils autorisés.",
|
||||
"retry": "Permission enregistrée. Relancez la requête pour utiliser l'outil.",
|
||||
"error": "Impossible de mettre à jour les permissions. Veuillez réessayer.",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
},
|
||||
"todo": {
|
||||
"updated": "La liste de tâches a été mise à jour avec succès",
|
||||
"current": "Liste de tâches actuelle"
|
||||
},
|
||||
"plan": {
|
||||
"viewPlan": "📋 Voir le plan d'implémentation",
|
||||
"title": "Plan d'implémentation"
|
||||
},
|
||||
"usageLimit": {
|
||||
"resetAt": "Limite d'utilisation Claude atteinte. Votre limite sera réinitialisée à **{{time}} {{timezone}}** - {{date}}"
|
||||
},
|
||||
"codex": {
|
||||
"permissionMode": "Mode de permission",
|
||||
"modes": {
|
||||
"default": "Mode par défaut",
|
||||
"auto": "Mode automatique",
|
||||
"acceptEdits": "Accepter les modifications",
|
||||
"bypassPermissions": "Contourner les permissions",
|
||||
"plan": "Mode planification"
|
||||
},
|
||||
"descriptions": {
|
||||
"default": "Seules les commandes de confiance (ls, cat, grep, git status, etc.) s'exécutent automatiquement. Les autres commandes sont ignorées. Peut écrire dans l'espace de travail.",
|
||||
"auto": "Un classifieur de modèle décide pour chaque appel d'outil d'approuver ou refuser. Mode mains libres, mais plus sûr que le contournement — des refus peuvent toujours se produire.",
|
||||
"acceptEdits": "Toutes les commandes s'exécutent automatiquement dans l'espace de travail. Mode entièrement automatique avec exécution sandboxée.",
|
||||
"bypassPermissions": "Accès système complet sans restrictions. Toutes les commandes s'exécutent automatiquement avec accès disque et réseau complet. À utiliser avec précaution.",
|
||||
"plan": "Mode planification - aucune commande n'est exécutée"
|
||||
},
|
||||
"technicalDetails": "Détails techniques"
|
||||
},
|
||||
"gemini": {
|
||||
"permissionMode": "Mode de permission Gemini",
|
||||
"description": "Contrôlez comment Gemini CLI gère les approbations d'opérations.",
|
||||
"modes": {
|
||||
"default": {
|
||||
"title": "Standard (demander l'approbation)",
|
||||
"description": "Gemini demandera une approbation avant d'exécuter des commandes, d'écrire des fichiers et de récupérer des ressources web."
|
||||
},
|
||||
"autoEdit": {
|
||||
"title": "Modification automatique (ignorer les approbations de fichiers)",
|
||||
"description": "Gemini approuvera automatiquement les modifications de fichiers et les récupérations web, mais demandera toujours pour les commandes shell."
|
||||
},
|
||||
"yolo": {
|
||||
"title": "YOLO (contourner toutes les permissions)",
|
||||
"description": "Gemini exécutera toutes les opérations sans demander d'approbation. Utilisez avec prudence."
|
||||
}
|
||||
}
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Tapez / pour les commandes, @ pour les fichiers, ou posez une question à {{provider}}...",
|
||||
"placeholderDefault": "Tapez votre message...",
|
||||
"disabled": "Saisie désactivée",
|
||||
"attachFiles": "Joindre des fichiers",
|
||||
"attachImages": "Joindre des images",
|
||||
"send": "Envoyer",
|
||||
"stop": "Arrêter",
|
||||
"hintText": {
|
||||
"ctrlEnter": "Ctrl+Entrée pour envoyer • Maj+Entrée pour nouvelle ligne • Tab pour changer de mode • / pour les commandes slash",
|
||||
"enter": "Entrée pour envoyer • Maj+Entrée pour nouvelle ligne • Tab pour changer de mode • / pour les commandes slash"
|
||||
},
|
||||
"clickToChangeMode": "Cliquez pour changer le mode de permission (ou appuyez sur Tab dans la saisie)",
|
||||
"showAllCommands": "Afficher toutes les commandes",
|
||||
"clearInput": "Effacer la saisie",
|
||||
"scrollToBottom": "Défiler vers le bas"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "Choisissez votre assistant IA",
|
||||
"description": "Sélectionnez un fournisseur pour démarrer une nouvelle conversation",
|
||||
"selectModel": "Sélectionner un modèle",
|
||||
"providerInfo": {
|
||||
"anthropic": "par Anthropic",
|
||||
"openai": "par OpenAI",
|
||||
"cursorEditor": "Éditeur de code IA",
|
||||
"google": "par Google"
|
||||
},
|
||||
"readyPrompt": {
|
||||
"claude": "Prêt à utiliser Claude avec {{model}}. Commencez à taper votre message ci-dessous.",
|
||||
"cursor": "Prêt à utiliser Cursor avec {{model}}. Commencez à taper votre message ci-dessous.",
|
||||
"codex": "Prêt à utiliser Codex avec {{model}}. Commencez à taper votre message ci-dessous.",
|
||||
"gemini": "Prêt à utiliser Gemini avec {{model}}. Commencez à taper votre message ci-dessous.",
|
||||
"opencode": "Prêt à utiliser OpenCode avec {{model}}. Commencez à taper votre message ci-dessous.",
|
||||
"default": "Sélectionnez un fournisseur ci-dessus pour commencer"
|
||||
},
|
||||
"pressToSearch": "Appuyez sur <kbd>{{shortcut}}</kbd> pour rechercher sessions, fichiers et commits"
|
||||
},
|
||||
"session": {
|
||||
"continue": {
|
||||
"title": "Continuer votre conversation",
|
||||
"description": "Posez des questions sur votre code, demandez des modifications ou obtenez de l'aide pour vos tâches de développement"
|
||||
},
|
||||
"loading": {
|
||||
"olderMessages": "Chargement des messages précédents...",
|
||||
"sessionMessages": "Chargement des messages de la session..."
|
||||
},
|
||||
"messages": {
|
||||
"showingOf": "Affichage de {{shown}} sur {{total}} messages",
|
||||
"scrollToLoad": "Faites défiler vers le haut pour charger plus",
|
||||
"showingLast": "Affichage des {{count}} derniers messages ({{total}} au total)",
|
||||
"loadEarlier": "Charger les messages précédents",
|
||||
"loadAll": "Charger tous les messages",
|
||||
"loadingAll": "Chargement de tous les messages...",
|
||||
"allLoaded": "Tous les messages chargés",
|
||||
"perfWarning": "Tous les messages chargés — le défilement peut être plus lent. Cliquez sur « Défiler vers le bas » pour rétablir les performances."
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"selectProject": {
|
||||
"title": "Sélectionner un projet",
|
||||
"description": "Choisissez un projet pour ouvrir un shell interactif dans ce répertoire"
|
||||
},
|
||||
"status": {
|
||||
"newSession": "Nouvelle session",
|
||||
"initializing": "Initialisation...",
|
||||
"restarting": "Redémarrage..."
|
||||
},
|
||||
"actions": {
|
||||
"disconnect": "Déconnecter",
|
||||
"disconnectTitle": "Se déconnecter du shell",
|
||||
"restart": "Redémarrer",
|
||||
"restartTitle": "Redémarrer le shell",
|
||||
"connect": "Continuer dans le shell",
|
||||
"connectTitle": "Se connecter au shell"
|
||||
},
|
||||
"loading": "Chargement du terminal...",
|
||||
"connecting": "Connexion au shell...",
|
||||
"startSession": "Démarrer une nouvelle session Claude",
|
||||
"resumeSession": "Reprendre la session : {{displayName}}...",
|
||||
"runCommand": "Exécuter {{command}} dans {{projectName}}",
|
||||
"startCli": "Démarrage du CLI Claude dans {{projectName}}",
|
||||
"defaultCommand": "commande"
|
||||
},
|
||||
"claudeStatus": {
|
||||
"actions": {
|
||||
"thinking": "Réflexion",
|
||||
"processing": "Traitement",
|
||||
"analyzing": "Analyse",
|
||||
"working": "Travail",
|
||||
"computing": "Calcul",
|
||||
"reasoning": "Raisonnement"
|
||||
},
|
||||
"state": {
|
||||
"live": "En direct",
|
||||
"paused": "En pause"
|
||||
},
|
||||
"elapsed": {
|
||||
"seconds": "{{count}}s",
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"label": "{{time}} écoulé",
|
||||
"startingNow": "Démarrage"
|
||||
},
|
||||
"controls": {
|
||||
"stopGeneration": "Arrêter la génération",
|
||||
"pressEscToStop": "Appuyez sur Échap à tout moment pour arrêter"
|
||||
},
|
||||
"providers": {
|
||||
"assistant": "Assistant"
|
||||
}
|
||||
},
|
||||
"projectSelection": {
|
||||
"startChatWithProvider": "Sélectionnez un projet pour commencer à chatter avec {{provider}}"
|
||||
},
|
||||
"tasks": {
|
||||
"nextTaskPrompt": "Commencer la prochaine tâche"
|
||||
}
|
||||
}
|
||||
36
src/i18n/locales/fr/codeEditor.json
Normal file
36
src/i18n/locales/fr/codeEditor.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"toolbar": {
|
||||
"changes": "modifications",
|
||||
"previousChange": "Modification précédente",
|
||||
"nextChange": "Modification suivante",
|
||||
"hideDiff": "Masquer la mise en évidence des différences",
|
||||
"showDiff": "Afficher la mise en évidence des différences",
|
||||
"settings": "Paramètres de l'éditeur",
|
||||
"collapse": "Réduire l'éditeur",
|
||||
"expand": "Étendre l'éditeur en pleine largeur"
|
||||
},
|
||||
"loading": "Chargement de {{fileName}}...",
|
||||
"header": {
|
||||
"showingChanges": "Affichage des modifications"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Télécharger le fichier",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"saved": "Enregistré !",
|
||||
"exitFullscreen": "Quitter le plein écran",
|
||||
"fullscreen": "Plein écran",
|
||||
"close": "Fermer",
|
||||
"previewMarkdown": "Aperçu markdown",
|
||||
"editMarkdown": "Modifier le markdown"
|
||||
},
|
||||
"footer": {
|
||||
"lines": "Lignes :",
|
||||
"characters": "Caractères :",
|
||||
"shortcuts": "Ctrl+S pour enregistrer • Échap pour fermer"
|
||||
},
|
||||
"binaryFile": {
|
||||
"title": "Fichier binaire",
|
||||
"message": "Le fichier \"{{fileName}}\" ne peut pas être affiché dans l'éditeur de texte car c'est un fichier binaire."
|
||||
}
|
||||
}
|
||||
267
src/i18n/locales/fr/common.json
Normal file
267
src/i18n/locales/fr/common.json
Normal file
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"buttons": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"create": "Créer",
|
||||
"edit": "Modifier",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"submit": "Soumettre",
|
||||
"retry": "Réessayer",
|
||||
"refresh": "Actualiser",
|
||||
"search": "Rechercher",
|
||||
"clear": "Effacer",
|
||||
"copy": "Copier",
|
||||
"download": "Télécharger",
|
||||
"upload": "Envoyer",
|
||||
"browse": "Parcourir"
|
||||
},
|
||||
"tabs": {
|
||||
"chat": "Chat",
|
||||
"shell": "Terminal",
|
||||
"files": "Fichiers",
|
||||
"git": "Contrôle de source",
|
||||
"tasks": "Tâches"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
"success": "Succès",
|
||||
"error": "Erreur",
|
||||
"failed": "Échec",
|
||||
"pending": "En attente",
|
||||
"completed": "Terminé",
|
||||
"inProgress": "En cours"
|
||||
},
|
||||
"messages": {
|
||||
"savedSuccessfully": "Enregistré avec succès",
|
||||
"deletedSuccessfully": "Supprimé avec succès",
|
||||
"updatedSuccessfully": "Mis à jour avec succès",
|
||||
"operationFailed": "Opération échouée",
|
||||
"networkError": "Erreur réseau. Vérifiez votre connexion.",
|
||||
"unauthorized": "Non autorisé. Veuillez vous connecter.",
|
||||
"notFound": "Introuvable",
|
||||
"invalidInput": "Entrée invalide",
|
||||
"requiredField": "Ce champ est obligatoire",
|
||||
"unknownError": "Une erreur inconnue s'est produite"
|
||||
},
|
||||
"navigation": {
|
||||
"settings": "Paramètres",
|
||||
"home": "Accueil",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"previous": "Précédent",
|
||||
"logout": "Déconnexion"
|
||||
},
|
||||
"common": {
|
||||
"language": "Langue",
|
||||
"theme": "Thème",
|
||||
"darkMode": "Mode sombre",
|
||||
"lightMode": "Mode clair",
|
||||
"name": "Nom",
|
||||
"description": "Description",
|
||||
"enabled": "Activé",
|
||||
"disabled": "Désactivé",
|
||||
"optional": "Optionnel",
|
||||
"version": "Version",
|
||||
"select": "Sélectionner",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"deselectAll": "Tout désélectionner"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "À l'instant",
|
||||
"minutesAgo": "Il y a {{count}} min",
|
||||
"hoursAgo": "Il y a {{count}} h",
|
||||
"daysAgo": "Il y a {{count}} j",
|
||||
"yesterday": "Hier"
|
||||
},
|
||||
"fileOperations": {
|
||||
"newFile": "Nouveau fichier",
|
||||
"newFolder": "Nouveau dossier",
|
||||
"rename": "Renommer",
|
||||
"move": "Déplacer",
|
||||
"copyPath": "Copier le chemin",
|
||||
"openInEditor": "Ouvrir dans l'éditeur"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "Chargement de CloudCLI",
|
||||
"settingUpWorkspace": "Préparation de votre espace de travail...",
|
||||
"chooseProject": "Choisissez votre projet",
|
||||
"selectProjectDescription": "Sélectionnez un projet dans la barre latérale pour commencer à coder avec Claude. Chaque projet contient vos sessions de chat et l'historique des fichiers.",
|
||||
"tip": "Astuce",
|
||||
"createProjectMobile": "Appuyez sur le bouton menu ci-dessus pour accéder aux projets",
|
||||
"createProjectDesktop": "Créez un nouveau projet en cliquant sur l'icône de dossier dans la barre latérale",
|
||||
"newSession": "Nouvelle session",
|
||||
"untitledSession": "Session sans titre",
|
||||
"projectFiles": "Fichiers du projet"
|
||||
},
|
||||
"fileTree": {
|
||||
"loading": "Chargement des fichiers...",
|
||||
"files": "Fichiers",
|
||||
"simpleView": "Vue simple",
|
||||
"compactView": "Vue compacte",
|
||||
"detailedView": "Vue détaillée",
|
||||
"searchPlaceholder": "Rechercher fichiers et dossiers...",
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"name": "Nom",
|
||||
"size": "Taille",
|
||||
"modified": "Modifié",
|
||||
"permissions": "Permissions",
|
||||
"noFilesFound": "Aucun fichier trouvé",
|
||||
"checkProjectPath": "Vérifiez si le chemin du projet est accessible",
|
||||
"noMatchesFound": "Aucun résultat",
|
||||
"tryDifferentSearch": "Essayez un autre terme ou effacez la recherche",
|
||||
"justNow": "à l'instant",
|
||||
"minAgo": "il y a {{count}} min",
|
||||
"hoursAgo": "il y a {{count}} h",
|
||||
"daysAgo": "il y a {{count}} j",
|
||||
"newFile": "Nouveau fichier (Cmd+N)",
|
||||
"newFolder": "Nouveau dossier (Cmd+Maj+N)",
|
||||
"refresh": "Actualiser",
|
||||
"collapseAll": "Tout réduire",
|
||||
"context": {
|
||||
"rename": "Renommer",
|
||||
"delete": "Supprimer",
|
||||
"copyPath": "Copier le chemin",
|
||||
"download": "Télécharger",
|
||||
"newFile": "Nouveau fichier",
|
||||
"newFolder": "Nouveau dossier",
|
||||
"refresh": "Actualiser",
|
||||
"menuLabel": "Menu contextuel du fichier",
|
||||
"loading": "Chargement..."
|
||||
}
|
||||
},
|
||||
"projectWizard": {
|
||||
"title": "Créer un nouveau projet",
|
||||
"steps": {
|
||||
"type": "Type",
|
||||
"configure": "Configurer",
|
||||
"confirm": "Confirmer"
|
||||
},
|
||||
"step1": {
|
||||
"question": "Avez-vous déjà un espace de travail, ou souhaitez-vous en créer un nouveau ?",
|
||||
"existing": {
|
||||
"title": "Espace de travail existant",
|
||||
"description": "J'ai déjà un espace de travail sur mon serveur et je veux juste l'ajouter à la liste des projets"
|
||||
},
|
||||
"new": {
|
||||
"title": "Nouvel espace de travail",
|
||||
"description": "Créer un nouvel espace de travail, éventuellement cloné depuis un dépôt GitHub"
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"existingPath": "Chemin de l'espace de travail",
|
||||
"newPath": "Chemin de l'espace de travail",
|
||||
"existingPlaceholder": "/chemin/vers/espace-de-travail",
|
||||
"newPlaceholder": "/chemin/vers/nouvel-espace",
|
||||
"existingHelp": "Chemin complet vers votre répertoire d'espace de travail existant",
|
||||
"newHelp": "Chemin complet vers votre répertoire d'espace de travail",
|
||||
"githubUrl": "URL GitHub (optionnel)",
|
||||
"githubPlaceholder": "https://github.com/utilisateur/depot",
|
||||
"githubHelp": "Optionnel : fournissez une URL GitHub pour cloner un dépôt",
|
||||
"githubAuth": "Authentification GitHub (optionnel)",
|
||||
"githubAuthHelp": "Uniquement requis pour les dépôts privés. Les dépôts publics peuvent être clonés sans authentification.",
|
||||
"loadingTokens": "Chargement des tokens enregistrés...",
|
||||
"storedToken": "Token enregistré",
|
||||
"newToken": "Nouveau token",
|
||||
"nonePublic": "Aucun (Public)",
|
||||
"selectToken": "Sélectionner un token",
|
||||
"selectTokenPlaceholder": "-- Sélectionner un token --",
|
||||
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"tokenHelp": "Ce token sera utilisé uniquement pour cette opération",
|
||||
"publicRepoInfo": "Les dépôts publics ne nécessitent pas d'authentification. Vous pouvez ignorer le token pour cloner un dépôt public.",
|
||||
"noTokensHelp": "Aucun token enregistré. Vous pouvez en ajouter dans Paramètres → Clés API.",
|
||||
"optionalTokenPublic": "Token GitHub (optionnel pour les dépôts publics)",
|
||||
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (laisser vide pour les dépôts publics)"
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "Vérifiez votre configuration",
|
||||
"existingWorkspace": "Espace de travail existant",
|
||||
"newWorkspace": "Nouvel espace de travail",
|
||||
"path": "Chemin :",
|
||||
"cloneFrom": "Cloner depuis :",
|
||||
"authentication": "Authentification :",
|
||||
"usingStoredToken": "Utilisation du token enregistré :",
|
||||
"usingProvidedToken": "Utilisation du token fourni",
|
||||
"noAuthentication": "Sans authentification",
|
||||
"sshKey": "Clé SSH",
|
||||
"existingInfo": "L'espace de travail sera ajouté à votre liste de projets et disponible pour les sessions Claude/Cursor.",
|
||||
"newWithClone": "Le dépôt sera cloné depuis ce dossier.",
|
||||
"newEmpty": "L'espace de travail sera ajouté à votre liste de projets et disponible pour les sessions Claude/Cursor.",
|
||||
"cloningRepository": "Clonage du dépôt..."
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Annuler",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"createProject": "Créer le projet",
|
||||
"creating": "Création...",
|
||||
"cloning": "Clonage..."
|
||||
},
|
||||
"errors": {
|
||||
"selectType": "Veuillez indiquer si vous avez un espace de travail existant ou si vous souhaitez en créer un nouveau",
|
||||
"providePath": "Veuillez fournir un chemin d'espace de travail",
|
||||
"failedToCreate": "Échec de la création de l'espace de travail",
|
||||
"failedToCreateFolder": "Échec de la création du dossier"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "un outil",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "Notification"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "Action requise",
|
||||
"body": "{{toolName}} attend votre décision."
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "Exécution arrêtée",
|
||||
"body": "Raison : {{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "Exécution échouée"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "Notification de l'agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "Mise à jour disponible",
|
||||
"newVersionReady": "Une nouvelle version est prête",
|
||||
"currentVersion": "Version actuelle",
|
||||
"latestVersion": "Dernière version",
|
||||
"whatsNew": "Nouveautés :",
|
||||
"viewFullRelease": "Voir les notes de version complètes",
|
||||
"updateProgress": "Progression de la mise à jour :",
|
||||
"manualUpgrade": "Mise à jour manuelle :",
|
||||
"npmUpgradeCommand": "npm install -g @cloudcli-ai/cloudcli@latest",
|
||||
"manualUpgradeHint": "Ou cliquez sur « Mettre à jour maintenant » pour lancer la mise à jour automatiquement.",
|
||||
"updateCompleted": "Mise à jour effectuée avec succès !",
|
||||
"restartServer": "Veuillez redémarrer le serveur pour appliquer les modifications.",
|
||||
"updateFailed": "Échec de la mise à jour",
|
||||
"buttons": {
|
||||
"close": "Fermer",
|
||||
"later": "Plus tard",
|
||||
"copyCommand": "Copier la commande",
|
||||
"updateNow": "Mettre à jour maintenant",
|
||||
"updating": "Mise à jour..."
|
||||
},
|
||||
"ariaLabels": {
|
||||
"closeModal": "Fermer la fenêtre de mise à jour",
|
||||
"showSidebar": "Afficher la barre latérale",
|
||||
"settings": "Paramètres",
|
||||
"updateAvailable": "Mise à jour disponible",
|
||||
"closeSidebar": "Masquer la barre latérale"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user