mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-30 00:32:57 +08:00
Compare commits
7 Commits
fix/voice-
...
cloudcli-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6761f31a56 | ||
|
|
35da5d090d | ||
|
|
d882f80b6d | ||
|
|
053f244d14 | ||
|
|
97c9b67bfc | ||
|
|
ed4ae3114a | ||
|
|
591e8e7642 |
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
|
||||
305
.github/workflows/desktop-release.yml
vendored
Normal file
305
.github/workflows/desktop-release.yml
vendored
Normal file
@@ -0,0 +1,305 @@
|
||||
name: Desktop 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 <tag>")'
|
||||
required: false
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark the GitHub release as a prerelease"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
resolve-release:
|
||||
name: Resolve release metadata
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
release_name: ${{ steps.release.outputs.release_name }}
|
||||
server_bundle_tag: ${{ steps.release.outputs.server_bundle_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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 ${TAG}"
|
||||
fi
|
||||
RELEASE_NAME_DELIMITER="release_name_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}"
|
||||
|
||||
{
|
||||
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"
|
||||
|
||||
build-macos:
|
||||
name: Build signed macOS desktop app
|
||||
needs: resolve-release
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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: Configure release server bundle source
|
||||
env:
|
||||
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
|
||||
|
||||
- name: Verify macOS 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 macOS local server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Stage macOS release assets
|
||||
run: |
|
||||
mkdir -p desktop-release-assets server-release-assets
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||
shasum -a 256 release/desktop/*.dmg > desktop-release-assets/SHASUMS256-macos.txt
|
||||
cp release/desktop/*.dmg desktop-release-assets/
|
||||
|
||||
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)"
|
||||
cp release/local-server/* server-release-assets/
|
||||
|
||||
- name: Upload macOS desktop assets
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: desktop-release-macos
|
||||
path: desktop-release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload macOS server assets
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: server-release-macos
|
||||
path: server-release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows desktop app
|
||||
needs: resolve-release
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
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: Configure release server bundle source
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
|
||||
|
||||
- name: Check Windows signing secrets
|
||||
id: windows-signing
|
||||
shell: bash
|
||||
env:
|
||||
WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
|
||||
WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$WINDOWS_CSC_LINK" ] && [ -n "$WINDOWS_CSC_KEY_PASSWORD" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build signed Windows artifacts
|
||||
if: steps.windows-signing.outputs.enabled == 'true'
|
||||
run: npm run desktop:dist:win -- --publish never
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
|
||||
|
||||
- name: Build unsigned Windows artifacts
|
||||
if: steps.windows-signing.outputs.enabled != 'true'
|
||||
run: npm run desktop:dist:win -- --publish never
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
|
||||
- name: Build Windows local server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Stage Windows release assets
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p desktop-release-assets server-release-assets
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||
sha256sum release/desktop/*.exe > desktop-release-assets/SHASUMS256-windows.txt
|
||||
cp release/desktop/*.exe desktop-release-assets/
|
||||
|
||||
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)"
|
||||
cp release/local-server/* server-release-assets/
|
||||
|
||||
- name: Upload Windows desktop assets
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: desktop-release-windows
|
||||
path: desktop-release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Windows server assets
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: server-release-windows
|
||||
path: server-release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish desktop release
|
||||
needs:
|
||||
- resolve-release
|
||||
- build-macos
|
||||
- build-windows
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download desktop assets
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
with:
|
||||
pattern: desktop-release-*
|
||||
path: release/desktop
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download server assets
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
with:
|
||||
pattern: server-release-*
|
||||
path: release/local-server
|
||||
merge-multiple: true
|
||||
|
||||
- name: Verify release assets
|
||||
run: |
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||
test -f release/desktop/SHASUMS256-macos.txt
|
||||
test -f release/desktop/SHASUMS256-windows.txt
|
||||
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)"
|
||||
find release -maxdepth 2 -type f -print | sort
|
||||
|
||||
- name: Publish local server runtime assets
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: CloudCLI Local Server Runtime (${{ needs.resolve-release.outputs.tag }})
|
||||
body: |
|
||||
This prerelease contains the Local mode runtime for CloudCLI Desktop.
|
||||
|
||||
Download CloudCLI Desktop from the main ${{ needs.resolve-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: Publish GitHub release assets
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ needs.resolve-release.outputs.tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: ${{ needs.resolve-release.outputs.release_name }}
|
||||
body: |
|
||||
Download the CloudCLI Desktop installer for your platform.
|
||||
|
||||
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
|
||||
overwrite_files: true
|
||||
files: |
|
||||
release/desktop/*
|
||||
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
|
||||
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
@@ -4,28 +4,109 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
increment:
|
||||
description: 'Version bump: patch, minor, major, or explicit (e.g. 1.27.0)'
|
||||
description: "Version bump: patch, minor, major, or explicit (e.g. 1.27.0)"
|
||||
required: true
|
||||
default: 'patch'
|
||||
default: "patch"
|
||||
type: string
|
||||
release_name:
|
||||
description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")'
|
||||
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,23 @@ 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: Restore semantic helper permissions
|
||||
run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} +
|
||||
|
||||
- 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 }}"
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -143,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
|
||||
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -3,6 +3,59 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
|
||||
|
||||
### New Features
|
||||
|
||||
* add Electron desktop app ([97c9b67](https://github.com/siteboon/claudecodeui/commit/97c9b67bfc2d803560cd1559a4e79eea9731c7b5))
|
||||
* **chat:** derive activity indicator from per-session state and unify provider lifecycle events ([afc717e](https://github.com/siteboon/claudecodeui/commit/afc717e69e67f53173c30d2230722236f9180d39))
|
||||
* **chat:** unify session gateway with stable IDs and a single WS protocol ([f5eac2e](https://github.com/siteboon/claudecodeui/commit/f5eac2ec12c8575bf80202fafe807d9e04720105))
|
||||
* **i18n:** add French (fr) locale ([#878](https://github.com/siteboon/claudecodeui/issues/878)) ([f319d2c](https://github.com/siteboon/claudecodeui/commit/f319d2cf8d61452deaf6adf345494dd3e6898284))
|
||||
* play sound for pending tool requests ([#918](https://github.com/siteboon/claudecodeui/issues/918)) ([c947eaa](https://github.com/siteboon/claudecodeui/commit/c947eaaee5fbc959563efb917f4ec7c88847dd6b))
|
||||
* render changelog as markdown in version upgrade modal ([6a53c31](https://github.com/siteboon/claudecodeui/commit/6a53c31e907fffa79320997c27f99660c946b4a6))
|
||||
* **sidebar:** improve running session state tracking ([591b18e](https://github.com/siteboon/claudecodeui/commit/591b18e9e343fda23affe100a53911f76aaa8f57))
|
||||
* **skills:** add provider skill management ([#909](https://github.com/siteboon/claudecodeui/issues/909)) ([c5fe127](https://github.com/siteboon/claudecodeui/commit/c5fe127958d830eee19d008d8634c0e7d77fe1b9))
|
||||
* **version:** warn when the server was updated but not restarted ([#898](https://github.com/siteboon/claudecodeui/issues/898)) ([f6326c8](https://github.com/siteboon/claudecodeui/commit/f6326c8082dfbe8a65dcdb836d3e71c635594c26))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* changes provider logos to svg for fast load ([7bed675](https://github.com/siteboon/claudecodeui/commit/7bed675ad5fd1ecf7912d1a04afe9db5b1032823))
|
||||
* **chat:** prevent chat interface crash on malformed AskUserQuestion payload ([#920](https://github.com/siteboon/claudecodeui/issues/920)) ([ed4ae31](https://github.com/siteboon/claudecodeui/commit/ed4ae3114aafc1d4ecb0b621eaf9d3b26dbca5b1))
|
||||
* **chat:** prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks ([#903](https://github.com/siteboon/claudecodeui/issues/903)) ([4712431](https://github.com/siteboon/claudecodeui/commit/4712431be81718dfb559ef43d7d7d5315bf4e01a))
|
||||
* **chat:** sort messages appropriately ([123ae31](https://github.com/siteboon/claudecodeui/commit/123ae310207fe5969c3b313f62b9dee27e5d7489))
|
||||
* **claude-sync:** skip subagent transcripts to prevent main session corruption ([#854](https://github.com/siteboon/claudecodeui/issues/854)) ([a12ca8e](https://github.com/siteboon/claudecodeui/commit/a12ca8eed373ef56cd37fbdd097845eaab34dee9))
|
||||
* correct notification session id ([881e72d](https://github.com/siteboon/claudecodeui/commit/881e72d4a00ec9c1a5e1ae4799bffa900f27c1f8))
|
||||
* create one unified function for frontend session processing ([677d330](https://github.com/siteboon/claudecodeui/commit/677d330981ef29a856f09e62b9f69bac0fa580d4))
|
||||
* **i18n:** add missing sidebar message keys to all locales ([#896](https://github.com/siteboon/claudecodeui/issues/896)) ([7ca3556](https://github.com/siteboon/claudecodeui/commit/7ca355651f0a805965bc27af3d75def626c5fb96))
|
||||
* keep running-session polling active ([39b0473](https://github.com/siteboon/claudecodeui/commit/39b0473e38201c29ff1e5388946452d2eed44527))
|
||||
* normalize project session payloads ([d0adddb](https://github.com/siteboon/claudecodeui/commit/d0adddbbdafecfd5713a8ac5b95c87a8f7fc54f8))
|
||||
* **opencode:** bind watcher sessions to app rows early ([5b9adbb](https://github.com/siteboon/claudecodeui/commit/5b9adbbdee8561439a27ad90744388225823427b))
|
||||
* **opencode:** pass workspace dir explicitly ([416a737](https://github.com/siteboon/claudecodeui/commit/416a737d76e654d2fc649206c2b921a7db150775))
|
||||
* recover pending permission requests ([56b2e14](https://github.com/siteboon/claudecodeui/commit/56b2e1405967c50301d0c773567349763edc8560))
|
||||
* remove provider specific token usage calculator ([2abb456](https://github.com/siteboon/claudecodeui/commit/2abb45636b5e1109733cfa58c8ab92fd4c812165))
|
||||
* resolve session provider on backend reads ([9fb2d91](https://github.com/siteboon/claudecodeui/commit/9fb2d91b26bef9579337d953a29718802c466fed))
|
||||
* **sessions:** canonicalize sidebar ids and timestamps ([3bbb42c](https://github.com/siteboon/claudecodeui/commit/3bbb42c23324c3cbb5587f2bcab09b1dc23086a8))
|
||||
* **shell:** prioritize user npm binaries ([#913](https://github.com/siteboon/claudecodeui/issues/913)) ([4a503b1](https://github.com/siteboon/claudecodeui/commit/4a503b1dc87ff58821670c8bfb1d8a8c1dab2bcf))
|
||||
* **shell:** use correct session id ([89f0524](https://github.com/siteboon/claudecodeui/commit/89f05247eddec4fe53bd1616c6a5563e3ae2427a))
|
||||
* **sidebar:** align session status controls across layouts ([1b336e9](https://github.com/siteboon/claudecodeui/commit/1b336e9aa9d2cccf0676d852815d9ba613ac04d2))
|
||||
* upgrade gemini logo ([9cb2afd](https://github.com/siteboon/claudecodeui/commit/9cb2afd67eb25a4f869b88abcf86f7748b2b6d71))
|
||||
* voice tts format settings ([#919](https://github.com/siteboon/claudecodeui/issues/919)) ([591e8e7](https://github.com/siteboon/claudecodeui/commit/591e8e7642589b0584f9b29b46b881aaab54624e))
|
||||
|
||||
### Documentation
|
||||
|
||||
* update available plugin readmes ([f549bd9](https://github.com/siteboon/claudecodeui/commit/f549bd99e7106362a27cf4ccee6e9d434b8b5363))
|
||||
* update session activity guard comment ([e23e6af](https://github.com/siteboon/claudecodeui/commit/e23e6af06a44cc4b016df5778984602d49e52629))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* add github issues board plugin ([21b0f14](https://github.com/siteboon/claudecodeui/commit/21b0f14e7a86f257c65484742c43b9f85152b32c))
|
||||
* add more plugins list ([bc34085](https://github.com/siteboon/claudecodeui/commit/bc34085af9912da8d8592881a5845cff84a53f7d))
|
||||
* move tests to appropriate folder ([d7a38a5](https://github.com/siteboon/claudecodeui/commit/d7a38a567a5e9039935353a886310b3c32b25a79))
|
||||
* move tests to appropriate folder ([c6c153e](https://github.com/siteboon/claudecodeui/commit/c6c153e7f2a60572b08d687b59f010b4ad4f5d72))
|
||||
* remove a log ([00e526b](https://github.com/siteboon/claudecodeui/commit/00e526b6e90ee0baf09ebf48873bc10824ab80ba))
|
||||
* remove unused modelConstants from the project ([92de0ed](https://github.com/siteboon/claudecodeui/commit/92de0ed6137bf4571056deb3b930cc9fd22e2a08))
|
||||
* upgrade gemini models ([3d94821](https://github.com/siteboon/claudecodeui/commit/3d948217ef3084e764171ebc5dda55f663150b2c))
|
||||
|
||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
766
electron/desktopWindow.js
Normal file
766
electron/desktopWindow.js
Normal file
@@ -0,0 +1,766 @@
|
||||
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';
|
||||
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)),
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
687
electron/launcher/launcher.js
Normal file
687
electron/launcher/launcher.js
Normal file
@@ -0,0 +1,687 @@
|
||||
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: [],
|
||||
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)); },
|
||||
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));
|
||||
},
|
||||
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';
|
||||
}
|
||||
|
||||
var CC = {
|
||||
icon: icon,
|
||||
esc: esc,
|
||||
statusMeta: statusMeta,
|
||||
connected: connected,
|
||||
authState: authState,
|
||||
accountLabel: accountLabel,
|
||||
localUrl: localUrl,
|
||||
envCount: envCount,
|
||||
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 '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 '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>'
|
||||
);
|
||||
};
|
||||
|
||||
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 sections = [
|
||||
CC.buildThemeSection(CC.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;
|
||||
}
|
||||
});
|
||||
|
||||
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 };
|
||||
944
electron/main.js
Normal file
944
electron/main.js
Normal file
@@ -0,0 +1,944 @@
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, session, shell } from 'electron';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { CloudController } from './cloud.js';
|
||||
import { DesktopWindowManager } from './desktopWindow.js';
|
||||
import { DesktopNotificationsController } from './desktopNotifications.js';
|
||||
import { LocalServerController } from './localServer.js';
|
||||
import { TabsController } from './tabs.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const APP_NAME = 'CloudCLI';
|
||||
const APP_USER_MODEL_ID = 'ai.cloudcli.desktop';
|
||||
const CALLBACK_PROTOCOL = 'cloudcli';
|
||||
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
|
||||
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
|
||||
const REMOTE_START_TIMEOUT_MS = 30000;
|
||||
const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const tabs = new TabsController();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId(APP_USER_MODEL_ID);
|
||||
}
|
||||
|
||||
let activeTarget = { kind: 'launcher', name: APP_NAME, url: null };
|
||||
let desktopWindow = null;
|
||||
let localServer = null;
|
||||
let cloud = null;
|
||||
let desktopNotifications = null;
|
||||
let isQuitting = false;
|
||||
let isRefreshingCloud = false;
|
||||
let pendingCloudConnectStartedAt = 0;
|
||||
|
||||
function getAppRoot() {
|
||||
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..');
|
||||
}
|
||||
|
||||
function getLauncherPath() {
|
||||
return path.join(__dirname, 'launcher', 'index.html');
|
||||
}
|
||||
|
||||
function getPreloadPath() {
|
||||
return path.join(__dirname, 'preload.cjs');
|
||||
}
|
||||
|
||||
function getWindowIconPath() {
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(getAppRoot(), 'electron', 'assets', 'logo-macos.png');
|
||||
}
|
||||
return path.join(getAppRoot(), 'public', 'logo-512.png');
|
||||
}
|
||||
|
||||
function getStorePath() {
|
||||
return path.join(app.getPath('userData'), 'cloud-account.json');
|
||||
}
|
||||
|
||||
function getSettingsPath() {
|
||||
return path.join(app.getPath('userData'), 'desktop-settings.json');
|
||||
}
|
||||
|
||||
function getDesktopNotificationsSettingsPath() {
|
||||
return path.join(app.getPath('userData'), 'desktop-notifications-settings.json');
|
||||
}
|
||||
|
||||
function getRunningEnvironmentUrls() {
|
||||
return cloud.getEnvironments()
|
||||
.filter((environment) => environment.status === 'running')
|
||||
.map((environment) => cloud.getEnvironmentUrl(environment))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getDisplayTargetName() {
|
||||
return activeTarget?.name || APP_NAME;
|
||||
}
|
||||
|
||||
function getCloudState() {
|
||||
return {
|
||||
account: cloud.getAccount(),
|
||||
environments: cloud.getEnvironments(),
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
function getLocalState() {
|
||||
return {
|
||||
desktopSettings: localServer.getSettings(),
|
||||
localServerRunning: Boolean(localServer.getLocalServerUrl()),
|
||||
localWebUrl: localServer.getLocalServerUrl(),
|
||||
shareableWebUrl: localServer.getShareableWebUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeEnvironment(environment) {
|
||||
return {
|
||||
id: environment.id,
|
||||
name: environment.name,
|
||||
subdomain: environment.subdomain,
|
||||
access_url: cloud.getEnvironmentUrl(environment),
|
||||
status: environment.status,
|
||||
created_at: environment.created_at,
|
||||
github_url: environment.github_url || null,
|
||||
region: environment.region || null,
|
||||
agent: environment.agent || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getDesktopState() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const localState = getLocalState();
|
||||
const authState = cloud.getAuthState();
|
||||
return {
|
||||
account: {
|
||||
connected: authState === 'connected',
|
||||
email: cloudAccount?.email || null,
|
||||
authState,
|
||||
requiresReconnect: authState === 'expired',
|
||||
},
|
||||
activeTarget,
|
||||
desktopSettings: localState.desktopSettings,
|
||||
localWebUrl: localState.localWebUrl,
|
||||
shareableWebUrl: localState.shareableWebUrl,
|
||||
localServerRunning: localState.localServerRunning,
|
||||
localStartupLogs: localServer.getStartupLogs(),
|
||||
cloudLoading: isRefreshingCloud,
|
||||
tabs: tabs.getSerializableTabs(),
|
||||
activeTabId: tabs.activeTabId,
|
||||
environments: cloud.getEnvironments().map(serializeEnvironment),
|
||||
desktopNotifications: desktopNotifications?.getState() || { enabled: false, supported: false, connectedCount: 0, targetCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async function openExternalUrl(url) {
|
||||
if (String(url).startsWith(CALLBACK_PROTOCOL + "://")) {
|
||||
await handleDeepLink(url);
|
||||
return;
|
||||
}
|
||||
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
|
||||
async function showError(title, error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`${title}: ${message}`);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'error',
|
||||
title,
|
||||
message: title,
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
|
||||
function isExpectedNavigationAbort(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return error?.code === 'ERR_ABORTED' || message.includes('ERR_ABORTED') || message.includes('(-3)');
|
||||
}
|
||||
|
||||
function syncDesktopState() {
|
||||
if (!desktopWindow) return;
|
||||
desktopWindow.buildAppMenu();
|
||||
desktopWindow.emitDesktopState();
|
||||
if (activeTarget?.kind === 'local' && !localServer?.getLocalServerUrl()) {
|
||||
void desktopWindow.showLocalStartupTarget(localServer.getPendingTarget(), localServer.getStartupLogs())
|
||||
.catch((error) => {
|
||||
if (isExpectedNavigationAbort(error)) return;
|
||||
void showError('Could not update local startup log', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveTarget(target) {
|
||||
activeTarget = target;
|
||||
}
|
||||
|
||||
function getEnvironmentTarget(environment) {
|
||||
return {
|
||||
kind: 'remote',
|
||||
id: environment.id,
|
||||
name: environment.name || environment.subdomain,
|
||||
url: cloud.getEnvironmentUrl(environment),
|
||||
};
|
||||
}
|
||||
|
||||
async function getEnvironmentLaunchTarget(environment) {
|
||||
const environmentUrl = cloud.getEnvironmentUrl(environment);
|
||||
return {
|
||||
...getEnvironmentTarget(environment),
|
||||
url: environmentUrl,
|
||||
loadUrl: await cloud.getEnvironmentLaunchUrl(environment),
|
||||
};
|
||||
}
|
||||
|
||||
async function hasCloudWebSession() {
|
||||
const cookies = await session.defaultSession.cookies.get({});
|
||||
return cookies.some((cookie) => {
|
||||
const cookieDomain = String(cookie.domain || '');
|
||||
return cookieDomain.includes('cloudcli.ai')
|
||||
&& /-auth-token(?:\.\d+)?$/.test(cookie.name)
|
||||
&& Boolean(cookie.value);
|
||||
});
|
||||
}
|
||||
|
||||
function isCloudAuthRedirect(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const controlPlane = new URL(CLOUDCLI_CONTROL_PLANE_URL);
|
||||
return parsed.origin === controlPlane.origin
|
||||
&& (parsed.pathname === '/login' || parsed.pathname.startsWith('/auth/'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getDiagnosticsText() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const localState = getLocalState();
|
||||
return JSON.stringify({
|
||||
app: APP_NAME,
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
appPath: getAppRoot(),
|
||||
userDataPath: app.getPath('userData'),
|
||||
activeTarget,
|
||||
localServerUrl: localState.localWebUrl,
|
||||
localServerPort: localServer.localServerPort,
|
||||
localWebUrl: localState.localWebUrl,
|
||||
shareableWebUrl: localState.shareableWebUrl,
|
||||
desktopSettings: localState.desktopSettings,
|
||||
cloudConnected: Boolean(cloudAccount?.apiKey),
|
||||
cloudEmail: cloudAccount?.email || null,
|
||||
cloudEnvironmentCount: cloud.getEnvironments().length,
|
||||
cloudRunningEnvironmentCount: getRunningEnvironmentUrls().length,
|
||||
cloudAuthState: cloud.getAuthState(),
|
||||
cloudAccountPath: getStorePath(),
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
async function copyDiagnostics() {
|
||||
clipboard.writeText(getDiagnosticsText());
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Diagnostics copied',
|
||||
message: 'CloudCLI desktop diagnostics were copied to the clipboard.',
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
||||
isRefreshingCloud = true;
|
||||
syncDesktopState();
|
||||
try {
|
||||
return await cloud.refreshCloudEnvironments();
|
||||
} catch (error) {
|
||||
const authState = cloud.getAuthState();
|
||||
if (authState === 'expired') {
|
||||
const expiredError = new Error('Your CloudCLI session expired. Reconnect your account.');
|
||||
if (showErrors) {
|
||||
await showError('CloudCLI login required', expiredError);
|
||||
return [];
|
||||
}
|
||||
throw expiredError;
|
||||
}
|
||||
if (showErrors) {
|
||||
await showError('Could not load CloudCLI environments', error);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
isRefreshingCloud = false;
|
||||
void desktopNotifications?.sync().catch((error) => console.error('[DesktopNotifications] sync failed:', error?.message || error));
|
||||
syncDesktopState();
|
||||
}
|
||||
}
|
||||
|
||||
async function connectCloudAccount() {
|
||||
const connectUrl = cloud.buildConnectUrl();
|
||||
pendingCloudConnectStartedAt = Date.now();
|
||||
clipboard.writeText(connectUrl);
|
||||
await openExternalUrl(connectUrl);
|
||||
return connectUrl;
|
||||
}
|
||||
|
||||
async function handleDeepLink(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== `${CALLBACK_PROTOCOL}:` || parsed.hostname !== 'auth') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingCloudConnectStartedAt || Date.now() - pendingCloudConnectStartedAt > AUTH_CALLBACK_TTL_MS) {
|
||||
await showError('CloudCLI account connection failed', new Error('No recent CloudCLI account connection was started from this app.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = parsed.searchParams.get('api_key');
|
||||
if (!apiKey) {
|
||||
await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.'));
|
||||
return;
|
||||
}
|
||||
|
||||
await cloud.saveFromCallback({
|
||||
apiKey,
|
||||
email: parsed.searchParams.get('email'),
|
||||
});
|
||||
pendingCloudConnectStartedAt = 0;
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
|
||||
dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'CloudCLI account connected',
|
||||
message: cloud.getAccount()?.email ? `Connected as ${cloud.getAccount().email}.` : 'CloudCLI account connected.',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function copyLocalWebUrl() {
|
||||
await localServer.ensureLocalServer();
|
||||
const shareableUrl = localServer.getShareableWebUrl();
|
||||
const localUrl = localServer.getLocalServerUrl();
|
||||
|
||||
if (!shareableUrl) {
|
||||
throw new Error('Local CloudCLI URL is not available yet.');
|
||||
}
|
||||
|
||||
clipboard.writeText(shareableUrl);
|
||||
const isLanUrl = shareableUrl !== localUrl;
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Web URL copied',
|
||||
message: isLanUrl ? 'LAN web URL copied.' : 'Local web URL copied.',
|
||||
detail: isLanUrl
|
||||
? `${shareableUrl}\n\nUse this URL from another device on the same network.`
|
||||
: `${shareableUrl}\n\nThis URL works on this computer. Enable LAN access before starting Local CloudCLI to copy a phone-accessible URL.`,
|
||||
});
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openLocalWebUi() {
|
||||
await localServer.ensureLocalServer();
|
||||
const url = localServer.getShareableWebUrl() || localServer.getLocalServerUrl();
|
||||
if (!url) {
|
||||
throw new Error('Local CloudCLI URL is not available yet.');
|
||||
}
|
||||
|
||||
await openExternalUrl(url);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function updateDesktopSetting(key, value) {
|
||||
const result = await localServer.updateDesktopSetting(key, value);
|
||||
syncDesktopState();
|
||||
|
||||
if (result.requiresRestartNotice) {
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Restart local server to apply',
|
||||
message: 'LAN access changes apply the next time the local server starts.',
|
||||
detail: 'Quit CloudCLI and stop the local server, then open Local CloudCLI again.',
|
||||
});
|
||||
}
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function showEnvironmentPicker() {
|
||||
let environments = cloud.getEnvironments();
|
||||
let refreshError = null;
|
||||
|
||||
if (cloud.getAccount()?.apiKey) {
|
||||
try {
|
||||
environments = await refreshCloudEnvironments({ showErrors: false });
|
||||
} catch (error) {
|
||||
refreshError = error;
|
||||
console.warn('[Cloud] Could not refresh environments before showing picker:', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
const choices = ['Local CloudCLI', ...environments.map((environment) => {
|
||||
const status = environment.status === 'running' ? '' : ` (${environment.status})`;
|
||||
return `${environment.name || environment.subdomain}${status}`;
|
||||
})];
|
||||
|
||||
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
|
||||
type: 'question',
|
||||
buttons: [...choices, 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: choices.length,
|
||||
title: 'Switch CloudCLI Environment',
|
||||
message: 'Choose where this desktop window should connect.',
|
||||
detail: refreshError ? `Cloud environments could not be refreshed. Showing cached environments.\n\n${refreshError.message || refreshError}` : undefined,
|
||||
});
|
||||
|
||||
if (response.response === choices.length) return getDesktopState();
|
||||
if (response.response === 0) return openLocalInDesktop();
|
||||
return openEnvironmentInDesktop(environments[response.response - 1]);
|
||||
}
|
||||
|
||||
async function startEnvironment(environment) {
|
||||
await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function stopEnvironment(environment) {
|
||||
await cloud.stopEnvironment(environment);
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInBrowser(environment) {
|
||||
await openExternalUrl(await cloud.getEnvironmentLaunchUrl(environment));
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getProjectFolder(environment) {
|
||||
return String(environment.name || environment.subdomain || 'workspace').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
}
|
||||
|
||||
function getSshTarget(credentials) {
|
||||
if (credentials.ssh_command) {
|
||||
const parts = String(credentials.ssh_command).split(/\s+/);
|
||||
if (parts.length >= 2) return parts[1];
|
||||
}
|
||||
return `${credentials.username}@ssh.cloudcli.ai`;
|
||||
}
|
||||
|
||||
function getSshHost(credentials) {
|
||||
const target = getSshTarget(credentials);
|
||||
const atIndex = target.indexOf('@');
|
||||
return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai';
|
||||
}
|
||||
|
||||
function getSafeSshUsername(credentials) {
|
||||
const username = String(credentials.username || '');
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
||||
throw new Error('Cloud environment returned an invalid SSH username.');
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
function getSafeSshHost(credentials) {
|
||||
const host = getSshHost(credentials);
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
|
||||
throw new Error('Cloud environment returned an invalid SSH host.');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function getEnvironmentCredentials(environment) {
|
||||
const credentials = await cloud.getEnvironmentCredentials(environment);
|
||||
if (credentials.password) {
|
||||
clipboard.writeText(credentials.password);
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function openEnvironmentInIde(environment, ide) {
|
||||
const credentials = await getEnvironmentCredentials(environment);
|
||||
const scheme = ide === 'cursor' ? 'cursor' : 'vscode';
|
||||
const remoteUri = `${scheme}://vscode-remote/ssh-remote+${getSafeSshUsername(credentials)}@${getSafeSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`;
|
||||
await shell.openExternal(remoteUri);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInSsh(environment) {
|
||||
const credentials = await getEnvironmentCredentials(environment);
|
||||
const remoteCommand = `cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l`;
|
||||
const sshCommand = `ssh -t ${shellQuote(getSshTarget(credentials))} ${shellQuote(remoteCommand)}`;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
spawn('osascript', ['-e', `tell application "Terminal" to do script "${escaped}"`], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
}).unref();
|
||||
} else {
|
||||
clipboard.writeText(sshCommand);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'SSH command copied',
|
||||
message: 'The SSH command was copied to the clipboard.',
|
||||
detail: sshCommand,
|
||||
});
|
||||
}
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function copyEnvironmentMobileUrl(environment) {
|
||||
const url = cloud.getEnvironmentUrl(environment);
|
||||
clipboard.writeText(url);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Environment URL copied',
|
||||
message: 'Use this URL from your mobile browser.',
|
||||
detail: url,
|
||||
});
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openCloudDashboard() {
|
||||
await openExternalUrl(CLOUDCLI_CONTROL_PLANE_URL);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getActiveRemoteEnvironment() {
|
||||
if (activeTarget?.kind !== 'remote') return null;
|
||||
return cloud.findEnvironment(activeTarget.id);
|
||||
}
|
||||
|
||||
async function runActiveEnvironmentAction(action) {
|
||||
const environment = getActiveRemoteEnvironment();
|
||||
if (!environment) {
|
||||
throw new Error('Open a cloud environment first.');
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'web':
|
||||
return openEnvironmentInBrowser(environment);
|
||||
case 'vscode':
|
||||
return openEnvironmentInIde(environment, 'vscode');
|
||||
case 'cursor':
|
||||
return openEnvironmentInIde(environment, 'cursor');
|
||||
case 'ssh':
|
||||
return openEnvironmentInSsh(environment);
|
||||
case 'mobile':
|
||||
return copyEnvironmentMobileUrl(environment);
|
||||
default:
|
||||
throw new Error(`Unknown environment action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLocalInDesktop() {
|
||||
const existingTab = tabs.getTab('local');
|
||||
if (existingTab && localServer.getLocalServerUrl()) {
|
||||
await desktopWindow.showTarget(await localServer.getResolvedTarget());
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
const pendingTarget = localServer.getPendingTarget();
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
setActiveTarget(pendingTarget);
|
||||
await desktopWindow.showLocalStartupTarget(pendingTarget, localServer.getStartupLogs());
|
||||
desktopWindow.emitDesktopState();
|
||||
|
||||
const target = await localServer.getResolvedTarget();
|
||||
await desktopWindow.showTarget(target);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInDesktop(environment) {
|
||||
const pendingTarget = getEnvironmentTarget(environment);
|
||||
const tabId = tabs.getTabIdForTarget(pendingTarget);
|
||||
const hadTab = Boolean(tabs.getTab(tabId));
|
||||
const previousTabId = tabs.activeTabId;
|
||||
|
||||
if (!hadTab) {
|
||||
await desktopWindow.showTabPlaceholder(
|
||||
pendingTarget,
|
||||
`${environment.status === 'running' ? 'Opening' : 'Starting'} ${pendingTarget.name}...`,
|
||||
);
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
desktopWindow.emitDesktopState();
|
||||
}
|
||||
|
||||
let nextEnvironment = environment;
|
||||
|
||||
if (environment.status !== 'running') {
|
||||
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
|
||||
type: 'question',
|
||||
buttons: ['Start Environment', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Start environment?',
|
||||
message: `${pendingTarget.name} is ${environment.status}.`,
|
||||
detail: 'CloudCLI can start it before opening the remote app.',
|
||||
});
|
||||
|
||||
if (response.response !== 0) {
|
||||
if (!hadTab) {
|
||||
tabs.remove(tabId);
|
||||
desktopWindow.destroyTabView(tabId);
|
||||
if (previousTabId && previousTabId !== tabId) {
|
||||
await desktopWindow.switchDesktopTab(previousTabId);
|
||||
} else {
|
||||
await desktopWindow.showLauncher();
|
||||
}
|
||||
}
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
if (hadTab) {
|
||||
await desktopWindow.showTabPlaceholder(pendingTarget, `Starting ${pendingTarget.name}...`);
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
desktopWindow.emitDesktopState();
|
||||
}
|
||||
|
||||
nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
let target = getEnvironmentTarget(nextEnvironment);
|
||||
if (!(await hasCloudWebSession())) {
|
||||
target = await getEnvironmentLaunchTarget(nextEnvironment);
|
||||
}
|
||||
|
||||
const usedBootstrap = Boolean(target.loadUrl);
|
||||
const finalUrl = await desktopWindow.showTarget(target);
|
||||
if (!usedBootstrap && isCloudAuthRedirect(finalUrl)) {
|
||||
const bootstrapTarget = await getEnvironmentLaunchTarget(nextEnvironment);
|
||||
bootstrapTarget.forceLoad = true;
|
||||
await desktopWindow.showTarget(bootstrapTarget);
|
||||
}
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function findEnvironmentByUrl(environmentUrl) {
|
||||
const targetOrigin = (() => {
|
||||
try {
|
||||
return new URL(environmentUrl).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!targetOrigin) return null;
|
||||
|
||||
return cloud.getEnvironments().find((environment) => {
|
||||
try {
|
||||
return new URL(cloud.getEnvironmentUrl(environment)).origin === targetOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function openNotificationTarget({ environmentUrl, sessionId = null }) {
|
||||
const window = desktopWindow?.getMainWindow();
|
||||
if (window) {
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
const environment = findEnvironmentByUrl(environmentUrl);
|
||||
if (environment) {
|
||||
await openEnvironmentInDesktop(environment);
|
||||
} else {
|
||||
const parsed = new URL(environmentUrl);
|
||||
await desktopWindow.showTarget({
|
||||
kind: 'remote',
|
||||
name: parsed.hostname,
|
||||
url: parsed.origin,
|
||||
});
|
||||
}
|
||||
|
||||
const targetUrl = new URL(sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/', environmentUrl).toString();
|
||||
await desktopWindow.navigateActiveView(targetUrl);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function getEnvironmentAuthToken(environmentUrl) {
|
||||
return (await desktopWindow?.readAuthTokenForTarget(environmentUrl)) || null;
|
||||
}
|
||||
|
||||
async function clearCloudAccount() {
|
||||
await cloud.clearCloudAccount();
|
||||
desktopNotifications?.stop();
|
||||
const removedTabs = tabs.removeByKind('remote');
|
||||
for (const tab of removedTabs) {
|
||||
desktopWindow?.destroyTabView(tab.id);
|
||||
}
|
||||
if (activeTarget?.kind === 'remote') {
|
||||
await desktopWindow?.showLauncher();
|
||||
} else {
|
||||
syncDesktopState();
|
||||
}
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getRemoteEnvironmentMenuItems() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const environments = cloud.getEnvironments();
|
||||
|
||||
if (!cloudAccount?.apiKey) {
|
||||
return [{ label: 'Connect CloudCLI Account...', click: () => void connectCloudAccount() }];
|
||||
}
|
||||
|
||||
if (!environments.length) {
|
||||
return [{ label: 'No environments found', enabled: false }];
|
||||
}
|
||||
|
||||
return environments.map((environment) => ({
|
||||
label: `${environment.name || environment.subdomain}${environment.status === 'running' ? '' : ` (${environment.status})`}`,
|
||||
click: () => void openEnvironmentInDesktop(environment)
|
||||
.catch((error) => showError('Could not open environment', error)),
|
||||
}));
|
||||
}
|
||||
|
||||
function registerProtocolHandler() {
|
||||
const appEntry = path.join(getAppRoot(), 'electron', 'main.js');
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL, process.execPath, [appEntry]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL);
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpcHandlers() {
|
||||
ipcMain.handle('cloudcli-desktop:connect-cloud', async () => ({
|
||||
...getDesktopState(),
|
||||
connectUrl: await connectCloudAccount(),
|
||||
}));
|
||||
|
||||
ipcMain.handle('cloudcli-desktop:copy-diagnostics', async () => {
|
||||
await copyDiagnostics();
|
||||
return getDesktopState();
|
||||
});
|
||||
|
||||
ipcMain.handle('cloudcli-desktop:copy-local-web-url', async () => copyLocalWebUrl());
|
||||
ipcMain.handle('cloudcli-desktop:get-state', () => getDesktopState());
|
||||
ipcMain.handle('cloudcli-desktop:open-cloud-dashboard', async () => openCloudDashboard());
|
||||
ipcMain.handle('cloudcli-desktop:run-active-environment-action', async (_event, action) => runActiveEnvironmentAction(action));
|
||||
ipcMain.handle('cloudcli-desktop:open-environment', async (_event, environmentId) => {
|
||||
const environment = cloud.findEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error('Environment not found. Refresh and try again.');
|
||||
}
|
||||
return openEnvironmentInDesktop(environment);
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:open-local', async () => openLocalInDesktop());
|
||||
ipcMain.handle('cloudcli-desktop:open-local-web-ui', async () => openLocalWebUi());
|
||||
ipcMain.handle('cloudcli-desktop:refresh-environments', async () => {
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:disconnect-cloud', async () => clearCloudAccount());
|
||||
ipcMain.handle('cloudcli-desktop:reload-active-tab', async () => desktopWindow.reloadActiveTab());
|
||||
ipcMain.handle('cloudcli-desktop:show-environment-picker', async () => showEnvironmentPicker());
|
||||
ipcMain.handle('cloudcli-desktop:show-launcher', async () => {
|
||||
await desktopWindow.showLauncher();
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:update-desktop-notifications', async (_event, settings) => {
|
||||
await desktopNotifications?.saveSettings(settings);
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
|
||||
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
|
||||
ipcMain.handle('cloudcli-desktop:close-settings-window', async () => {
|
||||
desktopWindow.closeSettingsWindow();
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:show-active-environment-actions-menu', async () => desktopWindow.showActiveEnvironmentActionsMenu());
|
||||
ipcMain.handle('cloudcli-desktop:show-environment-actions-menu', async (_event, environmentId) => desktopWindow.showEnvironmentActionsMenu(environmentId));
|
||||
ipcMain.handle('cloudcli-desktop:switch-tab', async (_event, tabId) => desktopWindow.switchDesktopTab(tabId));
|
||||
ipcMain.handle('cloudcli-desktop:close-tab', async (_event, tabId) => desktopWindow.closeDesktopTab(tabId));
|
||||
ipcMain.handle('cloudcli-desktop:update-setting', async (_event, key, value) => updateDesktopSetting(key, value));
|
||||
}
|
||||
|
||||
function registerAppEvents() {
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
void handleDeepLink(url);
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
if (desktopWindow) {
|
||||
void desktopWindow.createWindow();
|
||||
} else {
|
||||
void createDesktopWindow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const window = desktopWindow?.getMainWindow();
|
||||
if (window) {
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
desktopNotifications?.stop();
|
||||
});
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (isQuitting || !localServer?.hasOwnedServer()) return;
|
||||
if (localServer.getSettings().keepLocalServerRunning) {
|
||||
localServer.detachOwnedServer();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
isQuitting = true;
|
||||
void localServer.shutdownOwnedServer().finally(() => app.quit());
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createDesktopWindow() {
|
||||
desktopWindow = new DesktopWindowManager({
|
||||
appName: APP_NAME,
|
||||
getWindowIconPath,
|
||||
getLauncherPath,
|
||||
getPreloadPath,
|
||||
openExternalUrl,
|
||||
getDesktopState,
|
||||
getDisplayTargetName,
|
||||
getRemoteEnvironmentMenuItems,
|
||||
getCloudState,
|
||||
getLocalState,
|
||||
tabs,
|
||||
actions: {
|
||||
copyDiagnostics,
|
||||
copyText: (text) => clipboard.writeText(text),
|
||||
clearCloudAccount,
|
||||
connectCloudAccount,
|
||||
getActiveTarget: () => activeTarget,
|
||||
getEnvironmentUrl: (environment) => cloud.getEnvironmentUrl(environment),
|
||||
openEnvironmentInBrowser,
|
||||
openEnvironmentInDesktop,
|
||||
openEnvironmentInIde,
|
||||
openEnvironmentInSsh,
|
||||
openLocalInDesktop,
|
||||
openLocalWebUi,
|
||||
openCloudDashboard,
|
||||
refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
|
||||
setActiveTarget,
|
||||
showEnvironmentPicker,
|
||||
showError,
|
||||
startEnvironment,
|
||||
stopEnvironment,
|
||||
updateDesktopSetting,
|
||||
copyLocalWebUrl,
|
||||
openNotificationTarget,
|
||||
},
|
||||
});
|
||||
|
||||
desktopWindow.createTray();
|
||||
desktopWindow.configurePermissions();
|
||||
await desktopWindow.createWindow();
|
||||
}
|
||||
|
||||
function registerSingleInstance() {
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
return false;
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
const deepLink = argv.find((arg) => arg.startsWith(`${CALLBACK_PROTOCOL}://`));
|
||||
if (deepLink) {
|
||||
void handleDeepLink(deepLink);
|
||||
}
|
||||
|
||||
const window = desktopWindow?.getMainWindow();
|
||||
if (window) {
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
app.name = APP_NAME;
|
||||
app.setName(APP_NAME);
|
||||
process.title = APP_NAME;
|
||||
|
||||
await app.whenReady();
|
||||
app.setName(APP_NAME);
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: APP_NAME,
|
||||
applicationVersion: app.getVersion(),
|
||||
copyright: 'CloudCLI',
|
||||
});
|
||||
|
||||
localServer = new LocalServerController({
|
||||
appRoot: getAppRoot(),
|
||||
settingsPath: getSettingsPath(),
|
||||
isPackaged: app.isPackaged,
|
||||
appVersion: app.getVersion(),
|
||||
onChange: syncDesktopState,
|
||||
});
|
||||
cloud = new CloudController({
|
||||
storePath: getStorePath(),
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
callbackUrl: CALLBACK_URL,
|
||||
onChange: syncDesktopState,
|
||||
});
|
||||
desktopNotifications = new DesktopNotificationsController({
|
||||
settingsPath: getDesktopNotificationsSettingsPath(),
|
||||
appVersion: app.getVersion(),
|
||||
appName: APP_NAME,
|
||||
getDeviceId: () => cloud.getAccount()?.deviceId || '',
|
||||
getAccountEmail: () => cloud.getAccount()?.email || null,
|
||||
getRunningEnvironmentUrls,
|
||||
getApiKey: () => cloud.getAccount()?.apiKey || '',
|
||||
getAuthToken: getEnvironmentAuthToken,
|
||||
getIconPath: getWindowIconPath,
|
||||
openNotificationTarget,
|
||||
onChange: syncDesktopState,
|
||||
});
|
||||
|
||||
await localServer.loadDesktopSettings();
|
||||
await cloud.loadCloudAccount();
|
||||
await desktopNotifications.loadSettings();
|
||||
|
||||
registerProtocolHandler();
|
||||
registerIpcHandlers();
|
||||
registerAppEvents();
|
||||
await createDesktopWindow();
|
||||
void refreshCloudEnvironments({ showErrors: false });
|
||||
}
|
||||
|
||||
if (registerSingleInstance()) {
|
||||
bootstrap().catch(async (error) => {
|
||||
await showError('CloudCLI failed to start', error);
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
60
electron/preload.cjs
Normal file
60
electron/preload.cjs
Normal file
@@ -0,0 +1,60 @@
|
||||
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'),
|
||||
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
|
||||
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
|
||||
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
|
||||
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
|
||||
showEnvironmentActionsMenu: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:show-environment-actions-menu', environmentId),
|
||||
switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
|
||||
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
|
||||
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
|
||||
onStateUpdated: 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;
|
||||
}
|
||||
}
|
||||
1488
package-lock.json
generated
1488
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"productName": "CloudCLI",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
@@ -34,8 +34,12 @@
|
||||
"client": "vite",
|
||||
"desktop": "electron electron/main.js",
|
||||
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
||||
"desktop:pack": "npm run build && electron-builder --dir",
|
||||
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
|
||||
"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:client && npm run build:server",
|
||||
"build:client": "vite build",
|
||||
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
||||
@@ -54,9 +58,10 @@
|
||||
"build": {
|
||||
"appId": "ai.cloudcli.desktop",
|
||||
"productName": "CloudCLI",
|
||||
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
|
||||
"asar": false,
|
||||
"artifactName": "cloudcli-desktop-${version}-${os}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
"output": "release/desktop"
|
||||
},
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.js"
|
||||
@@ -68,7 +73,8 @@
|
||||
"dist-server/",
|
||||
"shared/",
|
||||
"server/",
|
||||
"package.json"
|
||||
"package.json",
|
||||
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
|
||||
],
|
||||
"protocols": [
|
||||
{
|
||||
@@ -80,9 +86,10 @@
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "electron/assets/logo-macos.icns",
|
||||
"notarize": true,
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
"dmg"
|
||||
],
|
||||
"extendInfo": {
|
||||
"CFBundleName": "CloudCLI",
|
||||
@@ -96,6 +103,16 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"icon": "electron/assets/logo-windows.ico",
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"installerIcon": "electron/assets/logo-windows.ico",
|
||||
"uninstallerIcon": "electron/assets/logo-windows.ico"
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
@@ -130,7 +147,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",
|
||||
@@ -223,5 +240,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"
|
||||
}
|
||||
}
|
||||
|
||||
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)}`);
|
||||
152
scripts/release/prepare-desktop-app.js
Normal file
152
scripts/release/prepare-desktop-app.js
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/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');
|
||||
|
||||
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(', ')}`);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ 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';
|
||||
@@ -202,6 +203,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);
|
||||
|
||||
@@ -1682,6 +1685,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() {
|
||||
@@ -1708,6 +1745,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)));
|
||||
@@ -1741,6 +1781,11 @@ async function startServer() {
|
||||
} 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 shutdownRuntimeServices());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -430,6 +430,17 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
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',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ProviderSkill,
|
||||
ProviderSkillCreateInput,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillRemoveInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
export const providerSkillsService = {
|
||||
@@ -27,4 +28,12 @@ export const providerSkillsService = {
|
||||
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,10 +1,11 @@
|
||||
import path from 'node:path';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { IProviderSkills } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
ProviderSkillCreateInput,
|
||||
ProviderSkillRemoveInput,
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillSource,
|
||||
@@ -236,6 +237,48 @@ export abstract class SkillsProvider implements IProviderSkills {
|
||||
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> {
|
||||
|
||||
@@ -662,6 +662,19 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
|
||||
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: [
|
||||
@@ -701,4 +714,11 @@ test('providerSkillsService rejects managed skill creation for opencode', { conc
|
||||
}),
|
||||
/does not support managed global skills/i,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
providerSkillsService.removeProviderSkill('opencode', {
|
||||
directoryName: 'opencode-global-dir',
|
||||
}),
|
||||
/does not support managed global skills/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { handleChatConnection } from '@/modules/websocket/services/chat-websocke
|
||||
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
||||
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
||||
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
||||
import { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js';
|
||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||
|
||||
type WebSocketServerDependencies = {
|
||||
@@ -63,6 +64,11 @@ export function createWebSocketServer(
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/desktop-notifications') {
|
||||
handleDesktopNotificationsConnection(ws, incomingRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/plugin-ws/')) {
|
||||
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ProviderMcpServer,
|
||||
ProviderSessionActiveModelChange,
|
||||
ProviderSkillCreateInput,
|
||||
ProviderSkillRemoveInput,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
@@ -111,6 +112,10 @@ export interface IProviderSkills {
|
||||
* records that were written.
|
||||
*/
|
||||
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
|
||||
|
||||
removeSkill(
|
||||
input: ProviderSkillRemoveInput,
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
|
||||
@@ -361,6 +361,10 @@ export type ProviderSkillCreateInput = {
|
||||
entries: ProviderSkillCreateEntry[];
|
||||
};
|
||||
|
||||
export type ProviderSkillRemoveInput = {
|
||||
directoryName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalized skill record returned by provider skill adapters.
|
||||
*
|
||||
|
||||
@@ -204,6 +204,8 @@ export function useChatComposerState({
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||
const textareaLineHeightRef = useRef<number | null>(null);
|
||||
const lastAutosizedInputRef = useRef<string | null>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
|
||||
>(null);
|
||||
@@ -457,6 +459,22 @@ export function useChatComposerState({
|
||||
inputHighlightRef.current.scrollLeft = target.scrollLeft;
|
||||
}, []);
|
||||
|
||||
const resizeTextarea = useCallback((target: HTMLTextAreaElement) => {
|
||||
target.style.height = 'auto';
|
||||
const nextHeight = Math.max(22, target.scrollHeight);
|
||||
target.style.height = `${nextHeight}px`;
|
||||
|
||||
let lineHeight = textareaLineHeightRef.current;
|
||||
if (!lineHeight) {
|
||||
lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
|
||||
textareaLineHeightRef.current = Number.isFinite(lineHeight) ? lineHeight : 24;
|
||||
}
|
||||
|
||||
const expanded = nextHeight > (textareaLineHeightRef.current || 24) * 2;
|
||||
setIsTextareaExpanded((previous) => previous === expanded ? previous : expanded);
|
||||
lastAutosizedInputRef.current = target.value;
|
||||
}, []);
|
||||
|
||||
const handleImageFiles = useCallback((files: File[]) => {
|
||||
const validFiles = files.filter((file) => {
|
||||
try {
|
||||
@@ -817,13 +835,13 @@ export function useChatComposerState({
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
|
||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
||||
setIsTextareaExpanded(expanded);
|
||||
}, [input]);
|
||||
if (lastAutosizedInputRef.current === input) {
|
||||
return;
|
||||
}
|
||||
// Re-run for restored drafts and programmatic input changes. User typing is
|
||||
// already resized in onInput, so this avoids doing the same forced layout twice.
|
||||
resizeTextarea(textareaRef.current);
|
||||
}, [input, resizeTextarea]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current || input.trim()) {
|
||||
@@ -905,15 +923,11 @@ export function useChatComposerState({
|
||||
const handleTextareaInput = useCallback(
|
||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
|
||||
resizeTextarea(target);
|
||||
setCursorPosition(target.selectionStart);
|
||||
syncInputOverlayScroll(target);
|
||||
|
||||
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
|
||||
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
|
||||
},
|
||||
[setCursorPosition, syncInputOverlayScroll],
|
||||
[resizeTextarea, setCursorPosition, syncInputOverlayScroll],
|
||||
);
|
||||
|
||||
const handleClearInput = useCallback(() => {
|
||||
|
||||
@@ -207,6 +207,15 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
break;
|
||||
}
|
||||
|
||||
// A result with a toolId but no matching tool_use in the loaded set is
|
||||
// almost always a tool_use/tool_result pair split across a pagination
|
||||
// boundary (older page not loaded yet). Rendering its raw content here
|
||||
// produces an unstyled dump that "fixes itself" once the older page
|
||||
// loads; skip it and let it attach to its tool_use when that arrives.
|
||||
if (msg.toolId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const content = formatToolResultContent(msg.content || '');
|
||||
if (!content.trim()) {
|
||||
break;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Project } from '../../../types/app';
|
||||
import type { SubagentChildTool } from '../types/types';
|
||||
|
||||
import { getToolConfig } from './configs/toolConfigs';
|
||||
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import { OneLineDisplay, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import { PlanDisplay } from './components/PlanDisplay';
|
||||
import { ToolStatusBadge } from './components/ToolStatusBadge';
|
||||
import type { ToolStatus } from './components/ToolStatusBadge';
|
||||
@@ -125,6 +125,39 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
|
||||
if (!displayConfig) return null;
|
||||
|
||||
// Bash renders as a Codex-style command row: the command on a single line with
|
||||
// a chevron that expands to show the output inline. The combined view lives on
|
||||
// the input render; the separate result section is suppressed in MessageComponent.
|
||||
if (toolName === 'Bash' && mode === 'input') {
|
||||
const command = typeof parsedData === 'object' && parsedData !== null && 'command' in parsedData
|
||||
? String(parsedData.command || '')
|
||||
: typeof toolInput === 'string'
|
||||
? toolInput
|
||||
: typeof rawToolInput === 'string'
|
||||
? rawToolInput
|
||||
: '';
|
||||
const description = typeof parsedData === 'object' && parsedData !== null && 'description' in parsedData
|
||||
? String(parsedData.description || '')
|
||||
: undefined;
|
||||
const output = typeof toolResult?.content === 'string'
|
||||
? toolResult.content
|
||||
: toolResult?.content != null
|
||||
? String(toolResult.content)
|
||||
: '';
|
||||
return (
|
||||
<BashCommandDisplay
|
||||
command={command}
|
||||
description={description}
|
||||
output={output}
|
||||
isError={Boolean(toolResult?.isError)}
|
||||
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
||||
// Commands stay collapsed by default (even consecutive ones); only
|
||||
// failures auto-expand so they remain visible.
|
||||
defaultOpen={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayConfig.type === 'one-line') {
|
||||
const value = displayConfig.getValue?.(parsedData) || '';
|
||||
const secondary = displayConfig.getSecondary?.(parsedData);
|
||||
|
||||
156
src/components/chat/tools/components/BashCommandDisplay.tsx
Normal file
156
src/components/chat/tools/components/BashCommandDisplay.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight, Copy, Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
import { ToolStatusBadge } from './ToolStatusBadge';
|
||||
import type { ToolStatus } from './ToolStatusBadge';
|
||||
|
||||
interface BashCommandDisplayProps {
|
||||
command: string;
|
||||
description?: string;
|
||||
/** Combined stdout/stderr from the tool result (empty while running). */
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
status?: ToolStatus;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex-in-VSCode style command row: a compact, single-line command with a
|
||||
* chevron on the left. When the command produced output, the row becomes a
|
||||
* dropdown that expands to reveal the output inline. Theme-integrated surfaces
|
||||
* keep it clean in both light and dark mode; consecutive commands stack tightly
|
||||
* into a clean list.
|
||||
*/
|
||||
export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
|
||||
command,
|
||||
description,
|
||||
output,
|
||||
isError = false,
|
||||
status,
|
||||
defaultOpen = false,
|
||||
}) => {
|
||||
const trimmedOutput = (output || '').replace(/\s+$/, '');
|
||||
const hasOutput = trimmedOutput.length > 0;
|
||||
const outputLineCount = hasOutput ? trimmedOutput.split('\n').length : 0;
|
||||
const isRunning = status === 'running';
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Output (and errors) often arrive after this component first mounts, so apply
|
||||
// the auto-open intent once when there is finally something to show. After that
|
||||
// the user is in control of the toggle.
|
||||
const autoAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoAppliedRef.current && hasOutput && (defaultOpen || isError)) {
|
||||
autoAppliedRef.current = true;
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasOutput, defaultOpen, isError]);
|
||||
|
||||
const toggle = () => {
|
||||
if (hasOutput) {
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const didCopy = await copyTextToClipboard(command);
|
||||
if (!didCopy) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/cmd overflow-hidden rounded-lg border bg-muted/40 backdrop-blur-sm transition-all duration-200',
|
||||
isError ? 'border-red-500/30' : 'border-border/60',
|
||||
hasOutput && !open && 'hover:border-border hover:bg-muted/60',
|
||||
open && 'bg-muted/50 shadow-sm',
|
||||
)}
|
||||
>
|
||||
{/* Command header — clickable when there is output to expand */}
|
||||
<div
|
||||
role={hasOutput ? 'button' : undefined}
|
||||
tabIndex={hasOutput ? 0 : undefined}
|
||||
aria-expanded={hasOutput ? open : undefined}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (hasOutput && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2.5 py-1.5 outline-none',
|
||||
hasOutput && 'cursor-pointer focus-visible:ring-1 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
|
||||
open && 'rotate-90',
|
||||
!hasOutput && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-shrink-0 select-none font-mono text-xs font-semibold text-emerald-500 dark:text-emerald-400">
|
||||
$
|
||||
</span>
|
||||
<code
|
||||
className={cn(
|
||||
'min-w-0 flex-1 font-mono text-xs text-foreground',
|
||||
open ? 'whitespace-pre-wrap break-all' : 'truncate',
|
||||
)}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
|
||||
{isRunning && (
|
||||
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
|
||||
)}
|
||||
{status && status !== 'running' && <ToolStatusBadge status={status} className="flex-shrink-0" />}
|
||||
{!open && hasOutput && !isRunning && (
|
||||
<span className="flex-shrink-0 text-[10px] tabular-nums text-muted-foreground/70 transition-opacity group-hover/cmd:opacity-0">
|
||||
{outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
|
||||
title="Copy command"
|
||||
aria-label="Copy command"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{description && !open && (
|
||||
<div className="truncate px-2.5 pb-1.5 pl-[2.4rem] text-[11px] italic text-muted-foreground/70">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded output */}
|
||||
{open && hasOutput && (
|
||||
<div className="settings-content-enter border-t border-border/50 bg-background/50">
|
||||
{description && (
|
||||
<div className="px-3 pt-2 text-[11px] italic text-muted-foreground/70">{description}</div>
|
||||
)}
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono text-xs leading-relaxed',
|
||||
isError ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{trimmedOutput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { QuestionAnswerContent } from './QuestionAnswerContent';
|
||||
|
||||
// Regression coverage for the chat-interface crash where an AskUserQuestion
|
||||
// payload loaded from a session transcript arrives with a non-array `questions`
|
||||
// or a question missing its `options` array. Rendering must degrade gracefully
|
||||
// instead of throwing "TypeError: e.map is not a function".
|
||||
|
||||
test('renders without throwing when questions is a non-array value', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
// Malformed: object instead of an array
|
||||
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
|
||||
answers: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders without throwing when a question is missing options[]', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [{ question: 'Pick one?', header: 'H' } as never],
|
||||
answers: { 'Pick one?': 'X' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders without throwing when options[] contains malformed entries', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
|
||||
answers: { 'Pick one?': 'A, Custom' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders without throwing when a questions entry is null/non-object', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
|
||||
answers: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders without throwing when an answer is a non-string value', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
|
||||
// Malformed: answer is an object instead of the expected string
|
||||
answers: { 'Pick one?': { unexpected: true } } as never,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('still renders a well-formed question + answer', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
|
||||
answers: { 'Pick one?': 'A' },
|
||||
}),
|
||||
);
|
||||
assert.ok(html.includes('Pick one?'));
|
||||
});
|
||||
@@ -15,7 +15,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
}) => {
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
// Tool inputs are runtime data loaded from session transcripts and may be
|
||||
// malformed (e.g. `questions` arriving as a non-array). Guard with
|
||||
// Array.isArray so a single bad payload can't crash the whole chat view
|
||||
// with "e.map is not a function".
|
||||
if (!Array.isArray(questions) || questions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -24,11 +28,23 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{questions.map((q, idx) => {
|
||||
{questions.map((rawQuestion, idx) => {
|
||||
// Entries come from session transcripts and may be malformed; skip
|
||||
// anything that isn't a proper question object with a string prompt.
|
||||
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const q = rawQuestion;
|
||||
const answer = answers?.[q.question];
|
||||
const answerLabels = answer ? answer.split(', ') : [];
|
||||
// `answer` may be a non-string (or absent) in malformed payloads.
|
||||
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
|
||||
const skipped = !answer;
|
||||
const isExpanded = expandedIdx === idx;
|
||||
// `options` is typed as an array but comes from untrusted runtime data;
|
||||
// keep only valid entries so `.some`/`.map` below never throw.
|
||||
const options = Array.isArray(q.options)
|
||||
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -74,7 +90,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
{!isExpanded && answerLabels.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{answerLabels.map((lbl) => {
|
||||
const isCustom = !q.options.some(o => o.label === lbl);
|
||||
const isCustom = !options.some(o => o.label === lbl);
|
||||
return (
|
||||
<span
|
||||
key={lbl}
|
||||
@@ -110,7 +126,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
||||
<div className="ml-6.5 space-y-1">
|
||||
{q.options.map((opt) => {
|
||||
{options.map((opt) => {
|
||||
const wasSelected = answerLabels.includes(opt.label);
|
||||
return (
|
||||
<div
|
||||
@@ -148,7 +164,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
||||
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
|
||||
<div
|
||||
key={lbl}
|
||||
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { CollapsibleSection } from './CollapsibleSection';
|
||||
export { ToolDiffViewer } from './ToolDiffViewer';
|
||||
export { OneLineDisplay } from './OneLineDisplay';
|
||||
export { BashCommandDisplay } from './BashCommandDisplay';
|
||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||
export { SubagentContainer } from './SubagentContainer';
|
||||
export * from './ContentRenderers';
|
||||
|
||||
62
src/components/chat/utils/toolGrouping.ts
Normal file
62
src/components/chat/utils/toolGrouping.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ChatMessage } from '../types/types';
|
||||
|
||||
export const TOOL_GROUP_THRESHOLD = 3;
|
||||
|
||||
export interface ToolGroupItem {
|
||||
_isGroup: true;
|
||||
toolName: string;
|
||||
messages: ChatMessage[];
|
||||
timestamp: ChatMessage['timestamp'];
|
||||
}
|
||||
|
||||
export type MessageListItem = ChatMessage | ToolGroupItem;
|
||||
|
||||
export function isToolGroupItem(item: MessageListItem): item is ToolGroupItem {
|
||||
return '_isGroup' in item && (item as ToolGroupItem)._isGroup === true;
|
||||
}
|
||||
|
||||
function isGroupableToolMessage(message: ChatMessage): message is ChatMessage & { toolName: string } {
|
||||
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
|
||||
}
|
||||
|
||||
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
|
||||
const items: MessageListItem[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < messages.length) {
|
||||
const message = messages[index];
|
||||
|
||||
if (!isGroupableToolMessage(message)) {
|
||||
items.push(message);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const run: ChatMessage[] = [message];
|
||||
let nextIndex = index + 1;
|
||||
|
||||
while (
|
||||
nextIndex < messages.length &&
|
||||
isGroupableToolMessage(messages[nextIndex]) &&
|
||||
messages[nextIndex].toolName === message.toolName
|
||||
) {
|
||||
run.push(messages[nextIndex]);
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
if (run.length >= TOOL_GROUP_THRESHOLD) {
|
||||
items.push({
|
||||
_isGroup: true,
|
||||
toolName: message.toolName,
|
||||
messages: run,
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
} else {
|
||||
items.push(...run);
|
||||
}
|
||||
|
||||
index = nextIndex;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -311,7 +311,7 @@ function ChatInterface({
|
||||
|
||||
return (
|
||||
<PermissionContext.Provider value={permissionContextValue}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<ChatMessagesPane
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onWheel={handleScroll}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
@@ -160,6 +161,17 @@ export default function ChatComposer({
|
||||
sendByCtrlEnter,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const commandMenuPosition = useMemo(() => {
|
||||
if (!isCommandMenuOpen) {
|
||||
return { top: 0, left: 16, bottom: 90 };
|
||||
}
|
||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||
return {
|
||||
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
|
||||
left: textareaRect ? textareaRect.left : 16,
|
||||
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
||||
};
|
||||
}, [input, isCommandMenuOpen, textareaRef]);
|
||||
|
||||
// Voice state is hosted here (not in the mic button) so the main Send button can stop
|
||||
// recording and send the transcript in one tap, the way the mic button drops it in the box.
|
||||
@@ -182,13 +194,6 @@ export default function ChatComposer({
|
||||
const isRecording = voiceState === 'recording';
|
||||
const isTranscribing = voiceState === 'transcribing';
|
||||
|
||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||
const commandMenuPosition = {
|
||||
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
|
||||
left: textareaRect ? textareaRect.left : 16,
|
||||
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
||||
};
|
||||
|
||||
// Detect if the AskUserQuestion interactive panel is active
|
||||
const hasQuestionPanel = pendingPermissionRequests.some(
|
||||
(r) => r.toolName === 'AskUserQuestion'
|
||||
@@ -198,7 +203,7 @@ export default function ChatComposer({
|
||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||
{!hasPendingPermissions && (
|
||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
@@ -10,9 +10,11 @@ import type {
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../../types/app';
|
||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
import ToolGroupContainer from './ToolGroupContainer';
|
||||
|
||||
interface ChatMessagesPaneProps {
|
||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||
@@ -65,7 +67,7 @@ interface ChatMessagesPaneProps {
|
||||
selectedProject: Project;
|
||||
}
|
||||
|
||||
export default function ChatMessagesPane({
|
||||
function ChatMessagesPane({
|
||||
scrollContainerRef,
|
||||
onWheel,
|
||||
onTouchMove,
|
||||
@@ -118,6 +120,7 @@ export default function ChatMessagesPane({
|
||||
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
||||
const allocatedKeysRef = useRef<Set<string>>(new Set());
|
||||
const generatedMessageKeyCounterRef = useRef(0);
|
||||
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
|
||||
|
||||
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
|
||||
const getMessageKey = useCallback((message: ChatMessage) => {
|
||||
@@ -148,7 +151,7 @@ export default function ChatMessagesPane({
|
||||
ref={scrollContainerRef}
|
||||
onWheel={onWheel}
|
||||
onTouchMove={onTouchMove}
|
||||
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
||||
className="chat-messages-pane relative min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
||||
>
|
||||
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||
@@ -252,28 +255,58 @@ export default function ChatMessagesPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((message, index) => {
|
||||
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
||||
return (
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
prevMessage={prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
let prevMessage: ChatMessage | null = null;
|
||||
|
||||
return groupedVisibleMessages.map((item) => {
|
||||
if (isToolGroupItem(item)) {
|
||||
const groupPrevMessage = prevMessage;
|
||||
prevMessage = item.messages[item.messages.length - 1] || prevMessage;
|
||||
|
||||
return (
|
||||
<ToolGroupContainer
|
||||
key={`tool-group-${getMessageKey(item.messages[0])}`}
|
||||
group={item}
|
||||
prevMessage={groupPrevMessage}
|
||||
createDiff={createDiff}
|
||||
getMessageKey={getMessageKey}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const messagePrevMessage = prevMessage;
|
||||
prevMessage = item;
|
||||
|
||||
return (
|
||||
<MessageComponent
|
||||
key={getMessageKey(item)}
|
||||
message={item}
|
||||
prevMessage={messagePrevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ChatMessagesPane);
|
||||
|
||||
@@ -2,9 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
CircleHelp,
|
||||
Clipboard,
|
||||
Coins,
|
||||
Cpu,
|
||||
Gauge,
|
||||
@@ -59,19 +57,6 @@ type ModelOption = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const formatUpdatedAt = (value?: string) => {
|
||||
if (!value) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
@@ -246,7 +231,6 @@ function HelpContent({ data }: { data: HelpCommandData }) {
|
||||
function ModelsContent({
|
||||
data,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
@@ -254,14 +238,12 @@ function ModelsContent({
|
||||
}: {
|
||||
data: ModelCommandData;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||
providerModelsRefreshing: boolean;
|
||||
onHardRefreshProviderModels: () => void;
|
||||
currentSessionId: string | null;
|
||||
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
||||
const [changingModel, setChangingModel] = useState<string | null>(null);
|
||||
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
||||
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
||||
@@ -269,7 +251,6 @@ function ModelsContent({
|
||||
const currentModel = data?.current?.model || 'Unknown';
|
||||
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
||||
const liveDefinition = providerModelCatalog[currentProvider];
|
||||
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
|
||||
const availableOptions = useMemo<ModelOption[]>(() => {
|
||||
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
||||
return liveDefinition.OPTIONS;
|
||||
@@ -282,7 +263,6 @@ function ModelsContent({
|
||||
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
||||
return availableModels.map((model) => ({ value: model, label: model }));
|
||||
}, [data, liveDefinition]);
|
||||
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
@@ -296,18 +276,8 @@ function ModelsContent({
|
||||
});
|
||||
}, [availableOptions, query]);
|
||||
|
||||
const activeOption = availableOptions.find((option) => option.value === currentModel);
|
||||
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
||||
|
||||
const copyModel = (model: string) => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(model).catch(() => undefined);
|
||||
}
|
||||
setCopiedModel(model);
|
||||
window.setTimeout(() => {
|
||||
setCopiedModel((current) => (current === model ? null : current));
|
||||
}, 1300);
|
||||
};
|
||||
const showSearch = availableOptions.length > 6;
|
||||
|
||||
const handleSelectModel = async (model: string) => {
|
||||
setChangingModel(model);
|
||||
@@ -330,162 +300,106 @@ function ModelsContent({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
||||
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
{providerLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
||||
{availableOptions.length} models
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
|
||||
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
|
||||
{currentModel}
|
||||
</p>
|
||||
{activeOption?.label && activeOption.label !== currentModel && (
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
|
||||
)}
|
||||
{activeOption?.description && (
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
|
||||
)}
|
||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Next response: {pendingSessionModel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
||||
Catalog Refresh
|
||||
</p>
|
||||
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
||||
All providers
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
||||
or when a new model is missing.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onHardRefreshProviderModels}
|
||||
disabled={providerModelsRefreshing}
|
||||
className="mt-2 h-8 w-full rounded-xl px-3"
|
||||
>
|
||||
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
||||
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
||||
{hasConcreteSessionId
|
||||
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
||||
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
||||
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
{/* Compact context bar: active model + refresh, no clutter */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Active model · {providerLabel}
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
|
||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
|
||||
→ {pendingSessionModel} next
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onHardRefreshProviderModels}
|
||||
disabled={providerModelsRefreshing}
|
||||
title="Refresh model list from providers"
|
||||
aria-label="Refresh model list from providers"
|
||||
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${providerModelsRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
|
||||
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||
</div>
|
||||
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
|
||||
{filteredOptions.length} shown
|
||||
</Badge>
|
||||
</div>
|
||||
{showSearch && (
|
||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||
)}
|
||||
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isCurrent = option.value === currentModel;
|
||||
const wasCopied = copiedModel === option.value;
|
||||
const isPendingSelection = option.value === pendingSessionModel;
|
||||
const isChanging = option.value === changingModel;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
|
||||
isCurrent
|
||||
? 'border-primary/45 bg-primary/10'
|
||||
: isPendingSelection
|
||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||
}`}
|
||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectModel(option.value)}
|
||||
disabled={Boolean(changingModel)}
|
||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Use model ${option.value}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</span>
|
||||
{option.label && option.label !== option.value && (
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
||||
)}
|
||||
{option.description && (
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
|
||||
{isPendingSelection && !isCurrent && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
||||
Next response selection
|
||||
</span>
|
||||
)}
|
||||
{isChanging && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Applying...
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyModel(option.value)}
|
||||
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
|
||||
aria-label={`Copy model id ${option.value}`}
|
||||
>
|
||||
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isCurrent = option.value === currentModel;
|
||||
const isPendingSelection = option.value === pendingSessionModel;
|
||||
const isChanging = option.value === changingModel;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelectModel(option.value)}
|
||||
disabled={Boolean(changingModel)}
|
||||
aria-label={`Select model ${option.value}`}
|
||||
className={`settings-content-enter group flex min-h-[4rem] flex-col rounded-2xl border p-3 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60 ${
|
||||
isCurrent
|
||||
? 'border-primary/45 bg-primary/10'
|
||||
: isPendingSelection
|
||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||
}`}
|
||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||
{isCurrent ? (
|
||||
<BadgeCheck className="h-4 w-4 shrink-0 text-primary" />
|
||||
) : isChanging ? (
|
||||
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
{option.label && option.label !== option.value && (
|
||||
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
|
||||
)}
|
||||
{option.description && (
|
||||
<span className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>
|
||||
)}
|
||||
{isPendingSelection && !isCurrent && (
|
||||
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-500 dark:text-emerald-400">
|
||||
Applies next response
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No models match that search.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single quiet line of guidance / feedback */}
|
||||
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
|
||||
{selectionNotice ? (
|
||||
<span className="text-foreground">{selectionNotice}</span>
|
||||
) : hasConcreteSessionId ? (
|
||||
'Your choice applies to this session on the next response.'
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No models match that search.
|
||||
</div>
|
||||
'Your choice becomes the default model for new turns.'
|
||||
)}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -606,7 +520,6 @@ export default function CommandResultModal({
|
||||
payload,
|
||||
onClose,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
@@ -624,9 +537,9 @@ export default function CommandResultModal({
|
||||
icon: CircleHelp,
|
||||
},
|
||||
models: {
|
||||
eyebrow: 'Model inventory',
|
||||
title: 'Available Models',
|
||||
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
||||
eyebrow: 'Model selection',
|
||||
title: 'Choose a Model',
|
||||
subtitle: 'Pick the model this provider should use.',
|
||||
icon: Cpu,
|
||||
},
|
||||
cost: {
|
||||
@@ -700,7 +613,6 @@ export default function CommandResultModal({
|
||||
<ModelsContent
|
||||
data={payload.data as ModelCommandData}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||
providerModelsRefreshing={providerModelsRefreshing}
|
||||
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
||||
currentSessionId={currentSessionId}
|
||||
|
||||
@@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
||||
|
||||
type MarkdownProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Links to the wider web (or in-page anchors) keep normal browser navigation;
|
||||
// everything else is treated as a workspace file reference.
|
||||
const isExternalHref = (href?: string): boolean =>
|
||||
!!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#'));
|
||||
|
||||
// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`).
|
||||
const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, '');
|
||||
|
||||
// A usable file path contains a separator or a filename with an extension.
|
||||
const looksLikeFilePath = (value?: string): value is string => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const cleaned = stripLineSuffix(value.trim());
|
||||
if (!cleaned || cleaned === '#') {
|
||||
return false;
|
||||
}
|
||||
return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned);
|
||||
};
|
||||
|
||||
// Extract plain text from link children so a reference rendered only as link
|
||||
// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened.
|
||||
const childrenToText = (children: React.ReactNode): string => {
|
||||
if (typeof children === 'string' || typeof children === 'number') {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(childrenToText).join('');
|
||||
}
|
||||
if (React.isValidElement(children)) {
|
||||
return childrenToText((children.props as { children?: React.ReactNode }).children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
type CodeBlockProps = {
|
||||
node?: any;
|
||||
inline?: boolean;
|
||||
@@ -123,11 +159,6 @@ const markdownComponents = {
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="my-2 overflow-x-auto">
|
||||
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) {
|
||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||
const { openFileInEditor } = usePaletteOps();
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
...markdownComponents,
|
||||
a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => {
|
||||
// Prefer the href when it is a real path; otherwise fall back to the
|
||||
// link text, since models often emit `[src/foo.ts]()` with an empty href.
|
||||
const linkText = childrenToText(linkChildren);
|
||||
const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined;
|
||||
|
||||
if (fileRef && !isExternalHref(href)) {
|
||||
return (
|
||||
<a
|
||||
href={href || fileRef}
|
||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
openFileInEditor(stripLineSuffix(fileRef));
|
||||
}}
|
||||
>
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[openFileInEditor],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -218,8 +218,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tool Result Section */}
|
||||
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||
{/* Tool Result Section — Bash renders its output inside the command row above. */}
|
||||
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||
message.toolResult.isError ? (
|
||||
// Error results - red error box with content
|
||||
<div
|
||||
|
||||
147
src/components/chat/view/subcomponents/ToolGroupContainer.tsx
Normal file
147
src/components/chat/view/subcomponents/ToolGroupContainer.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult, Provider } from '../../types/types';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { ToolGroupItem } from '../../utils/toolGrouping';
|
||||
import { getToolConfig } from '../../tools';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
content: string;
|
||||
lineNum: number;
|
||||
};
|
||||
|
||||
interface ToolGroupContainerProps {
|
||||
group: ToolGroupItem;
|
||||
prevMessage: ChatMessage | null;
|
||||
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||
getMessageKey: (message: ChatMessage) => string;
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
showThinking?: boolean;
|
||||
selectedProject?: Project | null;
|
||||
provider: Provider | string;
|
||||
}
|
||||
|
||||
function parseToolInput(toolInput: unknown): unknown {
|
||||
if (typeof toolInput !== 'string') {
|
||||
return toolInput;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(toolInput);
|
||||
} catch {
|
||||
return toolInput;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolInputPreview(message: ChatMessage): string {
|
||||
const config = getToolConfig(message.toolName || 'UnknownTool').input;
|
||||
const parsedInput = parseToolInput(message.toolInput);
|
||||
const title = typeof config.title === 'function' ? config.title(parsedInput) : config.title;
|
||||
const value = config.getValue?.(parsedInput);
|
||||
|
||||
return String(value || title || message.displayText || message.content || '').trim();
|
||||
}
|
||||
|
||||
function getToolGroupIcon(icon: string | undefined, toolName: string): string {
|
||||
if (icon === 'terminal') {
|
||||
return '$';
|
||||
}
|
||||
|
||||
return icon || toolName.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
export default function ToolGroupContainer({
|
||||
group,
|
||||
prevMessage,
|
||||
createDiff,
|
||||
getMessageKey,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
onGrantToolPermission,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
showThinking,
|
||||
selectedProject,
|
||||
provider,
|
||||
}: ToolGroupContainerProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const config = getToolConfig(group.toolName).input;
|
||||
const label = config.label || group.toolName;
|
||||
const borderClass = config.colorScheme?.border || 'border-border';
|
||||
const iconClass = config.colorScheme?.icon || 'text-muted-foreground';
|
||||
const icon = getToolGroupIcon(config.icon, group.toolName);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const visiblePreviews = group.messages
|
||||
.slice(0, 2)
|
||||
.map(getToolInputPreview)
|
||||
.filter(Boolean);
|
||||
|
||||
const extraCount = group.messages.length - visiblePreviews.length;
|
||||
const previewText = visiblePreviews.join(', ');
|
||||
|
||||
if (!previewText) {
|
||||
return extraCount > 0 ? `+${extraCount} more` : '';
|
||||
}
|
||||
|
||||
return extraCount > 0 ? `${previewText}, +${extraCount} more` : previewText;
|
||||
}, [group.messages]);
|
||||
|
||||
return (
|
||||
<div className="chat-message tool px-3 sm:px-0" data-message-timestamp={group.timestamp || undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group flex w-full items-center gap-2 border-l-2 ${borderClass} rounded-r-md bg-muted/25 px-3 py-2 text-left transition-colors hover:bg-muted/40 dark:bg-muted/10 dark:hover:bg-muted/20`}
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={`${iconClass} flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-background/80 text-xs font-medium`}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-shrink-0 text-xs font-medium text-foreground">{label}</span>
|
||||
<span className="flex-shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
x{group.messages.length}
|
||||
</span>
|
||||
{preview && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground/40">/</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-muted-foreground">{preview}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 space-y-3 sm:space-y-4">
|
||||
{group.messages.map((message, index) => (
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
prevMessage={index > 0 ? group.messages[index - 1] : prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
||||
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
||||
@@ -77,6 +78,10 @@ function MainContent({
|
||||
isMobile,
|
||||
});
|
||||
|
||||
// Resolves bare/partial file references (e.g. links inside chat messages) to
|
||||
// real project files before opening them in the in-app editor.
|
||||
const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen);
|
||||
|
||||
useEffect(() => {
|
||||
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
||||
// same identifier to key its internal maps.
|
||||
@@ -121,6 +126,10 @@ function MainContent({
|
||||
setActiveTab('files');
|
||||
handleFileOpen(filePath);
|
||||
},
|
||||
// Opens the editor side panel in place, keeping the current tab (e.g. chat).
|
||||
openFileInEditor: (filePath: string) => {
|
||||
resolvedFileOpen(filePath);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -29,7 +29,7 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
|
||||
}
|
||||
|
||||
if (activeTab === 'browser') {
|
||||
return 'Browser';
|
||||
return t('tabs.browser');
|
||||
}
|
||||
|
||||
return 'Project';
|
||||
|
||||
@@ -109,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
||||
channels: {
|
||||
inApp: true,
|
||||
webPush: false,
|
||||
desktop: false,
|
||||
sound: true,
|
||||
},
|
||||
events: {
|
||||
@@ -127,6 +128,7 @@ const normalizeNotificationPreferences = (
|
||||
channels: {
|
||||
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
||||
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
||||
desktop: preferences?.channels?.desktop ?? defaults.channels.desktop,
|
||||
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
||||
},
|
||||
events: {
|
||||
|
||||
@@ -30,6 +30,7 @@ export type NotificationPreferencesState = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
desktop: boolean;
|
||||
sound: boolean;
|
||||
};
|
||||
events: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -18,8 +19,22 @@ import { useSettingsController } from '../hooks/useSettingsController';
|
||||
import { useWebPush } from '../../../hooks/useWebPush';
|
||||
import type { SettingsProps } from '../types/types';
|
||||
|
||||
type DesktopNotificationsState = {
|
||||
enabled: boolean;
|
||||
supported: boolean;
|
||||
connectedCount?: number;
|
||||
targetCount?: number;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const desktopNotificationsBridge = useMemo(() => (
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: ((window as any).cloudcliDesktopNotifications || null)
|
||||
), []);
|
||||
const [desktopNotificationsState, setDesktopNotificationsState] = useState<DesktopNotificationsState | null>(null);
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
@@ -75,6 +90,45 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktopNotificationsBridge) return undefined;
|
||||
let mounted = true;
|
||||
desktopNotificationsBridge.getState().then((state: any) => {
|
||||
if (mounted) {
|
||||
setDesktopNotificationsState(state?.desktopNotifications || null);
|
||||
}
|
||||
}).catch(() => {});
|
||||
const unsubscribe = desktopNotificationsBridge.onStateUpdated?.((state: any) => {
|
||||
if (mounted) {
|
||||
setDesktopNotificationsState(state?.desktopNotifications || null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [desktopNotificationsBridge]);
|
||||
|
||||
const handleEnableDesktopNotifications = async () => {
|
||||
if (!desktopNotificationsBridge) return;
|
||||
const state = await desktopNotificationsBridge.update({ enabled: true });
|
||||
setDesktopNotificationsState(state?.desktopNotifications || null);
|
||||
setNotificationPreferences({
|
||||
...notificationPreferences,
|
||||
channels: { ...notificationPreferences.channels, desktop: true },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisableDesktopNotifications = async () => {
|
||||
if (!desktopNotificationsBridge) return;
|
||||
const state = await desktopNotificationsBridge.update({ enabled: false });
|
||||
setDesktopNotificationsState(state?.desktopNotifications || null);
|
||||
setNotificationPreferences({
|
||||
...notificationPreferences,
|
||||
channels: { ...notificationPreferences.channels, desktop: false },
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -153,6 +207,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
isPushLoading={isPushLoading}
|
||||
onEnablePush={handleEnablePush}
|
||||
onDisablePush={handleDisablePush}
|
||||
isDesktop={Boolean(desktopNotificationsBridge)}
|
||||
desktopNotifications={desktopNotificationsState}
|
||||
onEnableDesktopNotifications={handleEnableDesktopNotifications}
|
||||
onDisableDesktopNotifications={handleDisableDesktopNotifications}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ type NotificationsSettingsTabProps = {
|
||||
isPushLoading: boolean;
|
||||
onEnablePush: () => void;
|
||||
onDisablePush: () => void;
|
||||
isDesktop?: boolean;
|
||||
desktopNotifications?: {
|
||||
enabled: boolean;
|
||||
supported: boolean;
|
||||
connectedCount?: number;
|
||||
targetCount?: number;
|
||||
lastError?: string | null;
|
||||
} | null;
|
||||
onEnableDesktopNotifications?: () => void;
|
||||
onDisableDesktopNotifications?: () => void;
|
||||
};
|
||||
|
||||
export default function NotificationsSettingsTab({
|
||||
@@ -23,6 +33,10 @@ export default function NotificationsSettingsTab({
|
||||
isPushLoading,
|
||||
onEnablePush,
|
||||
onDisablePush,
|
||||
isDesktop = false,
|
||||
desktopNotifications = null,
|
||||
onEnableDesktopNotifications,
|
||||
onDisableDesktopNotifications,
|
||||
}: NotificationsSettingsTabProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
@@ -33,57 +47,107 @@ export default function NotificationsSettingsTab({
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-5 h-5 text-blue-600" />
|
||||
<Bell className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
||||
{!pushSupported ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
||||
) : pushDenied ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPushLoading}
|
||||
onClick={() => {
|
||||
if (isPushSubscribed) {
|
||||
onDisablePush();
|
||||
} else {
|
||||
onEnablePush();
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPushSubscribed
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{isPushLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isPushSubscribed ? (
|
||||
<BellOff className="w-4 h-4" />
|
||||
) : (
|
||||
<BellRing className="w-4 h-4" />
|
||||
{isDesktop ? (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||
<h4 className="font-medium text-foreground">
|
||||
{t('notifications.desktop.title', { defaultValue: 'Notify this desktop app' })}
|
||||
</h4>
|
||||
{desktopNotifications?.supported === false ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('notifications.desktop.unsupported', { defaultValue: 'Desktop notifications are not supported on this system.' })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (desktopNotifications?.enabled) {
|
||||
onDisableDesktopNotifications?.();
|
||||
} else {
|
||||
onEnableDesktopNotifications?.();
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||
desktopNotifications?.enabled
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{desktopNotifications?.enabled ? (
|
||||
<BellOff className="h-4 w-4" />
|
||||
) : (
|
||||
<BellRing className="h-4 w-4" />
|
||||
)}
|
||||
{desktopNotifications?.enabled
|
||||
? t('notifications.desktop.disable', { defaultValue: 'Disable desktop notifications' })
|
||||
: t('notifications.desktop.enable', { defaultValue: 'Enable desktop notifications' })}
|
||||
</button>
|
||||
{desktopNotifications?.enabled && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('notifications.desktop.enabled', { defaultValue: 'Desktop notifications are enabled' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{desktopNotifications?.lastError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{desktopNotifications.lastError}</p>
|
||||
)}
|
||||
{isPushLoading
|
||||
? t('notifications.webPush.loading')
|
||||
: isPushSubscribed
|
||||
? t('notifications.webPush.disable')
|
||||
: t('notifications.webPush.enable')}
|
||||
</button>
|
||||
{isPushSubscribed && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('notifications.webPush.enabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
||||
{!pushSupported ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
||||
) : pushDenied ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPushLoading}
|
||||
onClick={() => {
|
||||
if (isPushSubscribed) {
|
||||
onDisablePush();
|
||||
} else {
|
||||
onEnablePush();
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isPushSubscribed
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{isPushLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isPushSubscribed ? (
|
||||
<BellOff className="h-4 w-4" />
|
||||
) : (
|
||||
<BellRing className="h-4 w-4" />
|
||||
)}
|
||||
{isPushLoading
|
||||
? t('notifications.webPush.loading')
|
||||
: isPushSubscribed
|
||||
? t('notifications.webPush.disable')
|
||||
: t('notifications.webPush.enable')}
|
||||
</button>
|
||||
{isPushSubscribed && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('notifications.webPush.enabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -133,7 +197,7 @@ export default function NotificationsSettingsTab({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
@@ -149,7 +213,7 @@ export default function NotificationsSettingsTab({
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{t('notifications.events.actionRequired')}
|
||||
</label>
|
||||
@@ -167,7 +231,7 @@ export default function NotificationsSettingsTab({
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{t('notifications.events.stop')}
|
||||
</label>
|
||||
@@ -185,7 +249,7 @@ export default function NotificationsSettingsTab({
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{t('notifications.events.error')}
|
||||
</label>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function GitHubStarBadge() {
|
||||
>
|
||||
<GitHubIcon className="h-3.5 w-3.5" />
|
||||
<Star className="h-3 w-3" />
|
||||
<span className="font-medium">Star</span>
|
||||
<span className="font-normal">Star</span>
|
||||
{formattedCount && (
|
||||
<span className="border-l border-border/50 pl-1.5 tabular-nums">{formattedCount}</span>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
||||
parts.push(snippet.slice(cursor));
|
||||
}
|
||||
return (
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
<span className="min-w-0 flex-1 break-words text-xs leading-relaxed text-muted-foreground">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
@@ -266,7 +266,7 @@ export default function SidebarContent({
|
||||
<div key={projectResult.projectName} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{projectResult.projectDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -286,7 +286,7 @@ export default function SidebarContent({
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{session.sessionSummary}
|
||||
</span>
|
||||
{session.provider && session.provider !== 'claude' && (
|
||||
@@ -298,7 +298,7 @@ export default function SidebarContent({
|
||||
<div className="space-y-1 pl-4">
|
||||
{session.matches.map((match, idx) => (
|
||||
<div key={idx} className="flex items-start gap-1">
|
||||
<span className="mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60">
|
||||
<span className="mt-0.5 flex-shrink-0 text-[10px] font-normal uppercase text-muted-foreground/60">
|
||||
{match.role === 'user' ? 'U' : 'A'}
|
||||
</span>
|
||||
<HighlightedSnippet
|
||||
@@ -336,11 +336,11 @@ export default function SidebarContent({
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{t('running.title', 'Running now')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-normal text-emerald-700 dark:text-emerald-300">
|
||||
{runningSessionsCount}
|
||||
</span>
|
||||
</div>
|
||||
@@ -395,7 +395,7 @@ export default function SidebarContent({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
<span className="truncate text-sm font-normal text-foreground">
|
||||
{project.displayName}
|
||||
</span>
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||
@@ -448,7 +448,7 @@ export default function SidebarContent({
|
||||
<SessionProviderLogo provider={session.__provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{(typeof session.summary === 'string' && session.summary.trim().length > 0
|
||||
? session.summary
|
||||
: typeof session.name === 'string' && session.name.trim().length > 0
|
||||
@@ -484,7 +484,7 @@ export default function SidebarContent({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
<span className="truncate text-sm font-normal text-foreground">
|
||||
{group.projectDisplayName}
|
||||
</span>
|
||||
{group.isProjectArchived && (
|
||||
@@ -513,7 +513,7 @@ export default function SidebarContent({
|
||||
<SessionProviderLogo provider={session.provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
<span className="truncate text-xs font-normal text-foreground">
|
||||
{session.sessionTitle}
|
||||
</span>
|
||||
{session.lastActivity && (
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function SidebarFooter({
|
||||
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||
<span className="block truncate text-sm font-normal text-blue-600 dark:text-blue-300">
|
||||
{releaseInfo?.title || `v${latestVersion}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-blue-500/70 dark:text-blue-400/60">
|
||||
@@ -91,7 +91,7 @@ export default function SidebarFooter({
|
||||
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||
<span className="block truncate text-sm font-normal text-blue-600 dark:text-blue-300">
|
||||
{releaseInfo?.title || `v${latestVersion}`}
|
||||
</span>
|
||||
<span className="text-xs text-blue-500/70 dark:text-blue-400/60">
|
||||
@@ -168,7 +168,7 @@ export default function SidebarFooter({
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{t('actions.reportIssue')}</span>
|
||||
<span className="text-sm font-normal text-foreground">{t('actions.reportIssue')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function SidebarFooter({
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||
<DiscordIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{t('actions.joinCommunity')}</span>
|
||||
<span className="text-sm font-normal text-foreground">{t('actions.joinCommunity')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function SidebarFooter({
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{t('actions.settings')}</span>
|
||||
<span className="text-sm font-normal text-foreground">{t('actions.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function SidebarHeader({
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">{t('app.title')}</h1>
|
||||
<h1 className="truncate text-sm font-normal tracking-tight text-foreground">{t('app.title')}</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function SidebarHeader({
|
||||
onClick={() => onSearchModeChange('projects')}
|
||||
aria-pressed={searchMode === 'projects'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'projects'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -151,7 +151,7 @@ export default function SidebarHeader({
|
||||
onClick={() => onSearchModeChange('conversations')}
|
||||
aria-pressed={searchMode === 'conversations'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'conversations'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -167,7 +167,7 @@ export default function SidebarHeader({
|
||||
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||
title={t('search.runningTooltip', 'Running sessions')}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'running'
|
||||
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -190,7 +190,7 @@ export default function SidebarHeader({
|
||||
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'archived'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -278,7 +278,7 @@ export default function SidebarHeader({
|
||||
onClick={() => onSearchModeChange('projects')}
|
||||
aria-pressed={searchMode === 'projects'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'projects'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -291,7 +291,7 @@ export default function SidebarHeader({
|
||||
onClick={() => onSearchModeChange('conversations')}
|
||||
aria-pressed={searchMode === 'conversations'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'conversations'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -307,7 +307,7 @@ export default function SidebarHeader({
|
||||
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||
title={t('search.runningTooltip', 'Running sessions')}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'running'
|
||||
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -331,7 +331,7 @@ export default function SidebarHeader({
|
||||
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
title={t('search.archiveOnlyTooltip', 'Archive only')}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
||||
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
|
||||
searchMode === 'archived'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function SidebarProjectItem({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between">
|
||||
<h3 className="truncate text-sm font-medium text-foreground">{project.displayName}</h3>
|
||||
<h3 className="truncate text-sm font-normal text-foreground">{project.displayName}</h3>
|
||||
{tasksEnabled && (
|
||||
<TaskIndicator
|
||||
status={taskStatus}
|
||||
@@ -318,7 +318,7 @@ export default function SidebarProjectItem({
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="truncate text-sm font-semibold text-foreground" title={project.displayName}>
|
||||
<div className="truncate text-sm font-normal text-foreground" title={project.displayName}>
|
||||
{project.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
||||
import { Badge, Tooltip, buttonVariants } from '../../../../shared/view/ui';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionWithProvider } from '../../types/types';
|
||||
@@ -157,7 +157,7 @@ export default function SidebarSessionItem({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
|
||||
{isProcessing ? (
|
||||
<span className="ml-auto flex-shrink-0">
|
||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||
@@ -195,9 +195,10 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
variant="ghost"
|
||||
<a
|
||||
href={`/session/${session.id}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
|
||||
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
||||
!isSelected && isProcessing
|
||||
@@ -206,7 +207,13 @@ export default function SidebarSessionItem({
|
||||
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
|
||||
: 'hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => onSessionSelect(session, project.projectId)}
|
||||
// Left-click keeps in-app navigation; Ctrl/Cmd/middle-click and the
|
||||
// native right-click menu use the href to open a new tab/window.
|
||||
onClick={(event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
onSessionSelect(session, project.projectId);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<div
|
||||
@@ -219,7 +226,7 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
|
||||
{isProcessing ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -249,7 +256,7 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<div
|
||||
ref={editingContainerRef}
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function TaskIndicator({
|
||||
title={indicatorConfig.title}
|
||||
>
|
||||
<Icon className={sizeClassNames[size]} />
|
||||
<span className="font-medium">{indicatorConfig.label}</span>
|
||||
<span className="font-normal">{indicatorConfig.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { MutableRefObject, ReactNode } from 'react';
|
||||
|
||||
export type PaletteOps = {
|
||||
openFile: (path: string) => void;
|
||||
// Opens a file in the editor side panel without changing the active tab
|
||||
// (used by in-chat file links so they behave like the inline edit view).
|
||||
openFileInEditor: (path: string) => void;
|
||||
openSettings: (tab?: string) => void;
|
||||
refreshProjects: () => Promise<void> | void;
|
||||
};
|
||||
@@ -13,6 +16,7 @@ const PaletteOpsContext = createContext<Registry | null>(null);
|
||||
|
||||
const defaultOps: PaletteOps = {
|
||||
openFile: () => undefined,
|
||||
openFileInEditor: () => undefined,
|
||||
openSettings: () => undefined,
|
||||
refreshProjects: () => undefined,
|
||||
};
|
||||
@@ -27,6 +31,8 @@ export function usePaletteOps(): PaletteOps {
|
||||
return useMemo<PaletteOps>(
|
||||
() => ({
|
||||
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
|
||||
openFileInEditor: (path) =>
|
||||
(ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path),
|
||||
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
|
||||
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
|
||||
}),
|
||||
@@ -36,18 +42,20 @@ export function usePaletteOps(): PaletteOps {
|
||||
|
||||
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
|
||||
const ref = useContext(PaletteOpsContext);
|
||||
const { openFile, openSettings, refreshProjects } = partial;
|
||||
const { openFile, openFileInEditor, openSettings, refreshProjects } = partial;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref) return undefined;
|
||||
const prev = { ...ref.current };
|
||||
if (openFile) ref.current.openFile = openFile;
|
||||
if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor;
|
||||
if (openSettings) ref.current.openSettings = openSettings;
|
||||
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
|
||||
return () => {
|
||||
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
|
||||
if (openFileInEditor && ref.current.openFileInEditor === openFileInEditor) ref.current.openFileInEditor = prev.openFileInEditor;
|
||||
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
|
||||
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
|
||||
};
|
||||
}, [ref, openFile, openSettings, refreshProjects]);
|
||||
}, [ref, openFile, openFileInEditor, openSettings, refreshProjects]);
|
||||
}
|
||||
|
||||
@@ -91,4 +91,4 @@ export const ThemeProvider = ({ children }) => {
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
108
src/hooks/useFileOpenResolver.ts
Normal file
108
src/hooks/useFileOpenResolver.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import type { Project } from '../types/app';
|
||||
|
||||
type FileNode = {
|
||||
type: 'file' | 'directory';
|
||||
name: string;
|
||||
path: string;
|
||||
children?: FileNode[];
|
||||
};
|
||||
|
||||
type FlatFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
// `diffInfo` is intentionally `any` so this resolver can wrap editor handlers
|
||||
// that expect a concrete diff payload type as well as generic callers.
|
||||
type OnFileOpen = (filePath: string, diffInfo?: any) => void;
|
||||
|
||||
const normalize = (value: string): string => value.replace(/\\/g, '/');
|
||||
|
||||
const flatten = (nodes: FileNode[], out: FlatFile[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'file') {
|
||||
out.push({ name: node.name, path: node.path });
|
||||
} else if (node.children && node.children.length > 0) {
|
||||
flatten(node.children, out);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// References inside chat messages are often bare basenames (`foo.ts`) or partial
|
||||
// paths (`utils/foo.ts`) rather than full paths, so match by path suffix and
|
||||
// fall back to filename equality.
|
||||
const findBestMatch = (files: FlatFile[], ref: string): string | null => {
|
||||
const target = normalize(ref).replace(/^\.\//, '').replace(/^\/+/, '');
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffixMatch = files.find((file) => {
|
||||
const filePath = normalize(file.path);
|
||||
return filePath === target || filePath.endsWith(`/${target}`);
|
||||
});
|
||||
if (suffixMatch) {
|
||||
return suffixMatch.path;
|
||||
}
|
||||
|
||||
const base = target.split('/').pop() || target;
|
||||
return files.find((file) => file.name === base)?.path ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps an `onFileOpen` handler so a possibly bare/partial file reference is
|
||||
* resolved against the project's file tree (cached per project) before the file
|
||||
* is opened in the in-app editor.
|
||||
*/
|
||||
export function useFileOpenResolver(
|
||||
selectedProject: Project | null | undefined,
|
||||
onFileOpen: OnFileOpen,
|
||||
): OnFileOpen {
|
||||
const projectId = selectedProject?.projectId;
|
||||
const cacheRef = useRef<{ projectId?: string; files: Promise<FlatFile[]> | null }>({
|
||||
projectId: undefined,
|
||||
files: null,
|
||||
});
|
||||
|
||||
const loadFiles = useCallback((): Promise<FlatFile[]> => {
|
||||
if (!projectId) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (cacheRef.current.projectId === projectId && cacheRef.current.files) {
|
||||
return cacheRef.current.files;
|
||||
}
|
||||
|
||||
const filesPromise = (async () => {
|
||||
try {
|
||||
const response = await api.getFiles(projectId);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const data = await response.json();
|
||||
const tree: FileNode[] = Array.isArray(data) ? data : [];
|
||||
const flat: FlatFile[] = [];
|
||||
flatten(tree, flat);
|
||||
return flat;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
cacheRef.current = { projectId, files: filesPromise };
|
||||
return filesPromise;
|
||||
}, [projectId]);
|
||||
|
||||
return useCallback(
|
||||
(filePath: string, diffInfo?: any) => {
|
||||
const ref = normalize(filePath).trim();
|
||||
void loadFiles().then((files) => {
|
||||
const match = findBestMatch(files, ref);
|
||||
onFileOpen(match ?? filePath, diffInfo);
|
||||
});
|
||||
},
|
||||
[loadFiles, onFileOpen],
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
type WebPushState = {
|
||||
@@ -22,7 +23,12 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
|
||||
export function useWebPush(): WebPushState {
|
||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
|
||||
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
|
||||
if (
|
||||
typeof window === 'undefined'
|
||||
|| Boolean((window as any).cloudcliDesktopNotifications)
|
||||
|| !('Notification' in window)
|
||||
|| !('serviceWorker' in navigator)
|
||||
) {
|
||||
return 'unsupported';
|
||||
}
|
||||
return Notification.permission;
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "Dateien",
|
||||
"git": "Quellcodeverwaltung",
|
||||
"tasks": "Aufgaben",
|
||||
"browser": "Browser"
|
||||
"browser": "Browser",
|
||||
"computer": "Computer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Lädt...",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "Files",
|
||||
"git": "Source Control",
|
||||
"tasks": "Tasks",
|
||||
"browser": "Browser"
|
||||
"browser": "Browser",
|
||||
"computer": "Computer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -121,14 +121,21 @@
|
||||
"title": "Notifications",
|
||||
"description": "Control which notification events you receive.",
|
||||
"webPush": {
|
||||
"title": "Web Push Notifications",
|
||||
"enable": "Enable Push Notifications",
|
||||
"disable": "Disable Push Notifications",
|
||||
"enabled": "Push notifications are enabled",
|
||||
"title": "Notify this browser",
|
||||
"enable": "Enable notifications",
|
||||
"disable": "Disable notifications",
|
||||
"enabled": "Notifications are enabled for this browser",
|
||||
"loading": "Updating...",
|
||||
"unsupported": "Push notifications are not supported in this browser.",
|
||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||
},
|
||||
"desktop": {
|
||||
"title": "Notify this desktop app",
|
||||
"enable": "Enable notifications",
|
||||
"disable": "Disable notifications",
|
||||
"enabled": "Notifications are enabled for this desktop app",
|
||||
"unsupported": "Desktop notifications are not supported on this system."
|
||||
},
|
||||
"sound": {
|
||||
"title": "Sound",
|
||||
"description": "Play a short tone when a chat run finishes or needs tool approval.",
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
"shell": "Terminal",
|
||||
"files": "Fichiers",
|
||||
"git": "Contrôle de source",
|
||||
"tasks": "Tâches"
|
||||
"tasks": "Tâches",
|
||||
"browser": "Navigateur",
|
||||
"computer": "Ordinateur"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "File",
|
||||
"git": "Controllo Versione",
|
||||
"tasks": "Attività",
|
||||
"browser": "Browser"
|
||||
"browser": "Browser",
|
||||
"computer": "Computer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Caricamento...",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "ファイル",
|
||||
"git": "ソース管理",
|
||||
"tasks": "タスク",
|
||||
"browser": "Browser"
|
||||
"browser": "Browser",
|
||||
"computer": "Computer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "파일",
|
||||
"git": "소스 관리",
|
||||
"tasks": "작업",
|
||||
"browser": "Browser"
|
||||
"browser": "Browser",
|
||||
"computer": "Computer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "Файлы",
|
||||
"git": "Система контроля версий",
|
||||
"tasks": "Задачи",
|
||||
"browser": "Browser"
|
||||
"browser": "Browser",
|
||||
"computer": "Компьютер"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "Dosyalar",
|
||||
"git": "Kaynak Kontrolü",
|
||||
"tasks": "Görevler",
|
||||
"browser": "Browser"
|
||||
"browser": "Browser",
|
||||
"computer": "Bilgisayar"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Yükleniyor...",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API ve Token'lar",
|
||||
"tasks": "Görevler",
|
||||
"computer": "Bilgisayar Kullanımı",
|
||||
"notifications": "Bildirimler",
|
||||
"plugins": "Eklentiler",
|
||||
"about": "Hakkında"
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "文件",
|
||||
"git": "源代码管理",
|
||||
"tasks": "任务",
|
||||
"browser": "Browser"
|
||||
"browser": "浏览器",
|
||||
"computer": "计算机"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API 和令牌",
|
||||
"tasks": "任务",
|
||||
"computer": "计算机使用",
|
||||
"notifications": "通知",
|
||||
"plugins": "插件",
|
||||
"about": "关于"
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"files": "檔案",
|
||||
"git": "版本控制",
|
||||
"tasks": "任務",
|
||||
"browser": "Browser"
|
||||
"browser": "瀏覽器",
|
||||
"computer": "電腦"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API 和權杖",
|
||||
"tasks": "任務",
|
||||
"computer": "電腦使用",
|
||||
"notifications": "通知",
|
||||
"plugins": "外掛",
|
||||
"about": "關於"
|
||||
|
||||
@@ -129,6 +129,8 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -555,6 +557,30 @@
|
||||
|
||||
/* Mobile optimizations and components */
|
||||
@layer components {
|
||||
.chat-messages-pane {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.chat-composer-shell {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
contain: layout style paint;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 180px;
|
||||
}
|
||||
|
||||
.chat-message.assistant {
|
||||
contain-intrinsic-size: auto 240px;
|
||||
}
|
||||
|
||||
.chat-message.user,
|
||||
.chat-message.tool,
|
||||
.chat-message.error {
|
||||
contain-intrinsic-size: auto 96px;
|
||||
}
|
||||
|
||||
/* Mobile touch optimization and safe areas */
|
||||
@media (max-width: 768px) {
|
||||
* {
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
className="w-[100px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="w-auto min-w-[120px] max-w-[160px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
|
||||
Reference in New Issue
Block a user