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.
This commit is contained in:
2026-03-23 20:34:26 +11:00
parent b71268a38f
commit 07feddd44b
26 changed files with 7642 additions and 3 deletions

View 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

Submodule cinny updated: 5eb937ab3e...f76fee23bf

View File

@@ -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

View File

@@ -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
View 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

View 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
View 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

View 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);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 889 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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"
}

View File

@@ -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>

View File

@@ -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();
};
}

View File

@@ -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>

View File

@@ -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>

View 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"
}

View 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
View 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!');
});
});