mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-29 16:12:53 +08:00
Compare commits
105 Commits
fix/codex-
...
fix/sideba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98f6596b9d | ||
|
|
261690935f | ||
|
|
0168da7bcd | ||
|
|
3bcb541560 | ||
|
|
fcc469b55c | ||
|
|
2afe0955ed | ||
|
|
c88baaf8dc | ||
|
|
f8430dc886 | ||
|
|
98a3a3a1f4 | ||
|
|
2c08060f65 | ||
|
|
75bbafb438 | ||
|
|
7c8928c66d | ||
|
|
ed4ae3114a | ||
|
|
46ba8e56b4 | ||
|
|
a0899a252e | ||
|
|
591e8e7642 | ||
|
|
3bc2c777a3 | ||
|
|
63f3c3941d | ||
|
|
e6c6f89dda | ||
|
|
c947eaaee5 | ||
|
|
6f712269e8 | ||
|
|
52244404a3 | ||
|
|
8ad18f8587 | ||
|
|
fe116a7138 | ||
|
|
490e66ebdb | ||
|
|
81eb966904 | ||
|
|
0d68dc2cd0 | ||
|
|
4a503b1dc8 | ||
|
|
f6326c8082 | ||
|
|
c5fe127958 | ||
|
|
bb630ef739 | ||
|
|
1c05fe0905 | ||
|
|
4712431be8 | ||
|
|
077baee5f2 | ||
|
|
f150fa6b09 | ||
|
|
9f8cee8919 | ||
|
|
bb323fc566 | ||
|
|
5ef40be2d3 | ||
|
|
cf4b28273e | ||
|
|
f4c68942a5 | ||
|
|
4d70a2588c | ||
|
|
218e8e2e38 | ||
|
|
53c3c4c27a | ||
|
|
901c6fc956 | ||
|
|
278fe4f7b1 | ||
|
|
d7f4d4c342 | ||
|
|
d1930fecdb | ||
|
|
1726705459 | ||
|
|
a35200f340 | ||
|
|
06c9745489 | ||
|
|
0dd22db2bb | ||
|
|
e7aa72c41e | ||
|
|
9f24f80f33 | ||
|
|
25ab273b05 | ||
|
|
5be100ea1b | ||
|
|
2af3d38afe | ||
|
|
531833bc87 | ||
|
|
b2333e7d93 | ||
|
|
f75ae385dd | ||
|
|
7ca355651f | ||
|
|
a12ca8eed3 | ||
|
|
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 |
109
.github/workflows/desktop-macos-branch-build.yml
vendored
Normal file
109
.github/workflows/desktop-macos-branch-build.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
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: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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"
|
||||
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure branch server bundle source
|
||||
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
|
||||
|
||||
- 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:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
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 branch server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Verify branch server runtime artifacts
|
||||
run: |
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||
|
||||
- name: Publish branch server bundle
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
|
||||
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
|
||||
body: |
|
||||
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
|
||||
|
||||
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||
|
||||
You do not need to download these runtime files manually.
|
||||
prerelease: true
|
||||
fail_on_unmatched_files: false
|
||||
overwrite_files: true
|
||||
files: |
|
||||
release/local-server/*
|
||||
|
||||
- name: Verify macOS artifacts
|
||||
run: |
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
|
||||
cat release/SHASUMS256.txt
|
||||
|
||||
- name: Upload branch build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: |
|
||||
release/desktop/*.dmg
|
||||
release/SHASUMS256.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
151
.github/workflows/desktop-macos-release.yml
vendored
Normal file
151
.github/workflows/desktop-macos-release.yml
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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
|
||||
env:
|
||||
TAG_INPUT: ${{ inputs.tag }}
|
||||
RELEASE_NAME_INPUT: ${{ inputs.release_name }}
|
||||
run: |
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
TAG="$TAG_INPUT"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="v${VERSION}"
|
||||
fi
|
||||
TAG="$(printf '%s' "$TAG" | tr -d '\r\n' | sed 's/[^A-Za-z0-9._-]/-/g')"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "Resolved release tag is empty after normalization." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_NAME="$RELEASE_NAME_INPUT"
|
||||
if [ -z "$RELEASE_NAME" ]; then
|
||||
RELEASE_NAME="CloudCLI Desktop macOS ${TAG}"
|
||||
fi
|
||||
RELEASE_NAME_DELIMITER="release_name_$(uuidgen)"
|
||||
|
||||
{
|
||||
echo "tag=$TAG"
|
||||
echo "release_name<<$RELEASE_NAME_DELIMITER"
|
||||
printf '%s\n' "$RELEASE_NAME"
|
||||
echo "$RELEASE_NAME_DELIMITER"
|
||||
echo "server_bundle_tag=cloudcli-local-server-${TAG}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure release server bundle source
|
||||
env:
|
||||
SERVER_BUNDLE_TAG: ${{ steps.release.outputs.server_bundle_tag }}
|
||||
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
|
||||
|
||||
- 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:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
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 local server runtime artifacts
|
||||
run: |
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||
|
||||
- name: Publish local server runtime assets
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.release.outputs.server_bundle_tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: CloudCLI Local Server Runtime (${{ steps.release.outputs.tag }})
|
||||
body: |
|
||||
This prerelease contains the Local mode runtime for CloudCLI Desktop.
|
||||
|
||||
Download CloudCLI Desktop from the main ${{ steps.release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||
|
||||
You do not need to download these runtime files manually.
|
||||
prerelease: true
|
||||
fail_on_unmatched_files: false
|
||||
overwrite_files: true
|
||||
files: |
|
||||
release/local-server/*
|
||||
|
||||
- name: Verify macOS artifacts
|
||||
run: |
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
|
||||
cat release/SHASUMS256.txt
|
||||
|
||||
- name: Publish GitHub release assets
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.release.outputs.tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: ${{ steps.release.outputs.release_name }}
|
||||
body: |
|
||||
Download the CloudCLI Desktop installer for your Mac.
|
||||
|
||||
The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually.
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
fail_on_unmatched_files: false
|
||||
files: |
|
||||
release/desktop/*.dmg
|
||||
release/SHASUMS256.txt
|
||||
95
.github/workflows/desktop-windows-branch-build.yml
vendored
Normal file
95
.github/workflows/desktop-windows-branch-build.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
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: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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"
|
||||
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure branch server bundle source
|
||||
shell: bash
|
||||
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
|
||||
|
||||
- name: Build unsigned Windows artifacts
|
||||
run: npm run desktop:dist:win -- --publish never
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
|
||||
- name: Build branch server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Verify branch server runtime artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||
|
||||
- name: Publish branch server bundle
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
|
||||
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
|
||||
body: |
|
||||
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
|
||||
|
||||
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||
|
||||
You do not need to download these runtime files manually.
|
||||
prerelease: true
|
||||
fail_on_unmatched_files: false
|
||||
overwrite_files: true
|
||||
files: |
|
||||
release/local-server/*
|
||||
|
||||
- name: Verify Windows artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||
sha256sum release/desktop/*.exe > release/SHASUMS256.txt
|
||||
cat release/SHASUMS256.txt
|
||||
|
||||
- name: Upload branch build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: |
|
||||
release/desktop/*.exe
|
||||
release/SHASUMS256.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
99
.github/workflows/release.yml
vendored
99
.github/workflows/release.yml
vendored
@@ -13,19 +13,100 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# This workflow publishes releases with write credentials, so actions are pinned
|
||||
# to immutable commit SHAs. The trailing comments keep the original major tag
|
||||
# visible for maintenance context.
|
||||
jobs:
|
||||
build-macos-semantic-helper:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runs_on: macos-15
|
||||
target_dir: darwin-arm64
|
||||
- runs_on: macos-15-intel
|
||||
target_dir: darwin-x64
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build macOS semantic helper
|
||||
run: node scripts/build-computer-semantics.mjs
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
- name: Verify macOS semantic helper target
|
||||
run: test -x "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics"
|
||||
- name: Stage macOS semantic helper artifact
|
||||
run: |
|
||||
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
||||
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: semantic-helper-${{ matrix.target_dir }}
|
||||
path: semantic-helper-artifact/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows-semantic-helper:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runs_on: windows-2025
|
||||
target_dir: win32-x64
|
||||
- runs_on: windows-11-arm
|
||||
target_dir: win32-arm64
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build Windows semantic helper
|
||||
run: node scripts/build-computer-semantics.mjs
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
- name: Verify Windows semantic helper target
|
||||
shell: bash
|
||||
run: test -f "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe"
|
||||
- name: Stage Windows semantic helper artifact
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
||||
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: semantic-helper-${{ matrix.target_dir }}
|
||||
path: semantic-helper-artifact/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs:
|
||||
- build-macos-semantic-helper
|
||||
- build-windows-semantic-helper
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -37,6 +118,20 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
with:
|
||||
pattern: semantic-helper-*
|
||||
path: server/modules/computer-use/semantics/bin
|
||||
merge-multiple: true
|
||||
|
||||
- name: Verify bundled semantic helpers
|
||||
run: |
|
||||
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics
|
||||
test -x server/modules/computer-use/semantics/bin/darwin-x64/CloudCLISemantics
|
||||
test -f server/modules/computer-use/semantics/bin/win32-x64/CloudCLISemantics.exe
|
||||
test -f server/modules/computer-use/semantics/bin/win32-arm64/CloudCLISemantics.exe
|
||||
find server/modules/computer-use/semantics/bin -maxdepth 2 -type f -print
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
ARGS="--ci --increment=${{ inputs.increment }}"
|
||||
|
||||
9
.gitignore
vendored
9
.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,11 @@ tasks/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Local desktop packaging artifacts
|
||||
/.desktop-build/
|
||||
/release/
|
||||
/electron/server-bundle-config.json
|
||||
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 |
BIN
electron/assets/logo-windows.ico
Normal file
BIN
electron/assets/logo-windows.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
260
electron/cloud.js
Normal file
260
electron/cloud.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { safeStorage } from 'electron';
|
||||
|
||||
const CLOUD_API_TIMEOUT_MS = 15000;
|
||||
|
||||
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;
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
|
||||
...options,
|
||||
signal: options.signal || controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.cloudAccount.apiKey,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`CloudCLI API request timed out after ${Math.round(CLOUD_API_TIMEOUT_MS / 1000)} seconds.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
290
electron/computerAgent.js
Normal file
290
electron/computerAgent.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const IPC_PREFIX = '@@CUAGENT@@';
|
||||
const TARGET_STATUS_TIMEOUT_MS = 5000;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function isComputerUseEnabledTarget(httpUrl, apiKey) {
|
||||
let statusUrl;
|
||||
try {
|
||||
statusUrl = new URL('/api/computer-use/status', httpUrl).toString();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TARGET_STATUS_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(statusUrl, {
|
||||
signal: controller.signal,
|
||||
headers: apiKey ? { 'X-API-Key': apiKey } : undefined,
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
return response.ok && body?.success !== false && body?.data?.enabled === true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function filterEnabledComputerUseTargets(targets, apiKey) {
|
||||
const checks = await Promise.all(targets.map(async (target) => ({
|
||||
target,
|
||||
enabled: await isComputerUseEnabledTarget(target, apiKey),
|
||||
})));
|
||||
return checks.filter((item) => item.enabled).map((item) => item.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, getApiKey, promptConsent, onChange }) {
|
||||
this.appRoot = appRoot;
|
||||
this.settingsPath = settingsPath;
|
||||
this.isPackaged = isPackaged;
|
||||
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
|
||||
this.getApiKey = getApiKey;
|
||||
this.promptConsent = promptConsent;
|
||||
this.onChange = onChange;
|
||||
this.settings = { enabled: false, consentMode: 'ask' };
|
||||
this.child = null;
|
||||
this.connectedUrls = new Set();
|
||||
this.currentTargets = [];
|
||||
this.stdoutBuffer = '';
|
||||
this.lastEvent = null;
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
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,
|
||||
targetUrls: [...this.currentTargets],
|
||||
lastEvent: this.lastEvent,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
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 enabledTargets = this.settings.enabled ? await filterEnabledComputerUseTargets(targets, this.getApiKey?.() || '') : [];
|
||||
const wsTargets = enabledTargets.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 = [];
|
||||
this.lastEvent = this.settings.enabled ? 'no-targets' : 'disabled';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.child && sameTargets) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTargets = wsTargets;
|
||||
this.lastEvent = 'restarting';
|
||||
this.lastError = null;
|
||||
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_DESKTOP_AGENT_API_KEY: this.getApiKey?.() || '',
|
||||
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.lastEvent = 'start-error';
|
||||
this.lastError = 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()) {
|
||||
this.lastError = line.trim();
|
||||
console.error('[ComputerAgent]', line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.child.once('exit', (code) => {
|
||||
console.log(`[ComputerAgent] exited (code ${code ?? 'null'})`);
|
||||
this.lastEvent = `exit:${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.lastEvent = 'connected';
|
||||
this.lastError = null;
|
||||
this.onChange?.();
|
||||
break;
|
||||
case 'disconnected':
|
||||
this.connectedUrls.delete(payload.url);
|
||||
this.lastEvent = 'disconnected';
|
||||
this.onChange?.();
|
||||
if (payload.reason && /computer use.*disabled/i.test(payload.reason)) {
|
||||
void this.sync().catch((error) => {
|
||||
this.lastError = error instanceof Error ? error.message : 'Failed to sync Computer Use targets.';
|
||||
this.onChange?.();
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'starting':
|
||||
this.lastEvent = 'starting';
|
||||
this.lastError = null;
|
||||
this.onChange?.();
|
||||
break;
|
||||
case 'error':
|
||||
this.lastEvent = 'error';
|
||||
this.lastError = payload.message || 'Computer agent error.';
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
378
electron/desktopNotifications.js
Normal file
378
electron/desktopNotifications.js
Normal file
@@ -0,0 +1,378 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Notification } from 'electron';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const RECONNECT_MIN_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
const TARGET_REGISTER_TIMEOUT_MS = 8000;
|
||||
|
||||
function toNotificationsWsUrl(httpUrl) {
|
||||
try {
|
||||
const parsed = new URL(httpUrl);
|
||||
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
|
||||
parsed.pathname = '/desktop-notifications';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonMessage(raw) {
|
||||
try {
|
||||
return JSON.parse(String(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body = null, headers = {} } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TARGET_REGISTER_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
...(body == null ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export class DesktopNotificationsController {
|
||||
constructor({
|
||||
settingsPath,
|
||||
appVersion,
|
||||
appName,
|
||||
getDeviceId,
|
||||
getAccountEmail,
|
||||
getRunningEnvironmentUrls,
|
||||
getApiKey,
|
||||
getAuthToken,
|
||||
getIconPath,
|
||||
openNotificationTarget,
|
||||
onChange,
|
||||
}) {
|
||||
this.settingsPath = settingsPath;
|
||||
this.appVersion = appVersion;
|
||||
this.appName = appName;
|
||||
this.getDeviceId = getDeviceId;
|
||||
this.getAccountEmail = getAccountEmail;
|
||||
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
|
||||
this.getApiKey = getApiKey;
|
||||
this.getAuthToken = getAuthToken;
|
||||
this.getIconPath = getIconPath;
|
||||
this.openNotificationTarget = openNotificationTarget;
|
||||
this.onChange = onChange;
|
||||
this.settings = { enabled: false };
|
||||
this.connections = new Map();
|
||||
this.lastEvent = null;
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
getState() {
|
||||
const connectedTargets = [];
|
||||
for (const [url, connection] of this.connections.entries()) {
|
||||
if (connection.ws?.readyState === WebSocket.OPEN) {
|
||||
connectedTargets.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: this.settings.enabled,
|
||||
supported: Notification.isSupported(),
|
||||
targetCount: this.connections.size,
|
||||
connectedCount: connectedTargets.length,
|
||||
connectedTargets,
|
||||
lastEvent: this.lastEvent,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.settingsPath, 'utf8');
|
||||
const stored = JSON.parse(raw);
|
||||
this.settings = { enabled: Boolean(stored.enabled) };
|
||||
} catch {
|
||||
this.settings = { enabled: false };
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
async saveSettings(next) {
|
||||
const enabled = Boolean(next?.enabled);
|
||||
if (!enabled && this.settings.enabled) {
|
||||
await this.disableCurrentTargets();
|
||||
}
|
||||
this.settings = { enabled };
|
||||
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() {
|
||||
if (!this.settings.enabled) {
|
||||
this.stop();
|
||||
this.lastEvent = 'disabled';
|
||||
this.onChange?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
this.stop();
|
||||
this.lastEvent = 'unsupported';
|
||||
this.lastError = 'Native notifications are not supported on this system.';
|
||||
this.onChange?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = this.getDeviceId?.();
|
||||
if (!deviceId) {
|
||||
this.stop();
|
||||
this.lastEvent = 'missing-device';
|
||||
this.lastError = 'Connect a CloudCLI account before enabling desktop notifications.';
|
||||
this.onChange?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = (this.getRunningEnvironmentUrls?.() || [])
|
||||
.map((httpUrl) => ({
|
||||
httpUrl,
|
||||
wsUrl: toNotificationsWsUrl(httpUrl),
|
||||
}))
|
||||
.filter((target) => target.wsUrl);
|
||||
|
||||
const nextWsUrls = new Set(targets.map((target) => target.wsUrl));
|
||||
for (const [wsUrl, connection] of this.connections.entries()) {
|
||||
if (!nextWsUrls.has(wsUrl)) {
|
||||
this.closeConnection(connection);
|
||||
this.connections.delete(wsUrl);
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
if (!this.connections.has(target.wsUrl)) {
|
||||
void this.connect(target).catch((error) => {
|
||||
this.lastEvent = 'connect-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.lastEvent = targets.length ? 'sync' : 'no-targets';
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
async connect(target, attempt = 0) {
|
||||
const existing = this.connections.get(target.wsUrl);
|
||||
if (existing?.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(existing.ws.readyState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = {
|
||||
...target,
|
||||
ws: null,
|
||||
reconnectTimer: null,
|
||||
closed: false,
|
||||
attempt,
|
||||
};
|
||||
this.connections.set(target.wsUrl, connection);
|
||||
|
||||
const headers = await this.getTargetAuthHeaders(target.httpUrl);
|
||||
if (connection.closed || this.connections.get(target.wsUrl) !== connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(target.wsUrl, { headers: Object.keys(headers).length ? headers : undefined });
|
||||
connection.ws = ws;
|
||||
|
||||
ws.on('open', async () => {
|
||||
try {
|
||||
await this.registerTarget(target.httpUrl);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'register',
|
||||
deviceId: this.getDeviceId?.(),
|
||||
label: this.getAccountEmail?.() || this.appName,
|
||||
platform: process.platform,
|
||||
appVersion: this.appVersion,
|
||||
}));
|
||||
connection.attempt = 0;
|
||||
this.lastEvent = 'connected';
|
||||
this.lastError = null;
|
||||
this.onChange?.();
|
||||
} catch (error) {
|
||||
this.lastEvent = 'register-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
try { ws.close(); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => this.handleMessage(target, ws, raw));
|
||||
ws.on('close', () => this.scheduleReconnect(target.wsUrl));
|
||||
ws.on('error', (error) => {
|
||||
this.lastEvent = 'socket-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
});
|
||||
}
|
||||
|
||||
async registerTarget(httpUrl) {
|
||||
const url = new URL('/api/notifications/endpoints/current', httpUrl).toString();
|
||||
await requestJson(url, {
|
||||
method: 'POST',
|
||||
headers: await this.getTargetAuthHeaders(httpUrl),
|
||||
body: {
|
||||
channel: 'desktop',
|
||||
endpointId: this.getDeviceId?.(),
|
||||
label: this.getAccountEmail?.() || this.appName,
|
||||
metadata: {
|
||||
platform: process.platform,
|
||||
appVersion: this.appVersion,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async disableCurrentTargets() {
|
||||
const deviceId = this.getDeviceId?.();
|
||||
if (!deviceId) return;
|
||||
|
||||
const targets = new Set([
|
||||
...[...this.connections.values()].map((connection) => connection.httpUrl).filter(Boolean),
|
||||
...(this.getRunningEnvironmentUrls?.() || []),
|
||||
]);
|
||||
|
||||
const results = await Promise.allSettled([...targets].map(async (httpUrl) => {
|
||||
const url = new URL(`/api/notifications/endpoints/desktop/${encodeURIComponent(deviceId)}`, httpUrl).toString();
|
||||
await requestJson(url, {
|
||||
method: 'PATCH',
|
||||
headers: await this.getTargetAuthHeaders(httpUrl),
|
||||
body: { enabled: false },
|
||||
});
|
||||
}));
|
||||
|
||||
const rejected = results.find((result) => result.status === 'rejected');
|
||||
if (rejected) {
|
||||
this.lastEvent = 'disable-endpoint-error';
|
||||
this.lastError = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason);
|
||||
}
|
||||
}
|
||||
|
||||
async getTargetAuthHeaders(httpUrl) {
|
||||
const headers = {};
|
||||
const apiKey = this.getApiKey?.();
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
|
||||
const authToken = await Promise.resolve(this.getAuthToken?.(httpUrl)).catch(() => null);
|
||||
if (authToken) {
|
||||
headers.Authorization = `Bearer ${authToken}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
handleMessage(target, ws, raw) {
|
||||
const message = readJsonMessage(raw);
|
||||
if (!message || message.type !== 'notification' || !message.payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shown = this.showNativeNotification(target, message.payload);
|
||||
if (shown && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'notification_ack',
|
||||
id: message.id || message.payload?.data?.tag || null,
|
||||
action: 'shown',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
showNativeNotification(target, payload) {
|
||||
if (!Notification.isSupported()) return false;
|
||||
|
||||
const notification = new Notification({
|
||||
title: payload.title || this.appName,
|
||||
body: payload.body || '',
|
||||
icon: this.getIconPath?.(),
|
||||
silent: false,
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
void this.openNotificationTarget?.({
|
||||
environmentUrl: target.httpUrl,
|
||||
sessionId: payload.data?.sessionId || null,
|
||||
provider: payload.data?.provider || null,
|
||||
}).catch((error) => {
|
||||
this.lastEvent = 'click-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
});
|
||||
});
|
||||
|
||||
notification.show();
|
||||
this.lastEvent = 'notification-shown';
|
||||
this.lastError = null;
|
||||
this.onChange?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
scheduleReconnect(wsUrl) {
|
||||
const connection = this.connections.get(wsUrl);
|
||||
if (!connection || connection.closed || !this.settings.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = connection.attempt + 1;
|
||||
connection.attempt = attempt;
|
||||
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * (2 ** Math.min(attempt, 5)));
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
if (!this.connections.has(wsUrl) || !this.settings.enabled) return;
|
||||
void this.connect({
|
||||
httpUrl: connection.httpUrl,
|
||||
wsUrl: connection.wsUrl,
|
||||
}, attempt).catch((error) => {
|
||||
this.lastEvent = 'connect-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
});
|
||||
}, delay);
|
||||
this.lastEvent = 'reconnecting';
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
closeConnection(connection) {
|
||||
connection.closed = true;
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer);
|
||||
connection.reconnectTimer = null;
|
||||
}
|
||||
try { connection.ws?.close(); } catch {}
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const connection of this.connections.values()) {
|
||||
this.closeConnection(connection);
|
||||
}
|
||||
this.connections.clear();
|
||||
this.onChange?.();
|
||||
}
|
||||
}
|
||||
781
electron/desktopWindow.js
Normal file
781
electron/desktopWindow.js
Normal file
@@ -0,0 +1,781 @@
|
||||
import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session, webContents as electronWebContents } from 'electron';
|
||||
|
||||
import { ViewHost } from './viewHost.js';
|
||||
|
||||
const TITLEBAR_HEIGHT = 44;
|
||||
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
|
||||
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
|
||||
// between the desktop app and the web UI.
|
||||
const COMPUTER_USE_MENUS_ENABLED = 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;
|
||||
}
|
||||
}
|
||||
|
||||
function getWebContentsProcessId(contents) {
|
||||
return {
|
||||
osProcessId: typeof contents.getOSProcessId === 'function' ? contents.getOSProcessId() : null,
|
||||
processId: typeof contents.getProcessId === 'function' ? contents.getProcessId() : null,
|
||||
};
|
||||
}
|
||||
|
||||
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.viewHost = new ViewHost({
|
||||
appName: this.appName,
|
||||
getMainWindow: () => this.mainWindow,
|
||||
getContentViewBounds: () => this.getContentViewBounds(),
|
||||
getPreloadPath: this.getPreloadPath,
|
||||
openExternalUrl: this.openExternalUrl,
|
||||
showError: this.actions.showError,
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
detachActiveContentView() {
|
||||
this.viewHost.detachAll();
|
||||
}
|
||||
|
||||
async showTabPlaceholder(target, message) {
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
await this.viewHost.showTabPlaceholder(tabId, target, message);
|
||||
}
|
||||
|
||||
async showLocalStartupTarget(target, logs) {
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
await this.viewHost.showLocalStartupTarget(tabId, target, logs);
|
||||
}
|
||||
|
||||
async showContentTarget(target) {
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
await this.viewHost.showContentTarget(tabId, target);
|
||||
}
|
||||
|
||||
destroyTabView(tabId) {
|
||||
this.viewHost.destroyTabView(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,
|
||||
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.viewHost.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}`);
|
||||
const finalUrl = await this.showContentTarget(target);
|
||||
this.emitDesktopState();
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
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);
|
||||
this.mainWindow.webContents.focus();
|
||||
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 reloadActiveTab() {
|
||||
const activeTab = this.tabs.getActiveTab();
|
||||
if (!activeTab || activeTab.id === 'home' || activeTab.kind === 'launcher') {
|
||||
this.emitDesktopState();
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
const reloaded = this.viewHost.reloadTab(activeTab.id);
|
||||
if (!reloaded && activeTab.target?.url) {
|
||||
await this.showTarget(activeTab.target, { trackTab: false });
|
||||
}
|
||||
this.emitDesktopState();
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async navigateActiveView(url) {
|
||||
const navigated = await this.viewHost.navigateActiveView(url);
|
||||
this.emitDesktopState();
|
||||
return navigated;
|
||||
}
|
||||
|
||||
async readAuthTokenForTarget(url) {
|
||||
return this.viewHost.readLocalStorageValueForOrigin(url, AUTH_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
openActiveTabDevTools() {
|
||||
if (this.viewHost.openActiveViewDevTools()) return;
|
||||
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.'));
|
||||
}
|
||||
|
||||
reloadActiveBrowserViewForDiagnostics() {
|
||||
if (this.viewHost.reloadActiveView()) return;
|
||||
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before reloading the active BrowserView.'));
|
||||
}
|
||||
|
||||
detachActiveBrowserViewForDiagnostics() {
|
||||
if (this.viewHost.detachActiveView()) return;
|
||||
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before detaching the active BrowserView.'));
|
||||
}
|
||||
|
||||
copyWebContentsDiagnostics() {
|
||||
const tabViewDiagnostics = this.viewHost.getTabViewDiagnostics();
|
||||
const tabViewByContentsId = new Map(
|
||||
tabViewDiagnostics
|
||||
.filter((item) => item.webContentsId != null)
|
||||
.map((item) => [item.webContentsId, item])
|
||||
);
|
||||
|
||||
const rows = electronWebContents.getAllWebContents().map((contents) => {
|
||||
const destroyed = contents.isDestroyed();
|
||||
const processIds = destroyed ? { osProcessId: null, processId: null } : getWebContentsProcessId(contents);
|
||||
const tabView = tabViewByContentsId.get(contents.id);
|
||||
let owner = 'unknown';
|
||||
if (this.mainWindow?.webContents?.id === contents.id) {
|
||||
owner = 'main-window';
|
||||
} else if (this.settingsWindow?.webContents?.id === contents.id) {
|
||||
owner = 'settings-window';
|
||||
} else if (tabView) {
|
||||
owner = `browser-view:${tabView.tabId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: contents.id,
|
||||
owner,
|
||||
osProcessId: processIds.osProcessId,
|
||||
processId: processIds.processId,
|
||||
url: destroyed ? null : contents.getURL(),
|
||||
title: destroyed ? null : contents.getTitle(),
|
||||
destroyed,
|
||||
focused: destroyed || typeof contents.isFocused !== 'function' ? false : contents.isFocused(),
|
||||
attached: tabView ? tabView.attached : null,
|
||||
active: tabView ? tabView.active : null,
|
||||
};
|
||||
});
|
||||
|
||||
const activeTab = this.tabs.getActiveTab();
|
||||
const diagnostics = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeTabId: this.tabs.activeTabId,
|
||||
activeTab: activeTab
|
||||
? {
|
||||
id: activeTab.id,
|
||||
title: activeTab.title,
|
||||
kind: activeTab.kind,
|
||||
targetUrl: activeTab.target?.url || null,
|
||||
}
|
||||
: null,
|
||||
tabViews: tabViewDiagnostics,
|
||||
webContents: rows,
|
||||
};
|
||||
|
||||
clipboard.writeText(JSON.stringify(diagnostics, null, 2));
|
||||
}
|
||||
|
||||
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',
|
||||
visible: COMPUTER_USE_MENUS_ENABLED,
|
||||
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: 'Logout CloudCLI Account',
|
||||
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', 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' },
|
||||
{
|
||||
label: 'Open Active Tab DevTools',
|
||||
click: () => this.openActiveTabDevTools(),
|
||||
},
|
||||
{
|
||||
label: 'Copy WebContents Diagnostics',
|
||||
click: () => this.copyWebContentsDiagnostics(),
|
||||
},
|
||||
{
|
||||
label: 'Reload Active BrowserView',
|
||||
click: () => this.reloadActiveBrowserViewForDiagnostics(),
|
||||
},
|
||||
{
|
||||
label: 'Detach Active BrowserView',
|
||||
click: () => this.detachActiveBrowserViewForDiagnostics(),
|
||||
},
|
||||
{ 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: 'Logout CloudCLI Account',
|
||||
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', 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() {
|
||||
const isAllowedPermission = (webContents, permission) => {
|
||||
const sourceUrl = webContents.getURL();
|
||||
const allowedPermissions = new Set(['clipboard-read', 'media', 'notifications']);
|
||||
return isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission);
|
||||
};
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
callback(isAllowedPermission(webContents, permission));
|
||||
});
|
||||
session.defaultSession.setPermissionCheckHandler((webContents, permission) => {
|
||||
if (!webContents) return false;
|
||||
return isAllowedPermission(webContents, 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', () => {
|
||||
this.viewHost.resizeActiveView();
|
||||
this.syncSettingsWindowBounds();
|
||||
});
|
||||
|
||||
this.mainWindow.on('move', () => {
|
||||
this.syncSettingsWindowBounds();
|
||||
});
|
||||
|
||||
this.mainWindow.on('closed', () => {
|
||||
this.viewHost.clear();
|
||||
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>
|
||||
801
electron/launcher/launcher.css
Normal file
801
electron/launcher/launcher.css
Normal file
@@ -0,0 +1,801 @@
|
||||
* {
|
||||
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.28);
|
||||
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: min(720px, 82vh);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--b);
|
||||
background: color-mix(in srgb, var(--s1) 98%, transparent);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.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-permissions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--b-subtle);
|
||||
border-radius: 12px;
|
||||
background: var(--s1);
|
||||
}
|
||||
|
||||
.cc-note {
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cc-permission-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--b-subtle);
|
||||
}
|
||||
|
||||
.cc-permission-title {
|
||||
color: var(--tx);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-permission-detail {
|
||||
margin-top: 2px;
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cc-permission-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
829
electron/launcher/launcher.js
Normal file
829
electron/launcher/launcher.js
Normal file
@@ -0,0 +1,829 @@
|
||||
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 },
|
||||
computerUsePermissions: {
|
||||
platform: 'darwin',
|
||||
supported: true,
|
||||
accessibility: 'not_granted',
|
||||
screenRecording: 'not_determined',
|
||||
message: 'macOS requires Accessibility and Screen Recording for Computer Use.',
|
||||
},
|
||||
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));
|
||||
},
|
||||
disconnectCloud: function () {
|
||||
mockState.account = { connected: false, email: null };
|
||||
mockState.environments = [];
|
||||
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.kind !== 'remote'; });
|
||||
mockState.activeTabId = 'home';
|
||||
mockState.activeTarget = { kind: 'launcher', name: 'Launcher', url: null };
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
|
||||
refreshActiveTab: 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));
|
||||
},
|
||||
requestComputerUsePermission: function (permission) {
|
||||
mockState.computerUsePermissions = mockState.computerUsePermissions || {};
|
||||
if (permission === 'accessibility') mockState.computerUsePermissions.accessibility = 'granted';
|
||||
if (permission === 'screen') mockState.computerUsePermissions.screenRecording = 'granted';
|
||||
if (permission === 'all') {
|
||||
mockState.computerUsePermissions.accessibility = 'granted';
|
||||
mockState.computerUsePermissions.screenRecording = 'granted';
|
||||
}
|
||||
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"/>',
|
||||
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
};
|
||||
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 : {};
|
||||
var connectedCount = computerUse.connectedCount || 0;
|
||||
var environmentLabel = connectedCount + ' environment' + (connectedCount === 1 ? '' : 's');
|
||||
if (!computerUse.enabled) {
|
||||
return { label: 'Disabled', tone: 'idle', detail: 'CloudCLI cannot use this computer.' };
|
||||
}
|
||||
if (!connectedCount) {
|
||||
return { label: 'Not connected', tone: 'warn', detail: 'No environment connected.' };
|
||||
}
|
||||
if (computerUse.consentMode === 'auto') {
|
||||
return { label: 'Connected', tone: 'warn', detail: environmentLabel + ' connected. Unattended access is on.' };
|
||||
}
|
||||
return { label: 'Connected', tone: 'ok', detail: environmentLabel + ' connected.' };
|
||||
}
|
||||
|
||||
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: 'Launcher', 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 'logout':
|
||||
return CC.run('Logging out...', function () { return bridge.disconnectCloud(); });
|
||||
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':
|
||||
CC.state.computerUse = {
|
||||
...((CC.state && CC.state.computerUse) || {}),
|
||||
enabled: true,
|
||||
consentMode: node.value === 'auto' ? 'auto' : 'ask',
|
||||
};
|
||||
return CC.run('Saved', function () {
|
||||
return bridge.updateComputerUse({
|
||||
enabled: true,
|
||||
consentMode: node.value,
|
||||
});
|
||||
});
|
||||
case 'set-computer-enabled':
|
||||
CC.state.computerUse = {
|
||||
...((CC.state && CC.state.computerUse) || {}),
|
||||
enabled: !!node.value,
|
||||
};
|
||||
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 'computer-permission':
|
||||
return CC.run('Opening permission settings...', function () {
|
||||
return bridge.requestComputerUsePermission(node.getAttribute('data-cc-computer-permission'));
|
||||
});
|
||||
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 'refresh-environments':
|
||||
return CC.run('Refreshing cloud environments...', function () { return bridge.refreshEnvironments(); });
|
||||
case 'refresh-tab':
|
||||
return CC.run('Refreshing tab...', function () { return bridge.refreshActiveTab(); });
|
||||
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 activeTab = (state.tabs || []).filter(function (tab) { return tab.active; })[0] || null;
|
||||
var activeEnvironmentId = state.activeTarget && state.activeTarget.kind === 'remote' ? state.activeTarget.id : null;
|
||||
if (!activeEnvironmentId && activeTab && /^remote:/.test(activeTab.id || '')) {
|
||||
activeEnvironmentId = activeTab.id.replace(/^remote:/, '');
|
||||
}
|
||||
var activeRefreshable = (state.activeTarget && (state.activeTarget.kind === 'remote' || state.activeTarget.kind === 'local')) ||
|
||||
(activeTab && activeTab.id !== 'home');
|
||||
var envActions = activeEnvironmentId ? '<button class="btn sm tb-action no-drag" data-cc-action="env-row-menu" data-cc-environment-id="' + esc(activeEnvironmentId) + '" title="Open environment actions">Open environment in...</button>' : '';
|
||||
var refreshAction = activeRefreshable ? '<button class="icon-btn tb-action no-drag" data-cc-action="refresh-tab" title="Refresh tab">' + icon('refresh', 16) + '</button>' : '';
|
||||
var logoutAction = (conn || authState(state) === 'expired') ? '<button class="icon-btn tb-action no-drag" data-cc-action="logout" title="Logout">' + icon('logOut', 16) + '</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>' +
|
||||
refreshAction +
|
||||
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>' +
|
||||
logoutAction +
|
||||
'<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>'
|
||||
);
|
||||
};
|
||||
|
||||
function permissionLabel(value) {
|
||||
if (value === 'granted') return 'Granted';
|
||||
if (value === 'denied' || value === 'restricted') return 'Needs attention';
|
||||
if (value === 'not_applicable') return 'Not required';
|
||||
return 'Not granted';
|
||||
}
|
||||
|
||||
function permissionTone(value) {
|
||||
if (value === 'granted' || value === 'not_applicable') return 'ok';
|
||||
if (value === 'denied' || value === 'restricted') return 'warn';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
|
||||
// between the desktop app and the web UI.
|
||||
var COMPUTER_USE_MENUS_ENABLED = false;
|
||||
|
||||
function renderComputerPermissionRow(key, label, detail, status) {
|
||||
return '<div class="cc-permission-row">' +
|
||||
'<div><div class="cc-permission-title">' + CC.esc(label) + '</div><div class="cc-permission-detail">' + CC.esc(detail) + '</div></div>' +
|
||||
'<div class="cc-permission-actions"><span class="badge ' + permissionTone(status) + '">' + CC.esc(permissionLabel(status)) + '</span>' +
|
||||
(status === 'granted' || status === 'not_applicable'
|
||||
? ''
|
||||
: '<button class="btn sm" data-cc-action="computer-permission" data-cc-computer-permission="' + CC.esc(key) + '">Open settings</button>') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderComputerPermissions(state) {
|
||||
var permissions = state.computerUsePermissions || {};
|
||||
if (!permissions.supported) {
|
||||
return '<div class="cc-note">' + CC.esc(permissions.message || 'No additional OS permission setup is required from CloudCLI on this platform.') + '</div>';
|
||||
}
|
||||
return '<div class="cc-note">' + CC.esc(permissions.message || 'Grant the required OS permissions before approving agent control.') + '</div>' +
|
||||
renderComputerPermissionRow('accessibility', 'Accessibility', 'Allows CloudCLI to click, type, and use accessibility actions.', permissions.accessibility) +
|
||||
renderComputerPermissionRow('screen', 'Screen Recording', 'Allows CloudCLI to capture screenshots for agent observation.', permissions.screenRecording);
|
||||
}
|
||||
|
||||
CC.buildComputerUseSection = function (state) {
|
||||
var computerUse = state.computerUse || {};
|
||||
var status = computerUseStatus(state);
|
||||
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>' +
|
||||
'<div class="cc-row2"><span class="badge ' + CC.esc(status.tone) + '">' + CC.esc(status.label) + '</span><span class="cc-meta">' + CC.esc(status.detail) + '</span><button class="btn sm" data-cc-action="refresh-environments">' + CC.icon('refresh', 14) + 'Refresh / relink</button></div>';
|
||||
if (computerUse.enabled) {
|
||||
body += '<div class="cc-permissions">' + renderComputerPermissions(state) + '</div>';
|
||||
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),
|
||||
];
|
||||
if (COMPUTER_USE_MENUS_ENABLED) {
|
||||
sections.push(CC.buildComputerUseSection(state));
|
||||
}
|
||||
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance.', 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 servers</h2><p class="pane-sub">Manage Local CloudCLI 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>';
|
||||
}
|
||||
|
||||
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">Launcher</div>' +
|
||||
navItem('local', 'terminal', 'Local servers', state.localServerRunning ? 'on' : 'idle', section) +
|
||||
navItem('cloud', 'cloud', 'Cloud environments', (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();
|
||||
})();
|
||||
550
electron/localServer.js
Normal file
550
electron/localServer.js
Normal file
@@ -0,0 +1,550 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function readServerBundleConfig(appRoot) {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(appRoot, 'electron', 'server-bundle-config.json'), 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return {
|
||||
releaseTag: typeof config.releaseTag === 'string' && config.releaseTag.trim()
|
||||
? config.releaseTag.trim()
|
||||
: '',
|
||||
};
|
||||
} catch {
|
||||
return { releaseTag: '' };
|
||||
}
|
||||
}
|
||||
|
||||
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 bundleConfig = await readServerBundleConfig(this.appRoot);
|
||||
const installer = new ServerInstaller({
|
||||
version: this.appVersion,
|
||||
bundleReleaseTag: bundleConfig.releaseTag,
|
||||
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');
|
||||
await this.shutdownOwnedServer();
|
||||
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 };
|
||||
1060
electron/main.js
Normal file
1060
electron/main.js
Normal file
File diff suppressed because it is too large
Load Diff
63
electron/preload.cjs
Normal file
63
electron/preload.cjs
Normal file
@@ -0,0 +1,63 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
function isCloudCliAppOrigin(location) {
|
||||
if (location.protocol === 'file:') return true;
|
||||
|
||||
if (location.protocol === 'http:') {
|
||||
return location.hostname === '127.0.0.1' || location.hostname === 'localhost';
|
||||
}
|
||||
|
||||
return location.protocol === 'https:' && (
|
||||
location.hostname === 'cloudcli.ai' || location.hostname.endsWith('.cloudcli.ai')
|
||||
);
|
||||
}
|
||||
|
||||
function onDesktopStateUpdated(callback) {
|
||||
const listener = (_event, state) => callback(state);
|
||||
ipcRenderer.on('cloudcli-desktop:state-updated', listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener('cloudcli-desktop:state-updated', listener);
|
||||
};
|
||||
}
|
||||
|
||||
if (isCloudCliAppOrigin(window.location)) {
|
||||
contextBridge.exposeInMainWorld('cloudcliDesktopNotifications', {
|
||||
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
|
||||
update: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-desktop-notifications', settings),
|
||||
onStateUpdated: onDesktopStateUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
if (window.location.protocol === 'file:') {
|
||||
contextBridge.exposeInMainWorld('cloudcliDesktop', {
|
||||
connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
|
||||
disconnectCloud: () => ipcRenderer.invoke('cloudcli-desktop:disconnect-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'),
|
||||
refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'),
|
||||
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),
|
||||
requestComputerUsePermission: (permission) => ipcRenderer.invoke('cloudcli-desktop:request-computer-use-permission', permission),
|
||||
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: onDesktopStateUpdated,
|
||||
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));
|
||||
277
electron/serverInstaller.js
Normal file
277
electron/serverInstaller.js
Normal file
@@ -0,0 +1,277 @@
|
||||
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,
|
||||
bundleReleaseTag = process.env.CLOUDCLI_SERVER_BUNDLE_RELEASE_TAG || '',
|
||||
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.bundleReleaseTag = bundleReleaseTag || `v${this.version}`;
|
||||
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-local-server-${this.version}-${this.platform}-${this.arch}.tar.gz`;
|
||||
}
|
||||
|
||||
getBundleUrl() {
|
||||
const url = new URL(`${this.bundleBaseUrl}/${this.bundleReleaseTag}/${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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
87
electron/tabs.js
Normal file
87
electron/tabs.js
Normal file
@@ -0,0 +1,87 @@
|
||||
export class TabsController {
|
||||
constructor() {
|
||||
this.activeTabId = 'home';
|
||||
this.tabs = [
|
||||
{
|
||||
id: 'home',
|
||||
title: 'Launcher',
|
||||
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' ? 'Launcher' : 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;
|
||||
}
|
||||
|
||||
removeByKind(kind) {
|
||||
const removed = this.tabs.filter((tab) => tab.kind === kind && tab.closable);
|
||||
if (!removed.length) return [];
|
||||
|
||||
const removedIds = new Set(removed.map((tab) => tab.id));
|
||||
this.tabs = this.tabs.filter((tab) => !removedIds.has(tab.id));
|
||||
if (removedIds.has(this.activeTabId)) {
|
||||
this.activeTabId = 'home';
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
getActiveTab() {
|
||||
return this.getTab(this.activeTabId);
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
331
electron/viewHost.js
Normal file
331
electron/viewHost.js
Normal file
@@ -0,0 +1,331 @@
|
||||
import { BrowserView } from 'electron';
|
||||
|
||||
const TARGET_LOAD_TIMEOUT_MS = 20000;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUrlWithTimeout(webContents, url, timeoutMs = TARGET_LOAD_TIMEOUT_MS) {
|
||||
let timedOut = false;
|
||||
let timeout = null;
|
||||
const loadPromise = webContents.loadURL(url);
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
webContents.stop();
|
||||
} catch {
|
||||
// Ignore teardown races while reporting the original timeout.
|
||||
}
|
||||
reject(new Error(`Timed out loading ${url} after ${Math.round(timeoutMs / 1000)} seconds.`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([loadPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
if (timedOut) {
|
||||
loadPromise.catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export class ViewHost {
|
||||
constructor({ appName, getMainWindow, getContentViewBounds, getPreloadPath, openExternalUrl, showError }) {
|
||||
this.appName = appName;
|
||||
this.getMainWindow = getMainWindow;
|
||||
this.getContentViewBounds = getContentViewBounds;
|
||||
this.getPreloadPath = getPreloadPath;
|
||||
this.openExternalUrl = openExternalUrl;
|
||||
this.showError = showError;
|
||||
this.activeContentView = null;
|
||||
this.tabViews = new Map();
|
||||
}
|
||||
|
||||
configureChildWebContents(webContents) {
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
void this.openExternalUrl(url).catch((error) => this.showError('Could not open external link', error));
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
detachAll() {
|
||||
const mainWindow = this.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
try {
|
||||
for (const view of mainWindow.getBrowserViews()) {
|
||||
mainWindow.removeBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
// BrowserViews may already be gone during BrowserWindow teardown.
|
||||
}
|
||||
this.activeContentView = null;
|
||||
}
|
||||
|
||||
detachActiveView() {
|
||||
const mainWindow = this.getMainWindow();
|
||||
const view = this.activeContentView;
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !view) return false;
|
||||
try {
|
||||
if (mainWindow.getBrowserViews().includes(view)) {
|
||||
mainWindow.removeBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
this.activeContentView = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
getActiveView() {
|
||||
const view = this.activeContentView;
|
||||
if (!view || view.webContents.isDestroyed()) return null;
|
||||
return view;
|
||||
}
|
||||
|
||||
openActiveViewDevTools() {
|
||||
const view = this.getActiveView();
|
||||
if (!view) return false;
|
||||
view.webContents.openDevTools({ mode: 'detach' });
|
||||
return true;
|
||||
}
|
||||
|
||||
reloadActiveView() {
|
||||
const view = this.getActiveView();
|
||||
if (!view) return false;
|
||||
view.webContents.reloadIgnoringCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
async readLocalStorageValueForOrigin(originUrl, key) {
|
||||
let targetOrigin;
|
||||
try {
|
||||
targetOrigin = new URL(originUrl).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const view of this.tabViews.values()) {
|
||||
if (!view || view.webContents.isDestroyed()) continue;
|
||||
let viewOrigin;
|
||||
try {
|
||||
viewOrigin = new URL(view.webContents.getURL()).origin;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (viewOrigin !== targetOrigin) continue;
|
||||
|
||||
try {
|
||||
const value = await view.webContents.executeJavaScript(
|
||||
`window.localStorage.getItem(${JSON.stringify(key)})`,
|
||||
true
|
||||
);
|
||||
return typeof value === 'string' && value ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getTabViewDiagnostics() {
|
||||
const mainWindow = this.getMainWindow();
|
||||
const attachedViews = new Set();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
try {
|
||||
for (const view of mainWindow.getBrowserViews()) {
|
||||
attachedViews.add(view);
|
||||
}
|
||||
} catch {
|
||||
// Ignore teardown races while gathering best-effort diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(this.tabViews.entries()).map(([tabId, view]) => {
|
||||
const { webContents } = view;
|
||||
const destroyed = webContents.isDestroyed();
|
||||
return {
|
||||
tabId,
|
||||
webContentsId: destroyed ? null : webContents.id,
|
||||
url: destroyed ? null : webContents.getURL(),
|
||||
title: destroyed ? null : webContents.getTitle(),
|
||||
osProcessId: destroyed || typeof webContents.getOSProcessId !== 'function' ? null : webContents.getOSProcessId(),
|
||||
processId: destroyed || typeof webContents.getProcessId !== 'function' ? null : webContents.getProcessId(),
|
||||
attached: attachedViews.has(view),
|
||||
active: this.activeContentView === view,
|
||||
destroyed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
attach(view) {
|
||||
const mainWindow = this.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (this.activeContentView && this.activeContentView !== view) {
|
||||
this.detachAll();
|
||||
}
|
||||
this.activeContentView = view;
|
||||
try {
|
||||
if (!mainWindow.getBrowserViews().includes(view)) {
|
||||
mainWindow.addBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
view.setBounds(this.getContentViewBounds());
|
||||
view.setAutoResize({ width: true, height: true });
|
||||
}
|
||||
|
||||
resizeActiveView() {
|
||||
if (this.activeContentView) {
|
||||
this.activeContentView.setBounds(this.getContentViewBounds());
|
||||
}
|
||||
}
|
||||
|
||||
async showTabPlaceholder(tabId, target, message) {
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
this.attach(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(tabId, target, logs) {
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
if (view.__cloudcliLoadingUrl) return;
|
||||
this.attach(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(tabId, target) {
|
||||
const loadUrl = target.loadUrl || target.url;
|
||||
if (!isHttpUrl(loadUrl)) {
|
||||
throw new Error(`Refusing to load unsupported app URL: ${loadUrl}`);
|
||||
}
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
this.attach(view);
|
||||
if (target.forceLoad || view.__cloudcliLoadedUrl !== target.url) {
|
||||
view.__cloudcliLoadingUrl = loadUrl;
|
||||
try {
|
||||
await loadUrlWithTimeout(view.webContents, loadUrl);
|
||||
view.__cloudcliLoadedUrl = target.url;
|
||||
view.__cloudcliStartupHtml = null;
|
||||
delete target.loadUrl;
|
||||
delete target.forceLoad;
|
||||
} finally {
|
||||
if (view.__cloudcliLoadingUrl === loadUrl) {
|
||||
view.__cloudcliLoadingUrl = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return view.webContents.getURL();
|
||||
}
|
||||
|
||||
reloadTab(tabId) {
|
||||
const view = this.tabViews.get(tabId);
|
||||
if (!view || view.webContents.isDestroyed()) return false;
|
||||
view.webContents.reloadIgnoringCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
async navigateActiveView(url) {
|
||||
const view = this.getActiveView();
|
||||
if (!view) return false;
|
||||
await loadUrlWithTimeout(view.webContents, url);
|
||||
view.__cloudcliLoadedUrl = url;
|
||||
view.__cloudcliStartupHtml = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
destroyTabView(tabId) {
|
||||
const view = this.tabViews.get(tabId);
|
||||
if (!view) return;
|
||||
const mainWindow = this.getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
try {
|
||||
if (mainWindow.getBrowserViews().includes(view)) {
|
||||
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);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.tabViews.clear();
|
||||
this.activeContentView = null;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
4448
package-lock.json
generated
4448
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
88
package.json
88
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",
|
||||
@@ -27,13 +29,26 @@
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others \"npm run server:dev\" \"npm run client\"",
|
||||
"server": "node dist-server/server/index.js",
|
||||
"preserver:dev": "npm run build:semantics",
|
||||
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
|
||||
"preserver:dev-watch": "npm run build:semantics",
|
||||
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
|
||||
"client": "vite",
|
||||
"build": "npm run build:client && npm run build:server",
|
||||
"desktop": "electron electron/main.js",
|
||||
"predesktop:dev": "npm run build:semantics",
|
||||
"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",
|
||||
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
|
||||
"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:semantics && npm run build:client && npm run build:server",
|
||||
"build:client": "vite build",
|
||||
"build:semantics": "node scripts/build-computer-semantics.mjs",
|
||||
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
||||
"build:server": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json",
|
||||
"postbuild:server": "node scripts/copy-computer-semantics-bin.mjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json",
|
||||
"lint": "eslint src/ server/",
|
||||
@@ -41,10 +56,70 @@
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "./release.sh",
|
||||
"prepublishOnly": "npm run build",
|
||||
"postinstall": "node scripts/fix-node-pty.js",
|
||||
"postinstall": "node scripts/fix-node-pty.js && npm run build:semantics",
|
||||
"prepare": "husky",
|
||||
"update:platform": "./update-platform.sh"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.cloudcli.desktop",
|
||||
"productName": "CloudCLI",
|
||||
"asar": false,
|
||||
"artifactName": "cloudcli-desktop-${version}-${os}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "release/desktop"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"extendInfo": {
|
||||
"CFBundleName": "CloudCLI",
|
||||
"CFBundleDisplayName": "CloudCLI",
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLName": "CloudCLI",
|
||||
"CFBundleURLSchemes": [
|
||||
"cloudcli"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"icon": "electron/assets/logo-windows.ico",
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"installerIcon": "electron/assets/logo-windows.ico",
|
||||
"uninstallerIcon": "electron/assets/logo-windows.ico"
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"claude code",
|
||||
"claude-code",
|
||||
@@ -77,7 +152,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@openai/codex-sdk": "^0.125.0",
|
||||
"@openai/codex-sdk": "^0.141.0",
|
||||
"@replit/codemirror-minimap": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@uiw/react-codemirror": "^4.23.13",
|
||||
@@ -141,6 +216,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 +245,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"
|
||||
}
|
||||
}
|
||||
|
||||
133
scripts/build-computer-semantics.mjs
Normal file
133
scripts/build-computer-semantics.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
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 platform = process.env.CLOUDCLI_SEMANTICS_PLATFORM || process.platform;
|
||||
const arch = process.env.CLOUDCLI_SEMANTICS_ARCH || process.arch;
|
||||
const platformArch = `${platform}-${arch}`;
|
||||
const semanticsRoot = path.join(rootDir, 'server', 'modules', 'computer-use', 'semantics');
|
||||
const outDir = path.join(semanticsRoot, 'bin', platformArch);
|
||||
const requireBuild = process.env.CLOUDCLI_SEMANTICS_BUILD_REQUIRED === '1';
|
||||
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
child.once('error', () => resolve(false));
|
||||
child.once('exit', (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isUpToDate(output, inputs) {
|
||||
if (!(await pathExists(output))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const outputStat = await fs.stat(output);
|
||||
for (const input of inputs) {
|
||||
const inputStat = await fs.stat(input);
|
||||
if (inputStat.mtimeMs > outputStat.mtimeMs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureCommand(command, helpText) {
|
||||
if (await commandExists(command)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = `${command} was not found. ${helpText}`;
|
||||
if (requireBuild) {
|
||||
throw new Error(message);
|
||||
}
|
||||
console.log(`Skipping semantic helper build: ${message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const source = path.join(semanticsRoot, 'helpers', 'macos', 'CloudCLISemantics.swift');
|
||||
const output = path.join(outDir, 'CloudCLISemantics');
|
||||
|
||||
if (!(await ensureCommand('swiftc', 'Install Xcode Command Line Tools to compile the macOS helper.'))) {
|
||||
process.exit(0);
|
||||
}
|
||||
if (await isUpToDate(output, [source])) {
|
||||
console.log(`Semantic helper is up to date: ${path.relative(rootDir, output)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
await run('swiftc', [
|
||||
source,
|
||||
'-o',
|
||||
output,
|
||||
'-framework',
|
||||
'AppKit',
|
||||
'-framework',
|
||||
'ApplicationServices',
|
||||
]);
|
||||
await fs.chmod(output, 0o755);
|
||||
console.log(`Built ${path.relative(rootDir, output)}`);
|
||||
} else if (platform === 'win32') {
|
||||
const project = path.join(semanticsRoot, 'helpers', 'windows', 'CloudCLISemantics.csproj');
|
||||
const source = path.join(semanticsRoot, 'helpers', 'windows', 'Program.cs');
|
||||
const output = path.join(outDir, 'CloudCLISemantics.exe');
|
||||
|
||||
if (!(await ensureCommand('dotnet', '.NET SDK is required to compile the Windows helper.'))) {
|
||||
process.exit(0);
|
||||
}
|
||||
if (await isUpToDate(output, [project, source])) {
|
||||
console.log(`Semantic helper is up to date: ${path.relative(rootDir, output)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
await run('dotnet', [
|
||||
'publish',
|
||||
project,
|
||||
'-c',
|
||||
'Release',
|
||||
'-r',
|
||||
arch === 'arm64' ? 'win-arm64' : 'win-x64',
|
||||
'--self-contained',
|
||||
'false',
|
||||
'-p:PublishSingleFile=true',
|
||||
'-o',
|
||||
outDir,
|
||||
]);
|
||||
console.log(`Built ${path.relative(rootDir, output)}`);
|
||||
} else {
|
||||
console.log(`Semantic helper build is not supported for ${platform}-${arch}.`);
|
||||
}
|
||||
24
scripts/copy-computer-semantics-bin.mjs
Normal file
24
scripts/copy-computer-semantics-bin.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
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 sourceDir = path.join(rootDir, 'server', 'modules', 'computer-use', 'semantics', 'bin');
|
||||
const targetDir = path.join(rootDir, 'dist-server', 'server', 'modules', 'computer-use', 'semantics', 'bin');
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (await pathExists(sourceDir)) {
|
||||
await fs.mkdir(path.dirname(targetDir), { recursive: true });
|
||||
await fs.cp(sourceDir, targetDir, { recursive: true });
|
||||
console.log(`Copied Computer Use semantic helpers to ${path.relative(rootDir, targetDir)}`);
|
||||
}
|
||||
176
scripts/release/build-server-bundle.js
Normal file
176
scripts/release/build-server-bundle.js
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/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.postinstall;
|
||||
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-local-server-${version}-${platform}-${arch}.tar.gz`;
|
||||
const bundleRoot = path.join(rootDir, 'release', 'local-server');
|
||||
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)}`);
|
||||
158
scripts/release/prepare-desktop-app.js
Normal file
158
scripts/release/prepare-desktop-app.js
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/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/desktop',
|
||||
},
|
||||
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,
|
||||
nsis: packageJson.build.nsis,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of [
|
||||
'@nut-tree-fork/default-clipboard-provider',
|
||||
'@nut-tree-fork/libnut',
|
||||
'@nut-tree-fork/provider-interfaces',
|
||||
'@nut-tree-fork/shared',
|
||||
'jimp',
|
||||
'node-abort-controller',
|
||||
'temp',
|
||||
]) {
|
||||
await copyNodeModule(name);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
279
server/computer-use-agent.ts
Normal file
279
server/computer-use-agent.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/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 {
|
||||
getRuntimeReadiness,
|
||||
type Point,
|
||||
type ClickButton,
|
||||
type ScrollDirection,
|
||||
} from './modules/computer-use/computer-executor.js';
|
||||
import { runRawComputerAction } from './modules/computer-use/actions/raw-action-dispatcher.js';
|
||||
import type { RawActionTarget, RawComputerAction } from './modules/computer-use/actions/raw-action-types.js';
|
||||
import { computerSemanticsService } from './modules/computer-use/computer-semantics.service.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';
|
||||
const desktopAgentApiKey = process.env.CLOUDCLI_DESKTOP_AGENT_API_KEY || '';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function rawActionFromRelay(type: string, params: Record<string, unknown>): RawComputerAction {
|
||||
const point = asPoint(params.point);
|
||||
|
||||
switch (type) {
|
||||
case 'screenshot':
|
||||
return { type: 'screenshot' };
|
||||
case 'cursor_position':
|
||||
return { type: 'cursor_position' };
|
||||
case 'mouse_move':
|
||||
if (!point) {
|
||||
throw new Error('mouse_move requires a valid point.');
|
||||
}
|
||||
return { type: 'mouse_move', point };
|
||||
case 'click':
|
||||
return {
|
||||
type: 'click',
|
||||
button: (params.button as ClickButton) || 'left',
|
||||
point,
|
||||
double: params.double === true,
|
||||
};
|
||||
case 'drag': {
|
||||
const from = asPoint(params.from);
|
||||
const to = asPoint(params.to);
|
||||
if (!from || !to) {
|
||||
throw new Error('drag requires valid from and to points.');
|
||||
}
|
||||
return { type: 'drag', from, to, button: (params.button as ClickButton) || 'left' };
|
||||
}
|
||||
case 'type':
|
||||
return { type: 'type', text: String(params.text ?? '') };
|
||||
case 'key':
|
||||
return { type: 'key', key: String(params.key ?? '') };
|
||||
case 'scroll':
|
||||
return {
|
||||
type: 'scroll',
|
||||
direction: (params.direction as ScrollDirection) || 'down',
|
||||
amount: typeof params.amount === 'number' ? params.amount : 3,
|
||||
point,
|
||||
};
|
||||
case 'wait':
|
||||
return { type: 'wait', ms: typeof params.ms === 'number' ? params.ms : undefined };
|
||||
default:
|
||||
throw new Error(`Unsupported computer action: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(type: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (type === 'semantic_tool') {
|
||||
const toolName = typeof params.toolName === 'string' ? params.toolName : '';
|
||||
const args = params.arguments && typeof params.arguments === 'object'
|
||||
? params.arguments as Record<string, unknown>
|
||||
: {};
|
||||
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : 'default';
|
||||
if (!toolName) {
|
||||
throw new Error('semantic_tool requires toolName.');
|
||||
}
|
||||
return await computerSemanticsService.callTool(toolName, { ...args, sessionId }) as 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: RawActionTarget = {
|
||||
displaySize: (params.displaySize as RawActionTarget['displaySize']) ?? null,
|
||||
};
|
||||
return await runRawComputerAction(rawActionFromRelay(type, params), target) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// --- Relay connection ------------------------------------------------------
|
||||
|
||||
function connect(url: string): void {
|
||||
let reconnectMs = RECONNECT_BASE_MS;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const open = () => {
|
||||
socket = new WebSocket(url, {
|
||||
headers: desktopAgentApiKey ? { 'X-API-Key': desktopAgentApiKey } : 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 = (code?: number, reason?: Buffer) => {
|
||||
const reasonText = reason?.toString() || '';
|
||||
emitToParent({ type: 'disconnected', url, code, reason: reasonText });
|
||||
if (code === 1008 && /computer use.*disabled/i.test(reasonText)) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
574
server/computer-use-mcp.ts
Normal file
574
server/computer-use-mcp.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
#!/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 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 readMouseButton = (value: unknown): 'left' | 'right' | 'middle' =>
|
||||
value === 'right' || value === 'middle' ? value : 'left';
|
||||
|
||||
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 || '';
|
||||
|
||||
const computerUseInstructions = `
|
||||
CloudCLI Computer Use lets you operate the user's real desktop through guarded sessions. Use it deliberately: observe first, act second, then verify.
|
||||
|
||||
Recommended app workflow:
|
||||
1. If you do not know the target app name, call computer_list_apps.
|
||||
2. Call computer_get_app_state for the target app before app-scoped actions. It returns a screenshot, accessibility elements, and a stateId.
|
||||
3. Prefer semantic element actions using stateId + element_index from the latest computer_get_app_state result. Do not guess element indexes or reuse them after large UI changes without refreshing state.
|
||||
4. Use x/y coordinates from the returned screenshot only when no suitable element_index is available.
|
||||
5. After every action, inspect the returned screenshot/state before deciding the next action.
|
||||
|
||||
Use app-scoped tools when the target app is known: computer_list_apps, computer_get_app_state, computer_click_element, computer_perform_secondary_action, computer_set_value, computer_type_text, computer_press_key, computer_scroll_element, and computer_app_drag.
|
||||
|
||||
Use raw desktop tools only when you need full-screen coordinate control, cursor position, or current-focus input: computer_screenshot, computer_cursor_position, computer_mouse_move, computer_click, computer_drag, computer_type, computer_key, computer_scroll, computer_wait, and computer_close_session. Raw coordinates are screenshot pixels, so call computer_screenshot first when you need a coordinate frame.
|
||||
|
||||
Most tools can use or create the active agent session automatically when sessionId is omitted. In local mode, input actions require the user to grant control in the Computer tab before they work. In cloud mode, approval is handled by the linked CloudCLI desktop app.
|
||||
|
||||
If a tool reports missing permission, denied control, or no available desktop session, stop retrying and ask the user to fix access. For local mode, ask them to open CloudCLI Desktop, go to the Computer tab, enable Computer Use, grant the requested OS permissions, and allow the session. On macOS this usually means Accessibility and Screen Recording. For cloud mode, ask them to keep the linked CloudCLI Desktop app running and approve the cloud agent's Computer Use request there.
|
||||
|
||||
Ask before sending, deleting, purchasing, approving, uploading, publishing, changing account settings, or making other externally visible or destructive changes. Do not inspect unrelated private content unless the user explicitly asked for that task.
|
||||
`.trim();
|
||||
|
||||
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: 'Optional. Omit to use or create the active agent session automatically.' },
|
||||
},
|
||||
};
|
||||
|
||||
const optionalSessionProperty = sessionIdSchema.properties.sessionId;
|
||||
|
||||
const withOptionalSession = (properties: Record<string, unknown> = {}) => ({
|
||||
sessionId: optionalSessionProperty,
|
||||
...properties,
|
||||
});
|
||||
|
||||
const optionalSessionInput = (args: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
|
||||
sessionId: readOptionalString(args.sessionId),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const stateIdProperty = {
|
||||
type: 'string',
|
||||
description: 'State id returned by the latest computer_get_app_state call for this app. Send it with element_index so the runtime can resolve the cached element.',
|
||||
};
|
||||
|
||||
const elementIndexProperty = {
|
||||
type: 'string',
|
||||
description: 'Element index from the latest computer_get_app_state result for this app. Use with stateId when possible.',
|
||||
};
|
||||
|
||||
const tools: ToolDefinition[] = [
|
||||
{
|
||||
name: 'computer_list_apps',
|
||||
description: 'Discover app names, bundle identifiers, process names, and window titles that can be used as the app target for app-scoped Computer Use tools. Call this first when you are unsure which app string to pass to computer_get_app_state.',
|
||||
inputSchema: { type: 'object', properties: withOptionalSession() },
|
||||
},
|
||||
{
|
||||
name: 'computer_get_app_state',
|
||||
description: 'Inspect a target app and return its current screenshot, accessibility elements, and stateId. Call this before element-targeted actions, after navigation, and whenever the UI may have changed enough that old element indexes could be stale.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: withOptionalSession({
|
||||
app: { type: 'string', description: 'App name, process name, bundle identifier, or window title from computer_list_apps or the user request.' },
|
||||
}),
|
||||
required: ['app'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_click_element',
|
||||
description: 'Click a target inside an app. Prefer stateId + element_index from computer_get_app_state; use x/y screenshot coordinates only when the target is not represented in the accessibility elements.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: withOptionalSession({
|
||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
||||
stateId: stateIdProperty,
|
||||
element_index: elementIndexProperty,
|
||||
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
||||
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
||||
click_count: { type: 'integer', description: 'Number of clicks, usually 1. Defaults to 1 and is capped by the runtime.' },
|
||||
mouse_button: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button for the click; omitted means left.' },
|
||||
}),
|
||||
required: ['app'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_perform_secondary_action',
|
||||
description: 'Open the secondary action for a target inside an app, typically a context menu. Prefer stateId + element_index; if native secondary actions are unavailable, the runtime falls back to a right-click at the resolved point.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: withOptionalSession({
|
||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
||||
stateId: stateIdProperty,
|
||||
element_index: elementIndexProperty,
|
||||
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
||||
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
||||
}),
|
||||
required: ['app'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_set_value',
|
||||
description: 'Set the value of a specific editable element in an app. Prefer stateId + element_index for a settable accessibility element; coordinate fallback focuses the resolved point and replaces the current value, so do not call this unless the target is resolved.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: withOptionalSession({
|
||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
||||
stateId: stateIdProperty,
|
||||
element_index: elementIndexProperty,
|
||||
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
||||
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
||||
value: { type: 'string', description: 'Exact value to put into the target element.' },
|
||||
}),
|
||||
required: ['app', 'value'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_type_text',
|
||||
description: 'Type literal text into the target app using keyboard input. Use after you have focused the intended field with computer_click_element or verified the correct focus in computer_get_app_state.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: withOptionalSession({
|
||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
||||
text: { type: 'string', description: 'Text to enter exactly as provided.' },
|
||||
}),
|
||||
required: ['app', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_press_key',
|
||||
description: 'Press a key or key combination in the target app. Use for navigation, shortcuts, and confirmation keys after verifying the intended app/focus.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: withOptionalSession({
|
||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
||||
key: { type: 'string', description: 'Key or chord, using names such as Return, Escape, Tab, ctrl+s, cmd+a, Up, or Page_Down.' },
|
||||
}),
|
||||
required: ['app', 'key'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_scroll_element',
|
||||
description: 'Scroll a target area inside an app. Prefer stateId + element_index for scrollable elements; use x/y screenshot coordinates only when the scroll target is visible but not represented as an element.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: withOptionalSession({
|
||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
||||
stateId: stateIdProperty,
|
||||
element_index: elementIndexProperty,
|
||||
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
||||
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
||||
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Direction to scroll the target.' },
|
||||
pages: { type: 'number', description: 'How far to scroll, measured in page units. Fractional values are allowed; default is 1.' },
|
||||
}),
|
||||
required: ['app', 'direction'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_app_drag',
|
||||
description: 'Drag inside a target app from one screenshot coordinate to another. Use for sliders, selections, map/canvas gestures, or drag-and-drop when no semantic element action is available.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: withOptionalSession({
|
||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
||||
from_x: { type: 'number', description: 'Start X coordinate in screenshot pixels.' },
|
||||
from_y: { type: 'number', description: 'Start Y coordinate in screenshot pixels.' },
|
||||
to_x: { type: 'number', description: 'End X coordinate in screenshot pixels.' },
|
||||
to_y: { type: 'number', description: 'End Y coordinate in screenshot pixels.' },
|
||||
}),
|
||||
required: ['app', 'from_x', 'from_y', 'to_x', 'to_y'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_screenshot',
|
||||
description: 'Capture the full desktop screenshot and current display size. Use this before raw coordinate actions when an app-specific accessibility state is unavailable or the task spans multiple apps.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
{
|
||||
name: 'computer_cursor_position',
|
||||
description: 'Get the current mouse cursor position in desktop screenshot pixel coordinates. Useful after a raw action misses or when coordinating pointer-relative steps.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
{
|
||||
name: 'computer_mouse_move',
|
||||
description: 'Move the mouse cursor to an exact full-desktop screenshot coordinate. Call computer_screenshot first if you do not already have a current coordinate frame.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: optionalSessionProperty,
|
||||
x: { type: 'number', description: 'X coordinate in full-desktop screenshot pixels.' },
|
||||
y: { type: 'number', description: 'Y coordinate in full-desktop screenshot pixels.' },
|
||||
},
|
||||
required: ['x', 'y'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_click',
|
||||
description: 'Raw desktop click at the current cursor or at optional full-desktop screenshot coordinates. Prefer computer_click_element when the target app and element are known.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: optionalSessionProperty,
|
||||
x: { type: 'number', description: 'Optional X coordinate in full-desktop screenshot pixels.' },
|
||||
y: { type: 'number', description: 'Optional Y coordinate in full-desktop screenshot pixels.' },
|
||||
mouseButton: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button for the click; omitted means left.' },
|
||||
clickCount: { type: 'integer', description: 'How many times to click; omitted means 1.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_drag',
|
||||
description: 'Raw desktop drag from start coordinates to end coordinates in full-desktop screenshot pixels. Prefer computer_app_drag for app-scoped drags when the target app is known.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: optionalSessionProperty,
|
||||
startX: { type: 'number', description: 'Start X coordinate in full-desktop screenshot pixels.' },
|
||||
startY: { type: 'number', description: 'Start Y coordinate in full-desktop screenshot pixels.' },
|
||||
endX: { type: 'number', description: 'End X coordinate in full-desktop screenshot pixels.' },
|
||||
endY: { type: 'number', description: 'End Y coordinate in full-desktop screenshot pixels.' },
|
||||
mouseButton: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button to hold during the drag; omitted means left.' },
|
||||
},
|
||||
required: ['startX', 'startY', 'endX', 'endY'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_type',
|
||||
description: 'Type literal text at the current desktop focus. This is not app-scoped; use only after verifying the intended field is focused.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { sessionId: optionalSessionProperty, text: { type: 'string', description: 'Text to enter exactly as provided at current focus.' } },
|
||||
required: ['text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_key',
|
||||
description: 'Press a key or key chord at the current desktop focus. This is not app-scoped; use computer_press_key when the target app is known.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { sessionId: optionalSessionProperty, key: { type: 'string', description: 'Key or chord, using names such as Return, Escape, Tab, ctrl+s, cmd+a, Up, or Page_Down.' } },
|
||||
required: ['key'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_scroll',
|
||||
description: 'Raw desktop scroll at the current cursor or optional full-desktop screenshot coordinates. Prefer computer_scroll_element when the target app/element is known.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: optionalSessionProperty,
|
||||
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Direction to scroll the desktop target.' },
|
||||
amount: { type: 'number', description: 'Scroll amount in wheel/page-like units. Defaults are runtime-defined.' },
|
||||
x: { type: 'number', description: 'Optional X coordinate in full-desktop screenshot pixels.' },
|
||||
y: { type: 'number', description: 'Optional Y coordinate in full-desktop screenshot pixels.' },
|
||||
},
|
||||
required: ['direction'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_wait',
|
||||
description: 'Wait briefly, up to 10000 ms, then return an updated desktop screenshot. Use after actions that trigger loading, animation, or delayed UI changes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { sessionId: optionalSessionProperty, timeoutMs: { type: 'number', description: 'Milliseconds to wait. The runtime caps long waits.' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computer_close_session',
|
||||
description: 'Stop the active auto-created Computer Use session, or the specified session, and revoke agent input control for that session.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
];
|
||||
|
||||
async function callTool(name: string, args: Record<string, unknown>) {
|
||||
switch (name) {
|
||||
case 'computer_app_drag':
|
||||
case 'computer_click_element':
|
||||
case 'computer_get_app_state':
|
||||
case 'computer_list_apps':
|
||||
case 'computer_perform_secondary_action':
|
||||
case 'computer_press_key':
|
||||
case 'computer_scroll_element':
|
||||
case 'computer_set_value':
|
||||
case 'computer_type_text':
|
||||
return toolResult(await callComputerUseApi(name, args));
|
||||
case 'computer_screenshot':
|
||||
case 'computer_cursor_position':
|
||||
case 'computer_close_session':
|
||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args)));
|
||||
case 'computer_mouse_move':
|
||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
||||
x: readNumber(args.x),
|
||||
y: readNumber(args.y),
|
||||
})));
|
||||
case 'computer_click':
|
||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
||||
x: readNumber(args.x),
|
||||
y: readNumber(args.y),
|
||||
mouseButton: readMouseButton(args.mouseButton ?? args.mouse_button ?? args.button),
|
||||
clickCount: readNumber(args.clickCount ?? args.click_count),
|
||||
})));
|
||||
case 'computer_drag':
|
||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
||||
startX: readNumber(args.startX),
|
||||
startY: readNumber(args.startY),
|
||||
endX: readNumber(args.endX),
|
||||
endY: readNumber(args.endY),
|
||||
mouseButton: readMouseButton(args.mouseButton ?? args.mouse_button ?? args.button),
|
||||
})));
|
||||
case 'computer_type':
|
||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
||||
text: readString(args.text, 'text'),
|
||||
})));
|
||||
case 'computer_key':
|
||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
||||
key: readString(args.key, 'key'),
|
||||
})));
|
||||
case 'computer_scroll':
|
||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
||||
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, optionalSessionInput(args, {
|
||||
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' },
|
||||
instructions: computerUseInstructions,
|
||||
};
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
type MessageFraming = 'content-length' | 'line';
|
||||
|
||||
function writeMessage(message: Record<string, unknown>, framing: MessageFraming) {
|
||||
const payload = JSON.stringify(message);
|
||||
if (framing === 'line') {
|
||||
process.stdout.write(`${payload}\n`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
|
||||
}
|
||||
|
||||
function sendResult(id: string | number | null | undefined, result: unknown, framing: MessageFraming) {
|
||||
if (id === undefined) {
|
||||
return;
|
||||
}
|
||||
writeMessage({ jsonrpc: '2.0', id, result }, framing);
|
||||
}
|
||||
|
||||
function sendError(id: string | number | null | undefined, error: unknown, framing: MessageFraming) {
|
||||
if (id === undefined) {
|
||||
return;
|
||||
}
|
||||
writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}, framing);
|
||||
}
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
function handleRawMessage(rawMessage: string, framing: MessageFraming) {
|
||||
void (async () => {
|
||||
let request: JsonRpcRequest | null = null;
|
||||
try {
|
||||
request = JSON.parse(rawMessage) as JsonRpcRequest;
|
||||
const result = await handleMessage(request);
|
||||
sendResult(request.id, result, framing);
|
||||
} catch (error) {
|
||||
sendError(request?.id ?? null, error, framing);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function findHeaderEnd(input: Buffer): { index: number; length: number } | null {
|
||||
const crlf = input.indexOf('\r\n\r\n');
|
||||
if (crlf !== -1) {
|
||||
return { index: crlf, length: 4 };
|
||||
}
|
||||
const lf = input.indexOf('\n\n');
|
||||
if (lf !== -1) {
|
||||
return { index: lf, length: 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
while (true) {
|
||||
const headerEnd = findHeaderEnd(buffer);
|
||||
if (!headerEnd) {
|
||||
if (/^Content-Length:/i.test(buffer.toString('utf8', 0, Math.min(buffer.length, 32)))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newline = buffer.indexOf('\n');
|
||||
if (newline === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawLine = buffer.slice(0, newline).toString('utf8').trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (!rawLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
handleRawMessage(rawLine, 'line');
|
||||
continue;
|
||||
}
|
||||
|
||||
const header = buffer.slice(0, headerEnd.index).toString('utf8');
|
||||
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
|
||||
if (!lengthMatch) {
|
||||
buffer = buffer.slice(headerEnd.index + headerEnd.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
const length = Number.parseInt(lengthMatch[1], 10);
|
||||
const messageStart = headerEnd.index + headerEnd.length;
|
||||
const messageEnd = messageStart + length;
|
||||
if (buffer.length < messageEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawMessage = buffer.slice(messageStart, messageEnd).toString('utf8');
|
||||
buffer = buffer.slice(messageEnd);
|
||||
handleRawMessage(rawMessage, 'content-length');
|
||||
}
|
||||
});
|
||||
104
server/index.js
104
server/index.js
@@ -57,10 +57,18 @@ import commandsRoutes from './routes/commands.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import agentRoutes from './routes/agent.js';
|
||||
import projectModuleRoutes from './modules/projects/projects.routes.js';
|
||||
import notificationRoutes from './modules/notifications/notifications.routes.js';
|
||||
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 voiceRoutes from './voice-proxy.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';
|
||||
@@ -73,6 +81,19 @@ const __dirname = getModuleDir(import.meta.url);
|
||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||
const APP_ROOT = findAppRoot(__dirname);
|
||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||
// Version of the code that is actually running, captured once at process
|
||||
// startup. This intentionally does NOT re-read package.json per request: after
|
||||
// an update replaces the files on disk, package.json reflects the NEW version
|
||||
// while this long-lived process still runs the OLD code. The frontend bundle is
|
||||
// rebuilt on update, so a mismatch between this value and the frontend's
|
||||
// build-time version means the server was updated but not restarted.
|
||||
const RUNNING_VERSION = (() => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||
const MAX_FILE_UPLOAD_COUNT = 20;
|
||||
@@ -153,7 +174,8 @@ app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
installMode
|
||||
installMode,
|
||||
version: RUNNING_VERSION
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +206,8 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
|
||||
// Settings API Routes (protected)
|
||||
app.use('/api/settings', authenticateToken, settingsRoutes);
|
||||
|
||||
app.use('/api/notifications', authenticateToken, notificationRoutes);
|
||||
|
||||
// User API Routes (protected)
|
||||
app.use('/api/user', authenticateToken, userRoutes);
|
||||
|
||||
@@ -193,12 +217,26 @@ 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);
|
||||
|
||||
// Agent API Routes (uses API key authentication)
|
||||
app.use('/api/agent', agentRoutes);
|
||||
|
||||
app.use('/api/voice', authenticateToken, voiceRoutes);
|
||||
|
||||
// Serve public files (like api-docs.html)
|
||||
app.use(express.static(path.join(APP_ROOT, 'public')));
|
||||
|
||||
@@ -1656,6 +1694,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 +1754,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 +1779,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);
|
||||
|
||||
@@ -22,7 +22,7 @@ try {
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
console.error('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
|
||||
// Keep the default database in a stable user-level location so rebuilding dist-server
|
||||
|
||||
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, []);
|
||||
});
|
||||
67
server/modules/computer-use/actions/raw-action-dispatcher.ts
Normal file
67
server/modules/computer-use/actions/raw-action-dispatcher.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
captureScreenshot,
|
||||
executor,
|
||||
type ExecutorTarget,
|
||||
} from '@/modules/computer-use/computer-executor.js';
|
||||
import type { RawActionResult, RawComputerAction, RawActionTarget } from '@/modules/computer-use/actions/raw-action-types.js';
|
||||
|
||||
const DEFAULT_WAIT_MS = 1000;
|
||||
const MAX_WAIT_MS = 10_000;
|
||||
|
||||
function normalizeWaitMs(ms: number | undefined): number {
|
||||
if (ms === undefined) {
|
||||
return DEFAULT_WAIT_MS;
|
||||
}
|
||||
if (!Number.isFinite(ms)) {
|
||||
throw new Error('Computer Use wait duration must be a finite number.');
|
||||
}
|
||||
return Math.trunc(Math.max(0, Math.min(ms, MAX_WAIT_MS)));
|
||||
}
|
||||
|
||||
async function snapshot(target: RawActionTarget): Promise<RawActionResult> {
|
||||
const { dataUrl, size } = await captureScreenshot();
|
||||
return { screenshotDataUrl: dataUrl, displaySize: size || target.displaySize };
|
||||
}
|
||||
|
||||
export async function runRawComputerAction(
|
||||
action: RawComputerAction,
|
||||
target: RawActionTarget,
|
||||
): Promise<RawActionResult> {
|
||||
const executorTarget: ExecutorTarget = {
|
||||
displaySize: target.displaySize,
|
||||
};
|
||||
|
||||
switch (action.type) {
|
||||
case 'screenshot':
|
||||
return snapshot(target);
|
||||
case 'cursor_position': {
|
||||
const position = await executor.cursorPosition(executorTarget);
|
||||
return { ...(await snapshot(target)), position, cursor: position };
|
||||
}
|
||||
case 'mouse_move':
|
||||
await executor.moveTo(executorTarget, action.point);
|
||||
return { ...(await snapshot(target)), cursor: action.point };
|
||||
case 'click':
|
||||
await executor.click(executorTarget, action.button, action.point, action.double === true);
|
||||
return { ...(await snapshot(target)), cursor: action.point ?? null };
|
||||
case 'drag':
|
||||
await executor.drag(executorTarget, action.from, action.to, action.button ?? 'left');
|
||||
return { ...(await snapshot(target)), cursor: action.to };
|
||||
case 'type':
|
||||
await executor.type(action.text);
|
||||
return snapshot(target);
|
||||
case 'key':
|
||||
await executor.pressChord(action.key);
|
||||
return snapshot(target);
|
||||
case 'scroll':
|
||||
await executor.scroll(executorTarget, action.direction, action.amount ?? 3, action.point);
|
||||
return { ...(await snapshot(target)), cursor: action.point ?? null };
|
||||
case 'wait':
|
||||
await new Promise((resolve) => setTimeout(resolve, normalizeWaitMs(action.ms)));
|
||||
return snapshot(target);
|
||||
default: {
|
||||
const exhaustive: never = action;
|
||||
throw new Error(`Unsupported computer action: ${(exhaustive as { type?: string }).type || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
server/modules/computer-use/actions/raw-action-types.ts
Normal file
28
server/modules/computer-use/actions/raw-action-types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
ClickButton,
|
||||
DisplaySize,
|
||||
Point,
|
||||
ScrollDirection,
|
||||
} from '@/modules/computer-use/computer-executor.js';
|
||||
|
||||
export type RawComputerAction =
|
||||
| { type: 'screenshot' }
|
||||
| { type: 'cursor_position' }
|
||||
| { 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 };
|
||||
|
||||
export type RawActionTarget = {
|
||||
displaySize: DisplaySize | null;
|
||||
};
|
||||
|
||||
export type RawActionResult = {
|
||||
screenshotDataUrl?: string | null;
|
||||
displaySize?: DisplaySize | null;
|
||||
cursor?: Point | null;
|
||||
position?: Point | null;
|
||||
};
|
||||
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);
|
||||
},
|
||||
};
|
||||
460
server/modules/computer-use/computer-semantics.service.ts
Normal file
460
server/modules/computer-use/computer-semantics.service.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
captureScreenshot,
|
||||
executor,
|
||||
type ClickButton,
|
||||
type ExecutorTarget,
|
||||
type Point,
|
||||
type ScrollDirection,
|
||||
} from '@/modules/computer-use/computer-executor.js';
|
||||
import type { SemanticAdapter } from '@/modules/computer-use/semantics/adapters/semantic-adapter.js';
|
||||
import { createMacOsSemanticAdapter } from '@/modules/computer-use/semantics/adapters/macos/macos-semantic-adapter.js';
|
||||
import { createWindowsSemanticAdapter } from '@/modules/computer-use/semantics/adapters/windows/windows-semantic-adapter.js';
|
||||
import { resolveSemanticHelper } from '@/modules/computer-use/semantics/helpers/semantic-helper-resolver.js';
|
||||
import { semanticSessionStore } from '@/modules/computer-use/semantics/semantic-session-store.js';
|
||||
import type { SemanticAppState, SemanticElement } from '@/modules/computer-use/semantics/semantic-types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_APP_STATE_ELEMENTS = 250;
|
||||
let helperAdapter: SemanticAdapter | null | undefined;
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function requireApp(input: Record<string, unknown>): string {
|
||||
const app = readString(input.app);
|
||||
if (!app) {
|
||||
throw new Error('app is required.');
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readButton(value: unknown): ClickButton {
|
||||
return value === 'right' || value === 'middle' ? value : 'left';
|
||||
}
|
||||
|
||||
function readClickCount(value: unknown): number {
|
||||
const count = readNumber(value);
|
||||
if (count === undefined) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, Math.min(5, Math.trunc(count)));
|
||||
}
|
||||
|
||||
function readDirection(value: unknown): ScrollDirection {
|
||||
return value === 'up' || value === 'left' || value === 'right' ? value : 'down';
|
||||
}
|
||||
|
||||
function readSessionId(input: Record<string, unknown>): string {
|
||||
return readString(input.sessionId) || 'default';
|
||||
}
|
||||
|
||||
function centerOf(element: SemanticElement): Point | null {
|
||||
const bounds = element.bounds;
|
||||
if (!bounds) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: Math.round(bounds.x + bounds.width / 2),
|
||||
y: Math.round(bounds.y + bounds.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedElement(sessionId: string, app: string, index: string, stateId?: string): SemanticElement | null {
|
||||
return semanticSessionStore.getElement(sessionId, app, index, stateId);
|
||||
}
|
||||
|
||||
function getPoint(input: Record<string, unknown>, sessionId: string, app: string): Point | undefined {
|
||||
const x = readNumber(input.x);
|
||||
const y = readNumber(input.y);
|
||||
if (x !== undefined && y !== undefined) {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const elementIndex = readString(input.element_index);
|
||||
if (!elementIndex) {
|
||||
return undefined;
|
||||
}
|
||||
const element = getCachedElement(sessionId, app, elementIndex, readString(input.stateId) || undefined);
|
||||
return element ? centerOf(element) || undefined : undefined;
|
||||
}
|
||||
|
||||
function getHelperAdapter(): SemanticAdapter | null {
|
||||
if (helperAdapter !== undefined) {
|
||||
return helperAdapter;
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin' && process.platform !== 'win32') {
|
||||
helperAdapter = null;
|
||||
return helperAdapter;
|
||||
}
|
||||
|
||||
const resolution = resolveSemanticHelper();
|
||||
if (!resolution.available) {
|
||||
helperAdapter = null;
|
||||
return helperAdapter;
|
||||
}
|
||||
|
||||
helperAdapter = process.platform === 'darwin'
|
||||
? createMacOsSemanticAdapter()
|
||||
: createWindowsSemanticAdapter();
|
||||
return helperAdapter;
|
||||
}
|
||||
|
||||
function shouldFallbackFromHelper(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /not implemented|unavailable|not found|does not exist|timed out|not running|exited with code|failed to start/i.test(message);
|
||||
}
|
||||
|
||||
async function withHelperState(
|
||||
sessionId: string,
|
||||
operation: (adapter: SemanticAdapter) => Promise<SemanticAppState>,
|
||||
): Promise<SemanticAppState | null> {
|
||||
const adapter = getHelperAdapter();
|
||||
if (!adapter) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return semanticSessionStore.save(sessionId, await operation(adapter));
|
||||
} catch (error) {
|
||||
if (shouldFallbackFromHelper(error)) {
|
||||
console.warn('[ComputerSemantics] Falling back from helper:', error instanceof Error ? error.message : String(error));
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(command: string, args: string[], timeout = 5000): Promise<string> {
|
||||
const { stdout } = await execFileAsync(command, args, {
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024 * 4,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function listMacApps(): Promise<Array<Record<string, unknown>>> {
|
||||
const script = [
|
||||
'tell application "System Events"',
|
||||
'set appRows to {}',
|
||||
'repeat with p in (application processes whose background only is false)',
|
||||
'set end of appRows to (name of p as text)',
|
||||
'end repeat',
|
||||
'return appRows',
|
||||
'end tell',
|
||||
].join('\n');
|
||||
const output = await run('osascript', ['-e', script]);
|
||||
return output.split(', ')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => ({ name, running: true }));
|
||||
}
|
||||
|
||||
async function listWindowsApps(): Promise<Array<Record<string, unknown>>> {
|
||||
const script = [
|
||||
'Get-Process | Where-Object { $_.MainWindowTitle } |',
|
||||
'Select-Object ProcessName, Id, MainWindowTitle | ConvertTo-Json -Depth 3',
|
||||
].join(' ');
|
||||
const output = await run('powershell.exe', ['-NoProfile', '-Command', script]);
|
||||
const parsed = JSON.parse(output || '[]');
|
||||
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return rows.map((row) => ({
|
||||
name: row.ProcessName,
|
||||
pid: row.Id,
|
||||
windowTitle: row.MainWindowTitle,
|
||||
running: true,
|
||||
}));
|
||||
}
|
||||
|
||||
async function listLinuxApps(): Promise<Array<Record<string, unknown>>> {
|
||||
try {
|
||||
const output = await run('wmctrl', ['-lx']);
|
||||
return output.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
return {
|
||||
windowId: parts[0],
|
||||
desktop: parts[1],
|
||||
host: parts[2],
|
||||
className: parts[3],
|
||||
windowTitle: parts.slice(4).join(' '),
|
||||
running: true,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
const output = await run('ps', ['-eo', 'comm=']);
|
||||
return [...new Set(output.split(/\r?\n/).map((name) => name.trim()).filter(Boolean))]
|
||||
.slice(0, 200)
|
||||
.map((name) => ({ name, running: true }));
|
||||
}
|
||||
}
|
||||
|
||||
async function listApps(): Promise<Array<Record<string, unknown>>> {
|
||||
if (process.platform === 'darwin') {
|
||||
return listMacApps();
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return listWindowsApps();
|
||||
}
|
||||
return listLinuxApps();
|
||||
}
|
||||
|
||||
async function macAccessibilityTree(app: string): Promise<SemanticElement[]> {
|
||||
const escapedApp = app.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const script = `
|
||||
on safeText(v)
|
||||
try
|
||||
return v as text
|
||||
on error
|
||||
return ""
|
||||
end try
|
||||
end safeText
|
||||
|
||||
on emitElement(e, depth, maxDepth, counter)
|
||||
if depth > maxDepth then return {}
|
||||
set rows to {}
|
||||
try
|
||||
set roleText to my safeText(role of e)
|
||||
on error
|
||||
set roleText to "element"
|
||||
end try
|
||||
try
|
||||
set titleText to my safeText(title of e)
|
||||
on error
|
||||
set titleText to ""
|
||||
end try
|
||||
try
|
||||
set valueText to my safeText(value of e)
|
||||
on error
|
||||
set valueText to ""
|
||||
end try
|
||||
try
|
||||
set posValue to position of e
|
||||
set sizeValue to size of e
|
||||
set boundsText to ((item 1 of posValue) as text) & "," & ((item 2 of posValue) as text) & "," & ((item 1 of sizeValue) as text) & "," & ((item 2 of sizeValue) as text)
|
||||
on error
|
||||
set boundsText to ""
|
||||
end try
|
||||
set end of rows to ((counter as text) & tab & roleText & tab & titleText & tab & valueText & tab & boundsText)
|
||||
if counter > ${MAX_APP_STATE_ELEMENTS} then return rows
|
||||
try
|
||||
repeat with childElement in UI elements of e
|
||||
set childRows to my emitElement(childElement, depth + 1, maxDepth, counter + (count of rows))
|
||||
set rows to rows & childRows
|
||||
if (count of rows) > ${MAX_APP_STATE_ELEMENTS} then return rows
|
||||
end repeat
|
||||
end try
|
||||
return rows
|
||||
end emitElement
|
||||
|
||||
tell application "System Events"
|
||||
if not (exists process "${escapedApp}") then error "App is not running: ${escapedApp}"
|
||||
tell process "${escapedApp}"
|
||||
set rows to {}
|
||||
repeat with w in windows
|
||||
set rows to rows & my emitElement(w, 0, 4, (count of rows) + 1)
|
||||
if (count of rows) > ${MAX_APP_STATE_ELEMENTS} then exit repeat
|
||||
end repeat
|
||||
return rows
|
||||
end tell
|
||||
end tell
|
||||
`;
|
||||
const output = await run('osascript', ['-e', script], 10000);
|
||||
return output.split(/\r?\n|, /)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line, index) => {
|
||||
const [rawIndex, role, title, value, boundsText] = line.split('\t');
|
||||
const boundsParts = (boundsText || '').split(',').map((part) => Number.parseFloat(part));
|
||||
const hasBounds = boundsParts.length === 4 && boundsParts.every(Number.isFinite);
|
||||
return {
|
||||
index: rawIndex || String(index + 1),
|
||||
role: role || 'element',
|
||||
title: title || undefined,
|
||||
value: value || undefined,
|
||||
bounds: hasBounds
|
||||
? { x: boundsParts[0], y: boundsParts[1], width: boundsParts[2], height: boundsParts[3] }
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getAccessibilityTree(app: string): Promise<{ elements: SemanticElement[]; message?: string }> {
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
return { elements: await macAccessibilityTree(app) };
|
||||
} catch (error) {
|
||||
return { elements: [], message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: [],
|
||||
message: 'Native accessibility tree capture is not implemented for this platform yet.',
|
||||
};
|
||||
}
|
||||
|
||||
async function getAppState(sessionId: string, app: string): Promise<SemanticAppState> {
|
||||
if (!app) {
|
||||
throw new Error('app is required.');
|
||||
}
|
||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.getAppState({ sessionId, app }));
|
||||
if (helperState) {
|
||||
return helperState;
|
||||
}
|
||||
|
||||
const screenshot = await captureScreenshot();
|
||||
const tree = await getAccessibilityTree(app);
|
||||
const state: SemanticAppState = {
|
||||
stateId: semanticSessionStore.createStateId(),
|
||||
app,
|
||||
platform: process.platform,
|
||||
screenshotDataUrl: screenshot.dataUrl,
|
||||
displaySize: screenshot.size,
|
||||
elements: tree.elements,
|
||||
accessibilityTree: tree.elements,
|
||||
message: tree.message,
|
||||
};
|
||||
return semanticSessionStore.save(sessionId, state);
|
||||
}
|
||||
|
||||
async function targetFor(sessionId: string, app: string, stateId?: string): Promise<ExecutorTarget> {
|
||||
const cached = semanticSessionStore.getState(sessionId, app, stateId);
|
||||
return { displaySize: cached?.displaySize || (await captureScreenshot()).size };
|
||||
}
|
||||
|
||||
export const computerSemanticsService = {
|
||||
async callTool(name: string, input: Record<string, unknown>): Promise<unknown> {
|
||||
const sessionId = readSessionId(input);
|
||||
switch (name) {
|
||||
case 'list_apps': {
|
||||
const adapter = getHelperAdapter();
|
||||
if (adapter) {
|
||||
try {
|
||||
return { apps: await adapter.listApps(), platform: process.platform };
|
||||
} catch (error) {
|
||||
if (!shouldFallbackFromHelper(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn('[ComputerSemantics] Falling back from helper:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
return { apps: await listApps(), platform: process.platform };
|
||||
}
|
||||
case 'get_app_state':
|
||||
return getAppState(sessionId, readString(input.app));
|
||||
case 'click':
|
||||
case 'click_element': {
|
||||
const app = requireApp(input);
|
||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.clickElement({ ...input, sessionId, app }));
|
||||
if (helperState) {
|
||||
return helperState;
|
||||
}
|
||||
const stateId = readString(input.stateId) || undefined;
|
||||
const point = getPoint(input, sessionId, app);
|
||||
if (!point) {
|
||||
throw new Error('click requires x/y or an element_index from computer_get_app_state.');
|
||||
}
|
||||
const target = await targetFor(sessionId, app, stateId);
|
||||
const button = readButton(input.mouse_button ?? input.mouseButton);
|
||||
const clickCount = readClickCount(input.click_count ?? input.clickCount);
|
||||
for (let index = 0; index < clickCount; index += 1) {
|
||||
await executor.click(target, button, point, false);
|
||||
}
|
||||
return getAppState(sessionId, app);
|
||||
}
|
||||
case 'drag': {
|
||||
const app = requireApp(input);
|
||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.drag({ ...input, sessionId, app }));
|
||||
if (helperState) {
|
||||
return helperState;
|
||||
}
|
||||
const stateId = readString(input.stateId) || undefined;
|
||||
const fromX = readNumber(input.from_x);
|
||||
const fromY = readNumber(input.from_y);
|
||||
const toX = readNumber(input.to_x);
|
||||
const toY = readNumber(input.to_y);
|
||||
if (fromX === undefined || fromY === undefined || toX === undefined || toY === undefined) {
|
||||
throw new Error('drag requires from_x/from_y/to_x/to_y.');
|
||||
}
|
||||
await executor.drag(await targetFor(sessionId, app, stateId), { x: fromX, y: fromY }, { x: toX, y: toY }, readButton(input.mouse_button ?? input.mouseButton));
|
||||
return getAppState(sessionId, app);
|
||||
}
|
||||
case 'scroll':
|
||||
case 'scroll_element': {
|
||||
const app = requireApp(input);
|
||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.scrollElement({ ...input, sessionId, app }));
|
||||
if (helperState) {
|
||||
return helperState;
|
||||
}
|
||||
const stateId = readString(input.stateId) || undefined;
|
||||
const point = getPoint(input, sessionId, app);
|
||||
if (!point) {
|
||||
throw new Error('scroll requires x/y or an element_index from computer_get_app_state.');
|
||||
}
|
||||
await executor.scroll(await targetFor(sessionId, app, stateId), readDirection(input.direction), readNumber(input.pages) ?? 1, point);
|
||||
return getAppState(sessionId, app);
|
||||
}
|
||||
case 'type_text': {
|
||||
const app = requireApp(input);
|
||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.typeText({ ...input, sessionId, app }));
|
||||
if (helperState) {
|
||||
return helperState;
|
||||
}
|
||||
await executor.type(readString(input.text));
|
||||
return getAppState(sessionId, app);
|
||||
}
|
||||
case 'press_key': {
|
||||
const app = requireApp(input);
|
||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.pressKey({ ...input, sessionId, app }));
|
||||
if (helperState) {
|
||||
return helperState;
|
||||
}
|
||||
await executor.pressChord(readString(input.key));
|
||||
return getAppState(sessionId, app);
|
||||
}
|
||||
case 'set_value': {
|
||||
const app = requireApp(input);
|
||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.setValue({ ...input, sessionId, app }));
|
||||
if (helperState) {
|
||||
return helperState;
|
||||
}
|
||||
const stateId = readString(input.stateId) || undefined;
|
||||
const point = getPoint(input, sessionId, app);
|
||||
if (!point) {
|
||||
throw new Error('set_value requires x/y or an element_index from computer_get_app_state.');
|
||||
}
|
||||
await executor.click(await targetFor(sessionId, app, stateId), 'left', point, false);
|
||||
await executor.pressChord(process.platform === 'darwin' ? 'cmd+a' : 'ctrl+a');
|
||||
await executor.type(readString(input.value));
|
||||
return getAppState(sessionId, app);
|
||||
}
|
||||
case 'perform_secondary_action': {
|
||||
const app = requireApp(input);
|
||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.performSecondaryAction({ ...input, sessionId, app }));
|
||||
if (helperState) {
|
||||
return helperState;
|
||||
}
|
||||
const stateId = readString(input.stateId) || undefined;
|
||||
const point = getPoint(input, sessionId, app);
|
||||
if (!point) {
|
||||
throw new Error('perform_secondary_action requires x/y or an element_index from computer_get_app_state.');
|
||||
}
|
||||
await executor.click(await targetFor(sessionId, app, stateId), 'right', point, false);
|
||||
return getAppState(sessionId, app);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown semantic Computer Use tool: ${name}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
141
server/modules/computer-use/computer-use-mcp.routes.ts
Normal file
141
server/modules/computer-use/computer-use-mcp.routes.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import express from 'express';
|
||||
|
||||
import { computerUseService } from '@/modules/computer-use/computer-use.service.js';
|
||||
import { semanticOperationForMcpTool } from '@/modules/computer-use/semantics/semantic-tool-dispatcher.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function readBearerToken(header: unknown): string | null {
|
||||
if (typeof header !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = header.trim();
|
||||
const scheme = 'Bearer';
|
||||
if (trimmed.slice(0, scheme.length).toLowerCase() !== scheme.toLowerCase()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const separator = trimmed[scheme.length];
|
||||
if (separator !== ' ' && separator !== '\t') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.slice(scheme.length + 1).trimStart() || 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;
|
||||
}
|
||||
|
||||
function requireNumber(input: Record<string, unknown>, name: string): number {
|
||||
const value = input[name];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error(`${name} is required and must be a finite number.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePoint(input: Record<string, unknown>): { x: number; y: number } {
|
||||
return { x: requireNumber(input, 'x'), y: requireNumber(input, 'y') };
|
||||
}
|
||||
|
||||
function requireNamedPoint(input: Record<string, unknown>, xName: string, yName: string): { x: number; y: number } {
|
||||
return { x: requireNumber(input, xName), y: requireNumber(input, yName) };
|
||||
}
|
||||
|
||||
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 : undefined;
|
||||
const toolName = req.params.toolName;
|
||||
const semanticOperation = semanticOperationForMcpTool(toolName);
|
||||
let result: unknown;
|
||||
|
||||
if (semanticOperation) {
|
||||
result = await computerUseService.callSemanticTool(semanticOperation, input);
|
||||
res.json({ success: true, data: result });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
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, requirePoint(input));
|
||||
break;
|
||||
case 'computer_click':
|
||||
result = await computerUseService.agentUnifiedClick(sessionId, {
|
||||
button: toButton(input.mouseButton ?? input.mouse_button ?? input.button),
|
||||
point: point(input),
|
||||
clickCount: typeof input.clickCount === 'number'
|
||||
? input.clickCount
|
||||
: typeof input.click_count === 'number'
|
||||
? input.click_count
|
||||
: 1,
|
||||
});
|
||||
break;
|
||||
case 'computer_drag': {
|
||||
const from = requireNamedPoint(input, 'startX', 'startY');
|
||||
const to = requireNamedPoint(input, 'endX', 'endY');
|
||||
result = await computerUseService.agentDrag(sessionId, from, to, toButton(input.mouseButton ?? input.mouse_button ?? input.button));
|
||||
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;
|
||||
211
server/modules/computer-use/computer-use.routes.ts
Normal file
211
server/modules/computer-use/computer-use.routes.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import express from 'express';
|
||||
|
||||
import { computerUseService } from '@/modules/computer-use/computer-use.service.js';
|
||||
import { AppError } from '@/shared/utils.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 || String(userId).trim() === '') {
|
||||
throw new AppError('Authenticated user is required.', {
|
||||
code: 'AUTHENTICATED_USER_REQUIRED',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
return { id: userId };
|
||||
}
|
||||
|
||||
function getErrorStatusCode(error: unknown, fallbackStatusCode: number): number {
|
||||
if (error instanceof AppError) {
|
||||
return error.statusCode;
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
const statusCode = 'statusCode' in error ? error.statusCode : 'status' in error ? error.status : undefined;
|
||||
if (typeof statusCode === 'number' && Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
|
||||
return statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackStatusCode;
|
||||
}
|
||||
|
||||
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: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
requireUser(req);
|
||||
res.json({ success: true, data: { settings: await computerUseService.getSettings() } });
|
||||
} catch (error) {
|
||||
res.status(getErrorStatusCode(error, 500)).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load Computer Use settings.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/settings', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
requireUser(req);
|
||||
const settings = await computerUseService.updateSettings(req.body || {});
|
||||
res.json({ success: true, data: { settings } });
|
||||
} catch (error) {
|
||||
res.status(getErrorStatusCode(error, 400)).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save Computer Use settings.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/runtime/install', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
requireUser(req);
|
||||
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(getErrorStatusCode(error, 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(getErrorStatusCode(error, 500)).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list Computer Use sessions.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 x = Number(req.body?.x);
|
||||
const y = Number(req.body?.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Valid numeric coordinates are required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await computerUseService.userClick(requireUser(req), readParam(req.params.sessionId), {
|
||||
x,
|
||||
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/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;
|
||||
920
server/modules/computer-use/computer-use.service.ts
Normal file
920
server/modules/computer-use/computer-use.service.ts
Normal file
@@ -0,0 +1,920 @@
|
||||
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/index.js';
|
||||
import { providerMcpService } from '@/modules/providers/index.js';
|
||||
import { getModuleDir } from '@/utils/runtime-paths.js';
|
||||
import {
|
||||
getRuntimeReadiness as getExecutorReadiness,
|
||||
type Point,
|
||||
type ClickButton,
|
||||
type ScrollDirection,
|
||||
} from '@/modules/computer-use/computer-executor.js';
|
||||
import { runRawComputerAction } from '@/modules/computer-use/actions/raw-action-dispatcher.js';
|
||||
import type { RawComputerAction } from '@/modules/computer-use/actions/raw-action-types.js';
|
||||
import { desktopAgentRelay } from '@/modules/computer-use/desktop-agent-relay.service.js';
|
||||
import { computerSemanticsService } from '@/modules/computer-use/computer-semantics.service.js';
|
||||
import { semanticOperationNames } from '@/modules/computer-use/semantics/semantic-tool-dispatcher.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 STOPPED_SESSION_RETENTION_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_STOPPED_SESSION_RETENTION_MS || String(30 * 60 * 1000), 10);
|
||||
const MAX_STORED_SESSIONS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_STORED_SESSIONS || '100', 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;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
} 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,
|
||||
};
|
||||
|
||||
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 (!settings.enabled) {
|
||||
return 'Computer Use is disabled in settings.';
|
||||
}
|
||||
if (getRuntime() === 'cloud') {
|
||||
return 'Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.';
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeSessionId(sessionId?: string | null): string | null {
|
||||
if (typeof sessionId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = sessionId.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function findActiveAgentSession(): ComputerUseSession | null {
|
||||
return ownerSessions(AGENT_OWNER_ID)
|
||||
.filter((session) => session.status === 'ready')
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0] || null;
|
||||
}
|
||||
|
||||
function positiveDuration(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
||||
const sessionTtl = positiveDuration(SESSION_TTL_MS, 30 * 60 * 1000);
|
||||
const stoppedRetention = positiveDuration(STOPPED_SESSION_RETENTION_MS, sessionTtl);
|
||||
|
||||
for (const [sessionId, session] of sessions.entries()) {
|
||||
const updatedAt = Date.parse(session.updatedAt);
|
||||
if (!Number.isFinite(updatedAt)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (session.status === 'ready') {
|
||||
if (now - updatedAt <= sessionTtl) {
|
||||
continue;
|
||||
}
|
||||
session.status = 'stopped';
|
||||
session.agentAccessEnabled = false;
|
||||
session.updatedAt = new Date(now).toISOString();
|
||||
session.lastAction = 'expire';
|
||||
session.message = 'Computer Use session expired after inactivity.';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now - updatedAt > stoppedRetention) {
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const maxStoredSessions = Number.isFinite(MAX_STORED_SESSIONS) && MAX_STORED_SESSIONS > 0
|
||||
? MAX_STORED_SESSIONS
|
||||
: 100;
|
||||
if (sessions.size <= maxStoredSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removable = [...sessions.values()]
|
||||
.filter((session) => session.status !== 'ready')
|
||||
.sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt));
|
||||
for (const session of removable) {
|
||||
if (sessions.size <= maxStoredSessions) {
|
||||
break;
|
||||
}
|
||||
sessions.delete(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
function stripSessionArgs(args: Record<string, unknown>): Record<string, unknown> {
|
||||
const { sessionId: _sessionId, ...toolArgs } = args;
|
||||
return toolArgs;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
applyRelayResult(session, await runRawComputerAction({ type: 'screenshot' }, session));
|
||||
}
|
||||
|
||||
/** Runs one action and refreshes the session screenshot afterwards. */
|
||||
async function performAction(session: ComputerUseSession, action: RawComputerAction): 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;
|
||||
}
|
||||
|
||||
applyRelayResult(session, await runRawComputerAction(action, 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 };
|
||||
}
|
||||
const result = await runRawComputerAction({ type: 'cursor_position' }, session);
|
||||
applyRelayResult(session, result);
|
||||
return result.position || session.cursor || { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
function assertReady(session: ComputerUseSession): void {
|
||||
if (session.status !== 'ready') {
|
||||
throw new Error(session.message || 'Computer Use session is not available.');
|
||||
}
|
||||
}
|
||||
|
||||
function agentToolsAvailable(): boolean {
|
||||
const settings = readSettings();
|
||||
if (!settings.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (getRuntime() === 'cloud') {
|
||||
return desktopAgentRelay.isConnected();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertAgentToolsAvailable(): void {
|
||||
if (agentToolsAvailable()) {
|
||||
return;
|
||||
}
|
||||
const settings = readSettings();
|
||||
if (!settings.enabled) {
|
||||
throw new Error('Computer Use agent tools are disabled.');
|
||||
}
|
||||
throw new Error(
|
||||
getRuntime() === 'cloud'
|
||||
? 'No desktop is linked. Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.'
|
||||
: 'Computer Use agent tools are disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
function stopSessions(lastAction: string, message: string): void {
|
||||
for (const session of sessions.values()) {
|
||||
session.status = 'stopped';
|
||||
session.agentAccessEnabled = false;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
session.lastAction = lastAction;
|
||||
session.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
export const computerUseService = {
|
||||
async getSettings() {
|
||||
return readSettings();
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<ComputerUseSettings>) {
|
||||
const current = readSettings();
|
||||
const enabled = typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled;
|
||||
const next = writeSettings({ enabled });
|
||||
if (next.enabled) {
|
||||
await this.registerAgentMcp();
|
||||
} else {
|
||||
await this.unregisterAgentMcp();
|
||||
desktopAgentRelay.disconnectAll('Computer Use was disabled in this environment.');
|
||||
stopSessions('settings:disabled', 'Computer Use was disabled in settings.');
|
||||
}
|
||||
return next;
|
||||
},
|
||||
|
||||
async getStatus() {
|
||||
const settings = readSettings();
|
||||
const readiness = getRuntimeReadiness();
|
||||
const isCloud = getRuntime() === 'cloud';
|
||||
const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled;
|
||||
// Cloud mode still respects the saved feature setting. When enabled, cloud
|
||||
// availability comes from a linked desktop agent because the hosted server
|
||||
// has no screen of its own.
|
||||
const desktopAgentConnected = desktopAgentRelay.isConnected();
|
||||
const available = settings.enabled && (isCloud
|
||||
? desktopAgentConnected
|
||||
: runtimeReady);
|
||||
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
runtime: getRuntime(),
|
||||
available,
|
||||
desktopAgentConnected,
|
||||
desktopAgentCount: desktopAgentRelay.connectedCount(),
|
||||
nutInstalled: readiness.nutInstalled,
|
||||
screenshotInstalled: readiness.screenshotInstalled,
|
||||
installInProgress: readiness.installInProgress,
|
||||
sessionCount: sessions.size,
|
||||
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 = settings.enabled && (isCloud
|
||||
? desktopAgentRelay.isConnected()
|
||||
: 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 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) ------------------------------------
|
||||
|
||||
/**
|
||||
* 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 getOrCreateAgentSession(): Promise<ComputerUseSession> {
|
||||
assertAgentToolsAvailable();
|
||||
await expireStaleSessions();
|
||||
const existing = findActiveAgentSession();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = await this.createSession({ id: AGENT_OWNER_ID }, { createdBy: 'agent' });
|
||||
const session = sessions.get(created.id);
|
||||
if (!session) {
|
||||
throw new Error('Computer Use session could not be created.');
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
async getConsentedSession(sessionId?: string): Promise<ComputerUseSession> {
|
||||
assertAgentToolsAvailable();
|
||||
const normalizedSessionId = normalizeSessionId(sessionId);
|
||||
const session = normalizedSessionId
|
||||
? sessions.get(normalizedSessionId)
|
||||
: await this.getOrCreateAgentSession();
|
||||
if (!session) {
|
||||
throw new Error('Computer Use session not found.');
|
||||
}
|
||||
if (getRuntime() !== 'cloud' && !session.agentAccessEnabled) {
|
||||
throw new Error(`Computer Use session ${session.id} 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 | undefined, 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 agentUnifiedClick(sessionId: string | undefined, input: { button?: ClickButton; point?: Point; clickCount?: number }) {
|
||||
const session = await this.getConsentedSession(sessionId);
|
||||
const button = input.button || 'left';
|
||||
const clickCount = Math.max(1, Math.min(Math.trunc(input.clickCount || 1), 5));
|
||||
for (let index = 0; index < clickCount; index += 1) {
|
||||
await performAction(session, { type: 'click', button, point: input.point, double: false });
|
||||
}
|
||||
if (input.point) {
|
||||
session.cursor = { ...input.point, actor: 'agent' };
|
||||
}
|
||||
session.lastAction = clickCount > 1 ? `${button}_click:${clickCount}` : `${button}_click`;
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentDrag(sessionId: string | undefined, 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 = `${button}_drag`;
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentType(sessionId: string | undefined, text: string) {
|
||||
const session = await this.getConsentedSession(sessionId);
|
||||
await performAction(session, { type: 'type', text });
|
||||
session.lastAction = 'type';
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentKey(sessionId: string | undefined, 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 | undefined, 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();
|
||||
const normalizedSessionId = normalizeSessionId(sessionId);
|
||||
if (normalizedSessionId) {
|
||||
return this.stopSession({ id: AGENT_OWNER_ID }, normalizedSessionId);
|
||||
}
|
||||
|
||||
await expireStaleSessions();
|
||||
const existing = findActiveAgentSession();
|
||||
if (!existing) {
|
||||
return { stopped: false };
|
||||
}
|
||||
return this.stopSession({ id: AGENT_OWNER_ID }, existing.id);
|
||||
},
|
||||
|
||||
async callSemanticTool(toolName: string, args: Record<string, unknown>) {
|
||||
if (!semanticOperationNames.has(toolName)) {
|
||||
throw new Error(`Unsupported semantic Computer Use tool: ${toolName}`);
|
||||
}
|
||||
|
||||
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : undefined;
|
||||
const session = await this.getConsentedSession(normalizeSessionId(sessionId) ?? undefined);
|
||||
const toolArgs = { ...stripSessionArgs(args), sessionId: session.id };
|
||||
const semanticResult = getRuntime() === 'cloud'
|
||||
? await desktopAgentRelay.relay('semantic_tool', {
|
||||
sessionId: session.id,
|
||||
displaySize: session.displaySize,
|
||||
toolName,
|
||||
arguments: toolArgs,
|
||||
})
|
||||
: await computerSemanticsService.callTool(toolName, toolArgs);
|
||||
|
||||
applyRelayResult(session, semanticResult as RelayResult);
|
||||
session.lastAction = `semantic:${toolName}`;
|
||||
return { session: publicSession(session), result: semanticResult };
|
||||
},
|
||||
|
||||
/**
|
||||
* Cloud only: when a desktop agent links to this hosted environment, expose
|
||||
* the computer_* MCP tools only if the user enabled Computer Use in settings.
|
||||
*/
|
||||
async onDesktopAgentConnected() {
|
||||
if (getRuntime() !== 'cloud') {
|
||||
return;
|
||||
}
|
||||
if (!readSettings().enabled) {
|
||||
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() {
|
||||
stopSessions('shutdown', 'Computer Use session stopped during server shutdown.');
|
||||
},
|
||||
};
|
||||
|
||||
// Drive cloud MCP exposure + session teardown off desktop-agent connectivity.
|
||||
desktopAgentRelay.setHooks({
|
||||
canAcceptConnection: () => getRuntime() === 'cloud' && readSettings().enabled,
|
||||
onFirstConnect: () => computerUseService.onDesktopAgentConnected(),
|
||||
onLastDisconnect: () => computerUseService.onDesktopAgentDisconnected(),
|
||||
});
|
||||
|
||||
process.once('beforeExit', () => {
|
||||
void computerUseService.stopAllSessions();
|
||||
});
|
||||
158
server/modules/computer-use/desktop-agent-relay.service.ts
Normal file
158
server/modules/computer-use/desktop-agent-relay.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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 = {
|
||||
canAcceptConnection?: () => boolean;
|
||||
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'): boolean {
|
||||
if (hooks.canAcceptConnection && !hooks.canAcceptConnection()) {
|
||||
console.log(`[DesktopAgent] Rejected (${label}); Computer Use is disabled.`);
|
||||
try {
|
||||
ws.close(1008, 'Computer Use is disabled in this environment.');
|
||||
} catch {
|
||||
// ignore close failures
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const wasEmpty = pickAgent() === undefined;
|
||||
agents.set(ws, { ws, label, registeredAt: new Date().toISOString() });
|
||||
console.log(`[DesktopAgent] Registered (${label}); ${agents.size} connected.`);
|
||||
|
||||
ws.on('close', () => {
|
||||
const wasRegistered = agents.delete(ws);
|
||||
console.log(`[DesktopAgent] Disconnected (${label}); ${agents.size} remain.`);
|
||||
if (wasRegistered && pickAgent() === undefined) {
|
||||
rejectAllPending('Desktop agent disconnected.');
|
||||
void hooks.onLastDisconnect?.();
|
||||
}
|
||||
});
|
||||
|
||||
if (wasEmpty) {
|
||||
void hooks.onFirstConnect?.();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
disconnectAll(reason = 'Desktop agent disconnected.'): void {
|
||||
const hadAgent = pickAgent() !== undefined;
|
||||
const sockets = [...agents.keys()];
|
||||
agents.clear();
|
||||
for (const ws of sockets) {
|
||||
try {
|
||||
ws.close(1008, reason);
|
||||
} catch {
|
||||
// ignore close failures
|
||||
}
|
||||
}
|
||||
rejectAllPending(reason);
|
||||
if (hadAgent) {
|
||||
void hooks.onLastDisconnect?.();
|
||||
}
|
||||
},
|
||||
|
||||
/** 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 is linked. Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.'
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -0,0 +1,82 @@
|
||||
import { SemanticHelperProcess } from '@/modules/computer-use/semantics/helpers/semantic-helper-process.js';
|
||||
import { resolveSemanticHelper } from '@/modules/computer-use/semantics/helpers/semantic-helper-resolver.js';
|
||||
import type { SemanticAdapter, SemanticAdapterCapabilities } from '@/modules/computer-use/semantics/adapters/semantic-adapter.js';
|
||||
import type { SemanticApp, SemanticAppState, SemanticToolInput } from '@/modules/computer-use/semantics/semantic-types.js';
|
||||
|
||||
type HelperMethod =
|
||||
| 'list_apps'
|
||||
| 'get_app_state'
|
||||
| 'click_element'
|
||||
| 'perform_secondary_action'
|
||||
| 'set_value'
|
||||
| 'type_text'
|
||||
| 'press_key'
|
||||
| 'scroll_element'
|
||||
| 'drag';
|
||||
|
||||
export class HelperSemanticAdapter implements SemanticAdapter {
|
||||
private helper: SemanticHelperProcess | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly platform: NodeJS.Platform,
|
||||
private readonly arch: NodeJS.Architecture = process.arch,
|
||||
) {}
|
||||
|
||||
capabilities(): SemanticAdapterCapabilities {
|
||||
return {
|
||||
platform: this.platform,
|
||||
appDiscovery: true,
|
||||
accessibilityTree: true,
|
||||
nativeElementActions: true,
|
||||
nativeValueSetting: true,
|
||||
targetedInput: true,
|
||||
};
|
||||
}
|
||||
|
||||
async listApps(): Promise<SemanticApp[]> {
|
||||
return await this.request('list_apps', {}) as SemanticApp[];
|
||||
}
|
||||
|
||||
async getAppState(input: SemanticToolInput): Promise<SemanticAppState> {
|
||||
return await this.request('get_app_state', input) as SemanticAppState;
|
||||
}
|
||||
|
||||
async clickElement(input: SemanticToolInput): Promise<SemanticAppState> {
|
||||
return await this.request('click_element', input) as SemanticAppState;
|
||||
}
|
||||
|
||||
async performSecondaryAction(input: SemanticToolInput): Promise<SemanticAppState> {
|
||||
return await this.request('perform_secondary_action', input) as SemanticAppState;
|
||||
}
|
||||
|
||||
async setValue(input: SemanticToolInput): Promise<SemanticAppState> {
|
||||
return await this.request('set_value', input) as SemanticAppState;
|
||||
}
|
||||
|
||||
async typeText(input: SemanticToolInput): Promise<SemanticAppState> {
|
||||
return await this.request('type_text', input) as SemanticAppState;
|
||||
}
|
||||
|
||||
async pressKey(input: SemanticToolInput): Promise<SemanticAppState> {
|
||||
return await this.request('press_key', input) as SemanticAppState;
|
||||
}
|
||||
|
||||
async scrollElement(input: SemanticToolInput): Promise<SemanticAppState> {
|
||||
return await this.request('scroll_element', input) as SemanticAppState;
|
||||
}
|
||||
|
||||
async drag(input: SemanticToolInput): Promise<SemanticAppState> {
|
||||
return await this.request('drag', input) as SemanticAppState;
|
||||
}
|
||||
|
||||
private async request(method: HelperMethod, params: Record<string, unknown>): Promise<unknown> {
|
||||
if (!this.helper) {
|
||||
const resolution = resolveSemanticHelper(this.platform, this.arch);
|
||||
if (!resolution.available || !resolution.path) {
|
||||
throw new Error(resolution.reason || `Semantic helper is unavailable for ${this.platform}-${this.arch}.`);
|
||||
}
|
||||
this.helper = new SemanticHelperProcess(resolution.path);
|
||||
}
|
||||
return this.helper.request(method, params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HelperSemanticAdapter } from '@/modules/computer-use/semantics/adapters/helper-semantic-adapter.js';
|
||||
|
||||
export function createMacOsSemanticAdapter(): HelperSemanticAdapter {
|
||||
return new HelperSemanticAdapter('darwin');
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SemanticApp, SemanticAppState, SemanticToolInput } from '@/modules/computer-use/semantics/semantic-types.js';
|
||||
|
||||
export type SemanticAdapterCapabilities = {
|
||||
platform: NodeJS.Platform;
|
||||
appDiscovery: boolean;
|
||||
accessibilityTree: boolean;
|
||||
nativeElementActions: boolean;
|
||||
nativeValueSetting: boolean;
|
||||
targetedInput: boolean;
|
||||
};
|
||||
|
||||
export type SemanticAdapter = {
|
||||
capabilities(): SemanticAdapterCapabilities;
|
||||
listApps(): Promise<SemanticApp[]>;
|
||||
getAppState(input: SemanticToolInput): Promise<SemanticAppState>;
|
||||
clickElement(input: SemanticToolInput): Promise<SemanticAppState>;
|
||||
performSecondaryAction(input: SemanticToolInput): Promise<SemanticAppState>;
|
||||
setValue(input: SemanticToolInput): Promise<SemanticAppState>;
|
||||
typeText(input: SemanticToolInput): Promise<SemanticAppState>;
|
||||
pressKey(input: SemanticToolInput): Promise<SemanticAppState>;
|
||||
scrollElement(input: SemanticToolInput): Promise<SemanticAppState>;
|
||||
drag(input: SemanticToolInput): Promise<SemanticAppState>;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HelperSemanticAdapter } from '@/modules/computer-use/semantics/adapters/helper-semantic-adapter.js';
|
||||
|
||||
export function createWindowsSemanticAdapter(): HelperSemanticAdapter {
|
||||
return new HelperSemanticAdapter('win32');
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
|
||||
typealias JSON = [String: Any]
|
||||
|
||||
struct ElementRecord {
|
||||
let index: String
|
||||
let role: String
|
||||
let title: String?
|
||||
let value: String?
|
||||
let bounds: [String: Double]?
|
||||
let actions: [String]
|
||||
}
|
||||
|
||||
var stateElements: [String: [ElementRecord]] = [:]
|
||||
var stateAxElements: [String: [String: AXUIElement]] = [:]
|
||||
var stateOrder: [String] = []
|
||||
let maxStoredStates = 100
|
||||
|
||||
func jsonLine(_ object: Any) {
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
print("{\"error\":\"Failed to encode JSON\"}")
|
||||
fflush(stdout)
|
||||
return
|
||||
}
|
||||
print(text)
|
||||
fflush(stdout)
|
||||
}
|
||||
|
||||
func respond(id: Any?, result: Any) {
|
||||
jsonLine(["id": id ?? NSNull(), "result": result])
|
||||
}
|
||||
|
||||
func respondError(id: Any?, _ message: String) {
|
||||
jsonLine(["id": id ?? NSNull(), "error": message])
|
||||
}
|
||||
|
||||
func stringAttr(_ element: AXUIElement, _ attr: CFString) -> String? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return nil }
|
||||
return value as? String
|
||||
}
|
||||
|
||||
func boolAttr(_ element: AXUIElement, _ attr: CFString) -> Bool? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return nil }
|
||||
return value as? Bool
|
||||
}
|
||||
|
||||
func arrayAttr(_ element: AXUIElement, _ attr: CFString) -> [AXUIElement] {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return [] }
|
||||
return value as? [AXUIElement] ?? []
|
||||
}
|
||||
|
||||
func actions(_ element: AXUIElement) -> [String] {
|
||||
var names: CFArray?
|
||||
guard AXUIElementCopyActionNames(element, &names) == .success else { return [] }
|
||||
return names as? [String] ?? []
|
||||
}
|
||||
|
||||
func bounds(_ element: AXUIElement) -> [String: Double]? {
|
||||
var positionRef: CFTypeRef?
|
||||
var sizeRef: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionRef) == .success,
|
||||
AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef) == .success,
|
||||
let positionValue = positionRef,
|
||||
let sizeValue = sizeRef
|
||||
else { return nil }
|
||||
|
||||
var point = CGPoint.zero
|
||||
var size = CGSize.zero
|
||||
guard CFGetTypeID(positionValue) == AXValueGetTypeID(),
|
||||
CFGetTypeID(sizeValue) == AXValueGetTypeID()
|
||||
else { return nil }
|
||||
|
||||
let positionAxValue = positionValue as! AXValue
|
||||
let sizeAxValue = sizeValue as! AXValue
|
||||
guard AXValueGetValue(positionAxValue, .cgPoint, &point),
|
||||
AXValueGetValue(sizeAxValue, .cgSize, &size)
|
||||
else { return nil }
|
||||
|
||||
return [
|
||||
"x": Double(point.x),
|
||||
"y": Double(point.y),
|
||||
"width": Double(size.width),
|
||||
"height": Double(size.height),
|
||||
]
|
||||
}
|
||||
|
||||
func record(_ element: AXUIElement, index: String) -> ElementRecord {
|
||||
ElementRecord(
|
||||
index: index,
|
||||
role: stringAttr(element, kAXRoleAttribute as CFString) ?? "AXUnknown",
|
||||
title: stringAttr(element, kAXTitleAttribute as CFString) ?? stringAttr(element, kAXDescriptionAttribute as CFString),
|
||||
value: stringAttr(element, kAXValueAttribute as CFString),
|
||||
bounds: bounds(element),
|
||||
actions: actions(element)
|
||||
)
|
||||
}
|
||||
|
||||
func cachedElement(_ params: JSON) -> AXUIElement? {
|
||||
guard let stateId = params["stateId"] as? String,
|
||||
let elementIndex = params["element_index"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return stateAxElements[stateId]?[elementIndex]
|
||||
}
|
||||
|
||||
func dictionary(_ record: ElementRecord) -> JSON {
|
||||
var output: JSON = [
|
||||
"index": record.index,
|
||||
"role": record.role,
|
||||
"actions": record.actions,
|
||||
]
|
||||
if let title = record.title { output["title"] = title }
|
||||
if let value = record.value { output["value"] = value }
|
||||
if let bounds = record.bounds { output["bounds"] = bounds }
|
||||
return output
|
||||
}
|
||||
|
||||
func pruneStoredStates() {
|
||||
while stateOrder.count > maxStoredStates {
|
||||
let evicted = stateOrder.removeFirst()
|
||||
stateElements.removeValue(forKey: evicted)
|
||||
stateAxElements.removeValue(forKey: evicted)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveApp(_ query: String) throws -> NSRunningApplication {
|
||||
let normalized = query.lowercased()
|
||||
let apps = NSWorkspace.shared.runningApplications.filter { app in
|
||||
app.activationPolicy == .regular
|
||||
}
|
||||
if let app = apps.first(where: { $0.bundleIdentifier?.lowercased() == normalized }) {
|
||||
return app
|
||||
}
|
||||
if let app = apps.first(where: { ($0.localizedName ?? "").lowercased() == normalized }) {
|
||||
return app
|
||||
}
|
||||
if let app = apps.first(where: { ($0.localizedName ?? "").lowercased().contains(normalized) }) {
|
||||
return app
|
||||
}
|
||||
throw NSError(domain: "CloudCLISemantics", code: 404, userInfo: [NSLocalizedDescriptionKey: "App is not running: \(query)"])
|
||||
}
|
||||
|
||||
func listApps() -> [[String: Any]] {
|
||||
NSWorkspace.shared.runningApplications
|
||||
.filter { $0.activationPolicy == .regular }
|
||||
.map { app in
|
||||
[
|
||||
"id": app.bundleIdentifier ?? app.localizedName ?? "\(app.processIdentifier)",
|
||||
"name": app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||
"bundleIdentifier": app.bundleIdentifier ?? "",
|
||||
"pid": Int(app.processIdentifier),
|
||||
"running": true,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
func walk(_ element: AXUIElement, depth: Int, maxDepth: Int, records: inout [ElementRecord], axRecords: inout [String: AXUIElement], limit: Int) {
|
||||
if depth > maxDepth || records.count >= limit { return }
|
||||
let index = "\(records.count + 1)"
|
||||
records.append(record(element, index: index))
|
||||
axRecords[index] = element
|
||||
for child in arrayAttr(element, kAXChildrenAttribute as CFString) {
|
||||
walk(child, depth: depth + 1, maxDepth: maxDepth, records: &records, axRecords: &axRecords, limit: limit)
|
||||
if records.count >= limit { return }
|
||||
}
|
||||
}
|
||||
|
||||
func pngDataUrlForMainDisplay() -> String? {
|
||||
let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("cloudcli-semantics-\(UUID().uuidString).png")
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
|
||||
process.arguments = ["-x", "-t", "png", fileURL.path]
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else { return nil }
|
||||
let png = try Data(contentsOf: fileURL)
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return png.isEmpty ? nil : "data:image/png;base64,\(png.base64EncodedString())"
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getAppState(_ params: JSON) throws -> JSON {
|
||||
let appName = params["app"] as? String ?? ""
|
||||
let app = try resolveApp(appName)
|
||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
||||
let windows = arrayAttr(axApp, kAXWindowsAttribute as CFString)
|
||||
let root = windows.first ?? axApp
|
||||
var records: [ElementRecord] = []
|
||||
var axRecords: [String: AXUIElement] = [:]
|
||||
walk(root, depth: 0, maxDepth: 5, records: &records, axRecords: &axRecords, limit: 300)
|
||||
let stateId = "state_\(UUID().uuidString)"
|
||||
stateElements[stateId] = records
|
||||
stateAxElements[stateId] = axRecords
|
||||
stateOrder.append(stateId)
|
||||
pruneStoredStates()
|
||||
|
||||
let elements = records.map(dictionary)
|
||||
return [
|
||||
"stateId": stateId,
|
||||
"app": app.localizedName ?? app.bundleIdentifier ?? appName,
|
||||
"platform": "darwin",
|
||||
"screenshotDataUrl": pngDataUrlForMainDisplay() ?? NSNull(),
|
||||
"displaySize": [
|
||||
"width": Int(CGDisplayPixelsWide(CGMainDisplayID())),
|
||||
"height": Int(CGDisplayPixelsHigh(CGMainDisplayID())),
|
||||
],
|
||||
"elements": elements,
|
||||
"accessibilityTree": elements,
|
||||
"treeText": elements.map { "\($0["index"] ?? "") \($0["role"] ?? "") \($0["title"] ?? "")" }.joined(separator: "\n"),
|
||||
]
|
||||
}
|
||||
|
||||
func cgMouseButton(_ value: Any?) -> CGMouseButton {
|
||||
guard let button = value as? String else { return .left }
|
||||
switch button {
|
||||
case "right": return .right
|
||||
case "middle": return .center
|
||||
default: return .left
|
||||
}
|
||||
}
|
||||
|
||||
func mouseEventTypes(_ button: CGMouseButton) -> (CGEventType, CGEventType) {
|
||||
switch button {
|
||||
case .right: return (.rightMouseDown, .rightMouseUp)
|
||||
case .center: return (.otherMouseDown, .otherMouseUp)
|
||||
default: return (.leftMouseDown, .leftMouseUp)
|
||||
}
|
||||
}
|
||||
|
||||
func postMouseClick(point: CGPoint, button: CGMouseButton, clickCount: Int = 1) throws {
|
||||
guard let source = CGEventSource(stateID: .combinedSessionState) else {
|
||||
throw NSError(domain: "CloudCLISemantics", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to create CGEventSource"])
|
||||
}
|
||||
let eventTypes = mouseEventTypes(button)
|
||||
for _ in 0..<max(1, clickCount) {
|
||||
let down = CGEvent(mouseEventSource: source, mouseType: eventTypes.0, mouseCursorPosition: point, mouseButton: button)
|
||||
let up = CGEvent(mouseEventSource: source, mouseType: eventTypes.1, mouseCursorPosition: point, mouseButton: button)
|
||||
down?.post(tap: .cghidEventTap)
|
||||
up?.post(tap: .cghidEventTap)
|
||||
usleep(80_000)
|
||||
}
|
||||
}
|
||||
|
||||
func postDrag(from: CGPoint, to: CGPoint, button: CGMouseButton) throws {
|
||||
guard let source = CGEventSource(stateID: .combinedSessionState) else {
|
||||
throw NSError(domain: "CloudCLISemantics", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to create CGEventSource"])
|
||||
}
|
||||
let eventTypes = mouseEventTypes(button)
|
||||
CGEvent(mouseEventSource: source, mouseType: eventTypes.0, mouseCursorPosition: from, mouseButton: button)?.post(tap: .cghidEventTap)
|
||||
usleep(80_000)
|
||||
CGEvent(mouseEventSource: source, mouseType: .leftMouseDragged, mouseCursorPosition: to, mouseButton: button)?.post(tap: .cghidEventTap)
|
||||
usleep(80_000)
|
||||
CGEvent(mouseEventSource: source, mouseType: eventTypes.1, mouseCursorPosition: to, mouseButton: button)?.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
func runAppleScript(_ script: String) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
process.arguments = ["-e", script]
|
||||
process.standardOutput = Pipe()
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
if process.terminationStatus != 0 {
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let message = String(data: data, encoding: .utf8) ?? "AppleScript failed."
|
||||
throw NSError(domain: "CloudCLISemantics", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message])
|
||||
}
|
||||
}
|
||||
|
||||
func escapedAppleScriptString(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
|
||||
}
|
||||
|
||||
func pointForElement(_ params: JSON) -> CGPoint? {
|
||||
if let x = params["x"] as? Double, let y = params["y"] as? Double {
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
guard let stateId = params["stateId"] as? String,
|
||||
let elementIndex = params["element_index"] as? String,
|
||||
let element = stateElements[stateId]?.first(where: { $0.index == elementIndex }),
|
||||
let b = element.bounds,
|
||||
let x = b["x"], let y = b["y"], let width = b["width"], let height = b["height"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return CGPoint(x: x + width / 2, y: y + height / 2)
|
||||
}
|
||||
|
||||
func click(_ params: JSON) throws -> JSON {
|
||||
if let element = cachedElement(params),
|
||||
cgMouseButton(params["mouse_button"]) == .left,
|
||||
(params["click_count"] as? Int ?? 1) == 1,
|
||||
actions(element).contains(kAXPressAction as String),
|
||||
AXUIElementPerformAction(element, kAXPressAction as CFString) == .success {
|
||||
return try getAppState(params)
|
||||
}
|
||||
|
||||
guard let point = pointForElement(params) else {
|
||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "click_element requires x/y or stateId + element_index"])
|
||||
}
|
||||
let clickCount = params["click_count"] as? Int ?? 1
|
||||
try postMouseClick(point: point, button: cgMouseButton(params["mouse_button"]), clickCount: clickCount)
|
||||
return try getAppState(params)
|
||||
}
|
||||
|
||||
func performSecondaryAction(_ params: JSON) throws -> JSON {
|
||||
if let element = cachedElement(params),
|
||||
actions(element).contains(kAXShowMenuAction as String),
|
||||
AXUIElementPerformAction(element, kAXShowMenuAction as CFString) == .success {
|
||||
return try getAppState(params)
|
||||
}
|
||||
guard let point = pointForElement(params) else {
|
||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "perform_secondary_action requires x/y or stateId + element_index"])
|
||||
}
|
||||
try postMouseClick(point: point, button: .right)
|
||||
return try getAppState(params)
|
||||
}
|
||||
|
||||
func setValue(_ params: JSON) throws -> JSON {
|
||||
guard let value = params["value"] as? String else {
|
||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "set_value requires value"])
|
||||
}
|
||||
if let element = cachedElement(params),
|
||||
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success {
|
||||
return try getAppState(params)
|
||||
}
|
||||
guard let point = pointForElement(params) else {
|
||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "set_value requires x/y or stateId + element_index"])
|
||||
}
|
||||
try postMouseClick(point: point, button: .left)
|
||||
try runAppleScript("tell application \"System Events\" to keystroke \"a\" using command down")
|
||||
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(value))\"")
|
||||
return try getAppState(params)
|
||||
}
|
||||
|
||||
func typeText(_ params: JSON) throws -> JSON {
|
||||
let text = params["text"] as? String ?? ""
|
||||
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(text))\"")
|
||||
return try getAppState(params)
|
||||
}
|
||||
|
||||
func appleScriptModifiers(_ parts: [String]) -> String {
|
||||
let modifiers = parts.compactMap { part -> String? in
|
||||
switch part.lowercased() {
|
||||
case "cmd", "command", "meta": return "command down"
|
||||
case "ctrl", "control": return "control down"
|
||||
case "alt", "option": return "option down"
|
||||
case "shift": return "shift down"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
return modifiers.isEmpty ? "" : " using {\(modifiers.joined(separator: ", "))}"
|
||||
}
|
||||
|
||||
func appleScriptKeyCode(_ key: String) -> Int? {
|
||||
switch key.lowercased() {
|
||||
case "return", "enter": return 36
|
||||
case "tab": return 48
|
||||
case "space": return 49
|
||||
case "delete", "backspace": return 51
|
||||
case "escape", "esc": return 53
|
||||
case "left": return 123
|
||||
case "right": return 124
|
||||
case "down": return 125
|
||||
case "up": return 126
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func pressKey(_ params: JSON) throws -> JSON {
|
||||
let raw = params["key"] as? String ?? ""
|
||||
let parts = raw.split(separator: "+").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
|
||||
let key = parts.last ?? raw
|
||||
let modifiers = appleScriptModifiers(Array(parts.dropLast()))
|
||||
if let keyCode = appleScriptKeyCode(key) {
|
||||
try runAppleScript("tell application \"System Events\" to key code \(keyCode)\(modifiers)")
|
||||
} else {
|
||||
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(key))\"\(modifiers)")
|
||||
}
|
||||
return try getAppState(params)
|
||||
}
|
||||
|
||||
func scrollElement(_ params: JSON) throws -> JSON {
|
||||
guard let point = pointForElement(params) else {
|
||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "scroll_element requires x/y or stateId + element_index"])
|
||||
}
|
||||
CGWarpMouseCursorPosition(point)
|
||||
let direction = params["direction"] as? String ?? "down"
|
||||
let pages = params["pages"] as? Double ?? 1.0
|
||||
let amount = Int32(max(1.0, abs(pages) * 8.0))
|
||||
let vertical = direction == "up" ? amount : direction == "down" ? -amount : 0
|
||||
let horizontal = direction == "left" ? amount : direction == "right" ? -amount : 0
|
||||
CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 2, wheel1: vertical, wheel2: horizontal, wheel3: 0)?.post(tap: .cghidEventTap)
|
||||
return try getAppState(params)
|
||||
}
|
||||
|
||||
func drag(_ params: JSON) throws -> JSON {
|
||||
guard let fromX = params["from_x"] as? Double,
|
||||
let fromY = params["from_y"] as? Double,
|
||||
let toX = params["to_x"] as? Double,
|
||||
let toY = params["to_y"] as? Double
|
||||
else {
|
||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "drag requires from_x/from_y/to_x/to_y"])
|
||||
}
|
||||
try postDrag(from: CGPoint(x: fromX, y: fromY), to: CGPoint(x: toX, y: toY), button: cgMouseButton(params["mouse_button"]))
|
||||
return try getAppState(params)
|
||||
}
|
||||
|
||||
func handle(_ request: JSON) {
|
||||
let id = request["id"]
|
||||
let method = request["method"] as? String ?? ""
|
||||
let params = request["params"] as? JSON ?? [:]
|
||||
|
||||
do {
|
||||
switch method {
|
||||
case "list_apps":
|
||||
respond(id: id, result: listApps())
|
||||
case "get_app_state":
|
||||
respond(id: id, result: try getAppState(params))
|
||||
case "click_element":
|
||||
respond(id: id, result: try click(params))
|
||||
case "perform_secondary_action":
|
||||
respond(id: id, result: try performSecondaryAction(params))
|
||||
case "set_value":
|
||||
respond(id: id, result: try setValue(params))
|
||||
case "type_text":
|
||||
respond(id: id, result: try typeText(params))
|
||||
case "press_key":
|
||||
respond(id: id, result: try pressKey(params))
|
||||
case "scroll_element":
|
||||
respond(id: id, result: try scrollElement(params))
|
||||
case "drag":
|
||||
respond(id: id, result: try drag(params))
|
||||
default:
|
||||
respondError(id: id, "Method is not implemented yet: \(method)")
|
||||
}
|
||||
} catch {
|
||||
respondError(id: id, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
while let line = readLine() {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let request = object as? JSON
|
||||
else {
|
||||
respondError(id: nil, "Invalid JSON request")
|
||||
continue
|
||||
}
|
||||
handle(request)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import readline from 'node:readline';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_SEMANTICS_HELPER_TIMEOUT_MS || '60000', 10);
|
||||
|
||||
function timeoutMs(): number {
|
||||
return Number.isFinite(DEFAULT_TIMEOUT_MS) && DEFAULT_TIMEOUT_MS > 0 ? DEFAULT_TIMEOUT_MS : 60000;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export class SemanticHelperProcess {
|
||||
private child: ChildProcessWithoutNullStreams | null = null;
|
||||
private reader: readline.Interface | null = null;
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
|
||||
constructor(private readonly executablePath: string) {}
|
||||
|
||||
async request(method: string, params: JsonRecord): Promise<unknown> {
|
||||
this.ensureStarted();
|
||||
const child = this.child;
|
||||
if (!child?.stdin.writable) {
|
||||
throw new Error('Semantic helper process is not running.');
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Semantic helper request timed out: ${method}`));
|
||||
}, timeoutMs());
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const child = this.child;
|
||||
this.child = null;
|
||||
this.reader?.close();
|
||||
this.reader = null;
|
||||
this.rejectAll('Semantic helper stopped.');
|
||||
if (child) {
|
||||
try { child.kill('SIGTERM'); } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
private ensureStarted(): void {
|
||||
if (this.child) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.child = spawn(this.executablePath, [], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.reader = readline.createInterface({ input: this.child.stdout });
|
||||
this.reader.on('line', (line) => this.handleLine(line));
|
||||
|
||||
this.child.stderr.on('data', (chunk) => {
|
||||
const text = String(chunk).trim();
|
||||
if (text) {
|
||||
console.error('[SemanticHelper]', text);
|
||||
}
|
||||
});
|
||||
|
||||
this.child.once('error', (error) => {
|
||||
this.child = null;
|
||||
this.rejectAll(`Failed to start semantic helper: ${error.message}`);
|
||||
});
|
||||
|
||||
this.child.once('exit', (code) => {
|
||||
this.child = null;
|
||||
this.rejectAll(`Semantic helper exited with code ${code ?? 'null'}.`);
|
||||
});
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let message: JsonRecord;
|
||||
try {
|
||||
message = JSON.parse(line) as JsonRecord;
|
||||
} catch (error) {
|
||||
console.error('[SemanticHelper] Invalid JSON response:', errorMessage(error));
|
||||
return;
|
||||
}
|
||||
|
||||
const id = typeof message.id === 'number' ? message.id : null;
|
||||
if (id === null) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
|
||||
if (message.error) {
|
||||
pending.reject(new Error(typeof message.error === 'string' ? message.error : 'Semantic helper request failed.'));
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
|
||||
private rejectAll(reason: string): void {
|
||||
for (const [id, request] of this.pending.entries()) {
|
||||
clearTimeout(request.timer);
|
||||
request.reject(new Error(reason));
|
||||
this.pending.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export type SemanticHelperPlatform = 'darwin' | 'win32';
|
||||
|
||||
export type SemanticHelperResolution = {
|
||||
available: boolean;
|
||||
path: string | null;
|
||||
source: 'bundled' | 'dev' | 'missing';
|
||||
platform: NodeJS.Platform;
|
||||
arch: NodeJS.Architecture;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
function helperExecutableName(platform: NodeJS.Platform): string | null {
|
||||
if (platform === 'darwin') {
|
||||
return 'CloudCLISemantics';
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return 'CloudCLISemantics.exe';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathExists(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function candidatePaths(platform: NodeJS.Platform, arch: NodeJS.Architecture): Array<{ source: 'bundled' | 'dev'; path: string }> {
|
||||
const executable = helperExecutableName(platform);
|
||||
if (!executable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const platformArch = `${platform}-${arch}`;
|
||||
return [
|
||||
{
|
||||
source: 'bundled',
|
||||
path: path.resolve(__dirname, '..', 'bin', platformArch, executable),
|
||||
},
|
||||
{
|
||||
source: 'dev',
|
||||
path: path.resolve(process.cwd(), 'server', 'modules', 'computer-use', 'semantics', 'bin', platformArch, executable),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveSemanticHelper(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: NodeJS.Architecture = process.arch,
|
||||
): SemanticHelperResolution {
|
||||
const executable = helperExecutableName(platform);
|
||||
if (!executable) {
|
||||
return {
|
||||
available: false,
|
||||
path: null,
|
||||
source: 'missing',
|
||||
platform,
|
||||
arch,
|
||||
reason: `Semantic Computer Use helper is not supported on ${platform}.`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const candidate of candidatePaths(platform, arch)) {
|
||||
if (pathExists(candidate.path)) {
|
||||
return {
|
||||
available: true,
|
||||
path: candidate.path,
|
||||
source: candidate.source,
|
||||
platform,
|
||||
arch,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: false,
|
||||
path: null,
|
||||
source: 'missing',
|
||||
platform,
|
||||
arch,
|
||||
reason: `Bundled semantic helper was not found for ${platform}-${arch} (${executable}).`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<UseWPF>true</UseWPF>
|
||||
<AssemblyName>CloudCLISemantics</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
534
server/modules/computer-use/semantics/helpers/windows/Program.cs
Normal file
534
server/modules/computer-use/semantics/helpers/windows/Program.cs
Normal file
@@ -0,0 +1,534 @@
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Automation;
|
||||
|
||||
static class Program
|
||||
{
|
||||
private const int MaxStoredStates = 100;
|
||||
private static readonly Dictionary<string, List<ElementRecord>> StateElements = new();
|
||||
private static readonly Dictionary<string, Dictionary<string, AutomationElement>> StateAutomationElements = new();
|
||||
private static readonly Queue<string> StateOrder = new();
|
||||
|
||||
public static void Main()
|
||||
{
|
||||
string? line;
|
||||
while ((line = Console.ReadLine()) != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
var id = root.TryGetProperty("id", out var idValue) ? idValue.Clone() : default;
|
||||
var method = root.TryGetProperty("method", out var methodValue) ? methodValue.GetString() ?? "" : "";
|
||||
var parameters = root.TryGetProperty("params", out var paramsValue) && paramsValue.ValueKind == JsonValueKind.Object
|
||||
? paramsValue.Clone()
|
||||
: JsonDocument.Parse("{}").RootElement.Clone();
|
||||
|
||||
try
|
||||
{
|
||||
object result = method switch
|
||||
{
|
||||
"list_apps" => ListApps(),
|
||||
"get_app_state" => GetAppState(parameters),
|
||||
"click_element" => ClickElement(parameters),
|
||||
"perform_secondary_action" => PerformSecondaryAction(parameters),
|
||||
"set_value" => SetValue(parameters),
|
||||
"type_text" => TypeText(parameters),
|
||||
"press_key" => PressKey(parameters),
|
||||
"scroll_element" => ScrollElement(parameters),
|
||||
"drag" => Drag(parameters),
|
||||
_ => throw new InvalidOperationException($"Method is not implemented yet: {method}")
|
||||
};
|
||||
Write(new Dictionary<string, object?> { ["id"] = JsonValue(id), ["result"] = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write(new Dictionary<string, object?> { ["id"] = JsonValue(id), ["error"] = ex.Message });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write(new Dictionary<string, object?> { ["id"] = null, ["error"] = $"Invalid JSON request: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static object? JsonValue(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var number) ? number : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static void Write(object value)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(value));
|
||||
Console.Out.Flush();
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object?>> ListApps()
|
||||
{
|
||||
return Process.GetProcesses()
|
||||
.Where(process => process.MainWindowHandle != IntPtr.Zero)
|
||||
.OrderBy(process => process.ProcessName)
|
||||
.Select(process => new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = process.Id.ToString(),
|
||||
["name"] = process.ProcessName,
|
||||
["processName"] = process.ProcessName,
|
||||
["pid"] = process.Id,
|
||||
["running"] = true,
|
||||
["windowTitle"] = process.MainWindowTitle
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Process ResolveProcess(string query)
|
||||
{
|
||||
var normalized = query.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException("app is required.");
|
||||
}
|
||||
|
||||
var processes = Process.GetProcesses()
|
||||
.Where(process => process.MainWindowHandle != IntPtr.Zero)
|
||||
.ToList();
|
||||
|
||||
return processes.FirstOrDefault(process => process.ProcessName.Equals(normalized, StringComparison.OrdinalIgnoreCase))
|
||||
?? processes.FirstOrDefault(process => process.MainWindowTitle.Equals(normalized, StringComparison.OrdinalIgnoreCase))
|
||||
?? processes.FirstOrDefault(process => process.MainWindowTitle.Contains(normalized, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"App is not running: {query}");
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> GetAppState(JsonElement parameters)
|
||||
{
|
||||
var appQuery = ReadString(parameters, "app");
|
||||
var process = ResolveProcess(appQuery);
|
||||
var root = AutomationElement.FromHandle(process.MainWindowHandle)
|
||||
?? throw new InvalidOperationException("No UI Automation root window is available.");
|
||||
|
||||
var records = new List<ElementRecord>();
|
||||
var automationElements = new Dictionary<string, AutomationElement>();
|
||||
Walk(root, records, automationElements, 0, 5, 300);
|
||||
var stateId = $"state_{Guid.NewGuid()}";
|
||||
StateElements[stateId] = records;
|
||||
StateAutomationElements[stateId] = automationElements;
|
||||
StateOrder.Enqueue(stateId);
|
||||
PruneStoredStates();
|
||||
|
||||
var elements = records.Select(record => record.ToDictionary()).ToList();
|
||||
var bounds = root.Current.BoundingRectangle;
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["stateId"] = stateId,
|
||||
["app"] = process.ProcessName,
|
||||
["platform"] = "win32",
|
||||
["screenshotDataUrl"] = CaptureScreen(),
|
||||
["displaySize"] = new Dictionary<string, object?>
|
||||
{
|
||||
["width"] = (int)System.Windows.Forms.Screen.PrimaryScreen!.Bounds.Width,
|
||||
["height"] = (int)System.Windows.Forms.Screen.PrimaryScreen!.Bounds.Height
|
||||
},
|
||||
["window"] = new Dictionary<string, object?>
|
||||
{
|
||||
["title"] = process.MainWindowTitle,
|
||||
["bounds"] = BoundsDictionary(bounds)
|
||||
},
|
||||
["elements"] = elements,
|
||||
["accessibilityTree"] = elements,
|
||||
["treeText"] = string.Join("\n", elements.Select(element => $"{element["index"]} {element["role"]} {element.GetValueOrDefault("title")}"))
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ClickElement(JsonElement parameters)
|
||||
{
|
||||
var mouseButton = ReadString(parameters, "mouse_button");
|
||||
if ((mouseButton == "" || mouseButton == "left") && ReadInt(parameters, "click_count", 1) == 1)
|
||||
{
|
||||
var element = AutomationElementFor(parameters);
|
||||
if (element != null && TryInvoke(element))
|
||||
{
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
var point = PointFor(parameters);
|
||||
if (point == null)
|
||||
{
|
||||
throw new InvalidOperationException("click_element requires x/y or stateId + element_index.");
|
||||
}
|
||||
|
||||
SendMouseClick(point.Value.X, point.Value.Y, ReadString(parameters, "mouse_button"), ReadInt(parameters, "click_count", 1));
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> PerformSecondaryAction(JsonElement parameters)
|
||||
{
|
||||
var point = PointFor(parameters);
|
||||
if (point == null)
|
||||
{
|
||||
throw new InvalidOperationException("perform_secondary_action requires x/y or stateId + element_index.");
|
||||
}
|
||||
|
||||
SendMouseClick(point.Value.X, point.Value.Y, "right", 1);
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> SetValue(JsonElement parameters)
|
||||
{
|
||||
var value = ReadString(parameters, "value");
|
||||
var element = AutomationElementFor(parameters);
|
||||
var focused = false;
|
||||
if (element != null)
|
||||
{
|
||||
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var valuePattern))
|
||||
{
|
||||
((ValuePattern)valuePattern).SetValue(value);
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
element.SetFocus();
|
||||
focused = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to coordinate focus below.
|
||||
}
|
||||
}
|
||||
|
||||
var point = PointFor(parameters);
|
||||
if (point != null)
|
||||
{
|
||||
SendMouseClick(point.Value.X, point.Value.Y, "left", 1);
|
||||
focused = true;
|
||||
}
|
||||
else if (!focused && element == null)
|
||||
{
|
||||
throw new InvalidOperationException("set_value requires x/y or stateId + element_index.");
|
||||
}
|
||||
else if (!focused)
|
||||
{
|
||||
throw new InvalidOperationException("set_value could not focus the requested element.");
|
||||
}
|
||||
System.Windows.Forms.SendKeys.SendWait("^a");
|
||||
System.Windows.Forms.SendKeys.SendWait(EscapeSendKeys(value));
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> TypeText(JsonElement parameters)
|
||||
{
|
||||
var text = ReadString(parameters, "text");
|
||||
System.Windows.Forms.SendKeys.SendWait(EscapeSendKeys(text));
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> PressKey(JsonElement parameters)
|
||||
{
|
||||
var key = ReadString(parameters, "key");
|
||||
System.Windows.Forms.SendKeys.SendWait(ToSendKeysChord(key));
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ScrollElement(JsonElement parameters)
|
||||
{
|
||||
var element = AutomationElementFor(parameters);
|
||||
var direction = ReadString(parameters, "direction");
|
||||
var pages = ReadDouble(parameters, "pages", 1);
|
||||
if (element != null && element.TryGetCurrentPattern(ScrollPattern.Pattern, out var scrollPatternValue))
|
||||
{
|
||||
var scrollPattern = (ScrollPattern)scrollPatternValue;
|
||||
var vertical = direction == "up" ? ScrollAmount.LargeDecrement : direction == "down" ? ScrollAmount.LargeIncrement : ScrollAmount.NoAmount;
|
||||
var horizontal = direction == "left" ? ScrollAmount.LargeDecrement : direction == "right" ? ScrollAmount.LargeIncrement : ScrollAmount.NoAmount;
|
||||
scrollPattern.Scroll(horizontal, vertical);
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
var point = PointFor(parameters);
|
||||
if (point == null)
|
||||
{
|
||||
throw new InvalidOperationException("scroll_element requires x/y or stateId + element_index.");
|
||||
}
|
||||
SetCursorPos(point.Value.X, point.Value.Y);
|
||||
var wheel = (int)Math.Round(Math.Max(1, pages) * 120);
|
||||
if (direction == "down") wheel = -wheel;
|
||||
mouse_event(0x0800, 0, 0, unchecked((uint)wheel), UIntPtr.Zero);
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
private static void PruneStoredStates()
|
||||
{
|
||||
while (StateOrder.Count > MaxStoredStates)
|
||||
{
|
||||
var evicted = StateOrder.Dequeue();
|
||||
StateElements.Remove(evicted);
|
||||
StateAutomationElements.Remove(evicted);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> Drag(JsonElement parameters)
|
||||
{
|
||||
var fromX = ReadDouble(parameters, "from_x", double.NaN);
|
||||
var fromY = ReadDouble(parameters, "from_y", double.NaN);
|
||||
var toX = ReadDouble(parameters, "to_x", double.NaN);
|
||||
var toY = ReadDouble(parameters, "to_y", double.NaN);
|
||||
if (double.IsNaN(fromX) || double.IsNaN(fromY) || double.IsNaN(toX) || double.IsNaN(toY))
|
||||
{
|
||||
throw new InvalidOperationException("drag requires from_x/from_y/to_x/to_y.");
|
||||
}
|
||||
|
||||
SetCursorPos((int)Math.Round(fromX), (int)Math.Round(fromY));
|
||||
mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero);
|
||||
Thread.Sleep(80);
|
||||
SetCursorPos((int)Math.Round(toX), (int)Math.Round(toY));
|
||||
Thread.Sleep(80);
|
||||
mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero);
|
||||
return GetAppState(parameters);
|
||||
}
|
||||
|
||||
private static void Walk(AutomationElement element, List<ElementRecord> records, Dictionary<string, AutomationElement> automationElements, int depth, int maxDepth, int limit)
|
||||
{
|
||||
if (depth > maxDepth || records.Count >= limit) return;
|
||||
var index = (records.Count + 1).ToString();
|
||||
records.Add(ElementRecord.From(element, index));
|
||||
automationElements[index] = element;
|
||||
var children = element.FindAll(TreeScope.Children, Condition.TrueCondition);
|
||||
foreach (AutomationElement child in children)
|
||||
{
|
||||
Walk(child, records, automationElements, depth + 1, maxDepth, limit);
|
||||
if (records.Count >= limit) return;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadString(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
|
||||
private static int ReadInt(JsonElement element, string property, int defaultValue)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.TryGetInt32(out var number)
|
||||
? number
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
private static double ReadDouble(JsonElement element, string property, double defaultValue)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.TryGetDouble(out var number)
|
||||
? number
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
private static AutomationElement? AutomationElementFor(JsonElement parameters)
|
||||
{
|
||||
var stateId = ReadString(parameters, "stateId");
|
||||
var elementIndex = ReadString(parameters, "element_index");
|
||||
return !string.IsNullOrWhiteSpace(stateId)
|
||||
&& !string.IsNullOrWhiteSpace(elementIndex)
|
||||
&& StateAutomationElements.TryGetValue(stateId, out var elements)
|
||||
&& elements.TryGetValue(elementIndex, out var element)
|
||||
? element
|
||||
: null;
|
||||
}
|
||||
|
||||
private static System.Drawing.Point? PointFor(JsonElement parameters)
|
||||
{
|
||||
if (parameters.TryGetProperty("x", out var xValue) && parameters.TryGetProperty("y", out var yValue)
|
||||
&& xValue.TryGetDouble(out var x) && yValue.TryGetDouble(out var y))
|
||||
{
|
||||
return new System.Drawing.Point((int)Math.Round(x), (int)Math.Round(y));
|
||||
}
|
||||
|
||||
var stateId = ReadString(parameters, "stateId");
|
||||
var elementIndex = ReadString(parameters, "element_index");
|
||||
if (string.IsNullOrWhiteSpace(stateId) || string.IsNullOrWhiteSpace(elementIndex)) return null;
|
||||
if (!StateElements.TryGetValue(stateId, out var elements)) return null;
|
||||
var element = elements.FirstOrDefault(item => item.Index == elementIndex);
|
||||
if (element?.Bounds == null) return null;
|
||||
return new System.Drawing.Point(
|
||||
(int)Math.Round(element.Bounds.Value.Left + element.Bounds.Value.Width / 2),
|
||||
(int)Math.Round(element.Bounds.Value.Top + element.Bounds.Value.Height / 2)
|
||||
);
|
||||
}
|
||||
|
||||
private static string CaptureScreen()
|
||||
{
|
||||
var bounds = System.Windows.Forms.Screen.PrimaryScreen!.Bounds;
|
||||
using var bitmap = new Bitmap(bounds.Width, bounds.Height);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bounds.Size);
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return $"data:image/png;base64,{Convert.ToBase64String(stream.ToArray())}";
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BoundsDictionary(System.Windows.Rect rect)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["x"] = rect.X,
|
||||
["y"] = rect.Y,
|
||||
["width"] = rect.Width,
|
||||
["height"] = rect.Height
|
||||
};
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetCursorPos(int x, int y);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
|
||||
|
||||
private static void SendMouseClick(int x, int y, string button, int clickCount)
|
||||
{
|
||||
var (down, up) = button switch
|
||||
{
|
||||
"right" => (0x0008u, 0x0010u),
|
||||
"middle" => (0x0020u, 0x0040u),
|
||||
_ => (0x0002u, 0x0004u)
|
||||
};
|
||||
SetCursorPos(x, y);
|
||||
for (var i = 0; i < Math.Max(1, clickCount); i++)
|
||||
{
|
||||
mouse_event(down, 0, 0, 0, UIntPtr.Zero);
|
||||
mouse_event(up, 0, 0, 0, UIntPtr.Zero);
|
||||
Thread.Sleep(80);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryInvoke(AutomationElement element)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!element.TryGetCurrentPattern(InvokePattern.Pattern, out var pattern)) return false;
|
||||
((InvokePattern)pattern).Invoke();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeSendKeys(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("{", "{{}")
|
||||
.Replace("}", "{}}")
|
||||
.Replace("+", "{+}")
|
||||
.Replace("^", "{^}")
|
||||
.Replace("%", "{%}")
|
||||
.Replace("~", "{~}")
|
||||
.Replace("(", "{(}")
|
||||
.Replace(")", "{)}")
|
||||
.Replace("[", "{[}")
|
||||
.Replace("]", "{]}");
|
||||
}
|
||||
|
||||
private static string ToSendKeysChord(string key)
|
||||
{
|
||||
var normalized = key.Trim();
|
||||
if (normalized.Contains('+'))
|
||||
{
|
||||
var parts = normalized.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var modifiers = "";
|
||||
var last = parts.LastOrDefault() ?? "";
|
||||
foreach (var part in parts.Take(parts.Length - 1))
|
||||
{
|
||||
modifiers += part.ToLowerInvariant() switch
|
||||
{
|
||||
"ctrl" or "control" => "^",
|
||||
"alt" => "%",
|
||||
"shift" => "+",
|
||||
"cmd" or "win" or "windows" => "^",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
return modifiers + SendKeyName(last);
|
||||
}
|
||||
return SendKeyName(normalized);
|
||||
}
|
||||
|
||||
private static string SendKeyName(string key)
|
||||
{
|
||||
return key.ToLowerInvariant() switch
|
||||
{
|
||||
"return" or "enter" => "{ENTER}",
|
||||
"escape" or "esc" => "{ESC}",
|
||||
"tab" => "{TAB}",
|
||||
"backspace" => "{BACKSPACE}",
|
||||
"delete" or "del" => "{DELETE}",
|
||||
"left" => "{LEFT}",
|
||||
"right" => "{RIGHT}",
|
||||
"up" => "{UP}",
|
||||
"down" => "{DOWN}",
|
||||
"space" => " ",
|
||||
_ => key.Length == 1 ? EscapeSendKeys(key) : $"{{{key.ToUpperInvariant()}}}"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record ElementRecord(
|
||||
string Index,
|
||||
string Role,
|
||||
string? Title,
|
||||
string? Value,
|
||||
System.Windows.Rect? Bounds,
|
||||
List<string> Actions)
|
||||
{
|
||||
public static ElementRecord From(AutomationElement element, string index)
|
||||
{
|
||||
var patterns = element.GetSupportedPatterns().Select(pattern => pattern.ProgrammaticName).ToList();
|
||||
return new ElementRecord(
|
||||
index,
|
||||
element.Current.ControlType.ProgrammaticName.Replace("ControlType.", ""),
|
||||
element.Current.Name,
|
||||
TryValue(element),
|
||||
element.Current.BoundingRectangle,
|
||||
patterns
|
||||
);
|
||||
}
|
||||
|
||||
public Dictionary<string, object?> ToDictionary()
|
||||
{
|
||||
var output = new Dictionary<string, object?>
|
||||
{
|
||||
["index"] = Index,
|
||||
["role"] = Role,
|
||||
["actions"] = Actions
|
||||
};
|
||||
if (!string.IsNullOrEmpty(Title)) output["title"] = Title;
|
||||
if (!string.IsNullOrEmpty(Value)) output["value"] = Value;
|
||||
if (Bounds != null) output["bounds"] = BoundsDictionary(Bounds.Value);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static string? TryValue(AutomationElement element)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var pattern))
|
||||
{
|
||||
return ((ValuePattern)pattern).Current.Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { SemanticAppState, SemanticElement } from '@/modules/computer-use/semantics/semantic-types.js';
|
||||
|
||||
const DEFAULT_STATE_TTL_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_SEMANTIC_STATE_TTL_MS || String(10 * 60 * 1000), 10);
|
||||
|
||||
type StoredState = {
|
||||
sessionId: string;
|
||||
appKey: string;
|
||||
state: SemanticAppState;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function normalizeAppKey(app: string): string {
|
||||
return app.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export class SemanticSessionStore {
|
||||
private states = new Map<string, StoredState>();
|
||||
private latestBySessionApp = new Map<string, string>();
|
||||
|
||||
createStateId(): string {
|
||||
return `state_${randomUUID()}`;
|
||||
}
|
||||
|
||||
save(sessionId: string, state: SemanticAppState): SemanticAppState {
|
||||
const appKey = normalizeAppKey(state.app);
|
||||
const nextState = {
|
||||
...state,
|
||||
stateId: state.stateId || this.createStateId(),
|
||||
};
|
||||
this.states.set(nextState.stateId, {
|
||||
sessionId,
|
||||
appKey,
|
||||
state: nextState,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
this.latestBySessionApp.set(this.latestKey(sessionId, appKey), nextState.stateId);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
getState(sessionId: string, app: string, stateId?: string): SemanticAppState | null {
|
||||
this.expire();
|
||||
if (stateId) {
|
||||
const entry = this.states.get(stateId);
|
||||
const appKey = normalizeAppKey(app);
|
||||
return entry && entry.sessionId === sessionId && entry.appKey === appKey ? entry.state : null;
|
||||
}
|
||||
const latestStateId = this.latestBySessionApp.get(this.latestKey(sessionId, normalizeAppKey(app)));
|
||||
return latestStateId ? this.states.get(latestStateId)?.state || null : null;
|
||||
}
|
||||
|
||||
getElement(sessionId: string, app: string, elementIndex: string, stateId?: string): SemanticElement | null {
|
||||
const state = this.getState(sessionId, app, stateId);
|
||||
return state?.elements.find((element) => element.index === elementIndex) || null;
|
||||
}
|
||||
|
||||
clearSession(sessionId: string): void {
|
||||
for (const [stateId, entry] of this.states.entries()) {
|
||||
if (entry.sessionId === sessionId) {
|
||||
this.states.delete(stateId);
|
||||
this.latestBySessionApp.delete(this.latestKey(entry.sessionId, entry.appKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expire(now = Date.now()): void {
|
||||
const ttl = Number.isFinite(DEFAULT_STATE_TTL_MS) && DEFAULT_STATE_TTL_MS > 0
|
||||
? DEFAULT_STATE_TTL_MS
|
||||
: 10 * 60 * 1000;
|
||||
for (const [stateId, entry] of this.states.entries()) {
|
||||
if (now - entry.updatedAt > ttl) {
|
||||
this.states.delete(stateId);
|
||||
const key = this.latestKey(entry.sessionId, entry.appKey);
|
||||
if (this.latestBySessionApp.get(key) === stateId) {
|
||||
this.latestBySessionApp.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private latestKey(sessionId: string, appKey: string): string {
|
||||
return `${sessionId}:${appKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const semanticSessionStore = new SemanticSessionStore();
|
||||
@@ -0,0 +1,17 @@
|
||||
export const semanticMcpToolMap: Record<string, string> = {
|
||||
computer_app_drag: 'drag',
|
||||
computer_click_element: 'click_element',
|
||||
computer_get_app_state: 'get_app_state',
|
||||
computer_list_apps: 'list_apps',
|
||||
computer_perform_secondary_action: 'perform_secondary_action',
|
||||
computer_press_key: 'press_key',
|
||||
computer_scroll_element: 'scroll_element',
|
||||
computer_set_value: 'set_value',
|
||||
computer_type_text: 'type_text',
|
||||
};
|
||||
|
||||
export const semanticOperationNames = new Set(Object.values(semanticMcpToolMap));
|
||||
|
||||
export function semanticOperationForMcpTool(toolName: string): string | null {
|
||||
return semanticMcpToolMap[toolName] || null;
|
||||
}
|
||||
58
server/modules/computer-use/semantics/semantic-types.ts
Normal file
58
server/modules/computer-use/semantics/semantic-types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { DisplaySize, Point } from '@/modules/computer-use/computer-executor.js';
|
||||
|
||||
export type SemanticBounds = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type SemanticApp = {
|
||||
id?: string;
|
||||
name: string;
|
||||
bundleIdentifier?: string;
|
||||
processName?: string;
|
||||
pid?: number;
|
||||
running: boolean;
|
||||
windowTitle?: string;
|
||||
};
|
||||
|
||||
export type SemanticElement = {
|
||||
index: string;
|
||||
role: string;
|
||||
title?: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
focused?: boolean;
|
||||
selected?: boolean;
|
||||
bounds?: SemanticBounds;
|
||||
actions?: string[];
|
||||
settableValue?: boolean;
|
||||
};
|
||||
|
||||
export type SemanticAppState = {
|
||||
stateId: string;
|
||||
app: string;
|
||||
platform: NodeJS.Platform;
|
||||
screenshotDataUrl: string | null;
|
||||
displaySize: DisplaySize | null;
|
||||
elements: SemanticElement[];
|
||||
accessibilityTree: SemanticElement[];
|
||||
treeText?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type SemanticToolInput = Record<string, unknown> & {
|
||||
sessionId?: string;
|
||||
app?: string;
|
||||
stateId?: string;
|
||||
element_index?: string;
|
||||
};
|
||||
|
||||
export type SemanticToolResult = SemanticAppState | {
|
||||
apps: SemanticApp[];
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
|
||||
export type SemanticActionPoint = Point;
|
||||
@@ -4,6 +4,7 @@ export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
|
||||
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
|
||||
export { notificationChannelEndpointsDb } from '@/modules/database/repositories/notification-channel-endpoints.js';
|
||||
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
|
||||
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Database } from 'better-sqlite3';
|
||||
import {
|
||||
APP_CONFIG_TABLE_SCHEMA_SQL,
|
||||
LAST_SCANNED_AT_SQL,
|
||||
NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL,
|
||||
PROJECTS_TABLE_SCHEMA_SQL,
|
||||
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
|
||||
SESSIONS_TABLE_SCHEMA_SQL,
|
||||
@@ -440,6 +441,9 @@ export const runMigrations = (db: Database) => {
|
||||
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
|
||||
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
|
||||
db.exec(NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled)');
|
||||
|
||||
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||
rebuildProjectsTableWithPrimaryKeySchema(db);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
type NotificationChannelEndpointRow = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
channel: string;
|
||||
endpoint_id: string;
|
||||
label: string | null;
|
||||
metadata_json: string | null;
|
||||
enabled: number;
|
||||
last_seen_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type UpsertNotificationChannelEndpointInput = {
|
||||
userId: number;
|
||||
channel: string;
|
||||
endpointId: string;
|
||||
label?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function normalizeRequiredText(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeNullableText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
|
||||
if (!metadata || typeof metadata !== 'object') return null;
|
||||
return JSON.stringify(metadata);
|
||||
}
|
||||
|
||||
function parseMetadata(metadataJson: string | null): Record<string, unknown> {
|
||||
if (!metadataJson) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(metadataJson);
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationChannelEndpointsDb = {
|
||||
upsertEndpoint(input: UpsertNotificationChannelEndpointInput): NotificationChannelEndpointRow {
|
||||
const channel = normalizeRequiredText(input.channel);
|
||||
const endpointId = normalizeRequiredText(input.endpointId);
|
||||
if (!channel) throw new Error('channel is required');
|
||||
if (!endpointId) throw new Error('endpointId is required');
|
||||
|
||||
const enabled = input.enabled === false ? 0 : 1;
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
`INSERT INTO notification_channel_endpoints (
|
||||
user_id,
|
||||
channel,
|
||||
endpoint_id,
|
||||
label,
|
||||
metadata_json,
|
||||
enabled,
|
||||
last_seen_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, channel, endpoint_id) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
metadata_json = excluded.metadata_json,
|
||||
enabled = excluded.enabled,
|
||||
last_seen_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP`
|
||||
).run(
|
||||
input.userId,
|
||||
channel,
|
||||
endpointId,
|
||||
normalizeNullableText(input.label),
|
||||
serializeMetadata(input.metadata),
|
||||
enabled
|
||||
);
|
||||
|
||||
return notificationChannelEndpointsDb.getEndpoint(input.userId, channel, endpointId)!;
|
||||
},
|
||||
|
||||
getEndpoint(userId: number, channel: string, endpointId: string): NotificationChannelEndpointRow | null {
|
||||
const db = getConnection();
|
||||
const row = db.prepare(
|
||||
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||
FROM notification_channel_endpoints
|
||||
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||
).get(
|
||||
userId,
|
||||
normalizeRequiredText(channel),
|
||||
normalizeRequiredText(endpointId)
|
||||
) as NotificationChannelEndpointRow | undefined;
|
||||
return row || null;
|
||||
},
|
||||
|
||||
getEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
|
||||
const db = getConnection();
|
||||
return db.prepare(
|
||||
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||
FROM notification_channel_endpoints
|
||||
WHERE user_id = ? AND channel = ?
|
||||
ORDER BY last_seen_at DESC`
|
||||
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
|
||||
},
|
||||
|
||||
getEnabledEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
|
||||
const db = getConnection();
|
||||
return db.prepare(
|
||||
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||
FROM notification_channel_endpoints
|
||||
WHERE user_id = ? AND channel = ? AND enabled = 1
|
||||
ORDER BY last_seen_at DESC`
|
||||
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
|
||||
},
|
||||
|
||||
setEndpointEnabled(userId: number, channel: string, endpointId: string, enabled: boolean): boolean {
|
||||
const db = getConnection();
|
||||
const result = db.prepare(
|
||||
`UPDATE notification_channel_endpoints
|
||||
SET enabled = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||
).run(enabled ? 1 : 0, userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
touchEndpoint(userId: number, channel: string, endpointId: string): boolean {
|
||||
const db = getConnection();
|
||||
const result = db.prepare(
|
||||
`UPDATE notification_channel_endpoints
|
||||
SET last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
removeEndpoint(userId: number, channel: string, endpointId: string): boolean {
|
||||
const db = getConnection();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM notification_channel_endpoints WHERE user_id = ? AND channel = ? AND endpoint_id = ?'
|
||||
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
parseMetadata,
|
||||
};
|
||||
@@ -10,7 +10,9 @@ type NotificationPreferences = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
desktop: boolean;
|
||||
sound: boolean;
|
||||
[key: string]: boolean;
|
||||
};
|
||||
events: {
|
||||
actionRequired: boolean;
|
||||
@@ -23,6 +25,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: false,
|
||||
desktop: false,
|
||||
sound: true,
|
||||
},
|
||||
events: {
|
||||
@@ -34,11 +37,20 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
|
||||
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
|
||||
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
|
||||
const sourceChannels = source.channels && typeof source.channels === 'object'
|
||||
? source.channels as Record<string, unknown>
|
||||
: {};
|
||||
const extraChannels = Object.fromEntries(
|
||||
Object.entries(sourceChannels)
|
||||
.filter(([key, channelValue]) => !['inApp', 'webPush', 'desktop', 'sound'].includes(key) && typeof channelValue === 'boolean')
|
||||
) as Record<string, boolean>;
|
||||
|
||||
return {
|
||||
channels: {
|
||||
...extraChannels,
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush === true,
|
||||
desktop: source.channels?.desktop === true,
|
||||
sound: source.channels?.sound !== false,
|
||||
},
|
||||
events: {
|
||||
@@ -103,4 +115,3 @@ export const notificationPreferencesDb = {
|
||||
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -69,6 +69,23 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
);
|
||||
`;
|
||||
|
||||
export const NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_endpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
label TEXT,
|
||||
metadata_json TEXT,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, channel, endpoint_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const PROJECTS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
project_id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -144,6 +161,10 @@ ${VAPID_KEYS_TABLE_SCHEMA_SQL}
|
||||
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
|
||||
${NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled);
|
||||
|
||||
${PROJECTS_TABLE_SCHEMA_SQL}
|
||||
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
|
||||
-- Creating them here can fail on upgraded installs where projects lacks those columns.
|
||||
|
||||
13
server/modules/notifications/index.ts
Normal file
13
server/modules/notifications/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
buildNotificationPayload,
|
||||
createNotificationEvent,
|
||||
notifyUserIfEnabled,
|
||||
notifyRunFailed,
|
||||
notifyRunStopped,
|
||||
} from '@/modules/notifications/services/notification-orchestrator.service.js';
|
||||
export {
|
||||
registerDesktopNotificationClient,
|
||||
sendDesktopNotification,
|
||||
unregisterDesktopNotificationClient,
|
||||
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||
export { handleDesktopNotificationsConnection } from '@/modules/notifications/websocket/desktop-notifications-websocket.service.js';
|
||||
127
server/modules/notifications/notifications.routes.ts
Normal file
127
server/modules/notifications/notifications.routes.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import express from 'express';
|
||||
|
||||
import { notificationChannelEndpointsDb, notificationPreferencesDb } from '@/modules/database/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function readText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function sanitizeEndpoint(endpoint: any) {
|
||||
return {
|
||||
id: endpoint.id,
|
||||
channel: endpoint.channel,
|
||||
endpointId: endpoint.endpoint_id,
|
||||
label: endpoint.label,
|
||||
metadata: notificationChannelEndpointsDb.parseMetadata(endpoint.metadata_json),
|
||||
enabled: Boolean(endpoint.enabled),
|
||||
lastSeenAt: endpoint.last_seen_at,
|
||||
createdAt: endpoint.created_at,
|
||||
updatedAt: endpoint.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function readUserId(req: express.Request): number {
|
||||
const userId = Number((req as any).user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
throw new Error('Authenticated user is missing');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function updateChannelPreference(userId: number, channel: string): unknown {
|
||||
const currentPrefs = notificationPreferencesDb.getPreferences(userId);
|
||||
const hasEnabledEndpoint = notificationChannelEndpointsDb.getEnabledEndpoints(userId, channel).length > 0;
|
||||
return notificationPreferencesDb.updatePreferences(userId, {
|
||||
...currentPrefs,
|
||||
channels: { ...currentPrefs.channels, [channel]: hasEnabledEndpoint },
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/endpoints', (req, res) => {
|
||||
try {
|
||||
const channel = readText(req.query.channel);
|
||||
if (!channel) {
|
||||
return res.status(400).json({ error: 'channel is required' });
|
||||
}
|
||||
|
||||
const userId = readUserId(req);
|
||||
const endpoints = notificationChannelEndpointsDb
|
||||
.getEndpoints(userId, channel)
|
||||
.map(sanitizeEndpoint);
|
||||
return res.json({ success: true, endpoints });
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification endpoints:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch notification endpoints' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/endpoints/current', (req, res) => {
|
||||
try {
|
||||
const { channel, endpointId, label, metadata = {}, enabled = true } = req.body || {};
|
||||
const normalizedChannel = readText(channel);
|
||||
const normalizedEndpointId = readText(endpointId);
|
||||
if (!normalizedChannel || !normalizedEndpointId) {
|
||||
return res.status(400).json({ error: 'channel and endpointId are required' });
|
||||
}
|
||||
|
||||
const userId = readUserId(req);
|
||||
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
|
||||
userId,
|
||||
channel: normalizedChannel,
|
||||
endpointId: normalizedEndpointId,
|
||||
label,
|
||||
metadata: metadata && typeof metadata === 'object' ? metadata : {},
|
||||
enabled: enabled !== false,
|
||||
});
|
||||
|
||||
const preferences = updateChannelPreference(userId, normalizedChannel);
|
||||
return res.json({ success: true, endpoint: sanitizeEndpoint(endpoint), preferences });
|
||||
} catch (error) {
|
||||
console.error('Error registering notification endpoint:', error);
|
||||
return res.status(500).json({ error: 'Failed to register notification endpoint' });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/endpoints/:channel/:endpointId', (req, res) => {
|
||||
try {
|
||||
const { channel, endpointId } = req.params;
|
||||
const { enabled } = req.body || {};
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
}
|
||||
|
||||
const userId = readUserId(req);
|
||||
const updated = notificationChannelEndpointsDb.setEndpointEnabled(userId, channel, endpointId, enabled);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Notification endpoint not found' });
|
||||
}
|
||||
|
||||
const endpoint = notificationChannelEndpointsDb.getEndpoint(userId, channel, endpointId);
|
||||
const preferences = updateChannelPreference(userId, channel);
|
||||
return res.json({ success: true, endpoint: endpoint ? sanitizeEndpoint(endpoint) : null, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error updating notification endpoint:', error);
|
||||
return res.status(500).json({ error: 'Failed to update notification endpoint' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/endpoints/:channel/:endpointId', (req, res) => {
|
||||
try {
|
||||
const { channel, endpointId } = req.params;
|
||||
const userId = readUserId(req);
|
||||
const removed = notificationChannelEndpointsDb.removeEndpoint(userId, channel, endpointId);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ error: 'Notification endpoint not found' });
|
||||
}
|
||||
|
||||
const preferences = updateChannelPreference(userId, channel);
|
||||
return res.json({ success: true, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error removing notification endpoint:', error);
|
||||
return res.status(500).json({ error: 'Failed to remove notification endpoint' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import { notificationChannelEndpointsDb } from '@/modules/database/index.js';
|
||||
|
||||
const DESKTOP_CHANNEL = 'desktop';
|
||||
|
||||
const clientsByUserId = new Map<number, Map<string, WebSocket>>();
|
||||
const clientBySocket = new WeakMap<WebSocket, { userId: number; endpointId: string }>();
|
||||
|
||||
function normalizeUserId(userId: unknown): number | null {
|
||||
const numeric = Number(userId);
|
||||
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function normalizeEndpointId(endpointId: unknown): string {
|
||||
if (typeof endpointId !== 'string') return '';
|
||||
return endpointId.trim();
|
||||
}
|
||||
|
||||
function getUserClients(userId: unknown, create = false): Map<string, WebSocket> | null {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
if (!normalizedUserId) return null;
|
||||
let clients = clientsByUserId.get(normalizedUserId);
|
||||
if (!clients && create) {
|
||||
clients = new Map();
|
||||
clientsByUserId.set(normalizedUserId, clients);
|
||||
}
|
||||
return clients || null;
|
||||
}
|
||||
|
||||
export function registerDesktopNotificationClient({
|
||||
userId,
|
||||
deviceId,
|
||||
label = null,
|
||||
platform = null,
|
||||
appVersion = null,
|
||||
ws,
|
||||
}: {
|
||||
userId: number;
|
||||
deviceId: string;
|
||||
label?: string | null;
|
||||
platform?: string | null;
|
||||
appVersion?: string | null;
|
||||
ws: WebSocket;
|
||||
}) {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
const endpointId = normalizeEndpointId(deviceId);
|
||||
if (!normalizedUserId || !endpointId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
|
||||
userId: normalizedUserId,
|
||||
channel: DESKTOP_CHANNEL,
|
||||
endpointId,
|
||||
label,
|
||||
metadata: { platform, appVersion },
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const clients = getUserClients(normalizedUserId, true)!;
|
||||
const previous = clients.get(endpointId);
|
||||
if (previous && previous !== ws && previous.readyState === previous.OPEN) {
|
||||
previous.close(4000, 'Device reconnected');
|
||||
}
|
||||
|
||||
clients.set(endpointId, ws);
|
||||
clientBySocket.set(ws, { userId: normalizedUserId, endpointId });
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export function unregisterDesktopNotificationClient(ws: WebSocket): void {
|
||||
const registration = clientBySocket.get(ws);
|
||||
if (!registration) return;
|
||||
|
||||
const clients = getUserClients(registration.userId);
|
||||
if (clients?.get(registration.endpointId) === ws) {
|
||||
clients.delete(registration.endpointId);
|
||||
if (clients.size === 0) {
|
||||
clientsByUserId.delete(registration.userId);
|
||||
}
|
||||
}
|
||||
clientBySocket.delete(ws);
|
||||
}
|
||||
|
||||
export function sendDesktopNotification(userId: unknown, payload: unknown): { attempted: number; sent: number } {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
if (!normalizedUserId) return { attempted: 0, sent: 0 };
|
||||
|
||||
const clients = getUserClients(normalizedUserId);
|
||||
if (!clients?.size) return { attempted: 0, sent: 0 };
|
||||
|
||||
const enabledEndpointIds = new Set(
|
||||
notificationChannelEndpointsDb
|
||||
.getEnabledEndpoints(normalizedUserId, DESKTOP_CHANNEL)
|
||||
.map((endpoint) => endpoint.endpoint_id)
|
||||
);
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'notification',
|
||||
id: typeof (payload as any)?.data?.tag === 'string' ? (payload as any).data.tag : `${Date.now()}`,
|
||||
payload,
|
||||
});
|
||||
|
||||
let attempted = 0;
|
||||
let sent = 0;
|
||||
for (const [endpointId, ws] of clients.entries()) {
|
||||
if (!enabledEndpointIds.has(endpointId)) continue;
|
||||
attempted += 1;
|
||||
if (ws.readyState !== ws.OPEN) {
|
||||
unregisterDesktopNotificationClient(ws);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ws.send(message);
|
||||
notificationChannelEndpointsDb.touchEndpoint(normalizedUserId, DESKTOP_CHANNEL, endpointId);
|
||||
sent += 1;
|
||||
} catch {
|
||||
unregisterDesktopNotificationClient(ws);
|
||||
}
|
||||
}
|
||||
|
||||
return { attempted, sent };
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { sendDesktopNotification as sendDesktopNotificationToClients } from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||
|
||||
const KIND_TO_PREF_KEY = {
|
||||
action_required: 'actionRequired',
|
||||
stop: 'stop',
|
||||
error: 'error'
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
system: 'System'
|
||||
};
|
||||
|
||||
const recentEventKeys = new Map();
|
||||
const DEDUPE_WINDOW_MS = 20000;
|
||||
|
||||
const cleanupOldEventKeys = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of recentEventKeys.entries()) {
|
||||
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
||||
recentEventKeys.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function isNotificationEventEnabled(preferences, event) {
|
||||
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
||||
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
||||
|
||||
return eventEnabled;
|
||||
}
|
||||
|
||||
function isDuplicate(event) {
|
||||
cleanupOldEventKeys();
|
||||
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
||||
if (recentEventKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
recentEventKeys.set(key, Date.now());
|
||||
return false;
|
||||
}
|
||||
|
||||
function createNotificationEvent({
|
||||
provider,
|
||||
sessionId = null,
|
||||
kind = 'info',
|
||||
code = 'generic.info',
|
||||
meta = {},
|
||||
severity = 'info',
|
||||
dedupeKey = null,
|
||||
requiresUserAction = false
|
||||
}) {
|
||||
return {
|
||||
provider,
|
||||
sessionId,
|
||||
kind,
|
||||
code,
|
||||
meta,
|
||||
severity,
|
||||
requiresUserAction,
|
||||
dedupeKey,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error) {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error == null) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeSessionName(sessionName) {
|
||||
if (typeof sessionName !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = sessionName.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
||||
}
|
||||
|
||||
function rowMatchesProvider(row, provider) {
|
||||
return row && (!provider || row.provider === provider);
|
||||
}
|
||||
|
||||
function resolveSessionRow(sessionId, provider) {
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appSessionRow = sessionsDb.getSessionById(sessionId);
|
||||
if (rowMatchesProvider(appSessionRow, provider)) {
|
||||
return appSessionRow;
|
||||
}
|
||||
|
||||
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||
if (rowMatchesProvider(providerSessionRow, provider)) {
|
||||
return providerSessionRow;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeNotificationSession(event) {
|
||||
if (!event?.sessionId || !event.provider || event.provider === 'system') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const row = resolveSessionRow(event.sessionId, event.provider);
|
||||
if (!row || row.session_id === event.sessionId) {
|
||||
return event;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
sessionId: row.session_id
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionName(event) {
|
||||
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
||||
if (explicitSessionName) {
|
||||
return explicitSessionName;
|
||||
}
|
||||
|
||||
if (!event.sessionId || !event.provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
|
||||
}
|
||||
|
||||
function buildNotificationPayload(event) {
|
||||
const normalizedEvent = normalizeNotificationSession(event);
|
||||
const CODE_MAP = {
|
||||
'permission.required': normalizedEvent.meta?.toolName
|
||||
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
|
||||
: 'Action Required: A tool needs your approval',
|
||||
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
|
||||
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
|
||||
'push.enabled': 'Push notifications are now enabled!'
|
||||
};
|
||||
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
|
||||
const sessionName = resolveSessionName(normalizedEvent);
|
||||
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
|
||||
|
||||
return {
|
||||
title: sessionName || 'CloudCLI',
|
||||
body: `${providerLabel}: ${message}`,
|
||||
data: {
|
||||
sessionId: normalizedEvent.sessionId || null,
|
||||
code: normalizedEvent.code,
|
||||
provider: normalizedEvent.provider || null,
|
||||
sessionName,
|
||||
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sendWebPushPayload(userId, payload) {
|
||||
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
||||
if (!subscriptions.length) return Promise.resolve();
|
||||
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
return Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
webPush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.keys_p256dh,
|
||||
auth: sub.keys_auth
|
||||
}
|
||||
},
|
||||
serializedPayload
|
||||
)
|
||||
)
|
||||
).then((results) => {
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const statusCode = result.reason?.statusCode;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const notificationChannels = [
|
||||
{
|
||||
id: 'webPush',
|
||||
// TODO: Web push still uses push_subscriptions. Do not remove that table until
|
||||
// browser push subscriptions are migrated into notification_channel_endpoints.
|
||||
isEnabled: (preferences) => Boolean(preferences?.channels?.webPush),
|
||||
send: ({ userId, payload }) => sendWebPushPayload(userId, payload)
|
||||
},
|
||||
{
|
||||
id: 'desktop',
|
||||
isEnabled: (preferences) => Boolean(preferences?.channels?.desktop),
|
||||
send: ({ userId, payload }) => sendDesktopNotificationToClients(userId, payload)
|
||||
}
|
||||
];
|
||||
|
||||
function notifyUserIfEnabled({ userId, event }) {
|
||||
if (!userId || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedEvent = normalizeNotificationSession(event);
|
||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||
if (!isNotificationEventEnabled(preferences, normalizedEvent)) {
|
||||
return;
|
||||
}
|
||||
if (isDuplicate(normalizedEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildNotificationPayload(normalizedEvent);
|
||||
for (const channel of notificationChannels) {
|
||||
if (!channel.isEnabled(preferences)) {
|
||||
continue;
|
||||
}
|
||||
Promise.resolve(channel.send({ userId, event: normalizedEvent, payload })).catch((err) => {
|
||||
console.error(`Notification channel "${channel.id}" send error:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
|
||||
notifyUserIfEnabled({
|
||||
userId,
|
||||
event: createNotificationEvent({
|
||||
provider,
|
||||
sessionId,
|
||||
kind: 'stop',
|
||||
code: 'run.stopped',
|
||||
meta: { stopReason, sessionName },
|
||||
severity: 'info',
|
||||
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
|
||||
const errorMessage = normalizeErrorMessage(error);
|
||||
|
||||
notifyUserIfEnabled({
|
||||
userId,
|
||||
event: createNotificationEvent({
|
||||
provider,
|
||||
sessionId,
|
||||
kind: 'error',
|
||||
code: 'run.failed',
|
||||
meta: { error: errorMessage, sessionName },
|
||||
severity: 'error',
|
||||
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
buildNotificationPayload,
|
||||
createNotificationEvent,
|
||||
notifyUserIfEnabled,
|
||||
notifyRunStopped,
|
||||
notifyRunFailed
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import {
|
||||
registerDesktopNotificationClient,
|
||||
unregisterDesktopNotificationClient,
|
||||
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
type DesktopNotificationRegisterMessage = {
|
||||
type?: unknown;
|
||||
kind?: unknown;
|
||||
deviceId?: unknown;
|
||||
label?: unknown;
|
||||
platform?: unknown;
|
||||
appVersion?: unknown;
|
||||
};
|
||||
|
||||
function readRequestUserId(request: AuthenticatedWebSocketRequest): number | null {
|
||||
const user = request.user;
|
||||
const rawUserId = typeof user?.id === 'number' || typeof user?.id === 'string'
|
||||
? user.id
|
||||
: typeof user?.userId === 'number' || typeof user?.userId === 'string'
|
||||
? user.userId
|
||||
: null;
|
||||
const numericUserId = Number(rawUserId);
|
||||
return Number.isInteger(numericUserId) && numericUserId > 0 ? numericUserId : null;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function sendJson(ws: WebSocket, payload: unknown): void {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
export function handleDesktopNotificationsConnection(
|
||||
ws: WebSocket,
|
||||
request: AuthenticatedWebSocketRequest
|
||||
): void {
|
||||
const userId = readRequestUserId(request);
|
||||
if (!userId) {
|
||||
ws.close(1008, 'Missing authenticated user');
|
||||
return;
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
ws.on('message', (rawMessage) => {
|
||||
const data = parseIncomingJsonObject(rawMessage) as DesktopNotificationRegisterMessage | null;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof data.type === 'string' ? data.type : typeof data.kind === 'string' ? data.kind : '';
|
||||
if (type === 'notification_ack') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== 'register' || registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = readOptionalString(data.deviceId);
|
||||
if (!deviceId) {
|
||||
sendJson(ws, {
|
||||
type: 'error',
|
||||
code: 'DEVICE_ID_REQUIRED',
|
||||
message: 'Desktop notification registration requires deviceId.',
|
||||
});
|
||||
ws.close(1008, 'Missing deviceId');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = registerDesktopNotificationClient({
|
||||
userId,
|
||||
deviceId,
|
||||
label: readOptionalString(data.label),
|
||||
platform: readOptionalString(data.platform),
|
||||
appVersion: readOptionalString(data.appVersion),
|
||||
ws,
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
ws.close(1011, 'Registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
registered = true;
|
||||
sendJson(ws, {
|
||||
type: 'registered',
|
||||
deviceId: device.endpoint_id,
|
||||
enabled: Boolean(device.enabled),
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
unregisterDesktopNotificationClient(ws);
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
unregisterDesktopNotificationClient(ws);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -25,6 +25,21 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'claude' as const;
|
||||
private readonly claudeHome = path.join(os.homedir(), '.claude');
|
||||
|
||||
/**
|
||||
* Returns true when a JSONL file is a subagent transcript rather than a
|
||||
* top-level session.
|
||||
*
|
||||
* Claude stores subagent transcripts under a `subagents/` directory, e.g.
|
||||
* `~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl`.
|
||||
* Those files repeat the parent session's `sessionId`, so indexing them as
|
||||
* standalone sessions overwrites the parent row's `jsonl_path` and corrupts
|
||||
* the main session record. The recursive scan in `synchronize()` reaches
|
||||
* them, so both entry points must skip them.
|
||||
*/
|
||||
private isSubagentTranscript(filePath: string): boolean {
|
||||
return path.normalize(filePath).split(path.sep).includes('subagents');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans ~/.claude/projects and upserts discovered sessions into DB.
|
||||
*/
|
||||
@@ -38,6 +53,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
|
||||
let processed = 0;
|
||||
for (const filePath of files) {
|
||||
if (this.isSubagentTranscript(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
@@ -66,6 +85,9 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
if (!filePath.endsWith('.jsonl')) {
|
||||
return null;
|
||||
}
|
||||
if (this.isSubagentTranscript(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
|
||||
@@ -99,6 +99,14 @@ export class ClaudeSkillsProvider extends SkillsProvider {
|
||||
];
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(getClaudeHomePath(), 'skills'),
|
||||
commandPrefix: '/',
|
||||
};
|
||||
}
|
||||
|
||||
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
|
||||
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
|
||||
const enabledPlugins = readObjectRecord(settings.enabledPlugins);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -57,4 +57,12 @@ export class CodexSkillsProvider extends SkillsProvider {
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,12 @@ export class CursorSkillsProvider extends SkillsProvider {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
|
||||
commandPrefix: '/',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,4 +33,12 @@ export class GeminiSkillsProvider extends SkillsProvider {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderSkillCreateFile,
|
||||
ProviderSkillCreateInput,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
@@ -179,6 +181,104 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
||||
};
|
||||
};
|
||||
|
||||
const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateInput => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const rawEntries = Array.isArray(body.entries)
|
||||
? body.entries
|
||||
: typeof body.content === 'string'
|
||||
? [{
|
||||
content: body.content,
|
||||
directoryName: body.directoryName,
|
||||
fileName: body.fileName,
|
||||
files: body.files,
|
||||
}]
|
||||
: null;
|
||||
|
||||
if (!rawEntries || rawEntries.length === 0) {
|
||||
throw new AppError('At least one skill entry is required.', {
|
||||
code: 'PROVIDER_SKILLS_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const entries = rawEntries.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new AppError(`Skill entry ${index + 1} must be an object.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>;
|
||||
const content = typeof record.content === 'string' ? record.content : '';
|
||||
const directoryName = readOptionalQueryString(record.directoryName);
|
||||
const fileName = readOptionalQueryString(record.fileName);
|
||||
const rawFiles = record.files;
|
||||
|
||||
if (!content.trim()) {
|
||||
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
|
||||
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (rawFiles !== undefined && !Array.isArray(rawFiles)) {
|
||||
throw new AppError(`Skill entry ${index + 1} files must be an array.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const files: ProviderSkillCreateFile[] | undefined = rawFiles?.map((file, fileIndex) => {
|
||||
if (!file || typeof file !== 'object') {
|
||||
throw new AppError(`Skill entry ${index + 1} file ${fileIndex + 1} must be an object.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const fileRecord = file as Record<string, unknown>;
|
||||
const relativePath = readOptionalQueryString(fileRecord.relativePath);
|
||||
const fileContent = typeof fileRecord.content === 'string' ? fileRecord.content : null;
|
||||
const encoding = fileRecord.encoding === 'utf8' || fileRecord.encoding === 'base64'
|
||||
? fileRecord.encoding
|
||||
: null;
|
||||
|
||||
if (!relativePath || fileContent === null || !encoding) {
|
||||
throw new AppError(
|
||||
`Skill entry ${index + 1} file ${fileIndex + 1} requires relativePath, content, and encoding.`,
|
||||
{
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
relativePath,
|
||||
content: fileContent,
|
||||
encoding,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
directoryName,
|
||||
fileName,
|
||||
files,
|
||||
};
|
||||
});
|
||||
|
||||
return { entries };
|
||||
};
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (
|
||||
@@ -320,6 +420,27 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const input = parseProviderSkillCreatePayload(req.body);
|
||||
const skills = await providerSkillsService.addProviderSkills(provider, input);
|
||||
res.json(createApiSuccessResponse({ provider, skills }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:provider/skills/:directoryName',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.removeProviderSkill(provider, {
|
||||
directoryName: readPathParam(req.params.directoryName, 'directoryName'),
|
||||
});
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- MCP routes -----------------
|
||||
router.get(
|
||||
'/:provider/mcp/servers',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js';
|
||||
import type {
|
||||
ProviderSkill,
|
||||
ProviderSkillCreateInput,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillRemoveInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
export const providerSkillsService = {
|
||||
/**
|
||||
@@ -12,4 +17,23 @@ export const providerSkillsService = {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.listSkills(options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Writes one or more global skills for one provider.
|
||||
*/
|
||||
async addProviderSkills(
|
||||
providerName: string,
|
||||
input: ProviderSkillCreateInput,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.addSkills(input);
|
||||
},
|
||||
|
||||
async removeProviderSkill(
|
||||
providerName: string,
|
||||
input: ProviderSkillRemoveInput,
|
||||
): Promise<{ removed: boolean; provider: string; directoryName: string }> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.removeSkill(input);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,20 +1,87 @@
|
||||
import path from 'node:path';
|
||||
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { IProviderSkills } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
ProviderSkillCreateInput,
|
||||
ProviderSkillRemoveInput,
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillSource,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
findProviderSkillMarkdownFiles,
|
||||
readOptionalString,
|
||||
readProviderSkillMarkdownDefinitionFromContent,
|
||||
readProviderSkillMarkdownDefinition,
|
||||
AppError,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const resolveWorkspacePath = (workspacePath?: string): string =>
|
||||
path.resolve(workspacePath ?? process.cwd());
|
||||
|
||||
const stripMarkdownExtension = (value: string): string => value.replace(/\.md$/i, '');
|
||||
|
||||
const normalizeSkillDirectoryName = (value: string): string => (
|
||||
value
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, '-')
|
||||
.replace(/[<>:"|?*\x00-\x1F]/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^\.+|\.+$/g, '')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
);
|
||||
|
||||
type PendingSkillInstall = {
|
||||
skillDirectoryPath: string;
|
||||
skillPath: string;
|
||||
content: string;
|
||||
supportingFiles: Array<{
|
||||
targetPath: string;
|
||||
content: string | Buffer;
|
||||
}>;
|
||||
skill: ProviderSkill;
|
||||
};
|
||||
|
||||
const resolveSkillSupportingFilePath = (
|
||||
skillDirectoryPath: string,
|
||||
relativePath: string,
|
||||
entryIndex: number,
|
||||
): string => {
|
||||
const normalizedRelativePath = relativePath.trim().replace(/\\/g, '/');
|
||||
const pathSegments = normalizedRelativePath.split('/');
|
||||
if (
|
||||
!normalizedRelativePath
|
||||
|| path.isAbsolute(normalizedRelativePath)
|
||||
|| pathSegments.some((segment) => !segment || segment === '.' || segment === '..')
|
||||
|| normalizedRelativePath.toLowerCase() === 'skill.md'
|
||||
) {
|
||||
throw new AppError(
|
||||
`Skill entry ${entryIndex + 1} includes an invalid supporting file path "${relativePath}".`,
|
||||
{
|
||||
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
|
||||
statusCode: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath);
|
||||
const resolvedFilePath = path.resolve(resolvedSkillDirectoryPath, ...pathSegments);
|
||||
if (!resolvedFilePath.startsWith(`${resolvedSkillDirectoryPath}${path.sep}`)) {
|
||||
throw new AppError(
|
||||
`Skill entry ${entryIndex + 1} supporting files must stay inside the skill directory.`,
|
||||
{
|
||||
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
|
||||
statusCode: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedFilePath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared skills provider for provider-specific skill source discovery.
|
||||
*/
|
||||
@@ -60,5 +127,161 @@ export abstract class SkillsProvider implements IProviderSkills {
|
||||
return skills;
|
||||
}
|
||||
|
||||
async addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]> {
|
||||
const globalSkillSource = await this.getGlobalSkillSource();
|
||||
if (!globalSkillSource) {
|
||||
throw new AppError(`${this.provider} does not support managed global skills.`, {
|
||||
code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.entries) || input.entries.length === 0) {
|
||||
throw new AppError('At least one skill entry is required.', {
|
||||
code: 'PROVIDER_SKILLS_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const seenSkillPaths = new Set<string>();
|
||||
const pendingInstalls: PendingSkillInstall[] = [];
|
||||
|
||||
for (const [index, entry] of input.entries.entries()) {
|
||||
const content = typeof entry.content === 'string' ? entry.content.trim() : '';
|
||||
if (!content) {
|
||||
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
|
||||
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const fileNameFallback = readOptionalString(entry.fileName);
|
||||
const requestedDirectoryName = readOptionalString(entry.directoryName);
|
||||
const fallbackSkillName = normalizeSkillDirectoryName(
|
||||
requestedDirectoryName
|
||||
?? (fileNameFallback ? stripMarkdownExtension(fileNameFallback) : `skill-${index + 1}`),
|
||||
);
|
||||
const definition = readProviderSkillMarkdownDefinitionFromContent(content, fallbackSkillName);
|
||||
const resolvedDirectoryName = normalizeSkillDirectoryName(
|
||||
requestedDirectoryName ?? definition.name,
|
||||
);
|
||||
|
||||
if (!resolvedDirectoryName) {
|
||||
throw new AppError(`Skill entry ${index + 1} must include a valid skill name.`, {
|
||||
code: 'PROVIDER_SKILL_NAME_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const skillDirectoryPath = path.join(globalSkillSource.rootDir, resolvedDirectoryName);
|
||||
const skillPath = path.join(skillDirectoryPath, 'SKILL.md');
|
||||
const normalizedSkillPath = path.resolve(skillPath);
|
||||
if (seenSkillPaths.has(normalizedSkillPath)) {
|
||||
throw new AppError(`Duplicate skill target "${resolvedDirectoryName}" in one request.`, {
|
||||
code: 'PROVIDER_SKILL_DUPLICATE_TARGET',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
seenSkillPaths.add(normalizedSkillPath);
|
||||
const supportingFiles = (entry.files ?? []).map((file) => ({
|
||||
targetPath: resolveSkillSupportingFilePath(skillDirectoryPath, file.relativePath, index),
|
||||
content: file.encoding === 'base64'
|
||||
? Buffer.from(file.content, 'base64')
|
||||
: file.content,
|
||||
}));
|
||||
const seenSupportingPaths = new Set<string>();
|
||||
for (const file of supportingFiles) {
|
||||
if (seenSupportingPaths.has(file.targetPath)) {
|
||||
throw new AppError(`Skill entry ${index + 1} includes a duplicate supporting file path.`, {
|
||||
code: 'PROVIDER_SKILL_DUPLICATE_FILE',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
seenSupportingPaths.add(file.targetPath);
|
||||
}
|
||||
|
||||
const command = globalSkillSource.commandForSkill
|
||||
? globalSkillSource.commandForSkill(definition.name)
|
||||
: `${globalSkillSource.commandPrefix ?? '/'}${definition.name}`;
|
||||
|
||||
pendingInstalls.push({
|
||||
skillDirectoryPath,
|
||||
skillPath,
|
||||
content,
|
||||
supportingFiles,
|
||||
skill: {
|
||||
provider: this.provider,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
command,
|
||||
scope: globalSkillSource.scope,
|
||||
sourcePath: skillPath,
|
||||
pluginName: globalSkillSource.pluginName,
|
||||
pluginId: globalSkillSource.pluginId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const install of pendingInstalls) {
|
||||
// Replace the complete skill directory so removed scripts or assets do not remain stale.
|
||||
await rm(install.skillDirectoryPath, { recursive: true, force: true });
|
||||
await mkdir(install.skillDirectoryPath, { recursive: true });
|
||||
await writeFile(install.skillPath, `${install.content}\n`, 'utf8');
|
||||
for (const file of install.supportingFiles) {
|
||||
await mkdir(path.dirname(file.targetPath), { recursive: true });
|
||||
await writeFile(file.targetPath, file.content);
|
||||
}
|
||||
}
|
||||
|
||||
return pendingInstalls.map((install) => install.skill);
|
||||
}
|
||||
|
||||
async removeSkill(
|
||||
input: ProviderSkillRemoveInput,
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }> {
|
||||
const globalSkillSource = await this.getGlobalSkillSource();
|
||||
if (!globalSkillSource) {
|
||||
throw new AppError(`${this.provider} does not support managed global skills.`, {
|
||||
code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const directoryName = normalizeSkillDirectoryName(input.directoryName);
|
||||
if (!directoryName) {
|
||||
throw new AppError('Skill directoryName is required.', {
|
||||
code: 'PROVIDER_SKILL_DIRECTORY_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const skillDirectoryPath = path.join(globalSkillSource.rootDir, directoryName);
|
||||
const resolvedRoot = path.resolve(globalSkillSource.rootDir);
|
||||
const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath);
|
||||
if (
|
||||
resolvedSkillDirectoryPath !== resolvedRoot
|
||||
&& !resolvedSkillDirectoryPath.startsWith(`${resolvedRoot}${path.sep}`)
|
||||
) {
|
||||
throw new AppError('Skill directory must stay inside the managed skill root.', {
|
||||
code: 'PROVIDER_SKILL_DIRECTORY_INVALID',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const removed = await stat(resolvedSkillDirectoryPath)
|
||||
.then((stats) => stats.isDirectory())
|
||||
.catch(() => false);
|
||||
if (removed) {
|
||||
await rm(resolvedSkillDirectoryPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return { removed, provider: this.provider, directoryName };
|
||||
}
|
||||
|
||||
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,3 +510,215 @@ test('providerSkillsService lists gemini and cursor skills from their configured
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers managed global skill creation for providers that own a
|
||||
* writable user skill directory.
|
||||
*/
|
||||
test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
const createdClaudeSkills = await providerSkillsService.addProviderSkills('claude', {
|
||||
entries: [
|
||||
{
|
||||
directoryName: 'claude-global-dir',
|
||||
content: '---\nname: claude-global\ndescription: Claude global skill\n---\n\nClaude body.\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
const createdClaudeSkill = createdClaudeSkills[0];
|
||||
assert.ok(createdClaudeSkill);
|
||||
assert.equal(createdClaudeSkill.command, '/claude-global');
|
||||
assert.equal(
|
||||
createdClaudeSkill.sourcePath.endsWith(path.join('.claude', 'skills', 'claude-global-dir', 'SKILL.md')),
|
||||
true,
|
||||
);
|
||||
assert.match(
|
||||
await fs.readFile(createdClaudeSkill.sourcePath, 'utf8'),
|
||||
/Claude body\./,
|
||||
);
|
||||
|
||||
const createdCodexSkills = await providerSkillsService.addProviderSkills('codex', {
|
||||
entries: [
|
||||
{
|
||||
directoryName: 'uploaded-codex-folder',
|
||||
fileName: 'SKILL.md',
|
||||
content: '---\nname: codex-global\ndescription: Codex global skill\n---\n\nCodex body.\n',
|
||||
files: [
|
||||
{
|
||||
relativePath: 'scripts/run.js',
|
||||
content: Buffer.from('console.log("codex skill");\n').toString('base64'),
|
||||
encoding: 'base64',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const createdCodexSkill = createdCodexSkills[0];
|
||||
assert.ok(createdCodexSkill);
|
||||
assert.equal(createdCodexSkill.command, '$codex-global');
|
||||
assert.equal(
|
||||
createdCodexSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'uploaded-codex-folder', 'SKILL.md')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js'), 'utf8'),
|
||||
'console.log("codex skill");\n',
|
||||
);
|
||||
|
||||
const fallbackNamedSkills = await providerSkillsService.addProviderSkills('codex', {
|
||||
entries: [
|
||||
{
|
||||
fileName: 'fallback / skill.md',
|
||||
content: '---\ndescription: Normalized fallback skill\n---\n\nFallback body.\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
const fallbackNamedSkill = fallbackNamedSkills[0];
|
||||
assert.ok(fallbackNamedSkill);
|
||||
assert.equal(fallbackNamedSkill.name, 'fallback-skill');
|
||||
assert.equal(fallbackNamedSkill.command, '$fallback-skill');
|
||||
assert.equal(
|
||||
fallbackNamedSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'fallback-skill', 'SKILL.md')),
|
||||
true,
|
||||
);
|
||||
|
||||
const replacedCodexSkills = await providerSkillsService.addProviderSkills('codex', {
|
||||
entries: [
|
||||
{
|
||||
directoryName: 'uploaded-codex-folder',
|
||||
content: '---\nname: replacement\ndescription: Replacement skill\n---\n\nReplacement body.\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(replacedCodexSkills[0]?.command, '$replacement');
|
||||
assert.match(await fs.readFile(createdCodexSkill.sourcePath, 'utf8'), /Replacement body\./);
|
||||
await assert.rejects(
|
||||
fs.stat(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js')),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
|
||||
const pendingBatchSkillPath = path.join(tempRoot, '.agents', 'skills', 'pending-batch', 'SKILL.md');
|
||||
await assert.rejects(
|
||||
providerSkillsService.addProviderSkills('codex', {
|
||||
entries: [
|
||||
{
|
||||
directoryName: 'pending-batch',
|
||||
content: '---\nname: pending-batch\n---\n\nPending body.\n',
|
||||
},
|
||||
{
|
||||
directoryName: 'pending-batch',
|
||||
content: '---\nname: duplicate-batch\n---\n\nDuplicate body.\n',
|
||||
},
|
||||
],
|
||||
}),
|
||||
/duplicate skill target/i,
|
||||
);
|
||||
await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
|
||||
|
||||
const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
|
||||
entries: [
|
||||
{
|
||||
directoryName: 'gemini-global-dir',
|
||||
content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
const createdGeminiSkill = createdGeminiSkills[0];
|
||||
assert.ok(createdGeminiSkill);
|
||||
assert.equal(createdGeminiSkill.command, '/gemini-global');
|
||||
assert.equal(
|
||||
createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
|
||||
true,
|
||||
);
|
||||
|
||||
const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
|
||||
entries: [
|
||||
{
|
||||
directoryName: 'cursor-global-dir',
|
||||
content: '---\nname: cursor-global\ndescription: Cursor global skill\n---\n\nCursor body.\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
const createdCursorSkill = createdCursorSkills[0];
|
||||
assert.ok(createdCursorSkill);
|
||||
assert.equal(createdCursorSkill.command, '/cursor-global');
|
||||
assert.equal(
|
||||
createdCursorSkill.sourcePath.endsWith(path.join('.cursor', 'skills', 'cursor-global-dir', 'SKILL.md')),
|
||||
true,
|
||||
);
|
||||
|
||||
const listedClaudeSkills = await providerSkillsService.listProviderSkills('claude');
|
||||
assert.equal(listedClaudeSkills.some((skill) => skill.name === 'claude-global'), true);
|
||||
|
||||
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
|
||||
assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
|
||||
|
||||
const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
|
||||
assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
|
||||
|
||||
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
|
||||
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
|
||||
|
||||
const removedCodexSkill = await providerSkillsService.removeProviderSkill('codex', {
|
||||
directoryName: 'uploaded-codex-folder',
|
||||
});
|
||||
assert.equal(removedCodexSkill.removed, true);
|
||||
assert.equal(removedCodexSkill.provider, 'codex');
|
||||
assert.equal(removedCodexSkill.directoryName, 'uploaded-codex-folder');
|
||||
await assert.rejects(fs.stat(path.dirname(createdCodexSkill.sourcePath)), { code: 'ENOENT' });
|
||||
|
||||
const removedMissingSkill = await providerSkillsService.removeProviderSkill('codex', {
|
||||
directoryName: 'uploaded-codex-folder',
|
||||
});
|
||||
assert.equal(removedMissingSkill.removed, false);
|
||||
|
||||
await assert.rejects(
|
||||
providerSkillsService.addProviderSkills('codex', {
|
||||
entries: [
|
||||
{
|
||||
content: '---\nname: unsafe-skill\n---\n',
|
||||
files: [
|
||||
{
|
||||
relativePath: '../outside.js',
|
||||
content: '',
|
||||
encoding: 'utf8',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
/invalid supporting file path/i,
|
||||
);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* OpenCode reuses other providers' skill folders, so it should not accept
|
||||
* direct skill writes through the managed provider endpoint.
|
||||
*/
|
||||
test('providerSkillsService rejects managed skill creation for opencode', { concurrency: false }, async () => {
|
||||
await assert.rejects(
|
||||
providerSkillsService.addProviderSkills('opencode', {
|
||||
entries: [
|
||||
{
|
||||
directoryName: 'opencode-global-dir',
|
||||
content: '---\nname: opencode-global\ndescription: Unsupported skill\n---\n\nOpenCode body.\n',
|
||||
},
|
||||
],
|
||||
}),
|
||||
/does not support managed global skills/i,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
providerSkillsService.removeProviderSkill('opencode', {
|
||||
directoryName: 'opencode-global-dir',
|
||||
}),
|
||||
/does not support managed global skills/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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 {
|
||||
let registered = false;
|
||||
|
||||
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 === 'register' && !registered) {
|
||||
const label = typeof data.label === 'string' && data.label.trim()
|
||||
? data.label.trim()
|
||||
: request.user?.username
|
||||
? `desktop:${request.user.username}`
|
||||
: 'desktop-agent';
|
||||
registered = true;
|
||||
console.log('[INFO] Desktop agent websocket registered:', label);
|
||||
desktopAgentRelay.register(ws, label);
|
||||
return;
|
||||
}
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -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') {
|
||||
@@ -174,6 +171,62 @@ function buildShellCommand(
|
||||
return command;
|
||||
}
|
||||
|
||||
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
||||
const resolvedKey = Object.keys(env).find((envKey) => envKey.toLowerCase() === key.toLowerCase());
|
||||
return resolvedKey ? env[resolvedKey] : undefined;
|
||||
}
|
||||
|
||||
function getPathEnvKey(env: NodeJS.ProcessEnv): string {
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
|
||||
}
|
||||
|
||||
function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } {
|
||||
const pathKey = getPathEnvKey(env);
|
||||
const currentPath = env[pathKey];
|
||||
if (!currentPath) {
|
||||
return { key: pathKey, value: currentPath };
|
||||
}
|
||||
|
||||
const delimiter = path.delimiter;
|
||||
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
||||
const npmPrefix = readEnvValue(env, 'npm_config_prefix');
|
||||
const appData = readEnvValue(env, 'APPDATA');
|
||||
const candidates = [
|
||||
npmPrefix || '',
|
||||
npmPrefix ? path.join(npmPrefix, 'bin') : '',
|
||||
appData ? path.join(appData, 'npm') : '',
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm'),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
].filter(Boolean);
|
||||
|
||||
const normalizedPathEntries = pathEntries.map((entry) => os.platform() === 'win32' ? entry.toLowerCase() : entry);
|
||||
const preferredEntries = candidates.filter((candidate, index) => {
|
||||
const normalizedCandidate = os.platform() === 'win32' ? candidate.toLowerCase() : candidate;
|
||||
return (
|
||||
candidates.indexOf(candidate) === index &&
|
||||
normalizedPathEntries.includes(normalizedCandidate)
|
||||
);
|
||||
});
|
||||
|
||||
if (preferredEntries.length === 0) {
|
||||
return { key: pathKey, value: currentPath };
|
||||
}
|
||||
|
||||
const normalizedPreferredEntries = preferredEntries.map((entry) =>
|
||||
os.platform() === 'win32' ? entry.toLowerCase() : entry
|
||||
);
|
||||
|
||||
const value = [
|
||||
...preferredEntries,
|
||||
...pathEntries.filter((entry) => {
|
||||
const normalizedEntry = os.platform() === 'win32' ? entry.toLowerCase() : entry;
|
||||
return !normalizedPreferredEntries.includes(normalizedEntry);
|
||||
}),
|
||||
].join(delimiter);
|
||||
|
||||
return { key: pathKey, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles websocket connections used by the standalone shell terminal UI.
|
||||
*/
|
||||
@@ -287,10 +340,7 @@ 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();
|
||||
const prioritizedPath = prioritizeUserNpmGlobalBin(process.env);
|
||||
|
||||
shellProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
@@ -298,7 +348,8 @@ export function handleShellConnection(
|
||||
rows: termRows,
|
||||
cwd: resolvedProjectPath,
|
||||
env: {
|
||||
...ptyEnv,
|
||||
...process.env,
|
||||
[prioritizedPath.key]: prioritizedPath.value,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js';
|
||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||
|
||||
type WebSocketServerDependencies = {
|
||||
@@ -63,6 +65,16 @@ export function createWebSocketServer(
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/desktop-agent') {
|
||||
handleDesktopAgentConnection(ws, incomingRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/desktop-notifications') {
|
||||
handleDesktopNotificationsConnection(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,6 +1,11 @@
|
||||
import express from 'express';
|
||||
|
||||
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
|
||||
import {
|
||||
apiKeysDb,
|
||||
credentialsDb,
|
||||
notificationPreferencesDb,
|
||||
pushSubscriptionsDb,
|
||||
} from '../modules/database/index.js';
|
||||
import { getPublicKey } from '../services/vapid-keys.js';
|
||||
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
|
||||
|
||||
|
||||
@@ -1,268 +1,7 @@
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '../modules/database/index.js';
|
||||
|
||||
const KIND_TO_PREF_KEY = {
|
||||
action_required: 'actionRequired',
|
||||
stop: 'stop',
|
||||
error: 'error'
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
system: 'System'
|
||||
};
|
||||
|
||||
const recentEventKeys = new Map();
|
||||
const DEDUPE_WINDOW_MS = 20000;
|
||||
|
||||
const cleanupOldEventKeys = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of recentEventKeys.entries()) {
|
||||
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
||||
recentEventKeys.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function shouldSendPush(preferences, event) {
|
||||
const webPushEnabled = Boolean(preferences?.channels?.webPush);
|
||||
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
||||
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
||||
|
||||
return webPushEnabled && eventEnabled;
|
||||
}
|
||||
|
||||
function isDuplicate(event) {
|
||||
cleanupOldEventKeys();
|
||||
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
||||
if (recentEventKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
recentEventKeys.set(key, Date.now());
|
||||
return false;
|
||||
}
|
||||
|
||||
function createNotificationEvent({
|
||||
provider,
|
||||
sessionId = null,
|
||||
kind = 'info',
|
||||
code = 'generic.info',
|
||||
meta = {},
|
||||
severity = 'info',
|
||||
dedupeKey = null,
|
||||
requiresUserAction = false
|
||||
}) {
|
||||
return {
|
||||
provider,
|
||||
sessionId,
|
||||
kind,
|
||||
code,
|
||||
meta,
|
||||
severity,
|
||||
requiresUserAction,
|
||||
dedupeKey,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error) {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error == null) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeSessionName(sessionName) {
|
||||
if (typeof sessionName !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = sessionName.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
||||
}
|
||||
|
||||
function rowMatchesProvider(row, provider) {
|
||||
return row && (!provider || row.provider === provider);
|
||||
}
|
||||
|
||||
function resolveSessionRow(sessionId, provider) {
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appSessionRow = sessionsDb.getSessionById(sessionId);
|
||||
if (rowMatchesProvider(appSessionRow, provider)) {
|
||||
return appSessionRow;
|
||||
}
|
||||
|
||||
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||
if (rowMatchesProvider(providerSessionRow, provider)) {
|
||||
return providerSessionRow;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeNotificationSession(event) {
|
||||
if (!event?.sessionId || !event.provider || event.provider === 'system') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const row = resolveSessionRow(event.sessionId, event.provider);
|
||||
if (!row || row.session_id === event.sessionId) {
|
||||
return event;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
sessionId: row.session_id
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionName(event) {
|
||||
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
||||
if (explicitSessionName) {
|
||||
return explicitSessionName;
|
||||
}
|
||||
|
||||
if (!event.sessionId || !event.provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
|
||||
}
|
||||
|
||||
function buildPushBody(event) {
|
||||
const normalizedEvent = normalizeNotificationSession(event);
|
||||
const CODE_MAP = {
|
||||
'permission.required': normalizedEvent.meta?.toolName
|
||||
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
|
||||
: 'Action Required: A tool needs your approval',
|
||||
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
|
||||
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
|
||||
'push.enabled': 'Push notifications are now enabled!'
|
||||
};
|
||||
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
|
||||
const sessionName = resolveSessionName(normalizedEvent);
|
||||
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
|
||||
|
||||
return {
|
||||
title: sessionName || 'CloudCLI',
|
||||
body: `${providerLabel}: ${message}`,
|
||||
data: {
|
||||
sessionId: normalizedEvent.sessionId || null,
|
||||
code: normalizedEvent.code,
|
||||
provider: normalizedEvent.provider || null,
|
||||
sessionName,
|
||||
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function sendWebPush(userId, event) {
|
||||
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
||||
if (!subscriptions.length) return;
|
||||
|
||||
const payload = JSON.stringify(buildPushBody(event));
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
webPush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.keys_p256dh,
|
||||
auth: sub.keys_auth
|
||||
}
|
||||
},
|
||||
payload
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Clean up gone subscriptions (410 Gone or 404)
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const statusCode = result.reason?.statusCode;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifyUserIfEnabled({ userId, event }) {
|
||||
if (!userId || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedEvent = normalizeNotificationSession(event);
|
||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||
if (!shouldSendPush(preferences, normalizedEvent)) {
|
||||
return;
|
||||
}
|
||||
if (isDuplicate(normalizedEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendWebPush(userId, normalizedEvent).catch((err) => {
|
||||
console.error('Web push send error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
|
||||
notifyUserIfEnabled({
|
||||
userId,
|
||||
event: createNotificationEvent({
|
||||
provider,
|
||||
sessionId,
|
||||
kind: 'stop',
|
||||
code: 'run.stopped',
|
||||
meta: { stopReason, sessionName },
|
||||
severity: 'info',
|
||||
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
|
||||
const errorMessage = normalizeErrorMessage(error);
|
||||
|
||||
notifyUserIfEnabled({
|
||||
userId,
|
||||
event: createNotificationEvent({
|
||||
provider,
|
||||
sessionId,
|
||||
kind: 'error',
|
||||
code: 'run.failed',
|
||||
meta: { error: errorMessage, sessionName },
|
||||
severity: 'error',
|
||||
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
buildNotificationPayload,
|
||||
createNotificationEvent,
|
||||
notifyUserIfEnabled,
|
||||
notifyRunStopped,
|
||||
notifyRunFailed
|
||||
};
|
||||
notifyRunFailed,
|
||||
} from '../modules/notifications/services/notification-orchestrator.service.js';
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
pushSubscriptionsDb,
|
||||
sessionsDb,
|
||||
userDb,
|
||||
} from '../modules/database/index.js';
|
||||
} from '../../modules/database/index.js';
|
||||
|
||||
import { notifyRunStopped } from './notification-orchestrator.js';
|
||||
import { notifyRunStopped } from '../notification-orchestrator.js';
|
||||
|
||||
async function withIsolatedDatabase(runTest) {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user