Add Paarrot Stream Deck Plugin with core functionality
Some checks failed
Build / increment-version (push) Successful in 8s
Build / build-linux (push) Successful in 2m26s
Build Paarrot Windows / build (push) Has been cancelled
Build Paarrot Windows / start-vm (push) Has been cancelled
Build / build-windows (push) Successful in 4m28s
Build / create-release (push) Successful in 30s
- Implemented main plugin structure including manifest, HTML, and JavaScript files. - Added actions for toggling mute, toggling deafen, changing channels, sending messages, and getting status. - Created property inspector for changing channels and sending messages. - Developed SVG icons for actions and status display. - Established WebSocket connection for communication with the Stream Deck. - Implemented API calls to interact with the Paarrot voice chat application. - Added validation script to ensure proper plugin structure and manifest compliance. - Included test Electron application for window creation and loading content.
365
.gitea/workflows/build-windows.yml
Normal file
@@ -0,0 +1,365 @@
|
||||
name: Build Paarrot Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, development ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
start-vm:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Check if VM is already running
|
||||
id: check-vm
|
||||
run: |
|
||||
if pgrep -f "qemu-system-x86_64.*windows.qcow2" > /dev/null; then
|
||||
echo "VM is already running"
|
||||
echo "vm_running=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "VM is not running"
|
||||
echo "vm_running=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Start Windows VM with retry
|
||||
run: |
|
||||
MAX_RETRIES=3
|
||||
RETRY_COUNT=0
|
||||
BOOT_WAIT=90
|
||||
RUNNER_CHECK_TIMEOUT=120
|
||||
|
||||
start_vm() {
|
||||
echo "Starting Windows QEMU VM (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)..."
|
||||
sudo -u litruv setsid bash -c 'DISPLAY=:0 XAUTHORITY=/home/litruv/.Xauthority qemu-system-x86_64 \
|
||||
-m 18G \
|
||||
-smp 4 \
|
||||
-enable-kvm \
|
||||
-cpu host \
|
||||
-drive file=/home/litruv/windows.qcow2,format=qcow2 \
|
||||
-boot d \
|
||||
-vga virtio \
|
||||
-display vnc=:0 \
|
||||
> /tmp/qemu.log 2>&1' &
|
||||
disown
|
||||
}
|
||||
|
||||
kill_vm() {
|
||||
echo "Killing VM..."
|
||||
sudo pkill -9 qemu-system-x86 || true
|
||||
sleep 5
|
||||
}
|
||||
|
||||
check_runner() {
|
||||
API_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runners"
|
||||
RESPONSE=$(curl -s -H "Authorization: token ${{ secrets.CI_BOT_TOKEN }}" "$API_URL" 2>/dev/null)
|
||||
|
||||
if [ -z "$RESPONSE" ]; then
|
||||
echo " Could not query runner API"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ONLINE=$(echo "$RESPONSE" | jq -r '.runners[] | select(.labels[].name == "windows-vm") | select(.status == "online") | .name' 2>/dev/null)
|
||||
|
||||
if [ -n "$ONLINE" ]; then
|
||||
echo " Runner found online!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " Windows runner (windows-vm) is NOT online"
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_runner() {
|
||||
local max_checks=$1
|
||||
local required_successes=${2:-1}
|
||||
local successes=0
|
||||
echo "Waiting for runner to come online (need ${required_successes} successful checks, max ${max_checks} attempts)..."
|
||||
for i in $(seq 1 $max_checks); do
|
||||
if check_runner; then
|
||||
successes=$((successes + 1))
|
||||
echo "Runner online check $successes/$required_successes passed ($i/$max_checks)"
|
||||
if [ $successes -ge $required_successes ]; then
|
||||
echo "Runner confirmed online after $successes consecutive checks!"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
successes=0
|
||||
echo "Waiting for runner... ($i/$max_checks)"
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "Checking if runner is online..."
|
||||
if wait_for_runner 12 12; then
|
||||
echo "Runner verified online!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${{ steps.check-vm.outputs.vm_running }}" == "true" ]; then
|
||||
echo "VM process exists but runner not responding, restarting VM..."
|
||||
kill_vm
|
||||
fi
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
start_vm
|
||||
echo "Waiting ${BOOT_WAIT}s for VM to boot..."
|
||||
sleep $BOOT_WAIT
|
||||
|
||||
if ! pgrep -f "qemu-system-x86_64.*windows.qcow2" > /dev/null; then
|
||||
echo "VM process not found, retrying..."
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if wait_for_runner $((RUNNER_CHECK_TIMEOUT / 5)); then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Runner did not come online, killing VM and retrying..."
|
||||
kill_vm
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
done
|
||||
|
||||
echo "ERROR: Failed to start VM after $MAX_RETRIES attempts"
|
||||
exit 1
|
||||
|
||||
- name: VM Status
|
||||
run: |
|
||||
if [ "${{ steps.check-vm.outputs.vm_running }}" == "true" ]; then
|
||||
echo "Using existing VM"
|
||||
else
|
||||
echo "Started new VM"
|
||||
fi
|
||||
|
||||
if pgrep -f "qemu-system-x86_64.*windows.qcow2" > /dev/null; then
|
||||
echo "VM process confirmed running"
|
||||
else
|
||||
echo "ERROR: VM process not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
runs-on: windows-vm
|
||||
needs: start-vm
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Cancel pending shutdown
|
||||
shell: powershell
|
||||
run: |
|
||||
shutdown /s /t 1000 2>$null
|
||||
shutdown /a 2>$null
|
||||
Write-Host "Cancelled any pending shutdown"
|
||||
|
||||
- name: Disable sleep and power saving
|
||||
shell: powershell
|
||||
run: |
|
||||
powercfg /change standby-timeout-ac 0
|
||||
powercfg /change standby-timeout-dc 0
|
||||
powercfg /change hibernate-timeout-ac 0
|
||||
powercfg /change hibernate-timeout-dc 0
|
||||
powercfg /change monitor-timeout-ac 0
|
||||
powercfg /change monitor-timeout-dc 0
|
||||
powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
|
||||
Write-Host "Power saving disabled for build"
|
||||
|
||||
- name: Fast checkout (persistent repo)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
$repoDir = "C:\BuildCache\cinny-desktop\Repo"
|
||||
$serverUrl = "${{ github.server_url }}"
|
||||
$repo = "${{ github.repository }}"
|
||||
$branch = "${{ github.ref_name }}"
|
||||
$token = "${{ secrets.CI_BOT_TOKEN }}"
|
||||
|
||||
$uri = [System.Uri]$serverUrl
|
||||
$scheme = $uri.Scheme
|
||||
$authority = $uri.Authority
|
||||
$repoUrl = "${scheme}://${authority}/${repo}"
|
||||
|
||||
Write-Host "Using server: ${scheme}://${authority}"
|
||||
|
||||
if (-not (Test-Path "C:\BuildCache\cinny-desktop")) {
|
||||
New-Item -ItemType Directory -Path "C:\BuildCache\cinny-desktop" -Force
|
||||
}
|
||||
|
||||
git config --global credential.helper ""
|
||||
git config --global "http.${scheme}://${authority}/.extraheader" "Authorization: token $token"
|
||||
|
||||
function Remove-GitLocks {
|
||||
$lockFiles = @(
|
||||
"$repoDir\.git\shallow.lock",
|
||||
"$repoDir\.git\index.lock",
|
||||
"$repoDir\.git\HEAD.lock",
|
||||
"$repoDir\.git\config.lock"
|
||||
)
|
||||
foreach ($lock in $lockFiles) {
|
||||
if (Test-Path $lock) {
|
||||
Write-Host "Removing stale lock: $lock"
|
||||
Remove-Item -Force $lock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Do-Clone {
|
||||
Write-Host "Cloning repository fresh..."
|
||||
if (Test-Path $repoDir) {
|
||||
Remove-Item -Recurse -Force $repoDir
|
||||
}
|
||||
|
||||
$cloneSuccess = $false
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
Write-Host "Clone attempt $i/3..."
|
||||
& git clone --depth=1 --branch $branch $repoUrl $repoDir 2>&1 | Out-Host
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$cloneSuccess = $true
|
||||
break
|
||||
}
|
||||
Write-Host "Clone attempt $i failed, retrying in 5 seconds..."
|
||||
if (Test-Path $repoDir) {
|
||||
Remove-Item -Recurse -Force $repoDir
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
|
||||
if (-not $cloneSuccess) {
|
||||
throw "Clone failed after 3 attempts"
|
||||
}
|
||||
|
||||
Push-Location $repoDir
|
||||
& git submodule update --init --recursive --depth=1 2>&1 | Out-Host
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
function Do-Update {
|
||||
Write-Host "Updating existing repo..."
|
||||
Push-Location $repoDir
|
||||
|
||||
Remove-GitLocks
|
||||
|
||||
git remote set-url origin $repoUrl 2>$null
|
||||
Write-Host "Remote URL updated"
|
||||
|
||||
git reset --hard HEAD 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Reset failed, repo may be corrupted"
|
||||
Pop-Location
|
||||
return $false
|
||||
}
|
||||
|
||||
git clean -fdx -e node_modules/ -e cinny/node_modules/ 2>$null
|
||||
|
||||
$fetchSuccess = $false
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
Write-Host "Fetch attempt $i/3..."
|
||||
& git fetch origin $branch --depth=1 2>&1 | Out-Host
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$fetchSuccess = $true
|
||||
break
|
||||
}
|
||||
Write-Host "Fetch attempt $i failed, retrying in 5 seconds..."
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
|
||||
if (-not $fetchSuccess) {
|
||||
Write-Host "All fetch attempts failed"
|
||||
Pop-Location
|
||||
return $false
|
||||
}
|
||||
|
||||
& git checkout -B $branch origin/$branch 2>&1 | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Checkout failed"
|
||||
Pop-Location
|
||||
return $false
|
||||
}
|
||||
|
||||
& git submodule update --init --recursive --depth=1 2>&1 | Out-Host
|
||||
|
||||
Pop-Location
|
||||
return $true
|
||||
}
|
||||
|
||||
if (Test-Path "$repoDir\.git") {
|
||||
$success = Do-Update
|
||||
if (-not $success) {
|
||||
Write-Host "Update failed, will re-clone..."
|
||||
Do-Clone
|
||||
}
|
||||
} else {
|
||||
Do-Clone
|
||||
}
|
||||
|
||||
if (-not (Test-Path "$repoDir\package.json")) {
|
||||
throw "Checkout verification failed - package.json not found"
|
||||
}
|
||||
|
||||
Write-Host "Checkout complete at: $repoDir"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoDir = "C:\BuildCache\cinny-desktop\Repo"
|
||||
|
||||
Push-Location $repoDir
|
||||
|
||||
Write-Host "Installing root dependencies..."
|
||||
npm ci --prefer-offline --no-audit
|
||||
|
||||
Write-Host "Installing cinny dependencies..."
|
||||
Push-Location cinny
|
||||
npm ci --prefer-offline --no-audit
|
||||
Pop-Location
|
||||
|
||||
Pop-Location
|
||||
Write-Host "Dependencies installed"
|
||||
|
||||
- name: Build Paarrot
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoDir = "C:\BuildCache\cinny-desktop\Repo"
|
||||
|
||||
Push-Location $repoDir
|
||||
|
||||
Write-Host "Building Paarrot..."
|
||||
npm run build:win
|
||||
|
||||
Pop-Location
|
||||
Write-Host "Build completed successfully!"
|
||||
|
||||
- name: Upload artifacts
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoDir = "C:\BuildCache\cinny-desktop\Repo"
|
||||
$artifactDir = "$repoDir\dist-electron"
|
||||
|
||||
if (-not (Test-Path $artifactDir)) {
|
||||
Write-Error "Build artifacts not found at $artifactDir"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Build artifacts:"
|
||||
Get-ChildItem $artifactDir -Recurse | Select-Object FullName
|
||||
|
||||
# TODO: Copy artifacts to artifact server or release location
|
||||
Write-Host "Artifacts ready at: $artifactDir"
|
||||
|
||||
- name: Schedule delayed shutdown
|
||||
if: always()
|
||||
shell: powershell
|
||||
run: |
|
||||
shutdown /s /t 900 /c "Build complete - shutting down in 15 minutes"
|
||||
Write-Host "VM will shutdown in 15 minutes unless another build starts"
|
||||
2
cinny
@@ -183,7 +183,7 @@ if (!gotTheLock) {
|
||||
|
||||
// Development mode detection
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
const VITE_DEV_SERVER = 'http://localhost:8080';
|
||||
const VITE_DEV_SERVER = 'http://localhost:38347';
|
||||
const PORT = 44548;
|
||||
|
||||
// Helper to get correct icon path in dev vs packaged app
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
|
||||
"dev:vite": "cd cinny && npm start",
|
||||
"dev:electron": "wait-on http://localhost:8080 && cross-env NODE_ENV=development electron .",
|
||||
"dev:electron": "wait-on http://localhost:38347 && cross-env NODE_ENV=development electron .",
|
||||
"build": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish never",
|
||||
"build:linux": "npm run build -- --linux",
|
||||
"build:win": "npm run build -- --win",
|
||||
|
||||
26
streamdeck-plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Distribution files
|
||||
dist/
|
||||
*.streamDeckPlugin
|
||||
|
||||
# Node modules (if any dependencies are added)
|
||||
node_modules/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
207
streamdeck-plugin/QUICKSTART.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Quick Start Guide - Paarrot Stream Deck Plugin
|
||||
|
||||
## For Users
|
||||
|
||||
### Installation (3 simple steps)
|
||||
|
||||
1. **Download the plugin**
|
||||
- Get `com.paarrot.streamdeck-v1.0.0.streamDeckPlugin` from releases
|
||||
|
||||
2. **Install**
|
||||
- Double-click the `.streamDeckPlugin` file
|
||||
- Stream Deck software will open and install it automatically
|
||||
|
||||
3. **Add to your Stream Deck**
|
||||
- Open Stream Deck software
|
||||
- Find "Paarrot" in the actions list (under "Voice & Chat")
|
||||
- Drag actions to your buttons
|
||||
|
||||
### Quick Action Guide
|
||||
|
||||
| Action | What it does | Setup needed? |
|
||||
|--------|--------------|---------------|
|
||||
| Toggle Mute | Mute/unmute mic | No |
|
||||
| Toggle Deafen | Mute/unmute speakers | No |
|
||||
| Change Channel | Switch rooms | Yes - pick room |
|
||||
| Send Message | Send preset message | Yes - type message |
|
||||
| Get Status | Show mute/deafen state | No |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
**Change Channel**:
|
||||
1. Drag to button
|
||||
2. Click button in Stream Deck to open settings
|
||||
3. Select channel from dropdown
|
||||
4. Done!
|
||||
|
||||
**Send Message**:
|
||||
1. Drag to button
|
||||
2. Click button in Stream Deck to open settings
|
||||
3. Type message (e.g., "BRB!")
|
||||
4. Done!
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (for build scripts)
|
||||
- Stream Deck software
|
||||
- Text editor
|
||||
|
||||
### Development Setup
|
||||
|
||||
```powershell
|
||||
# Clone or download the repo
|
||||
cd streamdeck-plugin
|
||||
|
||||
# Validate plugin structure
|
||||
node validate.js
|
||||
|
||||
# Install to Stream Deck (Windows)
|
||||
Copy-Item -Recurse -Force com.paarrot.streamdeck.sdPlugin "$env:APPDATA\Elgato\StreamDeck\Plugins\"
|
||||
|
||||
# Install to Stream Deck (macOS)
|
||||
cp -r com.paarrot.streamdeck.sdPlugin ~/Library/Application\ Support/com.elgato.StreamDeck/Plugins/
|
||||
|
||||
# Restart Stream Deck software
|
||||
```
|
||||
|
||||
### Making Changes
|
||||
|
||||
1. Edit files in `com.paarrot.streamdeck.sdPlugin/`
|
||||
2. Run validation: `node validate.js`
|
||||
3. Restart Stream Deck software
|
||||
4. Test your changes
|
||||
|
||||
### Building for Distribution
|
||||
|
||||
```powershell
|
||||
# Validate first
|
||||
node validate.js
|
||||
|
||||
# Build .streamDeckPlugin file
|
||||
node build.js
|
||||
|
||||
# Output will be in dist/ folder
|
||||
```
|
||||
|
||||
### File Guide
|
||||
|
||||
```
|
||||
com.paarrot.streamdeck.sdPlugin/
|
||||
├── manifest.json # Edit: plugin info, actions
|
||||
├── plugin/
|
||||
│ └── index.js # Edit: main logic, API calls
|
||||
├── propertyinspector/
|
||||
│ ├── change-channel.html # Edit: channel picker UI
|
||||
│ └── send-message.html # Edit: message input UI
|
||||
└── images/ # Edit: replace SVGs with your icons
|
||||
└── *.svg
|
||||
```
|
||||
|
||||
### Adding a New Action
|
||||
|
||||
1. **Add to manifest.json**:
|
||||
```json
|
||||
{
|
||||
"Icon": "images/my-action",
|
||||
"Name": "My Action",
|
||||
"States": [{ "Image": "images/my-action" }],
|
||||
"Tooltip": "Does something cool",
|
||||
"UUID": "com.paarrot.streamdeck.myaction"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add icon**: `images/my-action.svg`
|
||||
|
||||
3. **Add logic in plugin/index.js**:
|
||||
```javascript
|
||||
const ACTIONS = {
|
||||
MY_ACTION: 'com.paarrot.streamdeck.myaction'
|
||||
};
|
||||
|
||||
// In handleKeyDown:
|
||||
case ACTIONS.MY_ACTION:
|
||||
// Your code here
|
||||
break;
|
||||
```
|
||||
|
||||
4. **Test**: Restart Stream Deck software
|
||||
|
||||
### Testing
|
||||
|
||||
1. Enable Stream Deck console: `View > Open Console`
|
||||
2. Watch for errors when pressing buttons
|
||||
3. Test all actions with Paarrot running
|
||||
4. Test with Paarrot not running (should fail gracefully)
|
||||
|
||||
### API Reference
|
||||
|
||||
The plugin calls these Paarrot API endpoints:
|
||||
|
||||
```javascript
|
||||
// Status polling (every 1 second)
|
||||
GET http://127.0.0.1:33384/status
|
||||
|
||||
// Toggle actions
|
||||
POST http://127.0.0.1:33384/mute/toggle
|
||||
POST http://127.0.0.1:33384/deafen/toggle
|
||||
|
||||
// Configured actions
|
||||
POST http://127.0.0.1:33384/channel
|
||||
Body: { "roomId": "!abc:server.com" }
|
||||
|
||||
POST http://127.0.0.1:33384/message/current
|
||||
Body: { "message": "Hello!" }
|
||||
|
||||
// Property inspector
|
||||
GET http://127.0.0.1:33384/channels
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Plugin not loading**:
|
||||
- Check manifest.json syntax (use validator)
|
||||
- Ensure folder name is exactly `com.paarrot.streamdeck.sdPlugin`
|
||||
- Restart Stream Deck software
|
||||
|
||||
**Actions not working**:
|
||||
- Open console (View > Open Console)
|
||||
- Check for JavaScript errors
|
||||
- Verify Paarrot API is running
|
||||
|
||||
**Icons not showing**:
|
||||
- Ensure files exist at paths specified in manifest
|
||||
- Use .svg or .png format
|
||||
- Check file paths are relative to plugin root
|
||||
|
||||
### Resources
|
||||
|
||||
- [Stream Deck SDK Documentation](https://docs.elgato.com/sdk)
|
||||
- [Paarrot API Documentation](../API.md)
|
||||
- [Example Plugins](https://github.com/elgatosf/streamdeck-plugins)
|
||||
|
||||
### Publishing Checklist
|
||||
|
||||
- [ ] Update version in manifest.json
|
||||
- [ ] Run `node validate.js` (no errors)
|
||||
- [ ] Test all actions
|
||||
- [ ] Test with Paarrot off (graceful failure)
|
||||
- [ ] Update README.md version history
|
||||
- [ ] Run `node build.js`
|
||||
- [ ] Test .streamDeckPlugin file installs correctly
|
||||
- [ ] Create GitHub release with .streamDeckPlugin file
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
**Users**: See README.md troubleshooting section
|
||||
|
||||
**Developers**: Open an issue on GitHub with:
|
||||
- What you're trying to do
|
||||
- What's happening
|
||||
- Console errors
|
||||
- Plugin version
|
||||
263
streamdeck-plugin/README.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Paarrot Stream Deck Plugin
|
||||
|
||||
Control your Paarrot desktop app directly from your Elgato Stream Deck! Toggle mute, deafen, switch channels, and send quick messages with the press of a button.
|
||||
|
||||
## Features
|
||||
|
||||
- **Toggle Mute** - Mute/unmute your microphone with visual feedback
|
||||
- **Toggle Deafen** - Deafen/undeafen (mute speakers) with visual feedback
|
||||
- **Change Channel** - Switch between rooms/channels instantly
|
||||
- **Send Message** - Send predefined messages to the current room
|
||||
- **Get Status** - Display current mute/deafen status
|
||||
|
||||
## Requirements
|
||||
|
||||
- Elgato Stream Deck (any model)
|
||||
- Stream Deck software version 6.0 or later
|
||||
- Paarrot desktop app running with API server enabled
|
||||
- Windows 10+ or macOS 10.14+
|
||||
|
||||
## Installation
|
||||
|
||||
### Method 1: Install from Release (easiest)
|
||||
|
||||
1. Download the latest `.streamDeckPlugin` file from the [Releases](https://github.com/yourusername/paarrot-streamdeck/releases) page
|
||||
2. Double-click the file to install
|
||||
3. Stream Deck software will open and install the plugin automatically
|
||||
|
||||
### Method 2: Manual Installation
|
||||
|
||||
1. Download or clone this repository
|
||||
2. Copy the `com.paarrot.streamdeck.sdPlugin` folder to:
|
||||
- **Windows**: `%APPDATA%\Elgato\StreamDeck\Plugins\`
|
||||
- **macOS**: `~/Library/Application Support/com.elgato.StreamDeck/Plugins/`
|
||||
3. Restart the Stream Deck software
|
||||
|
||||
### Method 3: Developer Mode
|
||||
|
||||
1. Open Stream Deck software
|
||||
2. Open the Plugins view
|
||||
3. Enable "Developer Mode" in settings
|
||||
4. Drag the `com.paarrot.streamdeck.sdPlugin` folder into the Stream Deck window
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Ensure Paarrot API is Running
|
||||
|
||||
Make sure your Paarrot desktop app is running with the API server enabled on port 33384. You can test this by visiting:
|
||||
|
||||
```
|
||||
http://127.0.0.1:33384/health
|
||||
```
|
||||
|
||||
You should see:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"app": "Paarrot API",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Actions to Stream Deck
|
||||
|
||||
1. Open Stream Deck software
|
||||
2. Find "Paarrot" in the actions list
|
||||
3. Drag actions onto your Stream Deck buttons
|
||||
4. Configure actions as needed (see below)
|
||||
|
||||
## Actions
|
||||
|
||||
### Toggle Mute
|
||||
|
||||
**What it does**: Toggles your microphone mute status
|
||||
|
||||
**Configuration**: No configuration needed
|
||||
|
||||
**Visual Feedback**:
|
||||
- Gray microphone icon = unmuted
|
||||
- Red X over microphone = muted
|
||||
|
||||
**Usage**: Press to toggle between muted and unmuted. The button automatically updates to show the current state.
|
||||
|
||||
---
|
||||
|
||||
### Toggle Deafen
|
||||
|
||||
**What it does**: Toggles deafen status (mutes speakers and microphone)
|
||||
|
||||
**Configuration**: No configuration needed
|
||||
|
||||
**Visual Feedback**:
|
||||
- Gray speaker icon = not deafened
|
||||
- Red X over speaker = deafened
|
||||
|
||||
**Usage**: Press to toggle between deafened and not deafened. The button automatically updates to show the current state.
|
||||
|
||||
---
|
||||
|
||||
### Change Channel
|
||||
|
||||
**What it does**: Switches to a different room/channel
|
||||
|
||||
**Configuration**:
|
||||
1. Drag action to a button
|
||||
2. Click the button in Stream Deck to open settings
|
||||
3. Select a channel from the dropdown OR enter a room ID manually
|
||||
4. Click away to save
|
||||
|
||||
**Usage**: Press to instantly switch to the configured channel.
|
||||
|
||||
**Tips**:
|
||||
- Channels are grouped by server in the dropdown
|
||||
- Use manual room ID for rooms not in the list
|
||||
- Room ID format: `!abc123:matrix.org`
|
||||
|
||||
---
|
||||
|
||||
### Send Message
|
||||
|
||||
**What it does**: Sends a predefined message to the currently active room
|
||||
|
||||
**Configuration**:
|
||||
1. Drag action to a button
|
||||
2. Click the button in Stream Deck to open settings
|
||||
3. Type your message in the text area
|
||||
4. Click away to save
|
||||
|
||||
**Usage**: Press to send the message to whichever room is currently active.
|
||||
|
||||
**Example Messages**:
|
||||
- "BRB!" - Be right back
|
||||
- "AFK for 5 minutes"
|
||||
- "On my way!"
|
||||
- "Thanks everyone!"
|
||||
- "GG!" - Good game
|
||||
|
||||
---
|
||||
|
||||
### Get Status
|
||||
|
||||
**What it does**: Displays current mute/deafen status on the button
|
||||
|
||||
**Configuration**: No configuration needed
|
||||
|
||||
**Visual Feedback**:
|
||||
- Shows "OK" when not muted or deafened
|
||||
- Shows "M" when muted
|
||||
- Shows "D" when deafened
|
||||
- Shows "MD" when both muted and deafened
|
||||
|
||||
**Usage**: Button automatically updates every second. Press to force an immediate update.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin not showing up
|
||||
|
||||
- Restart Stream Deck software
|
||||
- Check that the plugin folder is in the correct location
|
||||
- Ensure folder is named exactly `com.paarrot.streamdeck.sdPlugin`
|
||||
|
||||
### Actions not working
|
||||
|
||||
- Verify Paarrot is running
|
||||
- Test API at `http://127.0.0.1:33384/health`
|
||||
- Check console for errors (View > Open Console in Stream Deck)
|
||||
- Ensure port 33384 is not blocked by firewall
|
||||
|
||||
### Button states not updating
|
||||
|
||||
- Check that Paarrot API is responding
|
||||
- Try pressing the action to force an update
|
||||
- Check Stream Deck console for connection errors
|
||||
|
||||
### "No channels available" in dropdown
|
||||
|
||||
- Ensure you're logged into Paarrot
|
||||
- Verify you've joined at least one room
|
||||
- Try entering the room ID manually instead
|
||||
|
||||
### Actions show red X (alert)
|
||||
|
||||
This means the API call failed. Common causes:
|
||||
- Paarrot app not running
|
||||
- API server not started
|
||||
- Network/firewall blocking localhost connections
|
||||
- Invalid room ID or message configuration
|
||||
|
||||
## Development
|
||||
|
||||
### Building from Source
|
||||
|
||||
No build step required! The plugin uses vanilla JavaScript.
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
com.paarrot.streamdeck.sdPlugin/
|
||||
├── manifest.json # Plugin metadata and action definitions
|
||||
├── plugin/
|
||||
│ ├── index.html # Plugin entry point
|
||||
│ └── index.js # Main plugin logic
|
||||
├── propertyinspector/
|
||||
│ ├── change-channel.html # Channel selector UI
|
||||
│ └── send-message.html # Message input UI
|
||||
└── images/ # Action icons
|
||||
├── mute-off.svg
|
||||
├── mute-on.svg
|
||||
├── deafen-off.svg
|
||||
├── deafen-on.svg
|
||||
├── change-channel.svg
|
||||
├── send-message.svg
|
||||
├── status.svg
|
||||
├── toggle-mute.svg
|
||||
├── toggle-deafen.svg
|
||||
├── plugin-icon.svg
|
||||
└── category-icon.svg
|
||||
```
|
||||
|
||||
### Modifying the Plugin
|
||||
|
||||
1. Make changes to the files
|
||||
2. Restart Stream Deck software to reload the plugin
|
||||
3. Test your changes
|
||||
|
||||
### API Endpoints Used
|
||||
|
||||
- `GET /health` - Check API is running
|
||||
- `GET /status` - Get current mute/deafen state
|
||||
- `POST /mute/toggle` - Toggle mute
|
||||
- `POST /deafen/toggle` - Toggle deafen
|
||||
- `POST /channel` - Change channel
|
||||
- `POST /message/current` - Send message
|
||||
- `GET /channels` - Get channel list
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Check the troubleshooting section above
|
||||
2. Enable Stream Deck console logging (View > Open Console)
|
||||
3. Check Paarrot's console for API errors
|
||||
4. File an issue on GitHub with logs and details
|
||||
|
||||
## License
|
||||
|
||||
This plugin is provided as-is. See LICENSE file for details.
|
||||
|
||||
## Credits
|
||||
|
||||
Created for Paarrot desktop app.
|
||||
|
||||
Icons designed specifically for this plugin.
|
||||
|
||||
## Version History
|
||||
|
||||
### 1.0.0 (Initial Release)
|
||||
- Toggle Mute action
|
||||
- Toggle Deafen action
|
||||
- Change Channel action with dropdown
|
||||
- Send Message action
|
||||
- Get Status action
|
||||
- Auto-updating button states
|
||||
- Visual feedback for all actions
|
||||
69
streamdeck-plugin/build.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Build script for Paarrot Stream Deck Plugin
|
||||
* Creates a distributable .streamDeckPlugin file
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const PLUGIN_DIR = 'com.paarrot.streamdeck.sdPlugin';
|
||||
const OUTPUT_DIR = 'dist';
|
||||
|
||||
console.log('Building Paarrot Stream Deck Plugin...');
|
||||
|
||||
// Create dist directory if it doesn't exist
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR);
|
||||
}
|
||||
|
||||
// Check if plugin directory exists
|
||||
if (!fs.existsSync(PLUGIN_DIR)) {
|
||||
console.error(`Error: Plugin directory '${PLUGIN_DIR}' not found!`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read manifest to get version
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(PLUGIN_DIR, 'manifest.json'), 'utf8')
|
||||
);
|
||||
|
||||
const version = manifest.Version || '1.0.0.0';
|
||||
const uuid = manifest.UUID || 'com.paarrot.streamdeck';
|
||||
const outputPath = path.join(OUTPUT_DIR, `${uuid}-v${version}.streamDeckPlugin`);
|
||||
|
||||
console.log(`Creating plugin archive: ${outputPath}`);
|
||||
|
||||
try {
|
||||
// Clean up old archive
|
||||
if (fs.existsSync(outputPath)) {
|
||||
fs.unlinkSync(outputPath);
|
||||
}
|
||||
if (fs.existsSync(`${outputPath}.zip`)) {
|
||||
fs.unlinkSync(`${outputPath}.zip`);
|
||||
}
|
||||
|
||||
// Use PowerShell's Compress-Archive on Windows
|
||||
// IMPORTANT: Archive must contain the .sdPlugin folder at root level
|
||||
if (process.platform === 'win32') {
|
||||
const command = `powershell -Command "Compress-Archive -Path '${PLUGIN_DIR}' -DestinationPath '${outputPath}.zip' -Force"`;
|
||||
execSync(command);
|
||||
|
||||
// Rename .zip to .streamDeckPlugin
|
||||
if (fs.existsSync(`${outputPath}.zip`)) {
|
||||
fs.renameSync(`${outputPath}.zip`, outputPath);
|
||||
}
|
||||
} else {
|
||||
// Use zip command on macOS/Linux - include the folder itself
|
||||
execSync(`zip -r "${outputPath}" "${PLUGIN_DIR}"`);
|
||||
}
|
||||
|
||||
console.log('✓ Plugin built successfully!');
|
||||
console.log(` Output: ${outputPath}`);
|
||||
console.log(` Version: ${version}`);
|
||||
console.log('\nInstallation:');
|
||||
console.log(` Double-click ${path.basename(outputPath)} to install`);
|
||||
} catch (error) {
|
||||
console.error('Error building plugin:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<rect x="30" y="30" width="40" height="40" rx="8" fill="#666" stroke="none"/>
|
||||
<rect x="74" y="30" width="40" height="40" rx="8" fill="#888" stroke="none"/>
|
||||
<rect x="30" y="74" width="40" height="40" rx="8" fill="#666" stroke="none"/>
|
||||
<rect x="74" y="74" width="40" height="40" rx="8" fill="#666" stroke="none"/>
|
||||
<path d="M50 50 L50 42 L58 50 L50 58 Z" fill="#444" stroke="none"/>
|
||||
<path d="M94 50 L94 42 L102 50 L94 58 Z" fill="#fff" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 606 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<path d="M40 50 L40 94 L56 94 L76 110 L76 34 L56 50 Z" fill="#888" stroke="none"/>
|
||||
<path d="M88 58 C94 64 94 80 88 86" stroke="#888" stroke-width="6" stroke-linecap="round" fill="none"/>
|
||||
<path d="M98 50 C108 60 108 84 98 94" stroke="#888" stroke-width="6" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,7 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<path d="M40 50 L40 94 L56 94 L76 110 L76 34 L56 50 Z" fill="#444" stroke="none"/>
|
||||
<path d="M88 58 C94 64 94 80 88 86" stroke="#444" stroke-width="6" stroke-linecap="round" fill="none"/>
|
||||
<path d="M98 50 C108 60 108 84 98 94" stroke="#444" stroke-width="6" stroke-linecap="round" fill="none"/>
|
||||
<line x1="112" y1="32" x2="32" y2="112" stroke="#ff4444" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<path d="M72 36 L90 54 L90 90 C90 100 82 108 72 108 C62 108 54 100 54 90 L54 54 Z" fill="#888" stroke="none"/>
|
||||
<ellipse cx="72" cy="54" rx="18" ry="24" fill="#888"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<path d="M72 36 L90 54 L90 90 C90 100 82 108 72 108 C62 108 54 100 54 90 L54 54 Z" fill="#444" stroke="none"/>
|
||||
<ellipse cx="72" cy="54" rx="18" ry="24" fill="#444"/>
|
||||
<line x1="108" y1="36" x2="36" y2="108" stroke="#ff4444" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
5634
streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/paarrot.svg
Normal file
|
After Width: | Height: | Size: 889 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<path d="M30 72 L114 36 L90 108 L64 88 L74 64 L50 78 Z" fill="#888" stroke="none"/>
|
||||
<line x1="64" y1="88" x2="74" y2="64" stroke="#1a1a1a" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,7 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<circle cx="72" cy="72" r="36" fill="none" stroke="#888" stroke-width="6"/>
|
||||
<circle cx="72" cy="72" r="8" fill="#888"/>
|
||||
<line x1="72" y1="72" x2="72" y2="48" stroke="#888" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="72" y1="72" x2="90" y2="72" stroke="#888" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,7 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<path d="M40 50 L40 94 L56 94 L76 110 L76 34 L56 50 Z" fill="#888" stroke="none"/>
|
||||
<path d="M88 58 C94 64 94 80 88 86" stroke="#888" stroke-width="6" stroke-linecap="round" fill="none"/>
|
||||
<path d="M98 50 C108 60 108 84 98 94" stroke="#888" stroke-width="6" stroke-linecap="round" fill="none"/>
|
||||
<path d="M108 54 L118 64 M108 90 L118 80" stroke="#888" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="144" height="144" fill="#1a1a1a"/>
|
||||
<path d="M72 36 L90 54 L90 90 C90 100 82 108 72 108 C62 108 54 100 54 90 L54 54 Z" fill="#888" stroke="none"/>
|
||||
<ellipse cx="72" cy="54" rx="18" ry="24" fill="#888"/>
|
||||
<path d="M98 58 L108 68 M98 86 L108 76" stroke="#888" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 413 B |
113
streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/manifest.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json",
|
||||
"Actions": [
|
||||
{
|
||||
"DisableAutomaticStates": true,
|
||||
"Icon": "images/toggle-mute",
|
||||
"Name": "Toggle Mute",
|
||||
"States": [
|
||||
{
|
||||
"Image": "images/mute-off",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": true
|
||||
},
|
||||
{
|
||||
"Image": "images/mute-on",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": true
|
||||
}
|
||||
],
|
||||
"SupportedInMultiActions": true,
|
||||
"Tooltip": "Toggle microphone mute",
|
||||
"UUID": "com.paarrot.streamdeck.togglemute"
|
||||
},
|
||||
{
|
||||
"DisableAutomaticStates": true,
|
||||
"Icon": "images/toggle-deafen",
|
||||
"Name": "Toggle Deafen",
|
||||
"States": [
|
||||
{
|
||||
"Image": "images/deafen-off",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": true
|
||||
},
|
||||
{
|
||||
"Image": "images/deafen-on",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": true
|
||||
}
|
||||
],
|
||||
"SupportedInMultiActions": true,
|
||||
"Tooltip": "Toggle deafen (mute speakers)",
|
||||
"UUID": "com.paarrot.streamdeck.toggledeafen"
|
||||
},
|
||||
{
|
||||
"Icon": "images/change-channel",
|
||||
"Name": "Change Channel",
|
||||
"PropertyInspectorPath": "propertyinspector/change-channel.html",
|
||||
"States": [
|
||||
{
|
||||
"Image": "images/change-channel",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": true
|
||||
}
|
||||
],
|
||||
"SupportedInMultiActions": true,
|
||||
"Tooltip": "Switch to a specific channel/room",
|
||||
"UUID": "com.paarrot.streamdeck.changechannel"
|
||||
},
|
||||
{
|
||||
"Icon": "images/send-message",
|
||||
"Name": "Send Message",
|
||||
"PropertyInspectorPath": "propertyinspector/send-message.html",
|
||||
"States": [
|
||||
{
|
||||
"Image": "images/send-message",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": true
|
||||
}
|
||||
],
|
||||
"SupportedInMultiActions": true,
|
||||
"Tooltip": "Send a message to current room",
|
||||
"UUID": "com.paarrot.streamdeck.sendmessage"
|
||||
},
|
||||
{
|
||||
"Icon": "images/status",
|
||||
"Name": "Get Status",
|
||||
"States": [
|
||||
{
|
||||
"Image": "images/status",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": true
|
||||
}
|
||||
],
|
||||
"SupportedInMultiActions": false,
|
||||
"Tooltip": "Display current status (mute/deafen state)",
|
||||
"UUID": "com.paarrot.streamdeck.getstatus"
|
||||
}
|
||||
],
|
||||
"Author": "Paarrot",
|
||||
"Category": "Paarrot",
|
||||
"CategoryIcon": "images/paarrot",
|
||||
"CodePath": "plugin/index.html",
|
||||
"Description": "Control Paarrot voice chat (mute, deafen, channels, messages)",
|
||||
"Icon": "images/paarrot",
|
||||
"Name": "Paarrot",
|
||||
"OS": [
|
||||
{
|
||||
"Platform": "windows",
|
||||
"MinimumVersion": "10"
|
||||
},
|
||||
{
|
||||
"Platform": "mac",
|
||||
"MinimumVersion": "10.14"
|
||||
}
|
||||
],
|
||||
"SDKVersion": 2,
|
||||
"Software": {
|
||||
"MinimumVersion": "6.6"
|
||||
},
|
||||
"UUID": "com.paarrot.streamdeck",
|
||||
"URL": "https://github.com/yourusername/paarrot-streamdeck",
|
||||
"Version": "1.0.0.0"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Paarrot Plugin</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Paarrot Stream Deck Plugin
|
||||
* Controls the Paarrot desktop app via its local API
|
||||
*/
|
||||
|
||||
const API_BASE_URL = 'http://127.0.0.1:33384';
|
||||
const POLL_INTERVAL = 1000; // Poll status every second
|
||||
|
||||
/**
|
||||
* Global plugin instance
|
||||
*/
|
||||
let websocket = null;
|
||||
let pluginUUID = null;
|
||||
let statusPollInterval = null;
|
||||
|
||||
/**
|
||||
* Track state for each action instance
|
||||
*/
|
||||
const actionStates = new Map();
|
||||
|
||||
/**
|
||||
* Action UUIDs
|
||||
*/
|
||||
const ACTIONS = {
|
||||
TOGGLE_MUTE: 'com.paarrot.streamdeck.togglemute',
|
||||
TOGGLE_DEAFEN: 'com.paarrot.streamdeck.toggledeafen',
|
||||
CHANGE_CHANNEL: 'com.paarrot.streamdeck.changechannel',
|
||||
SEND_MESSAGE: 'com.paarrot.streamdeck.sendmessage',
|
||||
GET_STATUS: 'com.paarrot.streamdeck.getstatus'
|
||||
};
|
||||
|
||||
/**
|
||||
* Make API call to Paarrot
|
||||
*/
|
||||
async function callAPI(endpoint, method = 'GET', body = null) {
|
||||
try {
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'API call failed');
|
||||
}
|
||||
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error);
|
||||
showAlert();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current app status
|
||||
*/
|
||||
async function getStatus() {
|
||||
return await callAPI('/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state explicitly
|
||||
* @param {boolean} muted - Whether to mute
|
||||
*/
|
||||
async function setMute(muted) {
|
||||
return await callAPI('/mute', 'POST', { muted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set deafen state explicitly
|
||||
* @param {boolean} deafened - Whether to deafen
|
||||
*/
|
||||
async function setDeafen(deafened) {
|
||||
return await callAPI('/deafen', 'POST', { deafened });
|
||||
}
|
||||
|
||||
/**
|
||||
* Change channel
|
||||
*/
|
||||
async function changeChannel(roomId) {
|
||||
return await callAPI('/channel', 'POST', { roomId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to current room
|
||||
*/
|
||||
async function sendMessage(message) {
|
||||
return await callAPI('/message/current', 'POST', { message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of channels
|
||||
*/
|
||||
async function getChannels() {
|
||||
return await callAPI('/channels');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update button state based on status
|
||||
*/
|
||||
function updateButtonStates(status) {
|
||||
actionStates.forEach((state, context) => {
|
||||
if (state.action === ACTIONS.TOGGLE_MUTE) {
|
||||
const newState = status.muted ? 1 : 0;
|
||||
if (state.currentState !== newState) {
|
||||
setState(context, newState);
|
||||
state.currentState = newState;
|
||||
}
|
||||
} else if (state.action === ACTIONS.TOGGLE_DEAFEN) {
|
||||
const newState = status.deafened ? 1 : 0;
|
||||
if (state.currentState !== newState) {
|
||||
setState(context, newState);
|
||||
state.currentState = newState;
|
||||
}
|
||||
} else if (state.action === ACTIONS.GET_STATUS) {
|
||||
const title = `${status.muted ? 'M' : ''}${status.deafened ? 'D' : ''}${!status.muted && !status.deafened ? 'OK' : ''}`;
|
||||
setTitle(context, title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for status updates
|
||||
*/
|
||||
function startStatusPolling() {
|
||||
if (statusPollInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusPollInterval = setInterval(async () => {
|
||||
try {
|
||||
const status = await getStatus();
|
||||
updateButtonStates(status);
|
||||
} catch (error) {
|
||||
// Silently fail - server might not be running
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for status updates
|
||||
*/
|
||||
function stopStatusPolling() {
|
||||
if (statusPollInterval) {
|
||||
clearInterval(statusPollInterval);
|
||||
statusPollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send command to Stream Deck
|
||||
*/
|
||||
function sendToStreamDeck(event, context, payload = {}) {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
event,
|
||||
context,
|
||||
...payload
|
||||
};
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set button state
|
||||
*/
|
||||
function setState(context, state) {
|
||||
sendToStreamDeck('setState', context, { payload: { state } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set button title
|
||||
*/
|
||||
function setTitle(context, title) {
|
||||
sendToStreamDeck('setTitle', context, { payload: { title } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show alert on button
|
||||
*/
|
||||
function showAlert(context) {
|
||||
if (context) {
|
||||
sendToStreamDeck('showAlert', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show OK on button
|
||||
*/
|
||||
function showOk(context) {
|
||||
sendToStreamDeck('showOk', context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle button press
|
||||
*/
|
||||
async function handleKeyDown(context, settings, action) {
|
||||
try {
|
||||
switch (action) {
|
||||
case ACTIONS.TOGGLE_MUTE: {
|
||||
// Get current state and set opposite
|
||||
const status = await getStatus();
|
||||
await setMute(!status.muted);
|
||||
showOk(context);
|
||||
break;
|
||||
}
|
||||
|
||||
case ACTIONS.TOGGLE_DEAFEN: {
|
||||
// Get current state and set opposite
|
||||
const status = await getStatus();
|
||||
await setDeafen(!status.deafened);
|
||||
showOk(context);
|
||||
break;
|
||||
}
|
||||
|
||||
case ACTIONS.CHANGE_CHANNEL:
|
||||
if (settings.roomId) {
|
||||
await changeChannel(settings.roomId);
|
||||
showOk(context);
|
||||
} else {
|
||||
showAlert(context);
|
||||
}
|
||||
break;
|
||||
|
||||
case ACTIONS.SEND_MESSAGE:
|
||||
if (settings.message) {
|
||||
await sendMessage(settings.message);
|
||||
showOk(context);
|
||||
} else {
|
||||
showAlert(context);
|
||||
}
|
||||
break;
|
||||
|
||||
case ACTIONS.GET_STATUS:
|
||||
const status = await getStatus();
|
||||
updateButtonStates(status);
|
||||
showOk(context);
|
||||
break;
|
||||
}
|
||||
|
||||
// Force immediate status update
|
||||
const status = await getStatus();
|
||||
updateButtonStates(status);
|
||||
} catch (error) {
|
||||
showAlert(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle action appearing
|
||||
*/
|
||||
function handleWillAppear(context, settings, action) {
|
||||
actionStates.set(context, {
|
||||
action,
|
||||
settings,
|
||||
currentState: 0
|
||||
});
|
||||
|
||||
// Get initial state
|
||||
getStatus()
|
||||
.then(status => updateButtonStates(status))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle action disappearing
|
||||
*/
|
||||
function handleWillDisappear(context) {
|
||||
actionStates.delete(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle settings change
|
||||
*/
|
||||
function handleDidReceiveSettings(context, settings, action) {
|
||||
const state = actionStates.get(context);
|
||||
if (state) {
|
||||
state.settings = settings;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle property inspector request
|
||||
*/
|
||||
function handleSendToPlugin(context, action, payload) {
|
||||
// Handle requests from property inspector
|
||||
if (payload.event === 'getChannels') {
|
||||
getChannels()
|
||||
.then(channels => {
|
||||
sendToStreamDeck('sendToPropertyInspector', context, {
|
||||
action,
|
||||
payload: {
|
||||
event: 'channelList',
|
||||
channels
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to get channels:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket connection to Stream Deck
|
||||
*/
|
||||
function connectElgatoStreamDeckSocket(inPort, inPluginUUID, inRegisterEvent, inInfo) {
|
||||
pluginUUID = inPluginUUID;
|
||||
|
||||
websocket = new WebSocket(`ws://127.0.0.1:${inPort}`);
|
||||
|
||||
websocket.onopen = () => {
|
||||
const registerPayload = {
|
||||
event: inRegisterEvent,
|
||||
uuid: inPluginUUID
|
||||
};
|
||||
websocket.send(JSON.stringify(registerPayload));
|
||||
|
||||
// Start status polling
|
||||
startStatusPolling();
|
||||
};
|
||||
|
||||
websocket.onmessage = (evt) => {
|
||||
try {
|
||||
const message = JSON.parse(evt.data);
|
||||
const { event, action, context, payload } = message;
|
||||
const settings = payload?.settings || {};
|
||||
|
||||
switch (event) {
|
||||
case 'keyDown':
|
||||
handleKeyDown(context, settings, action);
|
||||
break;
|
||||
|
||||
case 'willAppear':
|
||||
handleWillAppear(context, settings, action);
|
||||
break;
|
||||
|
||||
case 'willDisappear':
|
||||
handleWillDisappear(context);
|
||||
break;
|
||||
|
||||
case 'didReceiveSettings':
|
||||
handleDidReceiveSettings(context, settings, action);
|
||||
break;
|
||||
|
||||
case 'sendToPlugin':
|
||||
handleSendToPlugin(context, action, payload);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebSocket message error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
stopStatusPolling();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Change Channel Settings</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 10px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #d0d0d0;
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
select, input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 15px;
|
||||
background-color: #3d3d3d;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #d0d0d0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
select:focus, input:focus {
|
||||
outline: none;
|
||||
border-color: #0e99e0;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 8px;
|
||||
background-color: #3d3d3d;
|
||||
border-left: 3px solid #0e99e0;
|
||||
margin-bottom: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: #3d3d3d;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="info">
|
||||
Select a room/channel to switch to when this button is pressed.
|
||||
</div>
|
||||
|
||||
<label for="roomId">Channel/Room:</label>
|
||||
<select id="roomId">
|
||||
<option value="">Loading channels...</option>
|
||||
</select>
|
||||
|
||||
<label for="customRoomId">Or enter Room ID manually:</label>
|
||||
<input type="text" id="customRoomId" placeholder="!abc123:matrix.org" />
|
||||
|
||||
<script>
|
||||
let websocket = null;
|
||||
let uuid = null;
|
||||
let actionInfo = null;
|
||||
let currentSettings = {};
|
||||
|
||||
function connectElgatoStreamDeckSocket(inPort, inUUID, inRegisterEvent, inInfo, inActionInfo) {
|
||||
uuid = inUUID;
|
||||
actionInfo = JSON.parse(inActionInfo);
|
||||
currentSettings = actionInfo.payload.settings || {};
|
||||
|
||||
websocket = new WebSocket('ws://127.0.0.1:' + inPort);
|
||||
|
||||
websocket.onopen = function() {
|
||||
const json = {
|
||||
event: inRegisterEvent,
|
||||
uuid: inUUID
|
||||
};
|
||||
websocket.send(JSON.stringify(json));
|
||||
|
||||
// Request channel list
|
||||
sendToPlugin({ event: 'getChannels' });
|
||||
};
|
||||
|
||||
websocket.onmessage = function(evt) {
|
||||
const message = JSON.parse(evt.data);
|
||||
|
||||
if (message.event === 'sendToPropertyInspector') {
|
||||
if (message.payload.event === 'channelList') {
|
||||
populateChannels(message.payload.channels);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set current values
|
||||
if (currentSettings.roomId) {
|
||||
document.getElementById('customRoomId').value = currentSettings.roomId;
|
||||
}
|
||||
}
|
||||
|
||||
function sendToPlugin(payload) {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
action: actionInfo.action,
|
||||
event: 'sendToPlugin',
|
||||
context: uuid,
|
||||
payload: payload
|
||||
};
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const settings = {
|
||||
roomId: document.getElementById('customRoomId').value || document.getElementById('roomId').value
|
||||
};
|
||||
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
event: 'setSettings',
|
||||
context: uuid,
|
||||
payload: settings
|
||||
};
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function populateChannels(channels) {
|
||||
const select = document.getElementById('roomId');
|
||||
select.innerHTML = '<option value="">-- Select a channel --</option>';
|
||||
|
||||
if (!channels || channels.length === 0) {
|
||||
select.innerHTML = '<option value="">No channels available</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by server
|
||||
const grouped = {};
|
||||
channels.forEach(channel => {
|
||||
if (!grouped[channel.server]) {
|
||||
grouped[channel.server] = [];
|
||||
}
|
||||
grouped[channel.server].push(channel);
|
||||
});
|
||||
|
||||
// Add options grouped by server
|
||||
Object.keys(grouped).sort().forEach(server => {
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = server;
|
||||
|
||||
grouped[server].forEach(channel => {
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.roomId;
|
||||
option.textContent = channel.name;
|
||||
if (currentSettings.roomId === channel.roomId) {
|
||||
option.selected = true;
|
||||
}
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
|
||||
select.appendChild(optgroup);
|
||||
});
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('roomId').addEventListener('change', saveSettings);
|
||||
document.getElementById('customRoomId').addEventListener('input', saveSettings);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Send Message Settings</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 10px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #d0d0d0;
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 15px;
|
||||
background-color: #3d3d3d;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #d0d0d0;
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #0e99e0;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 8px;
|
||||
background-color: #3d3d3d;
|
||||
border-left: 3px solid #0e99e0;
|
||||
margin-bottom: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.examples {
|
||||
padding: 8px;
|
||||
background-color: #3d3d3d;
|
||||
border-left: 3px solid #666;
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.examples h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.examples ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.examples li {
|
||||
margin-bottom: 4px;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="info">
|
||||
Enter the message to send to the currently active room when this button is pressed.
|
||||
</div>
|
||||
|
||||
<label for="message">Message:</label>
|
||||
<textarea id="message" placeholder="Type your message here..."></textarea>
|
||||
|
||||
<div class="examples">
|
||||
<h4>Example Messages:</h4>
|
||||
<ul>
|
||||
<li>BRB! (Be Right Back)</li>
|
||||
<li>AFK for a few minutes</li>
|
||||
<li>On my way!</li>
|
||||
<li>Thanks everyone!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let websocket = null;
|
||||
let uuid = null;
|
||||
let actionInfo = null;
|
||||
let currentSettings = {};
|
||||
let saveTimeout = null;
|
||||
|
||||
function connectElgatoStreamDeckSocket(inPort, inUUID, inRegisterEvent, inInfo, inActionInfo) {
|
||||
uuid = inUUID;
|
||||
actionInfo = JSON.parse(inActionInfo);
|
||||
currentSettings = actionInfo.payload.settings || {};
|
||||
|
||||
websocket = new WebSocket('ws://127.0.0.1:' + inPort);
|
||||
|
||||
websocket.onopen = function() {
|
||||
const json = {
|
||||
event: inRegisterEvent,
|
||||
uuid: inUUID
|
||||
};
|
||||
websocket.send(JSON.stringify(json));
|
||||
};
|
||||
|
||||
// Set current value
|
||||
if (currentSettings.message) {
|
||||
document.getElementById('message').value = currentSettings.message;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
// Debounce saving
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
}
|
||||
|
||||
saveTimeout = setTimeout(() => {
|
||||
const settings = {
|
||||
message: document.getElementById('message').value
|
||||
};
|
||||
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
event: 'setSettings',
|
||||
context: uuid,
|
||||
payload: settings
|
||||
};
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Event listener
|
||||
document.getElementById('message').addEventListener('input', saveSettings);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
18
streamdeck-plugin/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "paarrot-streamdeck-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Elgato Stream Deck plugin for Paarrot voice chat app",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"validate": "node validate.js"
|
||||
},
|
||||
"keywords": [
|
||||
"stream-deck",
|
||||
"elgato",
|
||||
"paarrot",
|
||||
"voice-chat",
|
||||
"matrix"
|
||||
],
|
||||
"author": "Paarrot",
|
||||
"license": "MIT"
|
||||
}
|
||||
156
streamdeck-plugin/validate.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Validation script for Paarrot Stream Deck Plugin
|
||||
* Checks plugin structure and manifest
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLUGIN_DIR = 'com.paarrot.streamdeck.sdPlugin';
|
||||
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
|
||||
function error(message) {
|
||||
console.error(`❌ ERROR: ${message}`);
|
||||
errors++;
|
||||
}
|
||||
|
||||
function warn(message) {
|
||||
console.warn(`⚠️ WARNING: ${message}`);
|
||||
warnings++;
|
||||
}
|
||||
|
||||
function success(message) {
|
||||
console.log(`✓ ${message}`);
|
||||
}
|
||||
|
||||
console.log('Validating Paarrot Stream Deck Plugin...\n');
|
||||
|
||||
// Check plugin directory exists
|
||||
if (!fs.existsSync(PLUGIN_DIR)) {
|
||||
error(`Plugin directory '${PLUGIN_DIR}' not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check manifest.json
|
||||
const manifestPath = path.join(PLUGIN_DIR, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
error('manifest.json not found');
|
||||
} else {
|
||||
success('manifest.json exists');
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields = ['Actions', 'Author', 'CodePath', 'Description', 'Name', 'Version', 'SDKVersion'];
|
||||
requiredFields.forEach(field => {
|
||||
if (!manifest[field]) {
|
||||
error(`manifest.json missing required field: ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (manifest.Actions && Array.isArray(manifest.Actions)) {
|
||||
success(`Found ${manifest.Actions.length} actions`);
|
||||
|
||||
// Validate each action
|
||||
manifest.Actions.forEach((action, index) => {
|
||||
const actionName = action.Name || `Action ${index}`;
|
||||
|
||||
if (!action.UUID) {
|
||||
error(`${actionName}: Missing UUID`);
|
||||
}
|
||||
|
||||
if (!action.Icon) {
|
||||
error(`${actionName}: Missing Icon`);
|
||||
} else {
|
||||
// Check if icon file exists
|
||||
const iconPath = path.join(PLUGIN_DIR, `${action.Icon}.svg`);
|
||||
const iconPathPng = path.join(PLUGIN_DIR, `${action.Icon}.png`);
|
||||
if (!fs.existsSync(iconPath) && !fs.existsSync(iconPathPng)) {
|
||||
warn(`${actionName}: Icon file not found: ${action.Icon}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!action.States || !Array.isArray(action.States) || action.States.length === 0) {
|
||||
error(`${actionName}: Missing or invalid States`);
|
||||
} else {
|
||||
action.States.forEach((state, stateIndex) => {
|
||||
if (!state.Image) {
|
||||
warn(`${actionName} State ${stateIndex}: Missing Image`);
|
||||
} else {
|
||||
const imagePath = path.join(PLUGIN_DIR, `${state.Image}.svg`);
|
||||
const imagePathPng = path.join(PLUGIN_DIR, `${state.Image}.png`);
|
||||
if (!fs.existsSync(imagePath) && !fs.existsSync(imagePathPng)) {
|
||||
warn(`${actionName} State ${stateIndex}: Image file not found: ${state.Image}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (action.PropertyInspectorPath) {
|
||||
const piPath = path.join(PLUGIN_DIR, action.PropertyInspectorPath);
|
||||
if (!fs.existsSync(piPath)) {
|
||||
error(`${actionName}: PropertyInspector file not found: ${action.PropertyInspectorPath}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
error(`manifest.json parse error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check plugin files
|
||||
const pluginPath = path.join(PLUGIN_DIR, 'plugin', 'index.html');
|
||||
if (!fs.existsSync(pluginPath)) {
|
||||
error('plugin/index.html not found');
|
||||
} else {
|
||||
success('plugin/index.html exists');
|
||||
}
|
||||
|
||||
const pluginJsPath = path.join(PLUGIN_DIR, 'plugin', 'index.js');
|
||||
if (!fs.existsSync(pluginJsPath)) {
|
||||
error('plugin/index.js not found');
|
||||
} else {
|
||||
success('plugin/index.js exists');
|
||||
}
|
||||
|
||||
// Check property inspectors
|
||||
const piDir = path.join(PLUGIN_DIR, 'propertyinspector');
|
||||
if (fs.existsSync(piDir)) {
|
||||
const piFiles = fs.readdirSync(piDir);
|
||||
success(`Found ${piFiles.length} property inspector file(s)`);
|
||||
} else {
|
||||
warn('propertyinspector directory not found');
|
||||
}
|
||||
|
||||
// Check images directory
|
||||
const imagesDir = path.join(PLUGIN_DIR, 'images');
|
||||
if (fs.existsSync(imagesDir)) {
|
||||
const imageFiles = fs.readdirSync(imagesDir);
|
||||
success(`Found ${imageFiles.length} image file(s)`);
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
warn('images directory is empty');
|
||||
}
|
||||
} else {
|
||||
error('images directory not found');
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(50));
|
||||
if (errors === 0 && warnings === 0) {
|
||||
console.log('✓ Plugin validation passed!');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`Found ${errors} error(s) and ${warnings} warning(s)`);
|
||||
if (errors > 0) {
|
||||
console.log('\nPlease fix errors before building the plugin.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\nPlugin can be built, but consider fixing warnings.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
26
test-electron.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
|
||||
console.log('TEST: Electron starting...');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
console.log('TEST: App ready, creating window...');
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
title: 'TEST WINDOW',
|
||||
show: true,
|
||||
center: true
|
||||
});
|
||||
|
||||
console.log('TEST: Window created, loading content...');
|
||||
win.loadURL('https://www.google.com');
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
console.log('TEST: Window ready-to-show fired');
|
||||
});
|
||||
|
||||
win.on('show', () => {
|
||||
console.log('TEST: Window shown!');
|
||||
});
|
||||
});
|
||||