Compare commits
25 Commits
v0.7.2-bet
...
a60bd92858
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60bd92858 | ||
|
|
9f9f2bd2c6 | ||
|
|
97a25295fc | ||
|
|
5fd8cceabd | ||
|
|
a3487392c0 | ||
|
|
6e4ddc192e | ||
|
|
71978adb5f | ||
|
|
af5fbf9324 | ||
|
|
29953bde6d | ||
|
|
6d155e483b | ||
|
|
e621b13926 | ||
|
|
9d1d717999 | ||
|
|
d557b8e74c | ||
|
|
e31a01d200 | ||
|
|
b3a9a49680 | ||
|
|
87e606c853 | ||
|
|
79e467c32a | ||
|
|
075d168dcd | ||
|
|
ed4c963576 | ||
|
|
1ce5c49622 | ||
|
|
830e085c2a | ||
|
|
f8d706cdca | ||
|
|
24b09f5700 | ||
|
|
a9eedab0b5 | ||
|
|
1442b4fd8a |
@@ -32,13 +32,21 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Cache Node modules
|
- name: Cache Node modules (web)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: web/node_modules
|
path: web/node_modules
|
||||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-node-
|
${{ runner.os }}-node-web-
|
||||||
|
|
||||||
|
- name: Cache Node modules (extension)
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: extension/node_modules
|
||||||
|
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-ext-
|
||||||
|
|
||||||
- name: Download Go dependencies
|
- name: Download Go dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
@@ -49,6 +57,14 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: |
|
||||||
|
cd extension
|
||||||
|
npm ci
|
||||||
|
npx wxt zip
|
||||||
|
npx wxt zip --browser firefox
|
||||||
|
mv .output/muyue-extension-*.zip ../dist/
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
@@ -68,17 +84,25 @@ jobs:
|
|||||||
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
||||||
echo "Building beta release: ${VERSION}"
|
echo "Building beta release: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Generate Windows resource (icon)
|
||||||
|
run: |
|
||||||
|
go install github.com/akavel/rsrc@latest
|
||||||
|
RSRC="$(go env GOPATH)/bin/rsrc"
|
||||||
|
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
|
||||||
|
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
|
||||||
|
|
||||||
- name: Build (all platforms)
|
- name: Build (all platforms)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
VERSION=${{ steps.version.outputs.version }}
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
|
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
|
||||||
|
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||||
|
|
||||||
- name: Package archives
|
- name: Package archives
|
||||||
run: |
|
run: |
|
||||||
@@ -144,7 +168,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
|
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
|
||||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST "${UPLOAD_URL}" \
|
curl -s -X POST "${UPLOAD_URL}" \
|
||||||
|
|||||||
@@ -32,13 +32,21 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Cache Node modules
|
- name: Cache Node modules (web)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: web/node_modules
|
path: web/node_modules
|
||||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-node-
|
${{ runner.os }}-node-web-
|
||||||
|
|
||||||
|
- name: Cache Node modules (extension)
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: extension/node_modules
|
||||||
|
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-ext-
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
@@ -49,6 +57,14 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: |
|
||||||
|
cd extension
|
||||||
|
npm ci
|
||||||
|
npx wxt zip
|
||||||
|
npx wxt zip --browser firefox
|
||||||
|
mv .output/muyue-extension-*.zip ../dist/
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
@@ -64,16 +80,28 @@ jobs:
|
|||||||
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "Building stable release: ${VERSION}"
|
echo "Building stable release: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Generate Windows resource (icon)
|
||||||
|
run: |
|
||||||
|
go install github.com/akavel/rsrc@latest
|
||||||
|
RSRC="$(go env GOPATH)/bin/rsrc"
|
||||||
|
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
|
||||||
|
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
|
||||||
|
|
||||||
- name: Build (all platforms)
|
- name: Build (all platforms)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
LDFLAGS="-s -w"
|
LDFLAGS="-s -w"
|
||||||
|
# Windows builds use -H=windowsgui so the binary registers as a GUI
|
||||||
|
# subsystem app: double-clicking from the Desktop shortcut does not
|
||||||
|
# spawn a console window (and huh's "This is a command line tool"
|
||||||
|
# banner can never appear).
|
||||||
|
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||||
|
|
||||||
- name: Package archives
|
- name: Package archives
|
||||||
run: |
|
run: |
|
||||||
@@ -138,12 +166,17 @@ jobs:
|
|||||||
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
|
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
|
||||||
echo "\`\`\`"
|
echo "\`\`\`"
|
||||||
echo ""
|
echo ""
|
||||||
echo "**Windows (x86_64)**"
|
echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande \`muyue\` dans la session courante :"
|
||||||
echo "\`\`\`powershell"
|
echo "\`\`\`powershell"
|
||||||
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\""
|
echo "Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500"
|
||||||
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\""
|
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
|
||||||
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe"
|
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
|
||||||
|
echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
|
||||||
|
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
|
||||||
|
echo "\$env:Path += \";\$dest\""
|
||||||
echo "\`\`\`"
|
echo "\`\`\`"
|
||||||
|
echo ""
|
||||||
|
echo "Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le \`.exe\` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire."
|
||||||
} > /tmp/stable_changelog.md
|
} > /tmp/stable_changelog.md
|
||||||
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
|
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -224,7 +257,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
|
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
|
||||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
||||||
|
|||||||
@@ -30,13 +30,21 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Cache Node modules
|
- name: Cache Node modules (web)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: web/node_modules
|
path: web/node_modules
|
||||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-node-
|
${{ runner.os }}-node-web-
|
||||||
|
|
||||||
|
- name: Cache Node modules (extension)
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: extension/node_modules
|
||||||
|
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-ext-
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
@@ -47,13 +55,20 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: |
|
||||||
|
cd extension
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npm run build:firefox
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test ./... -v -race -timeout 60s
|
run: go test ./... -v -race -timeout 60s
|
||||||
|
|
||||||
- name: Build
|
- name: Build binary
|
||||||
run: |
|
run: |
|
||||||
go build -o muyue ./cmd/muyue/
|
go build -o muyue ./cmd/muyue/
|
||||||
./muyue version
|
./muyue version
|
||||||
|
|||||||
6
.gitignore
vendored
@@ -24,6 +24,7 @@ Thumbs.db
|
|||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
*.syso
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# Config with secrets
|
# Config with secrets
|
||||||
@@ -31,3 +32,8 @@ vendor/
|
|||||||
|
|
||||||
# Frontend (web/.gitignore handles specifics)
|
# Frontend (web/.gitignore handles specifics)
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|
||||||
|
# Extension build artifacts
|
||||||
|
extension/node_modules/
|
||||||
|
extension/.output/
|
||||||
|
extension/.wxt/
|
||||||
|
|||||||
433
CHANGELOG.md
@@ -4,6 +4,439 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## v0.8.0
|
||||||
|
|
||||||
|
### Extension navigateur — Chrome, Edge, Firefox
|
||||||
|
|
||||||
|
Nouvelle fonctionnalité majeure : une extension navigateur multi-plateforme qui remplace l'injection manuelle du snippet JS pour les tests pilotés par l'IA.
|
||||||
|
|
||||||
|
**Ce que fait l'extension :**
|
||||||
|
|
||||||
|
- **Auto-injection** du client Muyue sur chaque page HTTP/HTTPS — plus besoin de copier-coller le snippet dans la console DevTools
|
||||||
|
- **Capture console** temps réel (log/warn/error/debug + window.onerror + unhandledrejection) transmise au serveur Muyue
|
||||||
|
- **Screenshots natifs** via `chrome.tabs.captureVisibleTab` / `browser.tabs.captureVisibleTab` — pixels parfaits, pas le hack SVG foreignObject
|
||||||
|
- **Side Panel** (Chrome/Edge) et **Sidebar** (Firefox) pour monitoring statut serveur + sessions connectées
|
||||||
|
- **Popup toolbar** : statut serveur, nombre de sessions, erreurs console, lien vers le dashboard
|
||||||
|
- **Badge dynamique** : nombre de sessions connectées (vert) ou statut serveur (rouge/orange)
|
||||||
|
- **Détection URL** via interception History API (pushState, replaceState, popstate) + MutationObserver + fallback polling — survit aux navigations SPA
|
||||||
|
- **Auto-reconnect** avec backoff exponentiel en cas de déconnexion transitoire
|
||||||
|
- **Compatible Firefox** via Manifest V2 (sidebar_action) et Chrome/Edge via Manifest V3 (sidePanel API)
|
||||||
|
|
||||||
|
**Architecture technique :**
|
||||||
|
|
||||||
|
- **WXT framework** (build multi-navigateur) avec Vite 8
|
||||||
|
- **Content script** : même protocole WS que le snippet existant — aucun changement backend nécessaire
|
||||||
|
- **Background service worker** : `chrome.alarms` pour health checks périodiques (pas de `setInterval`), `chrome.storage.local` pour la config (pas de `localStorage` en MV3)
|
||||||
|
- **Builds** : `npm run build` (Chrome MV3) + `npm run build:firefox` (Firefox MV2) + `npm run zip` (packages stores)
|
||||||
|
- **CI** : les 3 workflows (PR, beta, stable) buildent l'extension et attachent les `.zip` aux releases
|
||||||
|
|
||||||
|
**Fichiers ajoutés :**
|
||||||
|
|
||||||
|
```
|
||||||
|
extension/
|
||||||
|
├── package.json, wxt.config.js, .gitignore, README.md
|
||||||
|
├── public/icon/ # Icons copiés depuis assets/
|
||||||
|
└── src/
|
||||||
|
├── entrypoints/
|
||||||
|
│ ├── background.js # Service worker (token, badge, screenshots)
|
||||||
|
│ ├── content.js # Auto-injection WS + console capture + History API
|
||||||
|
│ ├── popup/ # HTML + JS du popup toolbar
|
||||||
|
│ └── sidepanel/ # HTML + JS du side panel / sidebar
|
||||||
|
├── lib/
|
||||||
|
│ ├── config.js # Storage async (chrome.storage + localStorage)
|
||||||
|
│ └── page-rpc.js # DOM RPC (list_clickables, click, type, eval)
|
||||||
|
└── styles/panel.css # Thème cyberpunk cohérent avec Muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Autres changements :**
|
||||||
|
|
||||||
|
- **CI** : les 3 workflows (`ci-pr.yml`, `ci-develop.yml`, `ci-main.yml`) buildent l'extension et attachent les `.zip` aux releases
|
||||||
|
- **Makefile** : cibles `ext`, `ext-chrome`, `ext-firefox`, `ext-zip` ajoutées
|
||||||
|
- **README** : section "Browser Extension" ajoutée avec instructions install + dev
|
||||||
|
- **Version** : bump 0.7.9 → 0.8.0
|
||||||
|
|
||||||
|
## v0.7.9
|
||||||
|
|
||||||
|
### Tests pilotés par l'IA — robustesse + captures d'écran
|
||||||
|
|
||||||
|
Quatre problèmes signalés par l'utilisateur :
|
||||||
|
|
||||||
|
1. **Connexion perdue à chaque reload / navigation**. Le token était à usage unique (5 min TTL) et tombait dès la première reconnexion → l'IA perdait totalement la session.
|
||||||
|
2. **Page nouvellement ouverte invisible à l'IA**. Conséquence du même bug ci-dessus + JS context détruit à la navigation.
|
||||||
|
3. **Pas de captures d'écran**. L'IA ne pouvait pas prouver visuellement l'état d'une page.
|
||||||
|
4. **L'IA se perd en boucle d'outils** : ~150 appels pour l'équivalent de 5 actions humaines, parce qu'elle re-listait les éléments cliquables après chaque clic.
|
||||||
|
|
||||||
|
**Fixes** :
|
||||||
|
|
||||||
|
- **Token réutilisable avec TTL coulissant** (`ConsumeToken` ne supprime plus le token, refresh la TTL à 60 min sur chaque utilisation). L'utilisateur peut re-coller le même snippet de l'onglet Tests sans avoir à régénérer un token, et la session reprend transparente.
|
||||||
|
- **Auto-reconnect dans le snippet** avec backoff exponentiel (500ms × tentative, max 5 tentatives = ~2,5s). Couvre les déconnexions transitoires (réseau, hibernation, redémarrage du serveur Muyue). Pour une vraie navigation full-page (URL change, JS context détruit), aucun JS ne peut survivre — l'utilisateur doit recoller le snippet, mais c'est immédiat car le token reste valide.
|
||||||
|
- **Nouvelle action `screenshot`** : le snippet capture la viewport (ou un sélecteur via `selector`) en SVG `foreignObject` + canvas, retourne un data URL base64. Le serveur décode et sauve dans `~/.muyue/screenshots/<filename>.png` (nom personnalisable via `filename`, sinon timestamp). Best-effort — CSS externes / images cross-origin / iframes peuvent ne pas apparaître ; les sélecteurs sont sanitisés (pas d'évasion vers d'autres dossiers).
|
||||||
|
- **Stratégie de test re-écrite dans le system prompt Studio** : règle d'or *"ne PAS ré-appeler `list_clickables` après chaque clic"*. Tableau des actions avec leur coût relatif (`summary` cher mais utile au début, `eval` ciblé > `list_clickables` complet, etc.). Format de rapport final standardisé (✓ / ✗ / ⚠ / 📸).
|
||||||
|
|
||||||
|
Inclut également **v0.7.8** (fix régression v0.7.6 : `unsafe.Pointer(uintptr(hPC))` au lieu de `&hPC` dans `UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE)` — corrige les terminaux qui s'ouvraient en fenêtre externe).
|
||||||
|
|
||||||
|
## v0.7.8
|
||||||
|
|
||||||
|
### Fix régression v0.7.6 : terminaux ouverts en fenêtre externe
|
||||||
|
|
||||||
|
Symptôme rapporté : depuis v0.7.6, cliquer sur PowerShell / cmd dans l'onglet Terminal ouvre une **fenêtre console séparée** au lieu de s'afficher dans le tab xterm.js (régression — v0.7.5 fonctionnait).
|
||||||
|
|
||||||
|
**Cause** : le binding ConPTY introduit en v0.7.6 passait `&hPC` (pointeur vers la variable Go locale) à `UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, …)`. Or cet attribut est un quirk de l'API Win32 : `lpValue` doit être la **valeur du handle** (cast en `PVOID`), **pas** un pointeur vers la variable. Avec `&hPC`, le kernel lisait des octets aléatoires, l'attribut PSEUDOCONSOLE était silencieusement ignoré, et `CreateProcessW` créait une nouvelle console pour l'enfant — d'où la fenêtre externe.
|
||||||
|
|
||||||
|
**Fix** (1 ligne) :
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Avant
|
||||||
|
unsafe.Pointer(&hPC)
|
||||||
|
|
||||||
|
// Après
|
||||||
|
unsafe.Pointer(uintptr(hPC)) // le HPCON value comme PVOID
|
||||||
|
```
|
||||||
|
|
||||||
|
Référence : Microsoft EchoCon sample + bibliothèques Go ConPTY existantes (`UserExistsError/conpty`, `aymanbagabas/go-pty`) utilisent toutes la valeur du handle directement.
|
||||||
|
|
||||||
|
Conséquence : terminaux PowerShell / cmd / WSL s'ouvrent à nouveau **dans** le tab xterm.js avec TTY complet (ANSI, prompt couleur, vim, etc.).
|
||||||
|
|
||||||
|
## v0.7.7
|
||||||
|
|
||||||
|
### Changes since v0.7.6
|
||||||
|
|
||||||
|
- fix(install): kill running muyue before extracting (v0.7.7) (29953bd)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
|
||||||
|
```powershell
|
||||||
|
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
|
||||||
|
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
|
||||||
|
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
|
||||||
|
& "$dest\muyue-windows-amd64.exe" install-shortcuts
|
||||||
|
$env:Path += ";$dest"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le `.exe` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.7.7
|
||||||
|
|
||||||
|
### Fix : install Windows échoue silencieusement quand une version précédente tourne
|
||||||
|
|
||||||
|
Symptôme rapporté en mettant à jour de v0.7.5 → v0.7.6 : `Expand-Archive ... -Force` semble réussir mais le `.exe` n'est en réalité pas écrasé (Windows refuse de remplacer un fichier verrouillé), donc après l'install, `muyue` lance toujours l'ancienne version. Aucun message d'erreur visible — d'où le côté traître.
|
||||||
|
|
||||||
|
**Fix** : ajout d'une 1ʳᵉ ligne au snippet d'install qui tue toute instance Muyue déjà lancée :
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
|
||||||
|
```
|
||||||
|
|
||||||
|
`-ErrorAction SilentlyContinue` rend l'étape idempotente (pas d'erreur si rien ne tourne, cas d'install propre). Le `Start-Sleep` 500ms laisse Windows libérer le file handle. Le snippet officiel passe à 6 lignes ; une note explicative est ajoutée dans la section *Install* du changelog généré.
|
||||||
|
|
||||||
|
## v0.7.6
|
||||||
|
|
||||||
|
### Changes since v0.7.5
|
||||||
|
|
||||||
|
- fix(windows): native ConPTY + kernel32 metrics + agent loop cap (v0.7.6) (d557b8e)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
|
||||||
|
```powershell
|
||||||
|
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
|
||||||
|
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
|
||||||
|
& "$dest\muyue-windows-amd64.exe" install-shortcuts
|
||||||
|
$env:Path += ";$dest"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.7.6
|
||||||
|
|
||||||
|
### Trois fixes Windows + une amélioration agent
|
||||||
|
|
||||||
|
#### Métriques dashboard à 0 sur Windows
|
||||||
|
|
||||||
|
Symptôme : CPU / RAM / Réseau toujours à 0 dans le panneau Dashboard sous Windows. Cause : `handleSystemMetrics` lisait exclusivement `/proc/stat`, `/proc/meminfo`, `/proc/net/dev` — fichiers absents sur Windows, donc `os.ReadFile` échouait silencieusement et la struct restait à zéro.
|
||||||
|
|
||||||
|
Split en fichiers `_unix.go` / `_windows.go` :
|
||||||
|
- **`metrics_unix.go`** (`!windows`) : reprend tel quel le code `/proc/...` existant.
|
||||||
|
- **`metrics_windows.go`** : appelle `kernel32!GetSystemTimes` (CPU, ratio idle/total entre deux samples) et `kernel32!GlobalMemoryStatusEx` (RAM totale + dispo). Pas de spawn PowerShell, ~50 µs par appel. Réseau à zéro pour l'instant — `MIB_IF_ROW2` est trop sensible aux versions de Windows pour faire ça à la main proprement (TODO à part).
|
||||||
|
- `handleSystemMetrics` réduit à un appel à `collectSystemMetrics()`.
|
||||||
|
|
||||||
|
#### Terminal écran noir sur Windows
|
||||||
|
|
||||||
|
Symptôme : sous Windows native, le tab terminal ouvre la connexion mais l'écran reste noir, aucune sortie. Cause : `creack/pty/v2` retourne *"operating system not supported"* → fallback aux pipes. Pipes ne portent pas les signaux TTY, donc `cmd.exe` / `pwsh` / `wsl.exe` détectent l'absence de TTY et passent en mode silencieux ou attendent indéfiniment.
|
||||||
|
|
||||||
|
Implémentation **ConPTY** native via `kernel32!CreatePseudoConsole` (`internal/api/terminal_conpty_windows.go`) :
|
||||||
|
- Probe runtime `canUseConPTY()` (cache la disponibilité — Windows 10 1809+ requis).
|
||||||
|
- Crée un pseudo-console + 2 pipes anonymes, les passe au child via `STARTUPINFOEX` + `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` (utilise `windows.NewProcThreadAttributeList`).
|
||||||
|
- `CreateProcessW` lance le shell avec le PC attaché → ANSI / cursor / line discipline marchent comme sur un vrai TTY.
|
||||||
|
- `ResizePseudoConsole` câblé sur les events de redimensionnement xterm.
|
||||||
|
- Fallback `pipeSession` conservé si `canUseConPTY()` est false (Windows < 1809) ou si `startConptySession` échoue.
|
||||||
|
- Restructure des fichiers : `terminal_session.go` (interface + structs), `terminal_session_unix.go` (creack/pty), `terminal_session_windows.go` (ConPTY → pipe fallback), `terminal_conpty_windows.go` (impl).
|
||||||
|
|
||||||
|
#### Limite d'itérations d'outils agent
|
||||||
|
|
||||||
|
Symptôme : *"l'IA semble s'arrêter après 15 exécutions d'outils, je veux qu'elle puisse en faire 100, voire 1000"*. Cause : `MaxToolIterations = 15` dans `chat_engine.go`.
|
||||||
|
|
||||||
|
Bump : 15 → 500. Cap reste pour éviter les boucles infinies en cas de bug modèle, mais 500 itérations couvre largement les cas réels (refactor multi-fichiers, debug exploratoire). Documentation inline ajoutée pour expliquer pourquoi le cap existe et quand il faudrait s'inquiéter de le toucher.
|
||||||
|
|
||||||
|
## v0.7.5
|
||||||
|
|
||||||
|
### Changes since v0.7.4
|
||||||
|
|
||||||
|
- fix(windows): GUI subsystem + parent-console attach + canonical muyue.exe (v0.7.5) (79e467c)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
|
||||||
|
```powershell
|
||||||
|
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
|
||||||
|
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
|
||||||
|
& "$dest\muyue-windows-amd64.exe" install-shortcuts
|
||||||
|
$env:Path += ";$dest"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.7.5
|
||||||
|
|
||||||
|
### Fix Windows : commande `muyue` reconnue après install
|
||||||
|
|
||||||
|
Symptôme rapporté : après les commandes d'install, `muyue` retourne `n'est pas reconnu comme nom d'applet de commande`. Causes :
|
||||||
|
- Le binaire extrait s'appelle `muyue-windows-amd64.exe` — taper `muyue` ne résoud pas
|
||||||
|
- La PATH utilisateur a été mise à jour mais la session PowerShell courante n'en hérite que pour les NOUVEAUX processus
|
||||||
|
|
||||||
|
Corrections dans `install-shortcuts` :
|
||||||
|
- **Copie canonique** : `muyue.exe` est créé à côté de `muyue-windows-amd64.exe` (copy, pas rename — le binaire en cours d'exécution est verrouillé sur Windows). Les raccourcis Bureau / Menu Démarrer ciblent désormais cette copie.
|
||||||
|
- **Hint de session** : la commande imprime `$env:Path += ';...'` à coller pour activer `muyue` dans le shell courant sans rouvrir un terminal.
|
||||||
|
|
||||||
|
Snippet d'install passe à 5 lignes : la dernière (`$env:Path += ";$dest"`) rend la commande dispo immédiatement dans la session.
|
||||||
|
|
||||||
|
### Fix Windows : double-clic du raccourci fonctionne enfin
|
||||||
|
|
||||||
|
Symptôme rapporté : après installation, double-clic sur le raccourci Bureau → boîte de dialogue *"This is a command line tool. You need to open cmd.exe and run it from there."*. Cause : `charmbracelet/huh` (utilisé pour la TUI de premier lancement) détecte l'absence de TTY interactif quand le binaire est lancé via Explorer Windows et avorte avec ce message.
|
||||||
|
|
||||||
|
Double correctif :
|
||||||
|
|
||||||
|
1. **Skip de la TUI sans terminal interactif** (`cmd/muyue/commands/root.go::isInteractiveStdin`) — si `os.Stdin.Stat()` indique pas de `os.ModeCharDevice`, on saute `profiler.RunFirstTimeSetup` et on persiste un `config.Default()`. L'onboarding web (déjà existant) prend ensuite le relais dès l'ouverture du navigateur — aucune régression : avec un vrai terminal, la TUI continue de tourner comme avant.
|
||||||
|
|
||||||
|
2. **Build Windows en GUI subsystem** (`-H=windowsgui` ajouté aux Windows builds dans `ci-main.yml` et `ci-develop.yml`) — le binaire ne demande plus de console, donc plus aucun flash de fenêtre noire au double-clic.
|
||||||
|
|
||||||
|
Conséquence : les sous-commandes CLI (`muyue scan`, `muyue version`, `muyue install-shortcuts`) ne produiraient plus d'output quand lancées depuis cmd.exe. Mitigation : nouveau fichier `cmd/muyue/console_windows.go` qui appelle `kernel32!AttachConsole(ATTACH_PARENT_PROCESS)` au démarrage. Si un terminal parent existe, on s'y rattache et `os.Stdout` / `os.Stderr` / `os.Stdin` y sont rebindés ; sinon, on tourne silencieusement (cas double-clic). Compatible des deux usages sans deux binaires séparés.
|
||||||
|
|
||||||
|
## v0.7.4
|
||||||
|
|
||||||
|
### Changes since v0.7.2
|
||||||
|
|
||||||
|
- feat: integrate Muyue logo (icon embedded in Windows binary + web favicon) (830e085)
|
||||||
|
- feat: onboarding 2-keys + Windows install w/o admin (v0.7.3) (1442b4f)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer :
|
||||||
|
```powershell
|
||||||
|
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
|
||||||
|
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
|
||||||
|
& "$dest\muyue-windows-amd64.exe" install-shortcuts
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.7.4
|
||||||
|
|
||||||
|
### Logo Muyue intégré
|
||||||
|
|
||||||
|
- `LogoMuyue.png` ajouté à la racine + déclinaisons générées dans `assets/` (16/32/64/128/256/512 px) et `assets/muyue.ico` (multi-résolution 16-256 px).
|
||||||
|
- **Binaire Windows** : icône embarquée comme ressource Windows via `github.com/akavel/rsrc` au build CI (génération de `cmd/muyue/rsrc_windows_{amd64,arm64}.syso`). Conséquences :
|
||||||
|
- Explorateur Windows affiche l'icône Muyue sur le `.exe`
|
||||||
|
- Les raccourcis créés par `install-shortcuts` héritent de l'icône (via `IconLocation = "$exe,0"`)
|
||||||
|
- Aucune dépendance Go à runtime ; les `.syso` sont gitignorés et regénérés à chaque build
|
||||||
|
- **UI web** : favicon réel (16/32 px), apple-touch-icon (256 px) et logo affiché dans le header à côté de "MUYUE".
|
||||||
|
- Snippet d'install Windows : 1ʳᵉ ligne idempotente (`New-Item -ItemType Directory -Force`) pour gérer le cas d'une ré-exécution après install partielle.
|
||||||
|
- Préservation du logo source en pleine résolution (912×950 RGBA) — pas de perte d'information.
|
||||||
|
|
||||||
|
## v0.7.3
|
||||||
|
|
||||||
|
### Onboarding — focus MiniMax + MiMo
|
||||||
|
|
||||||
|
- L'étape `apikey` du wizard de premier lancement propose désormais **les deux clés** (MiniMax + MiMo) côte à côte ; au moins une doit être validée pour continuer.
|
||||||
|
- Les autres fournisseurs (OpenAI, Anthropic, Z.AI, Ollama) ne sont plus proposés dans le wizard — l'utilisateur les configure ensuite via l'onglet **Configuration** s'il le souhaite. Justification : pour les nouveaux utilisateurs, deux choix simples > six choix qui ralentissent le démarrage.
|
||||||
|
- Si MiniMax est validé, il devient le provider actif. Sinon, c'est MiMo. Si les deux sont validés, MiniMax reste actif (peut être basculé via `/model change` plus tard).
|
||||||
|
|
||||||
|
### Install Windows — pas d'admin + raccourcis automatiques
|
||||||
|
|
||||||
|
- **Avant** : la 3ᵉ ligne du snippet d'install (`Move-Item ... C:\Windows\muyue.exe`) échouait avec `UnauthorizedAccessException` sur PowerShell sans élévation.
|
||||||
|
- **Maintenant** : 4 lignes, toutes exécutables sans admin :
|
||||||
|
```powershell
|
||||||
|
$dest = "$env:LOCALAPPDATA\Muyue"
|
||||||
|
Invoke-WebRequest -Uri ".../muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
|
||||||
|
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
|
||||||
|
& "$dest\muyue-windows-amd64.exe" install-shortcuts
|
||||||
|
```
|
||||||
|
- Nouvelle commande `muyue install-shortcuts` (Windows uniquement) :
|
||||||
|
- crée `Muyue.lnk` sur le Bureau et dans le Menu Démarrer (résolus via `[Environment]::GetFolderPath`, robuste OneDrive / profils non-standards) ;
|
||||||
|
- utilise WScript.Shell COM via PowerShell pour générer les `.lnk` (pas de dépendance Go ajoutée) ;
|
||||||
|
- ajoute le dossier d'install au `PATH` utilisateur (scope User, pas de modif système).
|
||||||
|
- Une icône custom pourra être branchée plus tard en remplaçant la ressource embed du `.exe` ; pour l'instant, l'icône Windows par défaut du binaire est utilisée.
|
||||||
|
|
||||||
|
## v0.7.2
|
||||||
|
|
||||||
|
### Changes since v0.7.0
|
||||||
|
|
||||||
|
- feat(studio): force advanced reflection during browser-test sessions (v0.7.2) (a7d4b31)
|
||||||
|
- fix(terminal/windows): fallback to pipes when PTY unsupported (v0.7.1) (fc7a5b9)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## v0.7.2
|
## v0.7.2
|
||||||
|
|
||||||
### Amélioration
|
### Amélioration
|
||||||
|
|||||||
BIN
LogoMuyue.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
16
Makefile
@@ -7,7 +7,9 @@ NODE ?= node
|
|||||||
NPM ?= npm
|
NPM ?= npm
|
||||||
WEB_DIR = web
|
WEB_DIR = web
|
||||||
|
|
||||||
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop
|
EXT_DIR = extension
|
||||||
|
|
||||||
|
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop ext ext-chrome ext-firefox ext-zip
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
|
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
|
||||||
@@ -63,5 +65,17 @@ build-all: frontend
|
|||||||
GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY)-windows-amd64.exe ./cmd/muyue/
|
GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY)-windows-amd64.exe ./cmd/muyue/
|
||||||
GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/
|
GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/
|
||||||
|
|
||||||
|
ext:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build && $(NPM) run build:firefox
|
||||||
|
|
||||||
|
ext-chrome:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build
|
||||||
|
|
||||||
|
ext-firefox:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build:firefox
|
||||||
|
|
||||||
|
ext-zip:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip && $(NPM) run zip:firefox
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
|||||||
43
README.md
@@ -17,6 +17,45 @@ AI-powered development environment assistant by **La Légion de Muyue**.
|
|||||||
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
|
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
|
||||||
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
|
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
|
||||||
|
|
||||||
|
## Browser Extension
|
||||||
|
|
||||||
|
Muyue ships a **browser extension** (Chrome, Edge, Firefox) that replaces the manual snippet injection for the Tests tab:
|
||||||
|
|
||||||
|
- **Auto-injects** the Muyue test client on every HTTP/HTTPS page — no more copy-paste
|
||||||
|
- **Captures console** errors/warnings in real-time
|
||||||
|
- **Native screenshots** via `captureVisibleTab` — pixel-perfect
|
||||||
|
- **Side Panel** (Chrome/Edge) and **Sidebar** (Firefox) for status monitoring
|
||||||
|
- **Badge** shows active session count or server status
|
||||||
|
|
||||||
|
### Install from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension
|
||||||
|
npm install
|
||||||
|
npm run build # Chrome/Edge → .output/chrome-mv3/
|
||||||
|
npm run build:firefox # Firefox → .output/firefox-mv2/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then load the extension:
|
||||||
|
- **Chrome/Edge**: `chrome://extensions` → Developer mode → Load unpacked → select `extension/.output/chrome-mv3/`
|
||||||
|
- **Firefox**: `about:debugging#/runtime/this-firefox` → Load temporary Add-on → select any file in `extension/.output/firefox-mv2/`
|
||||||
|
|
||||||
|
### Download pre-built
|
||||||
|
|
||||||
|
Extension `.zip` files are attached to every [release](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases):
|
||||||
|
|
||||||
|
- `muyue-extension-*-chrome.zip` — Chrome Web Store ready
|
||||||
|
- `muyue-extension-*-firefox.zip` — Firefox Add-ons ready
|
||||||
|
- `muyue-extension-*-sources.zip` — Required source for Firefox Add-ons review
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension
|
||||||
|
npm run dev # Chrome dev mode with HMR
|
||||||
|
npm run dev -- --browser firefox # Firefox dev mode
|
||||||
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
@@ -186,6 +225,10 @@ The Go backend serves 15 REST endpoints under `/api/`:
|
|||||||
│ │ ├── styles/global.css # Full CSS theme system
|
│ │ ├── styles/global.css # Full CSS theme system
|
||||||
│ │ └── themes/index.js # 4 themes with CSS variable injection
|
│ │ └── themes/index.js # 4 themes with CSS variable injection
|
||||||
│ └── vite.config.js # Vite + dev proxy to :8095
|
│ └── vite.config.js # Vite + dev proxy to :8095
|
||||||
|
├── extension/ # Browser extension (WXT, Chrome/Edge/Firefox)
|
||||||
|
│ ├── src/entrypoints/ # background, content, popup, sidepanel
|
||||||
|
│ ├── src/lib/ # config, page-rpc (shared logic)
|
||||||
|
│ └── src/styles/ # cyberpunk panel CSS
|
||||||
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
|
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
|
||||||
└── Makefile # build, test, lint, cross-compile
|
└── Makefile # build, test, lint, cross-compile
|
||||||
```
|
```
|
||||||
|
|||||||
BIN
assets/muyue-128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/muyue-16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
assets/muyue-256.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
assets/muyue-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/muyue-512.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
BIN
assets/muyue-64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/muyue.ico
Normal file
|
After Width: | Height: | Size: 119 KiB |
189
cmd/muyue/commands/install_shortcuts.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// installShortcutsCmd creates desktop + Start Menu shortcuts on Windows so
|
||||||
|
// non-technical users can launch Muyue without opening a terminal. It also
|
||||||
|
// adds the install directory to the user's PATH (per-user, no admin).
|
||||||
|
//
|
||||||
|
// Implementation note: shortcut (.lnk) creation on Windows is most reliable
|
||||||
|
// via WScript.Shell COM. We invoke it via PowerShell — keeps the Go binary
|
||||||
|
// dependency-free and works on any Windows 10+ host.
|
||||||
|
var installShortcutsCmd = &cobra.Command{
|
||||||
|
Use: "install-shortcuts",
|
||||||
|
Short: "Create Desktop + Start Menu shortcuts (Windows only) and add Muyue to PATH",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
fmt.Println("install-shortcuts is a Windows-only command (no-op on this platform)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate executable: %w", err)
|
||||||
|
}
|
||||||
|
exe, _ = filepath.Abs(exe)
|
||||||
|
installDir := filepath.Dir(exe)
|
||||||
|
|
||||||
|
fmt.Println("Installing Muyue shortcuts...")
|
||||||
|
fmt.Printf(" Source : %s\n", exe)
|
||||||
|
|
||||||
|
// Provide a clean `muyue.exe` next to the platform-suffixed binary so
|
||||||
|
// users can type `muyue` once the install dir is on PATH. Copy (not
|
||||||
|
// rename) because the running .exe is locked on Windows.
|
||||||
|
canonicalExe := filepath.Join(installDir, "muyue.exe")
|
||||||
|
if !strings.EqualFold(exe, canonicalExe) {
|
||||||
|
if err := copyFile(exe, canonicalExe); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " Copy : warning — could not create muyue.exe: %v\n", err)
|
||||||
|
canonicalExe = exe
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Canonical : %s\n", canonicalExe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
desktop, err := userShellFolder("Desktop")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate Desktop folder: %w", err)
|
||||||
|
}
|
||||||
|
startMenu, err := userShellFolder("Programs")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate Start Menu Programs folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopLnk := filepath.Join(desktop, "Muyue.lnk")
|
||||||
|
startLnk := filepath.Join(startMenu, "Muyue.lnk")
|
||||||
|
|
||||||
|
if err := createWindowsShortcut(desktopLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
||||||
|
return fmt.Errorf("create desktop shortcut: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Desktop : %s\n", desktopLnk)
|
||||||
|
|
||||||
|
if err := createWindowsShortcut(startLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
||||||
|
return fmt.Errorf("create Start Menu shortcut: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Start Menu : %s\n", startLnk)
|
||||||
|
|
||||||
|
if err := addUserPATH(installDir); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " PATH : warning — could not add %s to user PATH: %v\n", installDir, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" PATH : added %s\n", installDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
|
||||||
|
fmt.Println("\nTo use 'muyue' from this PowerShell session right now, run:")
|
||||||
|
fmt.Printf(" $env:Path += ';%s'\n", installDir)
|
||||||
|
fmt.Println("(New terminals will pick up the user PATH automatically.)")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile duplicates src to dst, overwriting an existing dst (used to drop a
|
||||||
|
// `muyue.exe` next to the platform-suffixed binary so the command is callable
|
||||||
|
// as `muyue` from PATH).
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(installShortcutsCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userShellFolder asks Windows for a user shell folder via PowerShell —
|
||||||
|
// resilient to OneDrive redirection and non-default profile locations.
|
||||||
|
// `which` is one of: Desktop, Programs (Start Menu Programs), StartMenu.
|
||||||
|
func userShellFolder(which string) (string, error) {
|
||||||
|
ps := fmt.Sprintf(`[Environment]::GetFolderPath('%s')`, which)
|
||||||
|
out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", ps).Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := stripTrailingWhitespace(string(out))
|
||||||
|
if path == "" {
|
||||||
|
return "", fmt.Errorf("empty path for %s", which)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripTrailingWhitespace(s string) string {
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWindowsShortcut generates a .lnk via WScript.Shell COM. The arguments
|
||||||
|
// are passed through PowerShell variables (not interpolated into the script
|
||||||
|
// body) to avoid quoting issues with paths containing spaces or special chars.
|
||||||
|
func createWindowsShortcut(lnkPath, target, workingDir, description string) error {
|
||||||
|
script := `
|
||||||
|
$lnk = $env:MUYUE_LNK
|
||||||
|
$target = $env:MUYUE_TARGET
|
||||||
|
$workdir = $env:MUYUE_WORKDIR
|
||||||
|
$desc = $env:MUYUE_DESC
|
||||||
|
$wsh = New-Object -ComObject WScript.Shell
|
||||||
|
$sc = $wsh.CreateShortcut($lnk)
|
||||||
|
$sc.TargetPath = $target
|
||||||
|
$sc.WorkingDirectory = $workdir
|
||||||
|
$sc.Description = $desc
|
||||||
|
$sc.IconLocation = "$target,0"
|
||||||
|
$sc.Save()
|
||||||
|
`
|
||||||
|
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"MUYUE_LNK="+lnkPath,
|
||||||
|
"MUYUE_TARGET="+target,
|
||||||
|
"MUYUE_WORKDIR="+workingDir,
|
||||||
|
"MUYUE_DESC="+description,
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("powershell: %v: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addUserPATH appends installDir to the user's PATH if not already present.
|
||||||
|
// Uses PowerShell to read/write the User-scope environment via .NET API,
|
||||||
|
// which broadcasts WM_SETTINGCHANGE so new processes pick it up.
|
||||||
|
func addUserPATH(installDir string) error {
|
||||||
|
script := `
|
||||||
|
$dir = $env:MUYUE_INSTALL_DIR
|
||||||
|
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||||
|
if ($current -eq $null) { $current = '' }
|
||||||
|
$parts = $current -split ';' | Where-Object { $_ -ne '' }
|
||||||
|
if ($parts -notcontains $dir) {
|
||||||
|
$new = if ($current -eq '') { $dir } else { "$current;$dir" }
|
||||||
|
[Environment]::SetEnvironmentVariable('Path', $new, 'User')
|
||||||
|
}
|
||||||
|
`
|
||||||
|
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
|
||||||
|
cmd.Env = append(os.Environ(), "MUYUE_INSTALL_DIR="+installDir)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("powershell: %v: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -24,8 +24,29 @@ func Execute() error {
|
|||||||
return rootCmd.Execute()
|
return rootCmd.Execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isInteractiveStdin reports whether os.Stdin is connected to a real terminal.
|
||||||
|
// Used to decide between the TUI first-time setup (huh forms) and a no-op
|
||||||
|
// fallback that defers onboarding to the web wizard. Returns false when the
|
||||||
|
// binary is launched by a double-click on Windows (Explorer attaches a pseudo
|
||||||
|
// console without a usable TTY) — which is the exact case where huh prints
|
||||||
|
// "This is a command line tool. You need to open cmd.exe and run it from there."
|
||||||
|
// and exits.
|
||||||
|
func isInteractiveStdin() bool {
|
||||||
|
stat, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (stat.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
func loadOrSetupConfig() *config.MuyueConfig {
|
func loadOrSetupConfig() *config.MuyueConfig {
|
||||||
if !config.Exists() {
|
if !config.Exists() {
|
||||||
|
// No config yet. If we have a real terminal, run the rich TUI setup
|
||||||
|
// (huh forms). Otherwise — typically when the user double-clicked the
|
||||||
|
// shortcut on Windows — write defaults silently and let the React
|
||||||
|
// onboarding wizard handle the real first-run flow once the browser
|
||||||
|
// opens. This avoids huh aborting with "This is a command line tool".
|
||||||
|
if isInteractiveStdin() {
|
||||||
fmt.Println("First time setup detected!")
|
fmt.Println("First time setup detected!")
|
||||||
cfg, err := profiler.RunFirstTimeSetup()
|
cfg, err := profiler.RunFirstTimeSetup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,6 +72,16 @@ func loadOrSetupConfig() *config.MuyueConfig {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-interactive — skip the TUI, persist defaults, web onboarding
|
||||||
|
// will fill in the profile / API keys.
|
||||||
|
cfg := config.Default()
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)
|
||||||
|
|||||||
54
cmd/muyue/console_windows.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Windows-only: with -H=windowsgui the binary is registered as a GUI
|
||||||
|
// subsystem app, so double-clicking from the Desktop shortcut does NOT
|
||||||
|
// spawn a console window (good for the desktop UX). The downside is that
|
||||||
|
// sub-commands like `muyue scan`, `muyue version`, `muyue install-shortcuts`
|
||||||
|
// produce no output when invoked from cmd.exe.
|
||||||
|
//
|
||||||
|
// Workaround: at process start, try to attach to the parent's console via
|
||||||
|
// kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the parent has a console
|
||||||
|
// (i.e. we were launched from cmd.exe / PowerShell), stdout/stderr/stdin are
|
||||||
|
// rebound to it. If not (Explorer double-click), the call fails silently and
|
||||||
|
// the binary runs without any console — exactly what we want.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const attachParentProcess = ^uint32(0) // -1 cast to DWORD
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
kernel32, err := syscall.LoadLibrary("kernel32.dll")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer syscall.FreeLibrary(kernel32)
|
||||||
|
attachConsole, err := syscall.GetProcAddress(kernel32, "AttachConsole")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r0, _, _ := syscall.SyscallN(attachConsole, uintptr(attachParentProcess))
|
||||||
|
if r0 == 0 {
|
||||||
|
return // parent has no console (Explorer launch) — stay silent
|
||||||
|
}
|
||||||
|
// Re-bind the standard streams to the freshly attached console so
|
||||||
|
// fmt.Println / log output appear in the parent terminal.
|
||||||
|
if h, err := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE); err == nil && h != 0 {
|
||||||
|
os.Stdout = os.NewFile(uintptr(h), "stdout")
|
||||||
|
}
|
||||||
|
if h, err := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE); err == nil && h != 0 {
|
||||||
|
os.Stderr = os.NewFile(uintptr(h), "stderr")
|
||||||
|
}
|
||||||
|
if h, err := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE); err == nil && h != 0 {
|
||||||
|
os.Stdin = os.NewFile(uintptr(h), "stdin")
|
||||||
|
}
|
||||||
|
// log.Default() captured the original os.Stderr at init time — repoint it
|
||||||
|
// at the freshly attached console so log.Printf calls (e.g. desktop.Run)
|
||||||
|
// surface in the parent terminal.
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
}
|
||||||
4
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.output/
|
||||||
|
.wxt/
|
||||||
|
*.zip
|
||||||
81
extension/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Muyue Browser Extension
|
||||||
|
|
||||||
|
AI-powered browser testing & automation, connected to your [Muyue](https://github.com/muyue/muyue) desktop app.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Auto-injects** the Muyue test client on every page — no more manual snippet copy-paste
|
||||||
|
- **Captures console** errors/warnings in real-time, sent to the AI Studio
|
||||||
|
- **Enables AI-driven testing**: click buttons, fill inputs, evaluate JS, take screenshots
|
||||||
|
- **Side Panel** (Chrome/Edge) and **Sidebar** (Firefox) for status monitoring
|
||||||
|
- **Native screenshots** via `chrome.tabs.captureVisibleTab` — pixel-perfect, no SVG hacks
|
||||||
|
- **URL change detection** via History API interception (survives SPA navigation)
|
||||||
|
- **Badge indicator**: shows connected session count or server status
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### Chrome / Edge
|
||||||
|
|
||||||
|
1. Run `npm run build`
|
||||||
|
2. Open `chrome://extensions` → Enable **Developer mode**
|
||||||
|
3. Click **Load unpacked** → select `extension/.output/chrome-mv3/`
|
||||||
|
|
||||||
|
Or install the published extension from the Chrome Web Store.
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
1. Run `npm run build:firefox`
|
||||||
|
2. Open `about:debugging#/runtime/this-firefox`
|
||||||
|
3. Click **Load temporary Add-on** → select any file in `extension/.output/firefox-mv2/`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension
|
||||||
|
npm install
|
||||||
|
npm run dev # Chrome dev mode with HMR
|
||||||
|
npm run dev -- --browser firefox # Firefox dev mode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Chrome/Edge MV3 → .output/chrome-mv3/
|
||||||
|
npm run build:firefox # Firefox MV2 → .output/firefox-mv2/
|
||||||
|
npm run zip # Chrome .zip for Web Store
|
||||||
|
npm run zip:firefox # Firefox .zip + sources .zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Content Script (every HTTP/HTTPS page) │
|
||||||
|
│ - Console interception (log/warn/error) │
|
||||||
|
│ - RPC execution (click, type, eval, list) │
|
||||||
|
│ - URL change detection (History API + MutationObs) │
|
||||||
|
│ - WebSocket → Muyue server (same as snippet) │
|
||||||
|
└──────────────┬──────────────────────────────────────┘
|
||||||
|
│ chrome.runtime messaging
|
||||||
|
┌──────────────┴──────────────────────────────────────┐
|
||||||
|
│ Background Service Worker │
|
||||||
|
│ - Token management (GET /api/test/snippet) │
|
||||||
|
│ - Native screenshots (captureVisibleTab) │
|
||||||
|
│ - Badge updates (session count / server status) │
|
||||||
|
│ - chrome.alarms for periodic health checks │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ Popup │ │ Side Panel │
|
||||||
|
│ - Server status │ │ - Sessions list │
|
||||||
|
│ - Session count │ │ - Auto-refresh │
|
||||||
|
│ - Dashboard link │ │ - Dashboard link │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
| Browser | Manifest | Side Panel | Screenshots |
|
||||||
|
|---------|----------|------------|-------------|
|
||||||
|
| Chrome 89+ | MV3 | ✅ sidePanel API | ✅ captureVisibleTab |
|
||||||
|
| Edge 89+ | MV3 | ✅ sidePanel API | ✅ captureVisibleTab |
|
||||||
|
| Firefox | MV2 | ✅ sidebar API | ✅ tabs.captureVisibleTab |
|
||||||
4711
extension/package-lock.json
generated
Normal file
16
extension/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "muyue-extension",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wxt",
|
||||||
|
"build": "wxt build",
|
||||||
|
"build:firefox": "wxt build --browser firefox",
|
||||||
|
"zip": "wxt zip",
|
||||||
|
"zip:firefox": "wxt zip --browser firefox"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"wxt": "^0.20"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
extension/public/icon/128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
extension/public/icon/16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
extension/public/icon/32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
extension/public/icon/512.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
116
extension/src/entrypoints/background.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { fetchToken, fetchSessions, checkServerHealth, getServerUrl } from '../lib/config';
|
||||||
|
|
||||||
|
export default defineBackground(() => {
|
||||||
|
let token = null;
|
||||||
|
let wsUrl = null;
|
||||||
|
let serverOnline = false;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
async function refreshToken() {
|
||||||
|
try {
|
||||||
|
const data = await fetchToken();
|
||||||
|
token = data.token;
|
||||||
|
wsUrl = data.wsUrl;
|
||||||
|
serverOnline = true;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
serverOnline = false;
|
||||||
|
token = null;
|
||||||
|
wsUrl = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateBadge() {
|
||||||
|
try {
|
||||||
|
serverOnline = await checkServerHealth();
|
||||||
|
} catch {
|
||||||
|
serverOnline = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverOnline) {
|
||||||
|
chrome.action.setBadgeText({ text: '✕' });
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: '#ff6b6b' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await fetchSessions();
|
||||||
|
const count = sessions.length;
|
||||||
|
if (count > 0) {
|
||||||
|
chrome.action.setBadgeText({ text: String(count) });
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: '#3aaa61' });
|
||||||
|
} else {
|
||||||
|
chrome.action.setBadgeText({ text: '○' });
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: '#888' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
chrome.action.setBadgeText({ text: '?' });
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: '#f5a623' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScreenshot() {
|
||||||
|
try {
|
||||||
|
const dataUrl = await chrome.tabs.captureVisibleTab(null, {
|
||||||
|
format: 'png',
|
||||||
|
quality: 100,
|
||||||
|
});
|
||||||
|
return { ok: true, data_url: dataUrl };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: 'capture failed: ' + String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
|
if (msg.type === 'get_state') {
|
||||||
|
getServerUrl().then((url) => {
|
||||||
|
sendResponse({
|
||||||
|
serverOnline,
|
||||||
|
token,
|
||||||
|
wsUrl,
|
||||||
|
errorCount,
|
||||||
|
serverUrl: url,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'get_token') {
|
||||||
|
refreshToken().then((data) => sendResponse(data));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'check_health') {
|
||||||
|
checkServerHealth().then((ok) => {
|
||||||
|
serverOnline = ok;
|
||||||
|
sendResponse({ online: ok });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'screenshot') {
|
||||||
|
handleScreenshot().then(sendResponse);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'refresh_badge') {
|
||||||
|
updateBadge();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'increment_errors') {
|
||||||
|
errorCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.alarms.create('muyue-badge', { periodInMinutes: 0.17 });
|
||||||
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||||
|
if (alarm.name === 'muyue-badge') updateBadge();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateBadge();
|
||||||
|
});
|
||||||
193
extension/src/entrypoints/content.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { dispatch } from '../lib/page-rpc';
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['http://*/*', 'https://*/*'],
|
||||||
|
runAt: 'document_idle',
|
||||||
|
main() {
|
||||||
|
if (window.__muyueExtension) return;
|
||||||
|
window.__muyueExtension = true;
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let retryDelay = 0;
|
||||||
|
let token = null;
|
||||||
|
let wsBaseUrl = null;
|
||||||
|
const TAG = '[Muyue]';
|
||||||
|
|
||||||
|
function log(...args) {
|
||||||
|
console.log(TAG, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(obj) {
|
||||||
|
try {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reply(id, data) {
|
||||||
|
send({ type: 'reply', id, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendConsole(level, text) {
|
||||||
|
send({ type: 'console', level, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.runtime.sendMessage({ type: 'get_token' }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenshotNative(params) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.runtime.sendMessage({ type: 'screenshot', params }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
resolve({ ok: false, error: String(chrome.runtime.lastError) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response || { ok: false, error: 'no response' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const data = await getToken();
|
||||||
|
if (!data) {
|
||||||
|
retryDelay = Math.min(retryDelay + 1, 5);
|
||||||
|
setTimeout(connect, 1000 * retryDelay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
token = data.token;
|
||||||
|
wsBaseUrl = data.wsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wsUrl = wsBaseUrl || `ws://127.0.0.1:8080/api/ws/browser-test?token=${token}`;
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
} catch {
|
||||||
|
retryDelay = Math.min(retryDelay + 1, 5);
|
||||||
|
setTimeout(connect, 1000 * retryDelay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
retryDelay = 0;
|
||||||
|
send({ type: 'hello', url: location.href, title: document.title });
|
||||||
|
log('connected to Muyue server');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||||
|
|
||||||
|
if (msg.type === 'registered') {
|
||||||
|
log('session registered:', msg.session_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.action) {
|
||||||
|
if (msg.action === 'screenshot') {
|
||||||
|
screenshotNative(msg.params || {}).then((r) => reply(msg.id, r));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = dispatch(msg);
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
result.then((r) => reply(msg.id, r));
|
||||||
|
} else {
|
||||||
|
reply(msg.id, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
retryDelay = Math.min(retryDelay + 1, 5);
|
||||||
|
setTimeout(connect, 500 * retryDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
['log', 'info', 'warn', 'error', 'debug'].forEach((lvl) => {
|
||||||
|
const orig = console[lvl];
|
||||||
|
console[lvl] = function () {
|
||||||
|
try {
|
||||||
|
const parts = Array.from(arguments).map((a) => {
|
||||||
|
if (typeof a === 'string') return a;
|
||||||
|
try { return JSON.stringify(a); } catch { return String(a); }
|
||||||
|
});
|
||||||
|
const text = parts.join(' ');
|
||||||
|
if (!text.startsWith(TAG)) {
|
||||||
|
sendConsole(lvl, text);
|
||||||
|
if (lvl === 'error') {
|
||||||
|
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return orig.apply(console, arguments);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
sendConsole('error', 'window.onerror: ' + (e.message || 'unknown'));
|
||||||
|
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
sendConsole('error', 'unhandledrejection: ' + String(e.reason));
|
||||||
|
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastUrl = location.href;
|
||||||
|
const urlObserver = new MutationObserver(() => {
|
||||||
|
if (location.href !== lastUrl) {
|
||||||
|
lastUrl = location.href;
|
||||||
|
send({ type: 'url_change', url: lastUrl });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
urlObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
const origPushState = history.pushState;
|
||||||
|
history.pushState = function () {
|
||||||
|
origPushState.apply(this, arguments);
|
||||||
|
if (location.href !== lastUrl) {
|
||||||
|
lastUrl = location.href;
|
||||||
|
send({ type: 'url_change', url: lastUrl });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const origReplaceState = history.replaceState;
|
||||||
|
history.replaceState = function () {
|
||||||
|
origReplaceState.apply(this, arguments);
|
||||||
|
if (location.href !== lastUrl) {
|
||||||
|
lastUrl = location.href;
|
||||||
|
send({ type: 'url_change', url: lastUrl });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
if (location.href !== lastUrl) {
|
||||||
|
lastUrl = location.href;
|
||||||
|
send({ type: 'url_change', url: lastUrl });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (location.href !== lastUrl) {
|
||||||
|
lastUrl = location.href;
|
||||||
|
send({ type: 'url_change', url: lastUrl });
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
connect();
|
||||||
|
},
|
||||||
|
});
|
||||||
55
extension/src/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=320" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="panel">
|
||||||
|
<header>
|
||||||
|
<img src="/icon/32.png" alt="Muyue" />
|
||||||
|
<h1>Muyue</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Server</span>
|
||||||
|
<span class="status-value" id="server-status">
|
||||||
|
<span class="dot dot-yellow"></span>Checking…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Active sessions</span>
|
||||||
|
<span class="status-value" id="session-count">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Console errors</span>
|
||||||
|
<span class="status-value" id="error-count">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
|
||||||
|
Open Dashboard
|
||||||
|
</a>
|
||||||
|
<button id="btn-sidepanel" class="btn">
|
||||||
|
Open Side Panel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<label>Server URL</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
|
||||||
|
<button id="btn-save-url">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Muyue</span> browser extension v0.1.0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./main.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
extension/src/entrypoints/popup/main.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import '../../styles/panel.css';
|
||||||
|
import { getServerUrl, setServerUrl, fetchSessions } from '../../lib/config';
|
||||||
|
|
||||||
|
const $serverStatus = document.getElementById('server-status');
|
||||||
|
const $sessionCount = document.getElementById('session-count');
|
||||||
|
const $errorCount = document.getElementById('error-count');
|
||||||
|
const $btnDashboard = document.getElementById('btn-dashboard');
|
||||||
|
const $btnSidepanel = document.getElementById('btn-sidepanel');
|
||||||
|
const $serverUrl = document.getElementById('server-url');
|
||||||
|
const $btnSaveUrl = document.getElementById('btn-save-url');
|
||||||
|
|
||||||
|
function dot(color) {
|
||||||
|
return `<span class="dot dot-${color}"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const url = await getServerUrl();
|
||||||
|
$serverUrl.value = url;
|
||||||
|
$btnDashboard.href = url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await fetchSessions();
|
||||||
|
$serverStatus.innerHTML = `${dot('green')} Online`;
|
||||||
|
$sessionCount.textContent = sessions.length;
|
||||||
|
} catch {
|
||||||
|
$serverStatus.innerHTML = `${dot('red')} Offline`;
|
||||||
|
$sessionCount.textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
|
||||||
|
if (chrome.runtime.lastError || !state) return;
|
||||||
|
$errorCount.textContent = state.errorCount || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$btnSaveUrl.addEventListener('click', async () => {
|
||||||
|
const url = $serverUrl.value.trim().replace(/\/$/, '');
|
||||||
|
if (url) {
|
||||||
|
await setServerUrl(url);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$btnSidepanel.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (tab) {
|
||||||
|
chrome.sidePanel.open({ tabId: tab.id });
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
54
extension/src/entrypoints/sidepanel/index.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=100%" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="panel">
|
||||||
|
<header>
|
||||||
|
<img src="/icon/32.png" alt="Muyue" />
|
||||||
|
<h1>Muyue Side Panel</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Server</span>
|
||||||
|
<span class="status-value" id="server-status">
|
||||||
|
<span class="dot dot-yellow"></span>Checking…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Active sessions</span>
|
||||||
|
<span class="status-value" id="session-count">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Console errors</span>
|
||||||
|
<span class="status-value" id="error-count">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sessions-list"></div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
|
||||||
|
Open Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<label>Server URL</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
|
||||||
|
<button id="btn-save-url">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Muyue</span> browser extension v0.1.0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./main.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
extension/src/entrypoints/sidepanel/main.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import '../../styles/panel.css';
|
||||||
|
import { getServerUrl, setServerUrl, fetchSessions } from '../../lib/config';
|
||||||
|
|
||||||
|
const $serverStatus = document.getElementById('server-status');
|
||||||
|
const $sessionCount = document.getElementById('session-count');
|
||||||
|
const $errorCount = document.getElementById('error-count');
|
||||||
|
const $sessionsList = document.getElementById('sessions-list');
|
||||||
|
const $btnDashboard = document.getElementById('btn-dashboard');
|
||||||
|
const $serverUrl = document.getElementById('server-url');
|
||||||
|
const $btnSaveUrl = document.getElementById('btn-save-url');
|
||||||
|
|
||||||
|
function dot(color) {
|
||||||
|
return `<span class="dot dot-${color}"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessions(sessions) {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
$sessionsList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionsList.innerHTML = `
|
||||||
|
<div class="status-card" style="margin-top:12px">
|
||||||
|
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
|
||||||
|
Connected tabs
|
||||||
|
</div>
|
||||||
|
${sessions.map((s) => `
|
||||||
|
<div class="status-row">
|
||||||
|
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px" title="${s.url}">
|
||||||
|
${s.title || s.url || s.id}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:10px;color:var(--text-secondary);font-family:var(--font-mono)">
|
||||||
|
${s.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const url = await getServerUrl();
|
||||||
|
$serverUrl.value = url;
|
||||||
|
$btnDashboard.href = url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await fetchSessions();
|
||||||
|
$serverStatus.innerHTML = `${dot('green')} Online`;
|
||||||
|
$sessionCount.textContent = sessions.length;
|
||||||
|
renderSessions(sessions);
|
||||||
|
} catch {
|
||||||
|
$serverStatus.innerHTML = `${dot('red')} Offline`;
|
||||||
|
$sessionCount.textContent = '—';
|
||||||
|
$sessionsList.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
|
||||||
|
if (chrome.runtime.lastError || !state) return;
|
||||||
|
$errorCount.textContent = state.errorCount || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$btnSaveUrl.addEventListener('click', async () => {
|
||||||
|
const url = $serverUrl.value.trim().replace(/\/$/, '');
|
||||||
|
if (url) {
|
||||||
|
await setServerUrl(url);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 5000);
|
||||||
56
extension/src/lib/config.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const DEFAULT_PORT = 8080;
|
||||||
|
const DEFAULT_HOST = '127.0.0.1';
|
||||||
|
const DEFAULT_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;
|
||||||
|
|
||||||
|
function isServiceWorker() {
|
||||||
|
return typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerUrl() {
|
||||||
|
if (isServiceWorker()) {
|
||||||
|
const result = await chrome.storage.local.get('muyue_server_url');
|
||||||
|
return result.muyue_server_url || DEFAULT_URL;
|
||||||
|
}
|
||||||
|
const stored = localStorage.getItem('muyue_server_url');
|
||||||
|
return stored || DEFAULT_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setServerUrl(url) {
|
||||||
|
if (isServiceWorker()) {
|
||||||
|
await chrome.storage.local.set({ muyue_server_url: url });
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('muyue_server_url', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildWsUrl(token) {
|
||||||
|
const base = await getServerUrl();
|
||||||
|
const wsBase = base.replace(/^http/, 'ws');
|
||||||
|
return `${wsBase}/api/ws/browser-test?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchToken() {
|
||||||
|
const base = await getServerUrl();
|
||||||
|
const res = await fetch(`${base}/api/test/snippet`);
|
||||||
|
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return { token: data.token, wsUrl: data.ws_url, expiresIn: data.expires_in };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSessions() {
|
||||||
|
const base = await getServerUrl();
|
||||||
|
const res = await fetch(`${base}/api/test/sessions`);
|
||||||
|
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.sessions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkServerHealth() {
|
||||||
|
try {
|
||||||
|
const base = await getServerUrl();
|
||||||
|
const res = await fetch(`${base}/api/info`, { signal: AbortSignal.timeout(3000) });
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
extension/src/lib/page-rpc.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
let lastList = [];
|
||||||
|
|
||||||
|
function safeText(el) {
|
||||||
|
let t = (el.innerText || el.textContent || '').trim();
|
||||||
|
if (t.length > 80) t = t.slice(0, 80) + '…';
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(el) {
|
||||||
|
let sel = el.id ? '#' + el.id : el.tagName.toLowerCase();
|
||||||
|
if (!el.id && el.className && typeof el.className === 'string') {
|
||||||
|
sel += '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.');
|
||||||
|
}
|
||||||
|
const label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
|
||||||
|
return {
|
||||||
|
tag: el.tagName.toLowerCase(),
|
||||||
|
selector: sel,
|
||||||
|
text: safeText(el),
|
||||||
|
label,
|
||||||
|
type: el.getAttribute('type') || '',
|
||||||
|
disabled: !!el.disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listClickables() {
|
||||||
|
const els = Array.from(
|
||||||
|
document.querySelectorAll(
|
||||||
|
'button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
lastList = els.filter((e) => {
|
||||||
|
const r = e.getBoundingClientRect();
|
||||||
|
return r.width > 0 && r.height > 0;
|
||||||
|
});
|
||||||
|
return lastList.map((el, i) => {
|
||||||
|
const d = describe(el);
|
||||||
|
d.index = i;
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clickElement(params) {
|
||||||
|
let el;
|
||||||
|
if (params.selector) el = document.querySelector(params.selector);
|
||||||
|
else if (typeof params.index === 'number') el = lastList[params.index];
|
||||||
|
if (!el) return { ok: false, error: 'element not found' };
|
||||||
|
if (el.disabled) return { ok: false, error: 'element is disabled' };
|
||||||
|
try {
|
||||||
|
el.scrollIntoView({ block: 'center' });
|
||||||
|
el.click();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeText(params) {
|
||||||
|
let el;
|
||||||
|
if (params.selector) el = document.querySelector(params.selector);
|
||||||
|
else if (typeof params.index === 'number') el = lastList[params.index];
|
||||||
|
if (!el) return { ok: false, error: 'element not found' };
|
||||||
|
const proto = Object.getPrototypeOf(el);
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||||
|
try {
|
||||||
|
if (setter && setter.set) setter.set.call(el, params.text || '');
|
||||||
|
else el.value = params.text || '';
|
||||||
|
} catch {
|
||||||
|
el.value = params.text || '';
|
||||||
|
}
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evalExpr(params) {
|
||||||
|
try {
|
||||||
|
const r = (0, eval)(params.expr);
|
||||||
|
return { ok: true, value: serialize(r) };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentUrl() {
|
||||||
|
return { url: location.href, title: document.title };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(v) {
|
||||||
|
if (v === undefined) return 'undefined';
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(v));
|
||||||
|
} catch {
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatch(msg) {
|
||||||
|
const p = msg.params || {};
|
||||||
|
switch (msg.action) {
|
||||||
|
case 'list_clickables':
|
||||||
|
return listClickables();
|
||||||
|
case 'click':
|
||||||
|
return clickElement(p);
|
||||||
|
case 'eval':
|
||||||
|
return evalExpr(p);
|
||||||
|
case 'current_url':
|
||||||
|
return currentUrl();
|
||||||
|
case 'type':
|
||||||
|
return typeText(p);
|
||||||
|
default:
|
||||||
|
return { ok: false, error: 'unknown action: ' + msg.action };
|
||||||
|
}
|
||||||
|
}
|
||||||
211
extension/src/styles/panel.css
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--text-primary: #e8e8f0;
|
||||||
|
--text-secondary: #9999aa;
|
||||||
|
--accent: #ff4757;
|
||||||
|
--accent-dim: rgba(255, 71, 87, 0.15);
|
||||||
|
--accent-glow: rgba(255, 71, 87, 0.4);
|
||||||
|
--green: #3aaa61;
|
||||||
|
--yellow: #f5a623;
|
||||||
|
--red: #ff6b6b;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row + .status-row {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
.dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||||
|
.dot-yellow { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #e8414f;
|
||||||
|
box-shadow: 0 0 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
30
extension/wxt.config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
srcDir: 'src',
|
||||||
|
suppressWarnings: {
|
||||||
|
firefoxDataCollection: true,
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'Muyue',
|
||||||
|
description: 'AI-powered browser testing & automation — connected to your Muyue desktop app',
|
||||||
|
permissions: [
|
||||||
|
'storage',
|
||||||
|
'activeTab',
|
||||||
|
'tabs',
|
||||||
|
'sidePanel',
|
||||||
|
'scripting',
|
||||||
|
'notifications',
|
||||||
|
],
|
||||||
|
host_permissions: ['http://127.0.0.1:*/*', 'http://localhost:*/*'],
|
||||||
|
action: {
|
||||||
|
default_icon: {
|
||||||
|
16: 'icon/16.png',
|
||||||
|
32: 'icon/32.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
side_panel: {
|
||||||
|
default_path: 'sidepanel.html',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -66,23 +66,52 @@ Muyue gère :
|
|||||||
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
||||||
|
|
||||||
<browser_test_strategy>
|
<browser_test_strategy>
|
||||||
Quand l'utilisateur demande de **tester** une UI / une page (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit déjà être connectée via le snippet de l'onglet "Tests" — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet.
|
Quand l'utilisateur demande de **tester** une UI (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit être connectée via le snippet de l'onglet **Tests** — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet (le même token reste valide même après reload : si la connexion est perdue, l'utilisateur n'a qu'à re-coller).
|
||||||
|
|
||||||
Boucle recommandée :
|
## Règle d'or — économise les appels d'outils
|
||||||
|
|
||||||
1. `browser_test` action `summary` — voir l'URL, le titre et les dernières erreurs console déjà présentes.
|
**N'appelle PAS `list_clickables` après chaque clic.** C'est l'erreur n°1 qui fait exploser ta boucle (150+ appels pour 5 actions humaines). La liste change rarement et chaque appel renvoie ~30-100 éléments.
|
||||||
2. `browser_test` action `list_clickables` — récupérer la liste indexée des boutons / liens / inputs cliquables.
|
|
||||||
3. Pour chaque cible : `browser_test` action `click` (avec `index` ou `selector`).
|
Stratégie efficace :
|
||||||
4. Immédiatement après chaque clic, **regarde le `console_delta` retourné** : c'est la liste des messages console émis pendant le clic. `level: "error"` = bouton cassé.
|
|
||||||
5. Vérifie aussi `current_url` retourné — un changement d'URL inattendu peut signaler un bug.
|
1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche).
|
||||||
6. Si l'élément ouvre un dialog ou modifie le DOM, refais `list_clickables` pour découvrir les nouveaux éléments.
|
2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic.
|
||||||
7. Pour les inputs : utilise `type` avant `click` sur le bouton de soumission.
|
3. **Re-list seulement si** :
|
||||||
8. À la fin, fournis un **rapport** structuré : ✓ boutons OK / ✗ boutons cassés (avec le message d'erreur exact) / ⚠ boutons disabled ou non trouvés.
|
- le `current_url` retourné change ET la nouvelle page est inconnue,
|
||||||
|
- OU un clic ouvre un dialog / nouveau composant que tu dois inspecter,
|
||||||
|
- OU `click` retourne `element not found` (DOM a muté).
|
||||||
|
4. Pour les pages SPA qui rechargent côté URL mais pas le DOM, vérifie d'abord avec `eval document.querySelectorAll('button').length` — si stable, ne re-liste pas.
|
||||||
|
5. Si tu te sens bloqué, **ne boucle pas en aveugle**. Fais 1 `summary`, 1 `eval` ciblé, et demande de l'aide à l'utilisateur. Mieux vaut 5 appels et une question qu'une boucle de 50 appels.
|
||||||
|
|
||||||
|
## Actions disponibles
|
||||||
|
|
||||||
|
| Action | Quand l'utiliser |
|
||||||
|
|---|---|
|
||||||
|
| `summary` | État de la page (URL, titre, 20 dernières lignes console). Appel **bon marché**. |
|
||||||
|
| `list_clickables` | Liste indexée des boutons/liens/inputs visibles. **Appel cher** (~50+ items) — utilise avec parcimonie. |
|
||||||
|
| `click` (par `index` de préférence) | Clique. Retourne `console_delta` + `current_url`. |
|
||||||
|
| `type` | Remplit un input (par `selector` ou `index`). Toujours suivi d'un `click` sur le bouton submit. |
|
||||||
|
| `eval` | JS arbitraire. Idéal pour des questions ciblées (`document.title`, `document.querySelectorAll(X).length`, etc.) au lieu de `list_clickables` complet. |
|
||||||
|
| `current_url` | URL+titre. Très bon marché. |
|
||||||
|
| `wait` | Pause 200-500 ms après une action async (transition / fetch). |
|
||||||
|
| `console` | N dernières lignes console (default 50). Pour debug post-incident. |
|
||||||
|
| `screenshot` | Capture viewport (ou `selector`) et sauve dans `~/.muyue/screenshots/<filename>.png`. Utilise `filename` pour nommer ; sinon timestamp. Best-effort (CSS externe / images peuvent ne pas apparaître). |
|
||||||
|
|
||||||
|
## Rapport final
|
||||||
|
|
||||||
|
Quand tous les tests sont terminés, fournis un rapport **structuré et bref** :
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Boutons OK : <liste des labels>
|
||||||
|
✗ Boutons cassés : <label> — <message d'erreur exact du console_delta>
|
||||||
|
⚠ Bloqués : <label> — <pourquoi> (disabled, non trouvé, etc.)
|
||||||
|
📸 Captures : <chemins relatifs sous ~/.muyue/screenshots/>
|
||||||
|
```
|
||||||
|
|
||||||
Astuces :
|
Astuces :
|
||||||
- Préfère cliquer **par `index`** que par sélecteur — le sélecteur change avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
- Clique **par index** ; le sélecteur peut changer avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
||||||
- Entre deux actions sensibles, `wait` 200-500 ms si la page a des transitions / fetches asynchrones.
|
|
||||||
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
||||||
|
- Si la page se recharge (`current_url` change ou la connexion tombe), demande à l'utilisateur de recoller le snippet — le même token marche.
|
||||||
</browser_test_strategy>
|
</browser_test_strategy>
|
||||||
|
|
||||||
<tool_strategy>
|
<tool_strategy>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,8 +26,20 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// thin os wrappers (kept here so saveScreenshot stays independent of any
|
||||||
|
// existing helper file's evolution)
|
||||||
|
func osUserHomeDir() (string, error) { return os.UserHomeDir() }
|
||||||
|
func mkdirAll(p string, m os.FileMode) error { return os.MkdirAll(p, m) }
|
||||||
|
func writeFile(p string, b []byte, m os.FileMode) error { return os.WriteFile(p, b, m) }
|
||||||
|
func base64StdDecode(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
|
||||||
|
|
||||||
const (
|
const (
|
||||||
browserTestTokenTTL = 5 * time.Minute
|
// browserTestTokenTTL is a sliding window: every successful WS connect
|
||||||
|
// using the token resets it. So the user re-pasting the snippet after a
|
||||||
|
// page reload / navigation seamlessly resumes (same token, same session
|
||||||
|
// continuation in the AI's view), as long as no more than this gap of
|
||||||
|
// inactivity occurs.
|
||||||
|
browserTestTokenTTL = 60 * time.Minute
|
||||||
browserTestCommandTTL = 30 * time.Second
|
browserTestCommandTTL = 30 * time.Second
|
||||||
browserTestConsoleMax = 200
|
browserTestConsoleMax = 200
|
||||||
browserTestSessionsMax = 16
|
browserTestSessionsMax = 16
|
||||||
@@ -86,7 +100,11 @@ func (s *BrowserTestStore) IssueToken() string {
|
|||||||
return tok
|
return tok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsumeToken validates and removes a token in one step.
|
// ConsumeToken validates a token. Tokens are no longer single-use:
|
||||||
|
// the test snippet re-establishes the WS after every page reload /
|
||||||
|
// navigation, so the same token must work multiple times. We slide the
|
||||||
|
// expiration on each successful use so a long active test session keeps
|
||||||
|
// the token alive.
|
||||||
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
||||||
s.tokensMu.Lock()
|
s.tokensMu.Lock()
|
||||||
defer s.tokensMu.Unlock()
|
defer s.tokensMu.Unlock()
|
||||||
@@ -94,8 +112,12 @@ func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if time.Since(t) > browserTestTokenTTL {
|
||||||
delete(s.tokens, tok)
|
delete(s.tokens, tok)
|
||||||
return time.Since(t) <= browserTestTokenTTL
|
return false
|
||||||
|
}
|
||||||
|
s.tokens[tok] = time.Now() // sliding refresh
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register inserts a new session, evicting the oldest if at capacity.
|
// Register inserts a new session, evicting the oldest if at capacity.
|
||||||
@@ -377,14 +399,15 @@ func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
|
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
|
||||||
type BrowserTestParams struct {
|
type BrowserTestParams struct {
|
||||||
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary"`
|
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary, screenshot"`
|
||||||
SessionID string `json:"session_id,omitempty" description:"Browser session id (optional, defaults to most recent)"`
|
SessionID string `json:"session_id,omitempty" description:"Browser session id (optional, defaults to most recent)"`
|
||||||
Selector string `json:"selector,omitempty" description:"CSS selector for click/type actions"`
|
Selector string `json:"selector,omitempty" description:"CSS selector for click/type/screenshot actions (screenshot defaults to whole viewport when omitted)"`
|
||||||
Index int `json:"index,omitempty" description:"Alternative to selector: index into the last list_clickables result (0-based)"`
|
Index int `json:"index,omitempty" description:"Alternative to selector: index into the last list_clickables result (0-based)"`
|
||||||
Expr string `json:"expr,omitempty" description:"JS expression to evaluate (eval action only)"`
|
Expr string `json:"expr,omitempty" description:"JS expression to evaluate (eval action only)"`
|
||||||
Text string `json:"text,omitempty" description:"Text to type (type action only)"`
|
Text string `json:"text,omitempty" description:"Text to type (type action only)"`
|
||||||
WaitMs int `json:"wait_ms,omitempty" description:"Milliseconds to wait (wait action only, max 5000)"`
|
WaitMs int `json:"wait_ms,omitempty" description:"Milliseconds to wait (wait action only, max 5000)"`
|
||||||
Tail int `json:"tail,omitempty" description:"Console action: how many recent lines to return (default 50, max 200)"`
|
Tail int `json:"tail,omitempty" description:"Console action: how many recent lines to return (default 50, max 200)"`
|
||||||
|
Filename string `json:"filename,omitempty" description:"Screenshot action: optional file name (no path, no extension); defaults to a timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterBrowserTestTool wires the agent tool against a session store.
|
// RegisterBrowserTestTool wires the agent tool against a session store.
|
||||||
@@ -401,7 +424,7 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
|
|||||||
switch action {
|
switch action {
|
||||||
case "":
|
case "":
|
||||||
return agent.TextErrorResponse("action is required"), nil
|
return agent.TextErrorResponse("action is required"), nil
|
||||||
case "list_clickables", "click", "eval", "current_url", "type":
|
case "list_clickables", "click", "eval", "current_url", "type", "screenshot":
|
||||||
case "console", "summary", "wait":
|
case "console", "summary", "wait":
|
||||||
default:
|
default:
|
||||||
return agent.TextErrorResponse("unknown action: " + p.Action), nil
|
return agent.TextErrorResponse("unknown action: " + p.Action), nil
|
||||||
@@ -479,6 +502,23 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
|
|||||||
return agent.TextErrorResponse(err.Error()), nil
|
return agent.TextErrorResponse(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Screenshot post-processing: snippet returns a base64 data URL;
|
||||||
|
// decode and write to ~/.muyue/screenshots/<filename>.png so the
|
||||||
|
// AI can reference an on-disk path rather than streaming megabytes
|
||||||
|
// of base64 back through its context.
|
||||||
|
if action == "screenshot" {
|
||||||
|
saved, perr := saveScreenshot(payload, p.Filename)
|
||||||
|
if perr != nil {
|
||||||
|
return agent.TextErrorResponse("screenshot save: " + perr.Error()), nil
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(map[string]interface{}{
|
||||||
|
"action": "screenshot",
|
||||||
|
"saved_to": saved,
|
||||||
|
"current_url": sess.URL,
|
||||||
|
}, "", " ")
|
||||||
|
return agent.TextResponse(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Console delta: messages logged during this command.
|
// Console delta: messages logged during this command.
|
||||||
post := sess.SnapshotConsole()
|
post := sess.SnapshotConsole()
|
||||||
var delta []ConsoleEntry
|
var delta []ConsoleEntry
|
||||||
@@ -504,14 +544,21 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
|
|||||||
// Snippet generator ----------------------------------------------------------
|
// Snippet generator ----------------------------------------------------------
|
||||||
|
|
||||||
func buildBrowserTestSnippet(wsURL string) string {
|
func buildBrowserTestSnippet(wsURL string) string {
|
||||||
// Note: this is the JS injected into the user's target page. It opens the
|
// Inline JS injected into the user's target page. Responsibilities:
|
||||||
// WS, hooks console, and dispatches commands. Kept terse on purpose.
|
// - open the WS, with auto-reconnect (exponential backoff capped at 5s)
|
||||||
|
// - hook console.log/info/warn/error/debug + window.onerror + unhandledrejection
|
||||||
|
// - dispatch RPC commands: list_clickables, click, type, eval, current_url, screenshot
|
||||||
|
// - re-establish WS on transient close (network blip, server restart, etc.)
|
||||||
|
//
|
||||||
|
// Across full page navigation / reload the JS context is destroyed —
|
||||||
|
// no JS-only mechanism can survive that. The token is reusable (sliding
|
||||||
|
// 60-min TTL server-side), so the user just re-pastes the same snippet
|
||||||
|
// from the Tests tab to resume.
|
||||||
return `(function(){
|
return `(function(){
|
||||||
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
||||||
var WS_URL = ` + jsString(wsURL) + `;
|
var WS_URL = ` + jsString(wsURL) + `;
|
||||||
var ws = new WebSocket(WS_URL);
|
var ws = null, lastList = [], retry = 0;
|
||||||
var lastList = [];
|
function send(obj){ try{ if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }catch(e){} }
|
||||||
function send(obj){ try{ ws.send(JSON.stringify(obj)); }catch(e){} }
|
|
||||||
function reply(id, data){ send({type:'reply', id:id, data:data}); }
|
function reply(id, data){ send({type:'reply', id:id, data:data}); }
|
||||||
function safeText(el){
|
function safeText(el){
|
||||||
var t = (el.innerText || el.textContent || '').trim();
|
var t = (el.innerText || el.textContent || '').trim();
|
||||||
@@ -537,6 +584,36 @@ func buildBrowserTestSnippet(wsURL string) string {
|
|||||||
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
|
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
|
||||||
catch(e){ return { ok:false, error:String(e) }; }
|
catch(e){ return { ok:false, error:String(e) }; }
|
||||||
}
|
}
|
||||||
|
// Best-effort viewport screenshot via SVG foreignObject — works on most
|
||||||
|
// pages, but external CSS / images / iframes won't be inlined. Returns a
|
||||||
|
// base64 PNG data URL the server will save to disk.
|
||||||
|
function screenshot(p){
|
||||||
|
return new Promise(function(resolve){
|
||||||
|
try {
|
||||||
|
var w = Math.max(document.documentElement.clientWidth, 1024);
|
||||||
|
var h = Math.max(window.innerHeight, 768);
|
||||||
|
var node = (p && p.selector) ? document.querySelector(p.selector) : document.documentElement;
|
||||||
|
if (!node) { resolve({ ok:false, error:'selector not found' }); return; }
|
||||||
|
var rect = node.getBoundingClientRect();
|
||||||
|
if (node === document.documentElement) { rect = { width:w, height:h }; }
|
||||||
|
var clone = node.cloneNode(true);
|
||||||
|
var ser = new XMLSerializer().serializeToString(clone);
|
||||||
|
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'+Math.round(rect.width)+'" height="'+Math.round(rect.height)+'">' +
|
||||||
|
'<foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" style="background:white">' + ser + '</div></foreignObject></svg>';
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function(){
|
||||||
|
try {
|
||||||
|
var c = document.createElement('canvas');
|
||||||
|
c.width = Math.round(rect.width); c.height = Math.round(rect.height);
|
||||||
|
c.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
resolve({ ok:true, data_url: c.toDataURL('image/png'), width: c.width, height: c.height });
|
||||||
|
} catch(e){ resolve({ ok:false, error:'canvas: '+String(e) }); }
|
||||||
|
};
|
||||||
|
img.onerror = function(){ resolve({ ok:false, error:'image load failed (CSP or invalid SVG)' }); };
|
||||||
|
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||||
|
} catch(e){ resolve({ ok:false, error:String(e) }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
function dispatch(msg){
|
function dispatch(msg){
|
||||||
var p = msg.params || {};
|
var p = msg.params || {};
|
||||||
switch(msg.action){
|
switch(msg.action){
|
||||||
@@ -563,6 +640,8 @@ func buildBrowserTestSnippet(wsURL string) string {
|
|||||||
el.dispatchEvent(new Event('change', {bubbles:true}));
|
el.dispatchEvent(new Event('change', {bubbles:true}));
|
||||||
return { ok:true };
|
return { ok:true };
|
||||||
}
|
}
|
||||||
|
case 'screenshot':
|
||||||
|
return screenshot(p);
|
||||||
}
|
}
|
||||||
return { ok:false, error:'unknown action' };
|
return { ok:false, error:'unknown action' };
|
||||||
}
|
}
|
||||||
@@ -594,15 +673,29 @@ func buildBrowserTestSnippet(wsURL string) string {
|
|||||||
setInterval(function(){
|
setInterval(function(){
|
||||||
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
|
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
|
||||||
}, 500);
|
}, 500);
|
||||||
ws.onopen = function(){ send({type:'hello', url: location.href, title: document.title}); };
|
function connect(){
|
||||||
|
ws = new WebSocket(WS_URL);
|
||||||
|
ws.onopen = function(){ retry = 0; send({type:'hello', url: location.href, title: document.title}); };
|
||||||
ws.onmessage = function(ev){
|
ws.onmessage = function(ev){
|
||||||
try { var msg = JSON.parse(ev.data); }
|
try { var msg = JSON.parse(ev.data); } catch(e){ return; }
|
||||||
catch(e){ return; }
|
|
||||||
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
|
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
|
||||||
if (msg.action) reply(msg.id, dispatch(msg));
|
if (msg.action) {
|
||||||
|
var out = dispatch(msg);
|
||||||
|
if (out && typeof out.then === 'function') { out.then(function(r){ reply(msg.id, r); }); }
|
||||||
|
else { reply(msg.id, out); }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
ws.onclose = function(){ console.log('[Muyue] runner disconnected'); window.__muyueTestRunner = null; };
|
ws.onclose = function(){
|
||||||
window.__muyueTestRunner = { ws: ws, list: list };
|
// Same-page transient disconnect → reconnect with backoff up to ~5s.
|
||||||
|
// Full navigation kills the JS context entirely — this never runs in
|
||||||
|
// that case; the user re-pastes the snippet (same token works).
|
||||||
|
retry = Math.min(retry + 1, 5);
|
||||||
|
setTimeout(connect, 500 * retry);
|
||||||
|
};
|
||||||
|
ws.onerror = function(){ /* onclose will fire next */ };
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
window.__muyueTestRunner = { reconnect: connect, list: list };
|
||||||
})();`
|
})();`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,3 +703,70 @@ func jsString(s string) string {
|
|||||||
b, _ := json.Marshal(s)
|
b, _ := json.Marshal(s)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveScreenshot decodes the base64 PNG returned by the snippet's
|
||||||
|
// screenshot action and writes it to ~/.muyue/screenshots/<name>.png.
|
||||||
|
// Returns the absolute path saved, or an error.
|
||||||
|
func saveScreenshot(replyPayload json.RawMessage, requestedName string) (string, error) {
|
||||||
|
var reply struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DataURL string `json:"data_url,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(replyPayload, &reply); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid reply: %w", err)
|
||||||
|
}
|
||||||
|
if !reply.OK {
|
||||||
|
if reply.Error != "" {
|
||||||
|
return "", fmt.Errorf("snippet: %s", reply.Error)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("snippet returned ok=false")
|
||||||
|
}
|
||||||
|
const prefix = "data:image/png;base64,"
|
||||||
|
if !strings.HasPrefix(reply.DataURL, prefix) {
|
||||||
|
return "", fmt.Errorf("unexpected data URL prefix")
|
||||||
|
}
|
||||||
|
raw, err := base64StdDecode(reply.DataURL[len(prefix):])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("base64: %w", err)
|
||||||
|
}
|
||||||
|
dir, err := screenshotDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := sanitizeFilename(requestedName)
|
||||||
|
if name == "" {
|
||||||
|
name = time.Now().Format("20060102-150405")
|
||||||
|
}
|
||||||
|
path := dir + "/" + name + ".png"
|
||||||
|
if err := writeFile(path, raw, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func screenshotDir() (string, error) {
|
||||||
|
home, err := osUserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := home + "/.muyue/screenshots"
|
||||||
|
if err := mkdirAll(dir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeFilename keeps a safe subset (letters / digits / _ / - / .) so
|
||||||
|
// the user-supplied name cannot escape the screenshots directory.
|
||||||
|
func sanitizeFilename(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||||
|
r == '_', r == '-', r == '.':
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// MaxToolIterations bounds the inner tool-call loop in RunWithTools /
|
||||||
MaxToolIterations = 15
|
// RunNonStream. The cap exists only to avoid an infinite loop when a model
|
||||||
)
|
// keeps calling tools forever; the value is intentionally generous so a
|
||||||
|
// realistic agent run (multi-file refactor, exploratory debugging…) never
|
||||||
|
// hits it. If you find yourself raising this to absurd values, look for a
|
||||||
|
// loop bug in the model output instead.
|
||||||
|
const MaxToolIterations = 500
|
||||||
|
|
||||||
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
||||||
type ToolLimiter func(toolName string) (release func(), err error)
|
type ToolLimiter func(toolName string) (release func(), err error)
|
||||||
|
|||||||
@@ -756,93 +756,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
m := sysMetrics{}
|
m := collectSystemMetrics()
|
||||||
|
|
||||||
// CPU from /proc/stat
|
|
||||||
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
|
||||||
line := strings.Split(string(data), "\n")[0]
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 5 {
|
|
||||||
var idle, total float64
|
|
||||||
for i := 1; i < len(fields) && i <= 4; i++ {
|
|
||||||
var v float64
|
|
||||||
fmt.Sscanf(fields[i], "%f", &v)
|
|
||||||
total += v
|
|
||||||
if i == 4 {
|
|
||||||
idle = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if lastCPUSet {
|
|
||||||
dIdle := idle - lastCPU[0]
|
|
||||||
dTotal := total - lastCPU[1]
|
|
||||||
if dTotal > 0 {
|
|
||||||
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastCPU = [2]float64{idle, total}
|
|
||||||
lastCPUSet = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory from /proc/meminfo
|
|
||||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
|
||||||
var memTotal, memAvailable float64
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var v float64
|
|
||||||
fmt.Sscanf(fields[1], "%f", &v)
|
|
||||||
switch fields[0] {
|
|
||||||
case "MemTotal:":
|
|
||||||
memTotal = v
|
|
||||||
case "MemAvailable:":
|
|
||||||
memAvailable = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if memTotal > 0 {
|
|
||||||
m.MemTotalMB = memTotal / 1024
|
|
||||||
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
|
||||||
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network from /proc/net/dev
|
|
||||||
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
|
||||||
var rxBytes, txBytes float64
|
|
||||||
for _, line := range strings.Split(string(data), "\n")[2:] {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 10 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
iface := strings.TrimSuffix(fields[0], ":")
|
|
||||||
if iface == "lo" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var rx, tx float64
|
|
||||||
fmt.Sscanf(fields[1], "%f", &rx)
|
|
||||||
fmt.Sscanf(fields[9], "%f", &tx)
|
|
||||||
rxBytes += rx
|
|
||||||
txBytes += tx
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
if !lastNetTs.IsZero() {
|
|
||||||
elapsed := now.Sub(lastNetTs).Seconds()
|
|
||||||
if elapsed > 0 {
|
|
||||||
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
|
||||||
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
|
||||||
if m.NetRxKBs < 0 {
|
|
||||||
m.NetRxKBs = 0
|
|
||||||
}
|
|
||||||
if m.NetTxKBs < 0 {
|
|
||||||
m.NetTxKBs = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastNet = [2]float64{rxBytes, txBytes}
|
|
||||||
lastNetTs = now
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, m)
|
writeJSON(w, m)
|
||||||
}
|
}
|
||||||
|
|||||||
106
internal/api/metrics_unix.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// collectSystemMetrics reads /proc on Linux. On macOS / BSD this returns
|
||||||
|
// zeroes for files that don't exist — the dashboard panel renders blanks
|
||||||
|
// rather than crashing. macOS-specific metrics could be added later via
|
||||||
|
// `vm_stat` / `iostat` parsing.
|
||||||
|
func collectSystemMetrics() sysMetrics {
|
||||||
|
m := sysMetrics{}
|
||||||
|
|
||||||
|
// CPU from /proc/stat
|
||||||
|
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
||||||
|
line := strings.Split(string(data), "\n")[0]
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 5 {
|
||||||
|
var idle, total float64
|
||||||
|
for i := 1; i < len(fields) && i <= 4; i++ {
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(fields[i], "%f", &v)
|
||||||
|
total += v
|
||||||
|
if i == 4 {
|
||||||
|
idle = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastCPUSet {
|
||||||
|
dIdle := idle - lastCPU[0]
|
||||||
|
dTotal := total - lastCPU[1]
|
||||||
|
if dTotal > 0 {
|
||||||
|
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastCPU = [2]float64{idle, total}
|
||||||
|
lastCPUSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory from /proc/meminfo
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
var memTotal, memAvailable float64
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(fields[1], "%f", &v)
|
||||||
|
switch fields[0] {
|
||||||
|
case "MemTotal:":
|
||||||
|
memTotal = v
|
||||||
|
case "MemAvailable:":
|
||||||
|
memAvailable = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memTotal > 0 {
|
||||||
|
m.MemTotalMB = memTotal / 1024
|
||||||
|
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
||||||
|
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network from /proc/net/dev
|
||||||
|
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
||||||
|
var rxBytes, txBytes float64
|
||||||
|
for _, line := range strings.Split(string(data), "\n")[2:] {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface := strings.TrimSuffix(fields[0], ":")
|
||||||
|
if iface == "lo" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rx, tx float64
|
||||||
|
fmt.Sscanf(fields[1], "%f", &rx)
|
||||||
|
fmt.Sscanf(fields[9], "%f", &tx)
|
||||||
|
rxBytes += rx
|
||||||
|
txBytes += tx
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if !lastNetTs.IsZero() {
|
||||||
|
elapsed := now.Sub(lastNetTs).Seconds()
|
||||||
|
if elapsed > 0 {
|
||||||
|
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
||||||
|
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
||||||
|
if m.NetRxKBs < 0 {
|
||||||
|
m.NetRxKBs = 0
|
||||||
|
}
|
||||||
|
if m.NetTxKBs < 0 {
|
||||||
|
m.NetTxKBs = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastNet = [2]float64{rxBytes, txBytes}
|
||||||
|
lastNetTs = now
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
129
internal/api/metrics_windows.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// collectSystemMetrics reads CPU% and memory from kernel32 directly.
|
||||||
|
// Network throughput on Windows is left at zero for now — the iphlpapi
|
||||||
|
// MIB_IF_ROW2 layout is large and version-sensitive; reliable net stats
|
||||||
|
// would warrant a separate, well-tested implementation. CPU + RAM are
|
||||||
|
// enough for the dashboard's main signal.
|
||||||
|
func collectSystemMetrics() sysMetrics {
|
||||||
|
m := sysMetrics{}
|
||||||
|
|
||||||
|
if cpu, ok := readWindowsCPUPercent(); ok {
|
||||||
|
m.CPUPercent = cpu
|
||||||
|
}
|
||||||
|
if memTotalMB, memUsedMB, memPct, ok := readWindowsMemory(); ok {
|
||||||
|
m.MemTotalMB = memTotalMB
|
||||||
|
m.MemUsedMB = memUsedMB
|
||||||
|
m.MemPercent = memPct
|
||||||
|
}
|
||||||
|
// Net: zero (TODO).
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CPU ---------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
cpuOnce sync.Once
|
||||||
|
getSystemTimes *syscall.LazyProc
|
||||||
|
lastWinCPUIdle uint64
|
||||||
|
lastWinCPUTotal uint64
|
||||||
|
lastWinCPUSet bool
|
||||||
|
winCPUMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadCPUFns() {
|
||||||
|
cpuOnce.Do(func() {
|
||||||
|
k := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
getSystemTimes = k.NewProc("GetSystemTimes")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func filetimeToUint64(low, high uint32) uint64 {
|
||||||
|
return uint64(high)<<32 | uint64(low)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWindowsCPUPercent samples GetSystemTimes twice and computes the busy
|
||||||
|
// ratio as 1 - dIdle / (dKernel + dUser). The first call returns 0% and
|
||||||
|
// stores the baseline; subsequent calls return the delta-based percentage.
|
||||||
|
func readWindowsCPUPercent() (float64, bool) {
|
||||||
|
loadCPUFns()
|
||||||
|
if getSystemTimes == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var idle, kernel, user windows.Filetime
|
||||||
|
r1, _, _ := getSystemTimes.Call(
|
||||||
|
uintptr(unsafe.Pointer(&idle)),
|
||||||
|
uintptr(unsafe.Pointer(&kernel)),
|
||||||
|
uintptr(unsafe.Pointer(&user)),
|
||||||
|
)
|
||||||
|
if r1 == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
idleT := filetimeToUint64(idle.LowDateTime, idle.HighDateTime)
|
||||||
|
totalT := filetimeToUint64(kernel.LowDateTime, kernel.HighDateTime) +
|
||||||
|
filetimeToUint64(user.LowDateTime, user.HighDateTime)
|
||||||
|
winCPUMu.Lock()
|
||||||
|
defer winCPUMu.Unlock()
|
||||||
|
if !lastWinCPUSet {
|
||||||
|
lastWinCPUIdle = idleT
|
||||||
|
lastWinCPUTotal = totalT
|
||||||
|
lastWinCPUSet = true
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
dIdle := idleT - lastWinCPUIdle
|
||||||
|
dTotal := totalT - lastWinCPUTotal
|
||||||
|
lastWinCPUIdle = idleT
|
||||||
|
lastWinCPUTotal = totalT
|
||||||
|
if dTotal == 0 {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
pct := (1 - float64(dIdle)/float64(dTotal)) * 100
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
} else if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
return pct, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Memory ------------------------------------------------------------
|
||||||
|
|
||||||
|
type memoryStatusEx struct {
|
||||||
|
Length uint32
|
||||||
|
MemoryLoad uint32
|
||||||
|
TotalPhys uint64
|
||||||
|
AvailPhys uint64
|
||||||
|
TotalPageFile uint64
|
||||||
|
AvailPageFile uint64
|
||||||
|
TotalVirtual uint64
|
||||||
|
AvailVirtual uint64
|
||||||
|
AvailExtendedVirtual uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalMemoryStatusEx = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
|
||||||
|
|
||||||
|
func readWindowsMemory() (totalMB, usedMB, percent float64, ok bool) {
|
||||||
|
var ms memoryStatusEx
|
||||||
|
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||||
|
r1, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
|
||||||
|
if r1 == 0 {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
const mb = 1024 * 1024
|
||||||
|
totalMB = float64(ms.TotalPhys) / mb
|
||||||
|
usedMB = float64(ms.TotalPhys-ms.AvailPhys) / mb
|
||||||
|
if ms.TotalPhys > 0 {
|
||||||
|
percent = float64(ms.TotalPhys-ms.AvailPhys) * 100 / float64(ms.TotalPhys)
|
||||||
|
}
|
||||||
|
return totalMB, usedMB, percent, true
|
||||||
|
}
|
||||||
271
internal/api/terminal_conpty_windows.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
// Windows ConPTY (Pseudo Console) backend for the terminal tab.
|
||||||
|
//
|
||||||
|
// creack/pty/v2 returns "operating system not supported" on Windows, so the
|
||||||
|
// previous fallback was plain stdin/stdout pipes (terminal_session.go::
|
||||||
|
// pipeSession). Pipes don't carry TTY signals, so cmd.exe / pwsh / wsl
|
||||||
|
// detect "no TTY" and either go silent or wait forever — the user sees a
|
||||||
|
// black screen. This file implements a real pseudo console using the
|
||||||
|
// kernel32 ConPTY API, so the spawned shell behaves as if it were attached
|
||||||
|
// to a real terminal: prompts render, ANSI escapes are honoured, resize
|
||||||
|
// events propagate.
|
||||||
|
//
|
||||||
|
// Requires Windows 10 v1809 (build 17763) or newer. On older hosts
|
||||||
|
// CreatePseudoConsole returns an error and startTermSession_windows falls
|
||||||
|
// back to pipeSession.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
procThreadAttributePseudoconsole = 0x00020016
|
||||||
|
extendedStartupinfoPresent = 0x00080000
|
||||||
|
createUnicodeEnvironment = 0x00000400
|
||||||
|
)
|
||||||
|
|
||||||
|
// conptySession drives a Windows pseudo console.
|
||||||
|
type conptySession struct {
|
||||||
|
hPC windows.Handle
|
||||||
|
inWrite windows.Handle
|
||||||
|
outRead windows.Handle
|
||||||
|
procInfo windows.ProcessInformation
|
||||||
|
closed bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// startConptySession spins up the pseudo console, plumbs the pipes, and
|
||||||
|
// CreateProcessW's the child with the PC attached via STARTUPINFOEX.
|
||||||
|
func startConptySession(cmd *exec.Cmd) (termSession, error) {
|
||||||
|
// 1. Two pipe pairs: in (we write → child stdin) and out (child stdout → we read).
|
||||||
|
var inRead, inWrite, outRead, outWrite windows.Handle
|
||||||
|
if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil {
|
||||||
|
return nil, fmt.Errorf("create stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil {
|
||||||
|
windows.CloseHandle(inRead)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
return nil, fmt.Errorf("create stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create the pseudo console. After this call ConPTY effectively owns
|
||||||
|
// the child-facing pipe ends (inRead, outWrite); we close our copy.
|
||||||
|
var hPC windows.Handle
|
||||||
|
sz := windows.Coord{X: 120, Y: 30}
|
||||||
|
if err := windows.CreatePseudoConsole(sz, inRead, outWrite, 0, &hPC); err != nil {
|
||||||
|
windows.CloseHandle(inRead)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
windows.CloseHandle(outWrite)
|
||||||
|
return nil, fmt.Errorf("CreatePseudoConsole: %w", err)
|
||||||
|
}
|
||||||
|
windows.CloseHandle(inRead)
|
||||||
|
windows.CloseHandle(outWrite)
|
||||||
|
|
||||||
|
// 3. Allocate an attribute list with one slot for the PC attribute.
|
||||||
|
attrList, err := windows.NewProcThreadAttributeList(1)
|
||||||
|
if err != nil {
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, fmt.Errorf("NewProcThreadAttributeList: %w", err)
|
||||||
|
}
|
||||||
|
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is a quirk of the Win32 API: lpValue
|
||||||
|
// is the HPCON *value* (cast to PVOID), not a pointer to the handle. If
|
||||||
|
// we pass &hPC the kernel reads garbage, the PC attribute is silently
|
||||||
|
// ignored, and cmd/pwsh get their own external console window — which is
|
||||||
|
// exactly the regression v0.7.6 introduced. The cbSize stays the size of
|
||||||
|
// the handle (8 bytes on amd64). Reference: Microsoft EchoCon sample.
|
||||||
|
if err := attrList.Update(
|
||||||
|
procThreadAttributePseudoconsole,
|
||||||
|
unsafe.Pointer(uintptr(hPC)),
|
||||||
|
unsafe.Sizeof(hPC),
|
||||||
|
); err != nil {
|
||||||
|
attrList.Delete()
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, fmt.Errorf("attrList.Update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build command line.
|
||||||
|
cmdLine, err := buildCommandLine(cmd)
|
||||||
|
if err != nil {
|
||||||
|
attrList.Delete()
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmdLineUTF16, err := windows.UTF16PtrFromString(cmdLine)
|
||||||
|
if err != nil {
|
||||||
|
attrList.Delete()
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build the env block (key=value\0...\0\0).
|
||||||
|
var envBlock *uint16
|
||||||
|
if cmd.Env != nil {
|
||||||
|
eb, err := makeEnvBlock(cmd.Env)
|
||||||
|
if err != nil {
|
||||||
|
attrList.Delete()
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
envBlock = eb
|
||||||
|
}
|
||||||
|
|
||||||
|
si := windows.StartupInfoEx{}
|
||||||
|
si.StartupInfo.Cb = uint32(unsafe.Sizeof(si))
|
||||||
|
si.ProcThreadAttributeList = attrList.List()
|
||||||
|
|
||||||
|
flags := uint32(extendedStartupinfoPresent)
|
||||||
|
if envBlock != nil {
|
||||||
|
flags |= createUnicodeEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
var pi windows.ProcessInformation
|
||||||
|
err = windows.CreateProcess(
|
||||||
|
nil, // application name (null = parse from cmdline)
|
||||||
|
cmdLineUTF16,
|
||||||
|
nil, // process security attrs
|
||||||
|
nil, // thread security attrs
|
||||||
|
false, // inherit handles (ConPTY hands handles via attribute list)
|
||||||
|
flags,
|
||||||
|
envBlock,
|
||||||
|
nil, // working dir
|
||||||
|
&si.StartupInfo,
|
||||||
|
&pi,
|
||||||
|
)
|
||||||
|
attrList.Delete()
|
||||||
|
if err != nil {
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, fmt.Errorf("CreateProcess: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &conptySession{
|
||||||
|
hPC: hPC,
|
||||||
|
inWrite: inWrite,
|
||||||
|
outRead: outRead,
|
||||||
|
procInfo: pi,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Read(p []byte) (int, error) {
|
||||||
|
var n uint32
|
||||||
|
err := windows.ReadFile(s.outRead, p, &n, nil)
|
||||||
|
if err != nil {
|
||||||
|
if n > 0 {
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Write(p []byte) (int, error) {
|
||||||
|
var n uint32
|
||||||
|
err := windows.WriteFile(s.inWrite, p, &n, nil)
|
||||||
|
if err != nil {
|
||||||
|
return int(n), err
|
||||||
|
}
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Resize(rows, cols uint16) error {
|
||||||
|
return windows.ResizePseudoConsole(s.hPC, windows.Coord{X: int16(cols), Y: int16(rows)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Order matters: close the pseudo console first so the child sees EOF,
|
||||||
|
// then close our pipe ends, then terminate / close handles.
|
||||||
|
windows.ClosePseudoConsole(s.hPC)
|
||||||
|
windows.CloseHandle(s.inWrite)
|
||||||
|
windows.CloseHandle(s.outRead)
|
||||||
|
if s.procInfo.Process != 0 {
|
||||||
|
windows.TerminateProcess(s.procInfo.Process, 0)
|
||||||
|
windows.CloseHandle(s.procInfo.Process)
|
||||||
|
}
|
||||||
|
if s.procInfo.Thread != 0 {
|
||||||
|
windows.CloseHandle(s.procInfo.Thread)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Wait() error {
|
||||||
|
if s.procInfo.Process == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := windows.WaitForSingleObject(s.procInfo.Process, windows.INFINITE)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Pid() int {
|
||||||
|
return int(s.procInfo.ProcessId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
// buildCommandLine produces the Windows command-line string for an
|
||||||
|
// *exec.Cmd, mirroring what os/exec uses internally (escaping spaces and
|
||||||
|
// quotes per Windows convention).
|
||||||
|
func buildCommandLine(cmd *exec.Cmd) (string, error) {
|
||||||
|
if cmd.Path == "" {
|
||||||
|
return "", fmt.Errorf("empty cmd.Path")
|
||||||
|
}
|
||||||
|
parts := []string{cmd.Path}
|
||||||
|
if len(cmd.Args) > 1 {
|
||||||
|
parts = append(parts, cmd.Args[1:]...)
|
||||||
|
}
|
||||||
|
out := syscall.EscapeArg(parts[0])
|
||||||
|
for _, a := range parts[1:] {
|
||||||
|
out += " " + syscall.EscapeArg(a)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeEnvBlock packs a Go environ slice into the Windows UTF-16 env block
|
||||||
|
// format: key=value\0key=value\0\0.
|
||||||
|
func makeEnvBlock(env []string) (*uint16, error) {
|
||||||
|
var buf []uint16
|
||||||
|
for _, kv := range env {
|
||||||
|
s, err := syscall.UTF16FromString(kv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buf = append(buf, s...) // includes trailing NUL
|
||||||
|
}
|
||||||
|
buf = append(buf, 0) // final terminator
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &buf[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time interface assertion.
|
||||||
|
var _ termSession = (*conptySession)(nil)
|
||||||
@@ -2,19 +2,22 @@ package api
|
|||||||
|
|
||||||
// Cross-platform terminal session abstraction.
|
// Cross-platform terminal session abstraction.
|
||||||
//
|
//
|
||||||
// On Linux / macOS we have a real PTY via creack/pty: full TTY semantics,
|
// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires
|
||||||
// resize support, interactive apps (vim, top…) work. On Windows the same
|
// startTermSession to creack/pty for a real PTY: full TTY semantics,
|
||||||
// package returns "operating system not supported" at pty.Start time, so we
|
// resize support, interactive apps (vim, top…) work.
|
||||||
// fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't
|
//
|
||||||
// give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`,
|
// On Windows the windows-tagged file (terminal_session_windows.go) tries
|
||||||
// and most CLI tools emit usable line-buffered output, which is what the
|
// the kernel32 ConPTY API first, with a pipe-based fallback for older
|
||||||
// user actually clicks for.
|
// hosts. pipeSession does NOT carry TTY signals, so most shells go silent
|
||||||
|
// — it's only kept as a last resort.
|
||||||
|
//
|
||||||
|
// Both platforms share the termSession interface, the ptySession type
|
||||||
|
// (used by unix), and the pipeSession type (used by the Windows fallback).
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/creack/pty/v2"
|
"github.com/creack/pty/v2"
|
||||||
@@ -30,22 +33,7 @@ type termSession interface {
|
|||||||
Pid() int
|
Pid() int
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTermSession tries a real PTY first; on Windows or any pty.Start failure
|
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
|
||||||
// it falls back to a pipe-based session.
|
|
||||||
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
ptmx, err := pty.Start(cmd)
|
|
||||||
if err == nil {
|
|
||||||
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
|
||||||
}
|
|
||||||
// On unix, a pty.Start error is fatal — pipes won't help interactive
|
|
||||||
// shells without a TTY, and the unix build is the supported path.
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return startPipeSession(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ptySession wraps creack/pty's *os.File-backed PTY.
|
|
||||||
type ptySession struct {
|
type ptySession struct {
|
||||||
ptmx *os.File
|
ptmx *os.File
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
@@ -76,8 +64,10 @@ func (s *ptySession) Pid() int {
|
|||||||
return s.cmd.Process.Pid
|
return s.cmd.Process.Pid
|
||||||
}
|
}
|
||||||
|
|
||||||
// pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe,
|
// pipeSession is the Windows last-resort fallback when ConPTY is not
|
||||||
// running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to).
|
// available: stdin pipe + merged stdout/stderr, no TTY signals. Most
|
||||||
|
// interactive shells go silent in this mode, so it should rarely be hit on
|
||||||
|
// modern Windows (10 1809+).
|
||||||
type pipeSession struct {
|
type pipeSession struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
|
|||||||
19
internal/api/terminal_session_unix.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/creack/pty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startTermSession (unix) opens a real PTY via creack/pty. Fatal on error
|
||||||
|
// — the unix build assumes PTY availability.
|
||||||
|
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||||
|
ptmx, err := pty.Start(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
||||||
|
}
|
||||||
20
internal/api/terminal_session_windows.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startTermSession (windows) tries the kernel32 ConPTY API first. ConPTY
|
||||||
|
// gives a real pseudo terminal, so wsl.exe / pwsh / cmd render their
|
||||||
|
// prompt and the user can interact normally. If ConPTY is unavailable
|
||||||
|
// (Windows < 10 1809) or the call fails for any reason, we fall back to
|
||||||
|
// the line-buffered pipe session — degraded but functional for non-TUI
|
||||||
|
// commands.
|
||||||
|
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||||
|
if sess, err := startConptySession(cmd); err == nil {
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
return startPipeSession(cmd)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.7.2"
|
Version = "0.8.0"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0A0A0C" />
|
<meta name="theme-color" content="#0A0A0C" />
|
||||||
<title>muyue</title>
|
<title>Muyue</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
|
||||||
|
<link rel="shortcut icon" href="/muyue.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
web/public/favicon-16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
web/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
web/public/muyue-64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
web/public/muyue.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
@@ -103,6 +103,7 @@ export default function App() {
|
|||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header-brand">
|
<div className="header-brand">
|
||||||
|
<img src="/muyue-64.png" alt="Muyue" className="header-logo-img" width="22" height="22" style={{ borderRadius: 4, verticalAlign: 'middle' }} />
|
||||||
<span className="header-logo">MUYUE</span>
|
<span className="header-logo">MUYUE</span>
|
||||||
<span className="header-version">v{info.version || '...'}</span>
|
<span className="header-version">v{info.version || '...'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
language: 'fr',
|
language: 'fr',
|
||||||
keyboard: 'azerty',
|
keyboard: 'azerty',
|
||||||
apikey: '',
|
apikey: '',
|
||||||
|
apikey_mimo: '',
|
||||||
editor: '',
|
editor: '',
|
||||||
})
|
})
|
||||||
|
const [keyValidMimo, setKeyValidMimo] = useState(false)
|
||||||
|
const [errorMimo, setErrorMimo] = useState(null)
|
||||||
|
const [validatingMimo, setValidatingMimo] = useState(false)
|
||||||
const [editorList, setEditorList] = useState(BASE_EDITORS)
|
const [editorList, setEditorList] = useState(BASE_EDITORS)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -52,7 +56,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
case 'name': return answers.name.trim().length > 0
|
case 'name': return answers.name.trim().length > 0
|
||||||
case 'language': return !!answers.language
|
case 'language': return !!answers.language
|
||||||
case 'keyboard': return !!answers.keyboard
|
case 'keyboard': return !!answers.keyboard
|
||||||
case 'apikey': return keyValid && !scanning
|
case 'apikey': return (keyValid || keyValidMimo) && !scanning
|
||||||
case 'editor': return true
|
case 'editor': return true
|
||||||
case 'done': return true
|
case 'done': return true
|
||||||
default: return true
|
default: return true
|
||||||
@@ -173,6 +177,33 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
setValidating(false)
|
setValidating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleValidateKeyMimo = async () => {
|
||||||
|
if (!answers.apikey_mimo.trim()) return
|
||||||
|
setValidatingMimo(true)
|
||||||
|
setErrorMimo(null)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
})
|
||||||
|
setKeyValidMimo(true)
|
||||||
|
// Save MiMo. If MiniMax wasn't validated yet, MiMo becomes the active provider.
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
active: !keyValid,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMimo(err.message || 'Clé invalide')
|
||||||
|
setKeyValidMimo(false)
|
||||||
|
}
|
||||||
|
setValidatingMimo(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -201,6 +232,15 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
active: true,
|
active: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (answers.apikey_mimo.trim()) {
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
active: !answers.apikey.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Erreur lors de la sauvegarde')
|
setError(err.message || 'Erreur lors de la sauvegarde')
|
||||||
@@ -283,38 +323,71 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
|
|
||||||
{current.key === 'apikey' && (
|
{current.key === 'apikey' && (
|
||||||
<div className="onboarding-step">
|
<div className="onboarding-step">
|
||||||
<div className="onboarding-title">Clé API MiniMax</div>
|
<div className="onboarding-title">Clés API</div>
|
||||||
<div className="onboarding-desc">
|
<div className="onboarding-desc">
|
||||||
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
|
Renseignez au moins l'une des deux clés pour activer l'assistant. Les autres fournisseurs (OpenAI, Anthropic, Ollama, Z.AI) se configurent plus tard depuis l'onglet Configuration.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiniMax</label>
|
||||||
<input
|
<input
|
||||||
className="onboarding-input"
|
className="onboarding-input"
|
||||||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
placeholder="sk-xxxxxxxxxxxxxxxx (MiniMax)"
|
||||||
type="password"
|
type="password"
|
||||||
value={answers.apikey}
|
value={answers.apikey}
|
||||||
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && !keyValid && <div className="onboarding-required">{error}</div>}
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
|
|
||||||
{scanning && (
|
|
||||||
<div className="onboarding-scanning">
|
|
||||||
<Loader size={14} className="spin-icon" />
|
|
||||||
<span>{scanMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
|
||||||
<button
|
<button
|
||||||
className="sm primary"
|
className="sm primary"
|
||||||
onClick={handleValidateKey}
|
onClick={handleValidateKey}
|
||||||
disabled={validating || !answers.apikey.trim()}
|
disabled={validating || !answers.apikey.trim()}
|
||||||
>
|
>
|
||||||
{validating ? 'Validation...' : 'Valider la clé'}
|
{validating ? 'Validation...' : 'Valider MiniMax'}
|
||||||
</button>
|
</button>
|
||||||
|
{keyValid && <span className="onboarding-valid">✓ MiniMax OK</span>}
|
||||||
|
{error && !keyValid && <span className="onboarding-required">{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiMo (Xiaomi)</label>
|
||||||
|
<input
|
||||||
|
className="onboarding-input"
|
||||||
|
placeholder="sk-xxxxxxxxxxxxxxxx (MiMo)"
|
||||||
|
type="password"
|
||||||
|
value={answers.apikey_mimo}
|
||||||
|
onChange={e => { setAnswers(a => ({ ...a, apikey_mimo: e.target.value })); setKeyValidMimo(false); setErrorMimo(null) }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={handleValidateKeyMimo}
|
||||||
|
disabled={validatingMimo || !answers.apikey_mimo.trim()}
|
||||||
|
>
|
||||||
|
{validatingMimo ? 'Validation...' : 'Valider MiMo'}
|
||||||
|
</button>
|
||||||
|
{keyValidMimo && <span className="onboarding-valid">✓ MiMo OK</span>}
|
||||||
|
{errorMimo && !keyValidMimo && <span className="onboarding-required">{errorMimo}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scanning && (
|
||||||
|
<div className="onboarding-scanning" style={{ marginTop: 8 }}>
|
||||||
|
<Loader size={14} className="spin-icon" />
|
||||||
|
<span>{scanMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requiredError && (
|
||||||
|
<div className="onboarding-required" style={{ marginTop: 8 }}>
|
||||||
|
Veuillez valider au moins une clé (MiniMax ou MiMo) pour continuer.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(keyValid || keyValidMimo) && !scanning && (
|
||||||
|
<div className="onboarding-valid" style={{ marginTop: 8 }}>
|
||||||
|
Au moins une clé est valide — appuyez sur Suivant pour continuer.
|
||||||
</div>
|
</div>
|
||||||
{!keyValid && !error && answers.apikey.trim() && (
|
|
||||||
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||