Compare commits

1 Commits

Author SHA1 Message Date
GitHub Actions
0b7817638e chore: bump version to 4.10.3 [skip ci] 2026-02-21 06:54:49 +00:00
60 changed files with 3016 additions and 12556 deletions

View File

@@ -8,15 +8,13 @@ on:
tags: tags:
- 'v*' - 'v*'
# Large AppImage/exe uploads time out on Gitea's actions artifact pipeline.
# Build jobs publish assets straight to Gitea/GitHub releases via the REST API.
jobs: jobs:
increment-version: increment-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip ci]')
outputs: outputs:
version: ${{ steps.finalize.outputs.VERSION }} version: ${{ steps.bump.outputs.VERSION }}
should_build: ${{ steps.decide.outputs.SHOULD_BUILD }} should_build: ${{ steps.bump.outputs.SHOULD_BUILD }}
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -24,35 +22,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Decide version behavior
id: decide
run: |
CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
COMMIT_MSG="${{ github.event.head_commit.message || '' }}"
SHOULD_BUMP=false
SHOULD_BUILD=true
if [ "${{ github.ref }}" = "refs/heads/main" ] && ! echo "$COMMIT_MSG" | grep -qi '\[skip ci\]'; then
SHOULD_BUMP=true
fi
if echo "$COMMIT_MSG" | grep -qi '\[skip ci\]'; then
SHOULD_BUILD=false
fi
echo "CURRENT_VERSION=$CURRENT" >> $GITHUB_OUTPUT
echo "SHOULD_BUMP=$SHOULD_BUMP" >> $GITHUB_OUTPUT
echo "SHOULD_BUILD=$SHOULD_BUILD" >> $GITHUB_OUTPUT
echo "Current version: $CURRENT"
echo "Should bump: $SHOULD_BUMP"
echo "Should build: $SHOULD_BUILD"
- name: Bump patch version - name: Bump patch version
id: bump id: bump
if: ${{ steps.decide.outputs.SHOULD_BUMP == 'true' }}
run: | run: |
# Get current version from package.json # Get current version from package.json
CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1) CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
@@ -73,48 +45,122 @@ jobs:
grep '"version"' package.json | head -1 grep '"version"' package.json | head -1
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "SHOULD_BUILD=true" >> $GITHUB_OUTPUT
- name: Commit and push version bump - name: Commit and push version bump
if: ${{ steps.decide.outputs.SHOULD_BUMP == 'true' }}
run: | run: |
git config user.name "GitHub Actions" git config user.name "GitHub Actions"
git config user.email "actions@github.com" git config user.email "actions@github.com"
git add package.json git add package.json
git commit -m "chore: bump version to ${{ steps.bump.outputs.VERSION }} [skip ci]" git commit -m "chore: bump version to ${{ steps.bump.outputs.VERSION }} [skip ci]"
for attempt in 1 2 3; do git push
git fetch origin main
git rebase origin/main || {
git rebase --abort || true
echo "Failed to rebase version bump onto latest main"
exit 1
}
if git push origin HEAD:main; then build-windows:
exit 0 runs-on: windows
fi needs: increment-version
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main'
shell: powershell
run: git pull origin main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies (root)
run: npm install --prefer-offline
- name: Install dependencies (cinny)
working-directory: ./cinny
run: npm install --prefer-offline
- name: Build Electron app
run: npm run build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload NSIS installer
uses: actions/upload-artifact@v3
with:
name: Paarrot-Windows-x64.exe
path: dist-electron/Paarrot-*-win-x64.exe
- name: Upload Portable
uses: actions/upload-artifact@v3
with:
name: Paarrot-Windows-x64-portable.exe
path: dist-electron/Paarrot-*-win-x64.portable.exe
echo "Push rejected on attempt ${attempt}, retrying against latest main" build-linux:
done
echo "Failed to push version bump after 3 attempts"
exit 1
- name: Finalize version output
id: finalize
run: |
if [ -n "${{ steps.bump.outputs.VERSION }}" ]; then
echo "VERSION=${{ steps.bump.outputs.VERSION }}" >> $GITHUB_OUTPUT
else
echo "VERSION=${{ steps.decide.outputs.CURRENT_VERSION }}" >> $GITHUB_OUTPUT
fi
prepare-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: increment-version needs: increment-version
if: always() && needs.increment-version.outputs.should_build == 'true' if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
outputs:
version: ${{ steps.version.outputs.VERSION }} steps:
tag: ${{ steps.version.outputs.TAG_NAME }} - name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main'
run: git pull origin main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y rpm
- name: Install dependencies (root)
run: npm ci --prefer-offline
- name: Install dependencies (cinny)
working-directory: ./cinny
run: npm ci --prefer-offline
- name: Build Electron app
run: npm run build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload AppImage
uses: actions/upload-artifact@v3
with:
name: Paarrot-Linux-x64.AppImage
path: dist-electron/Paarrot-*-x64.AppImage
- name: Upload DEB package
uses: actions/upload-artifact@v3
with:
name: Paarrot-Linux-x64.deb
path: dist-electron/Paarrot-*-x64.deb
- name: Upload RPM package
uses: actions/upload-artifact@v3
with:
name: Paarrot-Linux-x64.rpm
path: dist-electron/Paarrot-*-x64.rpm
create-release:
runs-on: ubuntu-latest
needs: [increment-version, build-windows, build-linux]
if: always() && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success')
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -127,425 +173,94 @@ jobs:
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
run: git pull origin main run: git pull origin main
- name: Resolve version - name: Get version
id: version id: version
run: | run: |
# Use version from increment-version job if available, otherwise read from file
if [ -n "${{ needs.increment-version.outputs.version }}" ]; then if [ -n "${{ needs.increment-version.outputs.version }}" ]; then
VERSION="${{ needs.increment-version.outputs.version }}" VERSION="${{ needs.increment-version.outputs.version }}"
else else
VERSION=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1) VERSION=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
fi fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG_NAME=v$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION" echo "Version: $VERSION"
- name: Create and push tag - name: Check if tag exists
id: tag_check
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}" if git rev-parse "v$VERSION" >/dev/null 2>&1; then
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then echo "Tag v$VERSION already exists"
echo "Tag $TAG_NAME already exists" echo "exists=true" >> $GITHUB_OUTPUT
else else
git config user.name "GitHub Actions" echo "Tag v$VERSION does not exist"
git config user.email "actions@github.com" echo "exists=false" >> $GITHUB_OUTPUT
git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
git push origin "$TAG_NAME"
fi fi
- name: Ensure Gitea release - name: Create and push tag
if: steps.tag_check.outputs.exists == 'false'
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "v${{ steps.version.outputs.VERSION }}"
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release-files
find artifacts -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.dmg" -o -name "*.zip" \) -exec cp {} release-files/ \;
ls -la release-files/
- name: Create or Update Release
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.VERSION }}" VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}" TAG_NAME="v${VERSION}"
API_BASE="${{ github.server_url }}/api/v1" API_BASE="http://synbox.ruv.wtf:8418/api/v1"
REPO="${{ github.repository }}" REPO="litruv/cinny-desktop"
RELEASE_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \ # Check if release exists
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty' || true) RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "Creating Gitea release ${TAG_NAME}..." echo "Creating new release for ${TAG_NAME}..."
RELEASE_ID=$(curl -fsS -X POST \ RELEASE_ID=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \ -d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"${API_BASE}/repos/${REPO}/releases" | jq -r '.id') "${API_BASE}/repos/${REPO}/releases" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else
echo "Release exists with ID: ${RELEASE_ID}"
# Delete existing assets
echo "Deleting existing assets..."
ASSETS=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets" | jq -r '.[].id')
for ASSET_ID in $ASSETS; do
echo "Deleting asset ${ASSET_ID}..."
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
done
fi fi
echo "Gitea release id: ${RELEASE_ID}" # Upload new assets
echo "Uploading assets..."
- name: Ensure GitHub release for FILE in release-files/*; do
env:
GH_TOKEN: ${{ secrets.GHTOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
GITHUB_REPO="Paarrot/Paarrot-Desktop"
RELEASE_ID=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty' || true)
if [ -z "$RELEASE_ID" ]; then
echo "Creating GitHub release ${TAG_NAME}..."
RELEASE_ID=$(curl -fsS -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r '.id')
fi
echo "GitHub release id: ${RELEASE_ID}"
build-windows:
runs-on: windows
needs: [increment-version, prepare-release]
if: always() && needs.prepare-release.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: false
ref: ${{ github.ref_name }}
- name: Checkout submodules manually
shell: powershell
run: |
$ErrorActionPreference = "Stop"
# Use the same host/scheme the runner already used for origin (not public HTTPS).
$origin = (git remote get-url origin).Trim()
$origin = $origin.TrimEnd('/')
if ($origin.EndsWith('.git')) { $origin = $origin.Substring(0, $origin.Length - 4) }
$base = $origin.Substring(0, $origin.LastIndexOf('/'))
$cinnyUrl = "$base/cinny.git"
Write-Host "Rewriting submodule URL to $cinnyUrl"
git config submodule.cinny.url $cinnyUrl
git submodule sync --recursive
git submodule update --init --recursive
if ($LASTEXITCODE -eq 0) {
Write-Host "Submodules checked out at pinned commits."
exit 0
}
Write-Warning "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD."
git submodule deinit -f --all
git config submodule.cinny.url $cinnyUrl
git submodule sync --recursive
git submodule update --init --recursive --remote
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Resolved submodule commit:"
git -C cinny rev-parse HEAD
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main'
shell: powershell
run: git pull origin main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies (root)
run: npm ci --prefer-offline
- name: Install dependencies (cinny)
working-directory: ./cinny
run: npm ci --prefer-offline
- name: Clean previous builds
shell: powershell
run: |
if (Test-Path cinny/dist) { Remove-Item -Recurse -Force cinny/dist }
if (Test-Path dist-electron) { Remove-Item -Recurse -Force dist-electron }
- name: Build Electron app
run: npm run build:local -- --win
env:
GH_TOKEN: ${{ secrets.GHTOKEN }}
- name: Upload Windows assets to releases
shell: powershell
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GHTOKEN }}
run: |
$ErrorActionPreference = "Stop"
$TAG_NAME = "${{ needs.prepare-release.outputs.tag }}"
$API_BASE = "${{ github.server_url }}/api/v1"
$REPO = "${{ github.repository }}"
$GITHUB_REPO = "Paarrot/Paarrot-Desktop"
function Invoke-JsonCurl([string[]]$CurlArgs) {
$raw = & curl.exe @CurlArgs
if ($LASTEXITCODE -ne 0) { throw "curl failed: $($CurlArgs -join ' ')" }
return ($raw | ConvertFrom-Json)
}
$giteaRelease = Invoke-JsonCurl @(
"-fsS", "-H", "Authorization: token $env:GITEA_TOKEN",
"$API_BASE/repos/$REPO/releases/tags/$TAG_NAME"
)
$ghRelease = Invoke-JsonCurl @(
"-fsS", "-H", "Authorization: token $env:GH_TOKEN",
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$TAG_NAME"
)
# GitHub returns upload_url like .../assets{?name,label}
$ghUploadUrl = [string]$ghRelease.upload_url
if ([string]::IsNullOrWhiteSpace($ghUploadUrl)) {
throw "GitHub release $($ghRelease.id) has no upload_url"
}
$ghUploadUrl = $ghUploadUrl -replace '\{\?[^}]*\}', ''
Write-Host "GitHub upload base: $ghUploadUrl"
function Upload-One([string]$File) {
$filename = [System.IO.Path]::GetFileName($File)
$sizeMb = [math]::Round((Get-Item $File).Length / 1MB, 1)
Write-Host "Uploading $filename (${sizeMb} MB)..."
$giteaAssets = Invoke-JsonCurl @(
"-fsS", "-H", "Authorization: token $env:GITEA_TOKEN",
"$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets"
)
foreach ($asset in @($giteaAssets)) {
if ($asset.name -eq $filename) {
& curl.exe -fsS -X DELETE -H "Authorization: token $env:GITEA_TOKEN" `
"$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets/$($asset.id)" | Out-Null
}
}
& curl.exe -fsS --max-time 0 --retry 5 --retry-delay 10 `
-X POST `
-H "Authorization: token $env:GITEA_TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$File" `
"$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets?name=$filename" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Gitea upload failed for $filename" }
Write-Host " Gitea: ok"
$ghAssets = Invoke-JsonCurl @(
"-fsS", "-H", "Authorization: token $env:GH_TOKEN",
"https://api.github.com/repos/$GITHUB_REPO/releases/$($ghRelease.id)/assets"
)
foreach ($asset in @($ghAssets)) {
if ($asset.name -eq $filename) {
& curl.exe -fsS -X DELETE -H "Authorization: token $env:GH_TOKEN" `
"https://api.github.com/repos/$GITHUB_REPO/releases/assets/$($asset.id)" | Out-Null
}
}
# Brace the variable — `$url?name=` is parsed as PowerShell's null-conditional.
$ghAssetUrl = "${ghUploadUrl}?name=$([uri]::EscapeDataString($filename))"
Write-Host " GitHub URL: $ghAssetUrl"
& curl.exe -fsS --max-time 0 --retry 5 --retry-delay 10 `
-X POST `
-H "Authorization: token $env:GH_TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$File" `
$ghAssetUrl | Out-Null
if ($LASTEXITCODE -ne 0) { throw "GitHub upload failed for $filename" }
Write-Host " GitHub: ok"
}
Get-ChildItem -Path dist-electron -File |
Where-Object { $_.Name -like 'Paarrot-*-win-x64.exe' -or $_.Name -eq 'latest.yml' } |
ForEach-Object { Upload-One $_.FullName }
- name: List build output
shell: powershell
run: |
Get-ChildItem -Path dist-electron -Recurse | Select-Object FullName
build-linux:
runs-on: ubuntu-latest
needs: [increment-version, prepare-release]
if: always() && needs.prepare-release.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: false
ref: ${{ github.ref_name }}
- name: Checkout submodules manually
run: |
set -euo pipefail
# Use the same host/scheme the runner already used for origin (not public HTTPS).
ORIGIN_URL=$(git remote get-url origin)
ORIGIN_URL=${ORIGIN_URL%/}
ORIGIN_URL=${ORIGIN_URL%.git}
BASE_URL=${ORIGIN_URL%/*}
CINNY_URL="${BASE_URL}/cinny.git"
echo "Rewriting submodule URL to ${CINNY_URL}"
git config submodule.cinny.url "${CINNY_URL}"
git submodule sync --recursive
if git submodule update --init --recursive; then
echo "Submodules checked out at pinned commits."
exit 0
fi
echo "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD."
git submodule deinit -f --all || true
rm -rf cinny .git/modules/cinny
git config submodule.cinny.url "${CINNY_URL}"
git submodule sync --recursive
git submodule update --init --recursive --remote
echo "Resolved submodule commit:"
git -C cinny rev-parse HEAD
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main'
run: git pull origin main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies (root)
run: npm ci --prefer-offline
- name: Install dependencies (cinny)
working-directory: ./cinny
run: npm ci --prefer-offline
- name: Clean previous builds
run: |
rm -rf cinny/dist dist-electron
- name: Build Electron app
run: npm run build:local -- --linux
env:
GH_TOKEN: ${{ secrets.GHTOKEN }}
- name: Upload Linux assets to releases
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GHTOKEN }}
run: |
set -euo pipefail
VERSION="${{ needs.prepare-release.outputs.version }}"
TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
API_BASE="${{ github.server_url }}/api/v1"
REPO="${{ github.repository }}"
GITHUB_REPO="Paarrot/Paarrot-Desktop"
GITEA_RELEASE_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id')
GH_RELEASE_ID=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id')
GH_UPLOAD_URL=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
upload_one() {
local FILE="$1"
local FILENAME
FILENAME=$(basename "$FILE") FILENAME=$(basename "$FILE")
echo "Uploading ${FILENAME} ($(du -h "$FILE" | cut -f1))..." echo "Uploading ${FILENAME}..."
curl -s -X POST \
OLD_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets" \
| jq -r --arg n "$FILENAME" '.[] | select(.name == $n) | .id' || true)
if [ -n "${OLD_ID:-}" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets/${OLD_ID}" >/dev/null
fi
curl -fsS --max-time 0 --retry 5 --retry-delay 10 \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary @"${FILE}" \ --data-binary "@${FILE}" \
"${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets?name=${FILENAME}" \ "${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
>/dev/null echo ""
echo " Gitea: ok"
OLD_GH=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}/assets" \
| jq -r --arg n "$FILENAME" '.[] | select(.name == $n) | .id' || true)
if [ -n "${OLD_GH:-}" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${OLD_GH}" >/dev/null
fi
curl -fsS --max-time 0 --retry 5 --retry-delay 10 \
-X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${FILE}" \
"${GH_UPLOAD_URL}?name=${FILENAME}" \
>/dev/null
echo " GitHub: ok"
}
shopt -s nullglob
for FILE in dist-electron/Paarrot-*.AppImage dist-electron/latest-linux.yml; do
upload_one "$FILE"
done done
finalize-release: echo "Release complete!"
runs-on: ubuntu-latest
needs: [increment-version, prepare-release, build-windows, build-linux]
if: always() && needs.prepare-release.result == 'success' && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success')
steps:
- name: Cleanup old Gitea releases
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
API_BASE="${{ github.server_url }}/api/v1"
REPO="${{ github.repository }}"
echo "Cleaning up old Gitea releases (keeping ${TAG_NAME})..."
OLD_RELEASES=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases" | jq -r --arg t "$TAG_NAME" '.[] | select(.tag_name != $t) | .id')
for OLD_ID in $OLD_RELEASES; do
[ -n "$OLD_ID" ] || continue
OLD_TAG=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${OLD_ID}" | jq -r '.tag_name')
echo "Deleting Gitea release ${OLD_ID} (${OLD_TAG})..."
curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${OLD_ID}" >/dev/null || true
if [ -n "$OLD_TAG" ] && [ "$OLD_TAG" != "null" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/tags/${OLD_TAG}" >/dev/null || true
fi
done
- name: Cleanup old GitHub releases
env:
GH_TOKEN: ${{ secrets.GHTOKEN }}
run: |
set -euo pipefail
TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
GITHUB_REPO="Paarrot/Paarrot-Desktop"
echo "Cleaning up old GitHub releases (keeping ${TAG_NAME})..."
OLD_RELEASES=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r --arg t "$TAG_NAME" '.[] | select(.tag_name != $t) | .id')
for OLD_ID in $OLD_RELEASES; do
[ -n "$OLD_ID" ] || continue
OLD_TAG=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" | jq -r '.tag_name')
echo "Deleting GitHub release ${OLD_ID} (${OLD_TAG})..."
curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" >/dev/null || true
if [ -n "$OLD_TAG" ] && [ "$OLD_TAG" != "null" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${OLD_TAG}" >/dev/null || true
fi
done
echo "Release finalize complete for ${TAG_NAME}"

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
github: ajbura
liberapay: ajbura
open_collective: cinny

58
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: 🐞 Bug Report
description: Report a bug
labels: 'type: bug'
body:
- type: markdown
attributes:
value: |
## First of all
1. Please search for [existing issues](https://github.com/cinnyapp/cinny-desktop/issues?q=is%3Aissue) about this problem first.
2. Make sure Cinny is up to date.
3. Make sure it's an issue with Cinny and not something else you are using.
4. Remember to be friendly.
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear description of what the bug is. Include screenshots if applicable.
placeholder: Bug description
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Steps to reproduce the behavior.
placeholder: |
1. Go to ...
2. Click on ...
3. See error
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear description of what you expected to happen.
- type: textarea
id: info
attributes:
label: Platform and versions
description: "Provide OS, browser and Cinny version with your Homeserver."
placeholder: |
1. OS: [e.g. Windows 10, MacOS]
2. Cinny version: [e.g. 1.8.1]
3. Matrix homeserver: [e.g. matrix.org]
4. Downloaded from: [e.g. GitHub, Flatpak]
render: shell
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

4
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
contact_links:
- name: 💬 Matrix Chat
url: https://matrix.to/#/#cinny:matrix.org
about: Ask questions and talk to other Cinny users and the maintainers

View File

@@ -0,0 +1,34 @@
name: 💡 Feature Request
description: Suggest an idea
labels: 'type: feature'
body:
- type: textarea
id: problem
attributes:
label: Describe the problem
description: A clear description of the problem this feature would solve
placeholder: "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Describe the solution you'd like"
description: A clear description of what change you would like
placeholder: "I would like to..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: "Any alternative solutions you've considered"
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,22 @@
<!-- Please read https://github.com/cinnyapp/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
### Description
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes #
#### Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
### Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings

3
.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,3 @@
# Reporting a Vulnerability
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**

30
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
version: 2
updates:
# - package-ecosystem: npm
# directory: /
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 15
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
open-pull-requests-limit: 5
# - package-ecosystem: cargo
# directory: /src-tauri/
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 5

21
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":dependencyDashboardApproval"
],
"labels": [
"Dependencies"
],
"packageRules": [
{
"matchUpdateTypes": [
"lockFileMaintenance"
]
}
],
"lockFileMaintenance": {
"enabled": true
},
"dependencyDashboard": true
}

22
.github/workflows/archive.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: "Upload zip-archive"
on:
release:
types: [published]
jobs:
zip-archive:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.0
with:
submodules: true
- name: Create zip including submodules
run: |
cd ..
zip ${{ github.event.repository.name }}/${{ github.event.repository.name }}-${{ github.ref_name }}.zip ${{ github.event.repository.name }} -r
- name: Upload zip to release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
with:
files: |
${{ github.event.repository.name }}-${{ github.ref_name }}.zip

36
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: 'CLA Assistant'
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
with:
path-to-signatures: 'signatures.json'
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
# branch should not be protected
branch: 'main'
allowlist: ajbura,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name: cinnyapp
remote-repository-name: cla
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA

26
.github/workflows/lockfile.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: NPM Lockfile Changes
on:
pull_request:
paths:
- 'package-lock.json'
jobs:
lockfile_changes:
runs-on: ubuntu-latest
# Permission overwrite is required for Dependabot PRs, see "Common issues" below.
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4.2.0
- name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Optional inputs, can be deleted safely if you are happy with default values.
collapsibleThreshold: 25
failOnDowngrade: false
path: package-lock.json
updateComment: true

40
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: "Build pull request"
on:
#pull_request:
#types: ['opened', 'synchronize']
jobs:
publish-tauri:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-20.04, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.0
with:
submodules: true
- name: Setup node
uses: actions/setup-node@v4.4.0
with:
node-version: 20.12.2
cache: 'npm'
- name: Install Rust stable
uses: actions-rs/toolchain@v1.0.7
with:
toolchain: stable
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
- name: Install cinny dependencies
run: cd cinny && npm ci
- name: Install tauri dependencies
run: npm ci
- name: Build desktop app with Tauri
uses: tauri-apps/tauri-action@v0.5.14
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -4,4 +4,3 @@ node_modules
devAssets devAssets
.DS_Store .DS_Store
dist-electron/

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "cinny"] [submodule "cinny"]
path = cinny path = cinny
url = ../cinny.git url = http://synbox.ruv.wtf:8418/litruv/cinny.git

3
.npmrc
View File

@@ -1,2 +1 @@
save-exact=true save-exact=true
allow-git=all

View File

@@ -1,3 +0,0 @@
{
"liveServer.settings.port": 5501
}

View File

@@ -1,78 +1,32 @@
# Paarrot # Cinny desktop
<a href="https://github.com/cinnyapp/cinny-desktop/releases">
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/cinnyapp/cinny-desktop/total?style=social"></a>
Cinny is a matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron.
Paarrot is a Matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron and based on Cinny.
## Download ## Download
Installers for Windows and Linux can be downloaded from [releases](http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases). Installers for macOS, Windows and Linux can be downloaded from [releases](https://github.com/cinnyapp/cinny-desktop/releases).
Operating System | Download Operating System | Download
---|--- ---|---
Windows (x64) | <a href='http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases'>Get it on Windows</a> Windows | <a href='https://github.com/cinnyapp/cinny-desktop/releases/latest'>Get it on Windows</a>
Linux (AppImage) | <a href='http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases'>Get it on Linux</a> macOS | <a href='https://github.com/cinnyapp/cinny-desktop/releases/latest'>Get it on macOS</a>
Linux | <a href='https://github.com/cinnyapp/cinny-desktop/releases/latest'>Get it on Linux</a>
### Linux Installation
For the best AppImage experience, we recommend using [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher) which automatically integrates AppImages into your system.
## Local development ## Local development
To setup development locally run the following commands: To setup development locally run the following commands:
* `git clone --recursive http://synbox.ruv.wtf:8418/litruv/cinny-desktop.git` * `git clone --recursive https://github.com/cinnyapp/cinny-desktop.git`
* `cd cinny-desktop/cinny` * `cd cinny-desktop/cinny`
* `npm ci` * `npm ci`
* `cd ..` * `cd ..`
* `npm ci` * `npm ci`
To build the app locally, run: To build the app locally, run:
* `npm run build` * `npm run build`
To start local dev server, run: To start local dev server, run:
* `npm run dev` * `npm run dev`
## Plugin System
Paarrot includes a plugin system for extending and customising the app with JavaScript modules.
### Installing Plugins
1. Drop a plugin folder into your plugins directory:
- **Windows**: `%APPDATA%\paarrot\plugins\<plugin-name>\`
- **Linux**: `~/.config/Paarrot/plugins/<plugin-name>/`
- **macOS**: `~/Library/Application Support/Paarrot/plugins/<plugin-name>/`
2. Enable it in **Settings → Plugins → Installed**
### What Plugins Can Do
- Register custom slash commands
- Intercept and modify messages
- Inject buttons into 11 UI locations (nav lists, toolbars, headers, sidebar, menus)
- Register custom themes
- Hook into raw Matrix events
- Run background tasks
- Show system notifications
### Plugin Locations (UI Buttons)
Plugins can inject buttons as **nav list rows** or **icon buttons** across the app:
| Location | Style | Where |
|---|---|---|
| `channel-list` | Nav row | Space channel list |
| `home-section` | Nav row | Home panel, above room list |
| `direct-messages` | Nav row | DMs panel, below "Create Chat" |
| `sidebar-actions` | Icon | Left sidebar — above Explore and above Search |
| `text-composer-toolbar` | Icon | Message composer toolbar |
| `composer-actions` | Icon | Beside the `+` attach button |
| `room-header` | Icon | Room header bar |
| `room-menu` | Icon | Room ⋮ dropdown |
| `message-actions` | Icon | Message hover bar |
| `user-menu` | Icon | Right-click on user avatar |
| `search-notification-section` | Icon | Notifications page header |
### Documentation
- [Plugin System Overview](PLUGINS.md)
- [Full Plugin API Reference](docs/PLUGIN_API.md)
- [Button Registration API](docs/PLUGIN_BUTTON_API.md)
- [Example Plugins](plugins/)

View File

@@ -1,10 +0,0 @@
#!/bin/bash
# Set GitHub token for publishing
export GH_TOKEN="ghtoken"
# Run the build and publish
npm run build
# Exit with the build's exit code
exit $?

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# Post-installation script for .deb and .rpm packages
# Update desktop database for the .desktop file
if command -v update-desktop-database &> /dev/null; then
update-desktop-database -q /usr/share/applications || true
fi
# Update icon cache
if command -v gtk-update-icon-cache &> /dev/null; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
# Set appropriate permissions for chrome-sandbox (required for Electron)
SANDBOX_PATH="/opt/Paarrot/chrome-sandbox"
if [ -f "$SANDBOX_PATH" ]; then
chmod 4755 "$SANDBOX_PATH" || true
fi
exit 0

View File

@@ -1,14 +0,0 @@
#!/bin/bash
# Post-removal script for .deb and .rpm packages
# Update desktop database after removal
if command -v update-desktop-database &> /dev/null; then
update-desktop-database -q /usr/share/applications || true
fi
# Update icon cache
if command -v gtk-update-icon-cache &> /dev/null; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
exit 0

2
cinny

Submodule cinny updated: 2603ca8e5c...8b0960d366

View File

@@ -9,13 +9,30 @@
"xmr.se" "xmr.se"
], ],
"allowCustomHomeservers": true, "allowCustomHomeservers": true,
"calling": {
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
},
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": [ "spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:envs.net",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
], ],
"rooms": [ "rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
], ],
"servers": [] "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
}, },
"hashRouter": { "hashRouter": {

View File

@@ -1,4 +0,0 @@
provider: github
owner: Paarrot
repo: Paarrot-Desktop
updaterCacheDirName: paarrot-updater

View File

@@ -1,114 +1,131 @@
{ {
"appId": "com.paarrot.app", "appId": "com.paarrot.app",
"productName": "Paarrot", "productName": "Paarrot",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"copyright": "Copyright © 2026 Mates.Media", "copyright": "Copyright © 2024",
"directories": { "directories": {
"output": "dist-electron", "output": "dist-electron",
"buildResources": "build-resources" "buildResources": "build-resources"
}, },
"files": [ "files": [
"electron/**/*", "electron/**/*",
"cinny/dist/**/*", "cinny/dist/**/*",
"dev-app-update.yml", "!**/*.map",
"!**/*.map", "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
"!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}", "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
"!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}", "!**/node_modules/*.d.ts",
"!**/node_modules/*.d.ts", "!**/node_modules/.bin",
"!**/node_modules/.bin", "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}",
"!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}", "!.editorconfig",
"!.editorconfig", "!**/._*",
"!**/._*", "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}",
"!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}", "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}",
"!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}", "!**/{appveyor.yml,.travis.yml,circle.yml}",
"!**/{appveyor.yml,.travis.yml,circle.yml}", "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}"
"!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}" ],
], "extraResources": [
"extraResources": [ {
{ "from": "icons",
"from": "icons", "to": "icons",
"to": "icons", "filter": ["**/*"]
"filter": ["**/*"] }
}, ],
{ "linux": {
"from": "cinny/public/sound", "target": [
"to": "sound", {
"filter": ["**/*"] "target": "AppImage",
} "arch": ["x64", "arm64"]
], },
"protocols": [ {
{ "target": "deb",
"name": "Paarrot URL", "arch": ["x64", "arm64"]
"schemes": ["paarrot"] },
} {
], "target": "rpm",
"linux": { "arch": ["x64", "arm64"]
"target": [ }
{ ],
"target": "AppImage", "category": "Network;InstantMessaging",
"arch": ["x64"] "icon": "icons/icon.png",
} "desktop": {
], "Name": "Paarrot",
"category": "Network;InstantMessaging", "GenericName": "Matrix Client",
"maintainer": "Mates.Media <mates.media@users.noreply.github.com>", "Comment": "A Matrix client built with Cinny",
"icon": "icons/icon.png", "Categories": "Network;InstantMessaging;",
"desktop": { "Keywords": "matrix;chat;messaging;",
"entry": { "StartupWMClass": "paarrot"
"Name": "Paarrot", }
"GenericName": "Matrix Client", },
"Comment": "A Matrix client built with Cinny", "appImage": {
"MimeType": "x-scheme-handler/paarrot;", "license": "AGPL-3.0",
"Categories": "Network;InstantMessaging;", "artifactName": "${productName}-${version}-${arch}.${ext}"
"Keywords": "matrix;chat;messaging;", },
"StartupWMClass": "paarrot" "deb": {
} "depends": [
} "libnotify4",
}, "libxtst6",
"deb": { "libnss3"
"depends": [ ],
"libnotify4", "afterInstall": "build-resources/linux/after-install.sh",
"libxtst6", "afterRemove": "build-resources/linux/after-remove.sh"
"libnss3" },
], "rpm": {
"afterInstall": "build-resources/linux/after-install.sh", "depends": [
"afterRemove": "build-resources/linux/after-remove.sh" "libnotify",
}, "libXtst",
"rpm": { "nss"
"depends": [ ],
"libnotify", "afterInstall": "build-resources/linux/after-install.sh",
"libXtst", "afterRemove": "build-resources/linux/after-remove.sh"
"nss" },
], "win": {
"afterInstall": "build-resources/linux/after-install.sh", "target": [
"afterRemove": "build-resources/linux/after-remove.sh" {
}, "target": "nsis",
"win": { "arch": ["x64", "arm64"]
"target": [ },
{ {
"target": "nsis", "target": "portable",
"arch": ["x64"] "arch": ["x64", "arm64"]
} }
], ],
"icon": "icons/icon.ico", "icon": "icons/icon.ico",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}" "artifactName": "${productName}-${version}-${os}-${arch}.${ext}"
}, },
"nsis": { "nsis": {
"oneClick": true, "oneClick": false,
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": false, "allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false, "deleteAppDataOnUninstall": false,
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,
"shortcutName": "Paarrot", "shortcutName": "Paarrot"
"differentialPackage": false },
}, "mac": {
"compression": "normal", "target": [
"publish": [ {
{ "target": "dmg",
"provider": "github", "arch": ["x64", "arm64"]
"owner": "Paarrot", },
"repo": "Paarrot-Desktop", {
"releaseType": "release" "target": "zip",
} "arch": ["x64", "arm64"]
] }
} ],
"category": "public.app-category.social-networking",
"icon": "icons/icon.icns",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build-resources/entitlements.mac.plist",
"entitlementsInherit": "build-resources/entitlements.mac.plist"
},
"dmg": {
"sign": false,
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}"
},
"publish": {
"provider": "github",
"owner": "litruv",
"repo": "cinny-desktop"
}
}

View File

@@ -1,267 +0,0 @@
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
class PaarrotAPIServer {
constructor(mainWindow) {
this.mainWindow = mainWindow;
this.app = express();
this.server = null;
this.port = 33384; // Default port for Paarrot API
this.setupMiddleware();
this.setupRoutes();
}
setupMiddleware() {
// Enable CORS for all origins (you can restrict this if needed)
this.app.use(cors());
// Parse JSON bodies
this.app.use(bodyParser.json());
// Log all requests
this.app.use((req, res, next) => {
console.log(`Paarrot API: ${req.method} ${req.path}`);
next();
});
}
setupRoutes() {
// Health check
this.app.get('/health', (req, res) => {
res.json({ status: 'ok', app: 'Paarrot API', version: '1.0.0' });
});
// Get current status
this.app.get('/status', async (req, res) => {
try {
const status = await this.executeAction('get-status');
res.json({ success: true, data: status });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Mute/unmute
this.app.post('/mute', async (req, res) => {
try {
const { muted } = req.body;
const result = await this.executeAction('set-mute', { muted });
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Toggle mute
this.app.post('/mute/toggle', async (req, res) => {
try {
const result = await this.executeAction('toggle-mute');
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Deafen/undeafen
this.app.post('/deafen', async (req, res) => {
try {
const { deafened } = req.body;
const result = await this.executeAction('set-deafen', { deafened });
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Toggle deafen
this.app.post('/deafen/toggle', async (req, res) => {
try {
const result = await this.executeAction('toggle-deafen');
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Change channel/room
this.app.post('/channel', async (req, res) => {
try {
const { roomId } = req.body;
if (!roomId) {
return res.status(400).json({ success: false, error: 'roomId is required' });
}
const result = await this.executeAction('change-channel', { roomId });
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Get list of rooms/channels
this.app.get('/channels', async (req, res) => {
try {
const result = await this.executeAction('get-channels');
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Send message
this.app.post('/message', async (req, res) => {
try {
const { roomId, message } = req.body;
if (!roomId || !message) {
return res.status(400).json({
success: false,
error: 'roomId and message are required'
});
}
const result = await this.executeAction('send-message', { roomId, message });
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Send message to current room
this.app.post('/message/current', async (req, res) => {
try {
const { message } = req.body;
if (!message) {
return res.status(400).json({
success: false,
error: 'message is required'
});
}
const result = await this.executeAction('send-message-current', { message });
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Get current room info
this.app.get('/room/current', async (req, res) => {
try {
const result = await this.executeAction('get-current-room');
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 404 handler
this.app.use((req, res) => {
res.status(404).json({
success: false,
error: 'Endpoint not found',
availableEndpoints: [
'GET /health',
'GET /status',
'POST /mute',
'POST /mute/toggle',
'POST /deafen',
'POST /deafen/toggle',
'POST /channel',
'GET /channels',
'POST /message',
'POST /message/current',
'GET /room/current'
]
});
});
// Error handler
this.app.use((err, req, res, next) => {
console.error('Paarrot API Error:', err);
res.status(500).json({ success: false, error: err.message });
});
}
/**
* Execute an action by sending it to the renderer process
* @param {string} action - The action to execute
* @param {object} params - Parameters for the action
* @returns {Promise<any>} - Result from the renderer
*/
executeAction(action, params = {}) {
return new Promise((resolve, reject) => {
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
return reject(new Error('Main window is not available'));
}
// Create a unique response channel
const responseChannel = `api-response-${Date.now()}-${Math.random()}`;
// Set up one-time listener for the response
const { ipcMain } = require('electron');
const timeout = setTimeout(() => {
ipcMain.removeHandler(responseChannel);
reject(new Error('Action timed out'));
}, 10000); // 10 second timeout
ipcMain.handleOnce(responseChannel, async (event, result) => {
clearTimeout(timeout);
if (result.success) {
resolve(result.data);
} else {
reject(new Error(result.error || 'Unknown error'));
}
});
// Send the action to the renderer
this.mainWindow.webContents.send('api-action', {
action,
params,
responseChannel
});
});
}
start(port = this.port) {
return new Promise((resolve, reject) => {
this.server = this.app.listen(port, '127.0.0.1', () => {
console.log(`Paarrot API server listening on http://127.0.0.1:${port}`);
console.log('Available endpoints:');
console.log(' GET /health');
console.log(' GET /status');
console.log(' POST /mute');
console.log(' POST /mute/toggle');
console.log(' POST /deafen');
console.log(' POST /deafen/toggle');
console.log(' POST /channel');
console.log(' GET /channels');
console.log(' POST /message');
console.log(' POST /message/current');
console.log(' GET /room/current');
resolve(port);
}).on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Paarrot API: Port ${port} is already in use`);
reject(new Error(`Port ${port} is already in use`));
} else {
console.error('Paarrot API: Failed to start server:', err);
reject(err);
}
});
});
}
stop() {
return new Promise((resolve) => {
if (this.server) {
this.server.close(() => {
console.log('Paarrot API server stopped');
resolve();
});
} else {
resolve();
}
});
}
}
module.exports = PaarrotAPIServer;

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ const commandMap = {
'window_is_maximized': 'window:is-maximized', 'window_is_maximized': 'window:is-maximized',
'window_start_drag': 'window:start-drag', 'window_start_drag': 'window:start-drag',
'read_clipboard_image': 'read-clipboard-image', 'read_clipboard_image': 'read-clipboard-image',
'write_clipboard_text': 'write-clipboard-text',
'open_external_url': 'open-external-url', 'open_external_url': 'open-external-url',
'get_youtube_stream': 'get-youtube-stream', 'get_youtube_stream': 'get-youtube-stream',
'start_background_sync': 'start-background-sync', 'start_background_sync': 'start-background-sync',
@@ -18,8 +17,7 @@ const commandMap = {
'check_for_updates': 'check-for-updates', 'check_for_updates': 'check-for-updates',
'download_update': 'download-update', 'download_update': 'download-update',
'install_update': 'install-update', 'install_update': 'install-update',
'get_desktop_sources': 'get-desktop-sources', 'get_desktop_sources': 'get-desktop-sources'
'get_protocol_status': 'protocol:get-status'
}; };
// Shared invoke function for legacy API compatibility // Shared invoke function for legacy API compatibility
@@ -94,22 +92,15 @@ contextBridge.exposeInMainWorld('electron', {
unmaximize: () => ipcRenderer.invoke('window:unmaximize'), unmaximize: () => ipcRenderer.invoke('window:unmaximize'),
close: () => ipcRenderer.invoke('window:close'), close: () => ipcRenderer.invoke('window:close'),
isMaximized: () => ipcRenderer.invoke('window:is-maximized'), isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
startDrag: () => ipcRenderer.invoke('window:start-drag'), startDrag: () => ipcRenderer.invoke('window:start-drag')
focus: () => ipcRenderer.invoke('window:focus'),
flashFrame: (flash = true) => ipcRenderer.invoke('window:flash-frame', flash)
}, },
// Clipboard // Clipboard
clipboard: { clipboard: {
readImage: () => ipcRenderer.invoke('read-clipboard-image'), readImage: () => ipcRenderer.invoke('read-clipboard-image')
writeText: (text) => ipcRenderer.invoke('write-clipboard-text', text)
}, },
// Audio
audio: { // External URLs
playNotificationSound: (soundType = 'message') => ipcRenderer.invoke('play-notification-sound', soundType),
getSoundBaseUrl: () => ipcRenderer.invoke('get-sound-base-url'),
},
// External URLs
shell: { shell: {
openExternal: (url) => ipcRenderer.invoke('open-external-url', url) openExternal: (url) => ipcRenderer.invoke('open-external-url', url)
}, },
@@ -129,8 +120,8 @@ contextBridge.exposeInMainWorld('electron', {
// Auto-updater // Auto-updater
updater: { updater: {
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadAndInstallUpdate: () => ipcRenderer.invoke('download-and-install-update'), downloadUpdate: () => ipcRenderer.invoke('download-update'),
isMock: () => ipcRenderer.invoke('updater-is-mock'), installUpdate: () => ipcRenderer.invoke('install-update'),
onUpdateAvailable: (callback) => { onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (_, info) => callback(info)); ipcRenderer.on('update-available', (_, info) => callback(info));
}, },
@@ -139,54 +130,12 @@ contextBridge.exposeInMainWorld('electron', {
}, },
onUpdateDownloaded: (callback) => { onUpdateDownloaded: (callback) => {
ipcRenderer.on('update-downloaded', (_, info) => callback(info)); ipcRenderer.on('update-downloaded', (_, info) => callback(info));
}, }
onUpdateNotAvailable: (callback) => {
ipcRenderer.on('update-not-available', (_, info) => callback(info));
},
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
}, },
// Desktop capturer (for screen sharing) // Desktop capturer (for screen sharing)
desktopCapturer: { desktopCapturer: {
getSources: (opts) => ipcRenderer.invoke('get-desktop-sources', opts) getSources: (opts) => ipcRenderer.invoke('get-desktop-sources', opts)
},
// API action handler (for external API server)
api: {
onAction: (callback) => {
const handler = (_, data) => callback(data);
ipcRenderer.on('api-action', handler);
return () => ipcRenderer.removeListener('api-action', handler);
},
sendResponse: (channel, result) => {
ipcRenderer.invoke(channel, result);
}
},
// Desktop notifications
notification: {
show: (options) => ipcRenderer.invoke('show-notification', options),
onNavigate: (callback) => {
const handler = (_, data) => callback(data);
ipcRenderer.on('notification:navigate', handler);
return () => ipcRenderer.removeListener('notification:navigate', handler);
}
},
// Plugin management
plugins: {
getPath: () => ipcRenderer.invoke('plugin:get-path'),
download: (pluginId, downloadUrl, name) => ipcRenderer.invoke('plugin:download', { pluginId, downloadUrl, name }),
list: () => ipcRenderer.invoke('plugin:list'),
uninstall: (pluginId) => ipcRenderer.invoke('plugin:uninstall', pluginId),
readPluginCode: (pluginId) => ipcRenderer.invoke('plugin:read-code', pluginId)
},
// Desktop protocol diagnostics
protocol: {
getStatus: () => ipcRenderer.invoke('protocol:get-status'),
repair: () => ipcRenderer.invoke('protocol:repair')
} }
}); });

View File

@@ -1,161 +0,0 @@
// Example plugin for Paarrot - Demonstrates all plugin API features
// This plugin showcases commands, message interceptors, settings, logging, and more
module.exports = {
name: "Example Plugin",
version: "2.0.0",
/**
* Called when the plugin is loaded
* @param {PluginContext} ctx - The plugin context with access to all APIs
*/
onLoad: async (ctx) => {
ctx.log('🎉 Example plugin is loading...');
try {
// 1. Register commands with args
ctx.commands.register({
name: "shrug",
description: "Send a shrug emoji",
run: () => {
ctx.log('Shrug command executed');
return "¯\\_(ツ)_/¯";
}
});
ctx.commands.register({
name: "echo",
args: ["text"],
description: "Echo back your text",
run: ({ text }) => {
ctx.log('Echo command:', text);
return text || "Nothing to echo!";
}
});
ctx.commands.register({
name: "wave",
description: "Wave at someone",
args: ["name"],
run: ({ name }) => {
return `👋 Hey ${name || 'everyone'}!`;
}
});
// 2. Message interceptors
ctx.messages.onBeforeSend((msg) => {
// Auto-expand abbreviations
if (msg.content === "brb") {
msg.content = "be right back!";
ctx.log('Expanded brb to full phrase');
}
if (msg.content === "omw") {
msg.content = "on my way!";
}
});
ctx.messages.onReceive((msg) => {
// Log all received messages (for debugging)
ctx.log(`Received message in ${msg.roomId}:`, msg.content.substring(0, 50));
});
// 3. Define settings
ctx.settings.define({
autoShrug: {
type: "boolean",
label: "Auto Shrug",
description: "Automatically append ¯\\_(ツ)_/¯ to messages ending with '?'",
default: false
},
theme: {
type: "select",
label: "Plugin Theme",
description: "Choose your preferred theme",
options: [
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" },
{ value: "purple", label: "Purple" }
],
default: "dark"
},
customPrefix: {
type: "string",
label: "Custom Prefix",
description: "Prefix for plugin commands",
default: "[Plugin]"
}
});
// Use settings
const autoShrug = ctx.settings.get('autoShrug');
if (autoShrug) {
ctx.log('Auto shrug is enabled!');
ctx.messages.onBeforeSend((msg) => {
if (msg.content.endsWith('?')) {
msg.content += ' ¯\\_(ツ)_/¯';
}
});
}
// 4. Listen to raw Matrix events
ctx.matrix.on('Room.timeline', (event) => {
if (event.getType() === 'm.room.message') {
const content = event.getContent();
if (content.body?.includes('hello')) {
ctx.log('Someone said hello!');
}
}
});
// 5. Background tasks
ctx.timers.setInterval(() => {
ctx.log('Background task running... (every 30s)');
// You could check for notifications, clean up data, etc.
}, 30000);
// 6. Notifications
ctx.notify({
title: "Example Plugin",
body: "Plugin loaded successfully!",
type: "success"
});
// 7. Custom UI renderer (conceptual - would need UI integration)
ctx.ui.registerRenderer("custom-message", (msg) => {
ctx.log('Rendering custom message:', msg);
// Return custom React component
return null;
});
// 8. Export API for other plugins
this.exports = {
greet: (name) => {
return `Hello, ${name}!`;
},
version: "2.0.0"
};
ctx.log('✅ Example plugin loaded successfully!');
ctx.log('Registered commands: /shrug, /echo, /wave');
ctx.log('Settings:', {
autoShrug: ctx.settings.get('autoShrug'),
theme: ctx.settings.get('theme'),
prefix: ctx.settings.get('customPrefix')
});
} catch (error) {
ctx.error('Failed to load example plugin:', error);
}
},
/**
* Called when the plugin is unloaded or disabled
*/
onUnload: async () => {
console.log('[ExamplePlugin] Plugin is unloading...');
console.log('[ExamplePlugin] Cleaned up successfully!');
},
// Exports for other plugins to use via ctx.require()
exports: null // Will be set during onLoad
};

View File

@@ -1,9 +0,0 @@
{
"name": "Example Plugin",
"version": "2.0.0",
"description": "A comprehensive example plugin demonstrating all plugin API features including commands, interceptors, settings, and more",
"author": "Paarrot Team",
"homepage": "https://github.com/Paarrot/cinny-desktop",
"thumbnail": "https://raw.githubusercontent.com/Paarrot/cinny-desktop/main/icons/icon.png",
"tags": ["example", "demo", "utility", "commands", "interceptors"]
}

View File

@@ -1,274 +0,0 @@
{
"info": {
"_postman_id": "paarrot-api-collection",
"name": "Paarrot API",
"description": "API endpoints for controlling Paarrot (Cinny Desktop) features like muting, deafening, channel navigation, and messaging.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "paarrot-api"
},
"item": [
{
"name": "Health & Status",
"item": [
{
"name": "Health Check",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/health",
"host": [
"{{baseUrl}}"
],
"path": [
"health"
]
},
"description": "Check if the API server is running and healthy."
}
},
{
"name": "Get Status",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/status",
"host": [
"{{baseUrl}}"
],
"path": [
"status"
]
},
"description": "Get current application status including mute/deafen state, current room, connection status, and user ID."
}
}
]
},
{
"name": "Audio Controls",
"item": [
{
"name": "Toggle Mute",
"request": {
"method": "POST",
"header": [],
"url": {
"raw": "{{baseUrl}}/mute/toggle",
"host": [
"{{baseUrl}}"
],
"path": [
"mute",
"toggle"
]
},
"description": "Toggle microphone mute state. Works only when in an active call."
}
},
{
"name": "Set Mute",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"muted\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/mute",
"host": [
"{{baseUrl}}"
],
"path": [
"mute"
]
},
"description": "Set microphone mute to a specific state (true/false). Works only when in an active call."
}
},
{
"name": "Toggle Deafen",
"request": {
"method": "POST",
"header": [],
"url": {
"raw": "{{baseUrl}}/deafen/toggle",
"host": [
"{{baseUrl}}"
],
"path": [
"deafen",
"toggle"
]
},
"description": "Toggle deafen state (mutes all incoming audio and your microphone). Works only when in an active call."
}
},
{
"name": "Set Deafen",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"deafened\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/deafen",
"host": [
"{{baseUrl}}"
],
"path": [
"deafen"
]
},
"description": "Set deafen to a specific state (true/false). Works only when in an active call."
}
}
]
},
{
"name": "Rooms & Channels",
"item": [
{
"name": "Get Channels",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/channels",
"host": [
"{{baseUrl}}"
],
"path": [
"channels"
]
},
"description": "Get list of all available rooms/channels with their details (roomId, name, isDirect, avatar)."
}
},
{
"name": "Get Current Room",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/room/current",
"host": [
"{{baseUrl}}"
],
"path": [
"room",
"current"
]
},
"description": "Get information about the currently active room."
}
},
{
"name": "Change Channel",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"roomId\": \"!YourRoomId:server.com\"\n}"
},
"url": {
"raw": "{{baseUrl}}/channel",
"host": [
"{{baseUrl}}"
],
"path": [
"channel"
]
},
"description": "Navigate to a different room/channel by providing its roomId."
}
}
]
},
{
"name": "Messaging",
"item": [
{
"name": "Send Message to Room",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"roomId\": \"!YourRoomId:server.com\",\n \"message\": \"Hello from Postman!\"\n}"
},
"url": {
"raw": "{{baseUrl}}/message",
"host": [
"{{baseUrl}}"
],
"path": [
"message"
]
},
"description": "Send a text message to a specific room by providing roomId and message content."
}
},
{
"name": "Send Message to Current Room",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"message\": \"Hello from Postman!\"\n}"
},
"url": {
"raw": "{{baseUrl}}/message/current",
"host": [
"{{baseUrl}}"
],
"path": [
"message",
"current"
]
},
"description": "Send a text message to the currently active room."
}
}
]
}
],
"variable": [
{
"key": "baseUrl",
"value": "http://127.0.0.1:33384",
"type": "string"
}
]
}

4244
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,37 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.169", "version": "4.10.3",
"description": "Paarrot - A Matrix client based on Cinny", "description": "Yet another matrix client",
"homepage": "https://github.com/Paarrot/Paarrot-Desktop",
"repository": {
"type": "git",
"url": "https://github.com/Paarrot/Paarrot-Desktop.git"
},
"main": "electron/main.js", "main": "electron/main.js",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"", "dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
"dev:vite": "cross-env BROWSER=none sh -c 'cd cinny && npm start'", "dev:vite": "cd cinny && npm start",
"dev:electron": "wait-on http://localhost:38347 && cross-env NODE_ENV=development electron .", "dev:electron": "wait-on http://localhost:8080 && NODE_ENV=development electron .",
"build": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish always", "build": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder",
"build:local": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder",
"build:linux": "npm run build -- --linux", "build:linux": "npm run build -- --linux",
"build:win": "npm run build -- --win", "build:win": "npm run build -- --win",
"postman:generate": "node scripts/generate-postman-collection.js" "build:mac": "npm run build -- --mac"
}, },
"keywords": [], "keywords": [],
"author": { "author": "Ajay Bura",
"name": "Mates.Media",
"email": "mates.media@users.noreply.github.com"
},
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.18", "electron-store": "^8.2.0",
"body-parser": "2.3.0", "electron-updater": "^6.3.9",
"cors": "2.8.6",
"electron-store": "^11.0.2",
"electron-updater": "^6.8.9",
"express": "5.2.1",
"open": "11.0.0" "open": "11.0.0"
}, },
"devDependencies": { "devDependencies": {
"@actions/github": "9.1.1", "@actions/github": "6.0.0",
"@electron/rebuild": "4.2.0", "@electron/rebuild": "4.0.3",
"concurrently": "10.0.3", "concurrently": "9.2.1",
"cross-env": "10.1.0", "electron": "40.6.0",
"electron": "43.1.0", "electron-builder": "26.8.1",
"electron-builder": "26.15.3",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"png2icons": "2.0.1", "png2icons": "2.0.1",
"sharp": "0.35.3", "sharp": "0.34.5",
"wait-on": "9.0.10" "wait-on": "9.0.4"
},
"allowScripts": {
"electron-winstaller@5.4.0": true,
"electron@43.1.0": true
} }
} }

View File

@@ -1,224 +0,0 @@
#!/usr/bin/env node
/**
* Generate Postman collection from API documentation
* This script reads the API.md file and generates a Postman collection
*/
const fs = require('fs');
const path = require('path');
const collection = {
info: {
_postman_id: 'paarrot-api-collection',
name: 'Paarrot API',
description: 'API endpoints for controlling Paarrot (Cinny Desktop) features like muting, deafening, channel navigation, and messaging.',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
_exporter_id: 'paarrot-api',
},
item: [
{
name: 'Health & Status',
item: [
{
name: 'Health Check',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/health',
host: ['{{baseUrl}}'],
path: ['health'],
},
description: 'Check if the API server is running and healthy.',
},
},
{
name: 'Get Status',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/status',
host: ['{{baseUrl}}'],
path: ['status'],
},
description: 'Get current application status including mute/deafen state, current room, connection status, and user ID.',
},
},
],
},
{
name: 'Audio Controls',
item: [
{
name: 'Toggle Mute',
request: {
method: 'POST',
header: [],
url: {
raw: '{{baseUrl}}/mute/toggle',
host: ['{{baseUrl}}'],
path: ['mute', 'toggle'],
},
description: 'Toggle microphone mute state. Works only when in an active call.',
},
},
{
name: 'Set Mute',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify({ muted: true }, null, 2),
},
url: {
raw: '{{baseUrl}}/mute',
host: ['{{baseUrl}}'],
path: ['mute'],
},
description: 'Set microphone mute to a specific state (true/false). Works only when in an active call.',
},
},
{
name: 'Toggle Deafen',
request: {
method: 'POST',
header: [],
url: {
raw: '{{baseUrl}}/deafen/toggle',
host: ['{{baseUrl}}'],
path: ['deafen', 'toggle'],
},
description: 'Toggle deafen state (mutes all incoming audio and your microphone). Works only when in an active call.',
},
},
{
name: 'Set Deafen',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify({ deafened: true }, null, 2),
},
url: {
raw: '{{baseUrl}}/deafen',
host: ['{{baseUrl}}'],
path: ['deafen'],
},
description: 'Set deafen to a specific state (true/false). Works only when in an active call.',
},
},
],
},
{
name: 'Rooms & Channels',
item: [
{
name: 'Get Channels',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/channels',
host: ['{{baseUrl}}'],
path: ['channels'],
},
description: 'Get list of all available rooms/channels with their details (roomId, name, isDirect, avatar).',
},
},
{
name: 'Get Current Room',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/room/current',
host: ['{{baseUrl}}'],
path: ['room', 'current'],
},
description: 'Get information about the currently active room.',
},
},
{
name: 'Change Channel',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify({ roomId: '!YourRoomId:server.com' }, null, 2),
},
url: {
raw: '{{baseUrl}}/channel',
host: ['{{baseUrl}}'],
path: ['channel'],
},
description: 'Navigate to a different room/channel by providing its roomId.',
},
},
],
},
{
name: 'Messaging',
item: [
{
name: 'Send Message to Room',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify(
{
roomId: '!YourRoomId:server.com',
message: 'Hello from Postman!',
},
null,
2
),
},
url: {
raw: '{{baseUrl}}/message',
host: ['{{baseUrl}}'],
path: ['message'],
},
description: 'Send a text message to a specific room by providing roomId and message content.',
},
},
{
name: 'Send Message to Current Room',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify({ message: 'Hello from Postman!' }, null, 2),
},
url: {
raw: '{{baseUrl}}/message/current',
host: ['{{baseUrl}}'],
path: ['message', 'current'],
},
description: 'Send a text message to the currently active room.',
},
},
],
},
],
variable: [
{
key: 'baseUrl',
value: 'http://127.0.0.1:33384',
type: 'string',
},
],
};
// Write the collection to file (in the project root)
const outputPath = path.join(__dirname, '..', 'paarrot-api.postman_collection.json');
fs.writeFileSync(outputPath, JSON.stringify(collection, null, 2) + '\n');
console.log('✅ Postman collection generated:', outputPath);

View File

@@ -1,104 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const srcRoot = path.resolve(__dirname, '../cinny/src');
const iconsModule = path.resolve(srcRoot, 'app/components/icons');
const ICON_SYMBOLS = new Set(['Icon', 'Icons', 'IconName', 'IconSrc']);
function walk(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath, files);
} else if (/\.(tsx?)$/.test(entry.name)) {
files.push(fullPath);
}
}
return files;
}
function getIconsImportPath(filePath) {
const rel = path.relative(path.dirname(filePath), iconsModule).replace(/\\/g, '/');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function splitFoldsImport(importClause) {
const match = importClause.match(/^\{([^}]+)\}(?:\s+as\s+\w+)?$/);
if (!match) return null;
const specifiers = match[1]
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [local, imported] = part.split(/\s+as\s+/).map((s) => s.trim());
return { local: local ?? imported, imported: imported ?? local };
});
const iconSpecs = specifiers.filter((s) => ICON_SYMBOLS.has(s.imported));
const foldsSpecs = specifiers.filter((s) => !ICON_SYMBOLS.has(s.imported));
if (iconSpecs.length === 0) return null;
return { iconSpecs, foldsSpecs };
}
function migrateFile(filePath) {
let content = fs.readFileSync(filePath, 'utf8');
const importRegex = /import\s+(\{[^}]+\})\s+from\s+['"]folds['"];?/g;
let changed = false;
const iconImports = new Map();
content = content.replace(importRegex, (fullMatch, importClause) => {
const split = splitFoldsImport(importClause);
if (!split) return fullMatch;
changed = true;
for (const spec of split.iconSpecs) {
iconImports.set(spec.local, spec.imported);
}
if (split.foldsSpecs.length === 0) {
return '';
}
const foldsImport = split.foldsSpecs.map((s) => (s.local === s.imported ? s.local : `${s.imported} as ${s.local}`)).join(', ');
return `import { ${foldsImport} } from 'folds';`;
});
if (!changed) return false;
const iconsPath = getIconsImportPath(filePath);
const iconExportList = [...iconImports.entries()]
.map(([local, imported]) => (local === imported ? imported : `${imported} as ${local}`))
.join(', ');
const iconsImportLine = `import { ${iconExportList} } from '${iconsPath}';`;
const foldsImportMatch = content.match(/^import\s+\{[^}]+\}\s+from\s+['"]folds['"];?\s*$/m);
if (foldsImportMatch) {
const insertAt = content.indexOf(foldsImportMatch[0]) + foldsImportMatch[0].length;
content = `${content.slice(0, insertAt)}\n${iconsImportLine}${content.slice(insertAt)}`;
} else {
content = `${iconsImportLine}\n${content}`;
}
content = content.replace(/\n{3,}/g, '\n\n');
fs.writeFileSync(filePath, content);
return true;
}
const files = walk(srcRoot);
let migrated = 0;
for (const file of files) {
if (file.includes(`${path.sep}components${path.sep}icons${path.sep}`)) continue;
if (migrateFile(file)) {
migrated += 1;
}
}
console.log(`Migrated ${migrated} files.`);

View File

@@ -1,26 +0,0 @@
# 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

@@ -1,207 +0,0 @@
# 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

View File

@@ -1,263 +0,0 @@
# 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

@@ -1,69 +0,0 @@
/**
* 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

@@ -1,9 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 606 B

View File

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 444 B

View File

@@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 545 B

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 315 B

View File

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 416 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 889 KiB

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 307 B

View File

@@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 461 B

View File

@@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 544 B

View File

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 413 B

View File

@@ -1,113 +0,0 @@
{
"$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

@@ -1,10 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Paarrot Plugin</title>
</head>
<body>
<script src="index.js"></script>
</body>
</html>

View File

@@ -1,371 +0,0 @@
/**
* 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

@@ -1,178 +0,0 @@
<!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

@@ -1,145 +0,0 @@
<!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

@@ -1,18 +0,0 @@
{
"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

@@ -1,156 +0,0 @@
/**
* 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);
}
}

View File

@@ -1,91 +0,0 @@
#!/usr/bin/env node
/**
* Paarrot API Test Script
*
* Simple test script to verify the API server is working.
* Run this while Paarrot is running to test the API endpoints.
*
* Usage: node test-api.js
*/
const API_BASE = 'http://127.0.0.1:33384';
async function testEndpoint(name, method, path, body = null) {
console.log(`\n🧪 Testing: ${name}`);
console.log(` ${method} ${API_BASE}${path}`);
try {
const options = {
method,
headers: {
'Content-Type': 'application/json',
},
};
if (body) {
options.body = JSON.stringify(body);
console.log(` Body:`, body);
}
const response = await fetch(`${API_BASE}${path}`, options);
const data = await response.json();
if (response.ok) {
console.log(` ✅ Success (${response.status})`);
console.log(` Response:`, JSON.stringify(data, null, 2));
} else {
console.log(` ❌ Failed (${response.status})`);
console.log(` Error:`, data.error || data);
}
return data;
} catch (error) {
console.log(` ❌ Error:`, error.message);
return null;
}
}
async function runTests() {
console.log('🚀 Paarrot API Test Suite\n');
console.log('Make sure Paarrot is running before running these tests!\n');
// Health check
await testEndpoint('Health Check', 'GET', '/health');
// Get status
await testEndpoint('Get Status', 'GET', '/status');
// Get channels
await testEndpoint('Get Channels', 'GET', '/channels');
// Get current room
await testEndpoint('Get Current Room', 'GET', '/room/current');
// Toggle mute
await testEndpoint('Toggle Mute', 'POST', '/mute/toggle');
// Set mute
await testEndpoint('Set Mute (true)', 'POST', '/mute', { muted: true });
await new Promise(resolve => setTimeout(resolve, 500));
await testEndpoint('Set Mute (false)', 'POST', '/mute', { muted: false });
// Toggle deafen
await testEndpoint('Toggle Deafen', 'POST', '/deafen/toggle');
// Send message to current room (commented out to avoid spam)
// await testEndpoint('Send Message to Current', 'POST', '/message/current', {
// message: 'Test message from API'
// });
// Test invalid endpoint
await testEndpoint('Invalid Endpoint (should 404)', 'GET', '/invalid');
console.log('\n✨ Tests complete!\n');
}
// Run tests if this script is executed directly
if (require.main === module) {
runTests().catch(console.error);
}
module.exports = { testEndpoint, runTests };

View File

@@ -1,53 +0,0 @@
#!/bin/bash
# Paarrot API Quick Test Script
#
# Simple curl commands to test the API endpoints
# Make sure Paarrot is running before using these commands
API_BASE="http://127.0.0.1:33384"
echo "🚀 Paarrot API Quick Test"
echo ""
# Health check
echo "📡 Health Check:"
curl -s "$API_BASE/health" | jq '.' || curl -s "$API_BASE/health"
echo ""
echo ""
# Get status
echo "📊 Get Status:"
curl -s "$API_BASE/status" | jq '.' || curl -s "$API_BASE/status"
echo ""
echo ""
# Toggle mute
echo "🎤 Toggle Mute:"
curl -s -X POST "$API_BASE/mute/toggle" | jq '.' || curl -s -X POST "$API_BASE/mute/toggle"
echo ""
echo ""
# Toggle deafen
echo "🔇 Toggle Deafen:"
curl -s -X POST "$API_BASE/deafen/toggle" | jq '.' || curl -s -X POST "$API_BASE/deafen/toggle"
echo ""
echo ""
# Get channels
echo "📋 Get Channels:"
curl -s "$API_BASE/channels" | jq '.' || curl -s "$API_BASE/channels"
echo ""
echo ""
# Get current room
echo "🏠 Get Current Room:"
curl -s "$API_BASE/room/current" | jq '.' || curl -s "$API_BASE/room/current"
echo ""
echo ""
echo "✨ Done!"
echo ""
echo "💡 Tips:"
echo " - Use 'jq' for pretty JSON output (install with: sudo apt install jq)"
echo " - Check API.md for full documentation"
echo " - Test message sending: curl -X POST $API_BASE/message/current -H 'Content-Type: application/json' -d '{\"message\": \"Hello!\"}'"

View File

@@ -1,26 +0,0 @@
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!');
});
});