57 Commits

Author SHA1 Message Date
GitHub Actions
afc4ba1087 chore: bump version to 4.11.191 [skip ci] 2026-08-24 14:03:10 +00:00
f022155aa1 Update responsive image attachments
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 16s
Build / build-windows (push) Successful in 7m8s
Build / build-linux (push) Successful in 3m10s
Build / finalize-release (push) Successful in 6s
Advance Cinny to the responsive message image fix.
2026-08-25 00:02:49 +10:00
GitHub Actions
f4ec39f229 chore: bump version to 4.11.190 [skip ci] 2026-08-24 13:58:49 +00:00
848283da42 Add TikTok player embed
Some checks failed
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-linux (push) Has been cancelled
Build / finalize-release (push) Has been cancelled
Build / build-windows (push) Has been cancelled
Use TikTok's supported iframe player and safely invoke yt-dlp for YouTube streams.
2026-08-24 23:58:35 +10:00
GitHub Actions
2cd9409e3d chore: bump version to 4.11.189 [skip ci] 2026-08-24 10:07:31 +00:00
b85774e911 Bump cinny for Capacitor HTTP.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 6m37s
Build / build-linux (push) Successful in 3m10s
Build / finalize-release (push) Successful in 4s
Include the native HTTP configuration required for mobile collectible catalog downloads.
2026-08-24 20:07:17 +10:00
GitHub Actions
73d31a2cb4 chore: bump version to 4.11.188 [skip ci] 2026-08-24 09:53:09 +00:00
f9bc1c7c2f Bump cinny for mobile collectibles support.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 6m44s
Build / build-linux (push) Successful in 3m6s
Build / finalize-release (push) Successful in 4s
Use the mobile catalog and CDN fallback for profile cosmetics outside Electron.
2026-08-24 19:52:54 +10:00
GitHub Actions
85995d0744 chore: bump version to 4.11.187 [skip ci] 2026-08-24 09:30:06 +00:00
9d7ae1d63b Use published Discord collectibles catalog.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 6m54s
Build / build-linux (push) Successful in 3m5s
Build / finalize-release (push) Successful in 4s
Fetch and persist the public release catalog locally while retiring client-side Discord token handling.
2026-08-24 19:29:53 +10:00
GitHub Actions
ce7b2104ad chore: bump version to 4.11.186 [skip ci] 2026-08-24 08:49:07 +00:00
60d65a29ff Bump cinny for configurable sidebar nameplate background.
Some checks failed
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 6m49s
Build / build-linux (push) Failing after 3m18s
Build / finalize-release (push) Successful in 4s
Include immediate collectible rendering updates and sidebar opacity controls.
2026-08-24 18:48:53 +10:00
GitHub Actions
ce29b24c28 chore: bump version to 4.11.185 [skip ci] 2026-08-23 14:55:15 +00:00
608cbaf682 Add Discord collectibles IPC and bump cinny for profile overlays.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 15s
Build / build-windows (push) Successful in 6m55s
Build / build-linux (push) Successful in 3m10s
Build / finalize-release (push) Successful in 4s
Wire electron-side catalog fetch and asset download, exposing collectibles APIs to the renderer for live Discord shop browsing and Matrix profile uploads.
2026-08-24 00:55:01 +10:00
GitHub Actions
8ca7be8b42 chore: bump version to 4.11.184 [skip ci] 2026-08-23 06:55:17 +00:00
3c55fe65a3 Bump cinny: What's New dialog fixes and readable dev class names.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 7m2s
Build / build-linux (push) Successful in 3m4s
Build / finalize-release (push) Successful in 4s
2026-08-23 16:55:04 +10:00
GitHub Actions
f99b3c85d4 chore: bump version to 4.11.183 [skip ci] 2026-08-23 06:18:56 +00:00
fc4f087c68 Bump cinny for Android About/updates fixes and overlay user color crash guard.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 6m40s
Build / build-linux (push) Successful in 3m3s
Build / finalize-release (push) Successful in 4s
2026-08-23 16:18:43 +10:00
GitHub Actions
6d347a6f6b chore: bump version to 4.11.182 [skip ci] 2026-08-22 22:38:44 +00:00
316a5514e6 Bump cinny for Android View updates dialog fix.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 15s
Build / build-windows (push) Successful in 6m44s
Build / build-linux (push) Successful in 3m2s
Build / finalize-release (push) Successful in 4s
2026-08-23 08:38:29 +10:00
GitHub Actions
8392e3f7f5 chore: bump version to 4.11.181 [skip ci] 2026-08-22 22:26:52 +00:00
61b3cbc6d8 Point desktop build at cinny-local update manifest script for mobile CI.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 15s
Build / build-windows (push) Successful in 6m46s
Build / build-linux (push) Successful in 3m1s
Build / finalize-release (push) Successful in 7s
2026-08-23 08:26:33 +10:00
GitHub Actions
9befaa14a5 chore: bump version to 4.11.180 [skip ci] 2026-08-22 22:23:43 +00:00
ea45c4551b Bump cinny for What's New auto-show on first launch after update.
Some checks failed
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 14s
Build / finalize-release (push) Has been cancelled
Build / build-windows (push) Has been cancelled
Build / build-linux (push) Has been cancelled
2026-08-23 08:23:23 +10:00
GitHub Actions
146927fc10 chore: bump version to 4.11.179 [skip ci] 2026-08-22 22:20:20 +00:00
8edbc1b19a Bump cinny for Android updates dialog fix and add release notes manifest workflow.
Some checks failed
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Has started running
Build / finalize-release (push) Has been cancelled
Build / build-linux (push) Has been cancelled
2026-08-23 08:20:07 +10:00
GitHub Actions
9700c645a3 chore: bump version to 4.11.178 [skip ci] 2026-08-22 22:07:17 +00:00
2828ae425c Add native media save dialog and bump cinny submodule.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 6m51s
Build / build-linux (push) Successful in 3m11s
Build / finalize-release (push) Successful in 5s
Wire Electron/Tauri save-file IPC for desktop downloads and update the cinny submodule with username colors, release notes, and account profile layout fixes.
2026-08-23 08:07:02 +10:00
GitHub Actions
5e06256973 chore: bump version to 4.11.177 [skip ci] 2026-08-09 08:35:32 +00:00
28c7dedf65 Bump cinny for fullscreen sick emoji confetti spew.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 7m35s
Build / build-linux (push) Successful in 3m13s
Build / finalize-release (push) Successful in 4s
2026-08-09 18:35:17 +10:00
GitHub Actions
ca10121796 chore: bump version to 4.11.176 [skip ci] 2026-08-09 05:59:10 +00:00
1ad1fc7b70 Bump cinny for shadowbox image viewer.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 15s
Build / build-windows (push) Successful in 7m31s
Build / build-linux (push) Successful in 3m16s
Build / finalize-release (push) Successful in 6s
2026-08-09 15:58:52 +10:00
GitHub Actions
f16b84a9f1 chore: bump version to 4.11.175 [skip ci] 2026-08-09 05:50:38 +00:00
e917193b0d Harden Linux clipboard image paste and bump cinny.
Some checks failed
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 15s
Build / build-windows (push) Successful in 7m28s
Build / finalize-release (push) Has been cancelled
Build / build-linux (push) Has been cancelled
Require a real image/* clipboard format before treating paste as an image, and pick up firework confetti plus URL-paste fixes from cinny.
2026-08-09 15:50:23 +10:00
GitHub Actions
fc5d498673 chore: bump version to 4.11.174 [skip ci] 2026-08-09 03:17:02 +00:00
c01bd9a43e Bump cinny for bot-driven room app UI.
All checks were successful
Build / increment-version (push) Successful in 14s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 8m3s
Build / build-linux (push) Successful in 3m22s
Build / finalize-release (push) Successful in 4s
2026-08-09 13:16:48 +10:00
GitHub Actions
3f4b1b3fc4 chore: bump version to 4.11.173 [skip ci] 2026-08-03 05:22:25 +00:00
24b59a454a Ensure wine binary exists for Windows NSIS cross-builds.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-linux (push) Successful in 2m58s
Build / build-windows (push) Successful in 5m46s
Build / finalize-release (push) Successful in 4s
2026-08-03 15:22:12 +10:00
GitHub Actions
fac9a49d56 chore: bump version to 4.11.172 [skip ci] 2026-08-03 05:16:00 +00:00
83993efc83 Run Windows Electron builds on Linux with Wine.
Some checks failed
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-linux (push) Successful in 3m2s
Build / build-windows (push) Failing after 4m57s
Build / finalize-release (push) Successful in 4s
2026-08-03 15:15:46 +10:00
GitHub Actions
44a9230df3 chore: bump version to 4.11.171 [skip ci] 2026-08-03 05:08:38 +00:00
658ff1215c Bump cinny: local API message and unread endpoints by scope.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-linux (push) Successful in 2m55s
Build / build-windows (push) Successful in 3m54s
Build / finalize-release (push) Successful in 5s
2026-08-03 15:08:25 +10:00
GitHub Actions
829e9b06cb chore: bump version to 4.11.170 [skip ci] 2026-07-29 10:41:07 +00:00
e64279db74 Bump cinny: emoji confetti bursts and component playground.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 13s
Build / build-linux (push) Successful in 3m13s
Build / build-windows (push) Successful in 6m11s
Build / finalize-release (push) Successful in 4s
Wire the playground npm script through the desktop wrapper.
2026-07-29 20:40:53 +10:00
GitHub Actions
85d95faf11 chore: bump version to 4.11.169 [skip ci] 2026-07-25 10:09:49 +00:00
61de658029 Bump cinny: fix avatar upload for JPEG/WebP/GIF without profile color.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 16s
Build / build-linux (push) Successful in 3m21s
Build / build-windows (push) Successful in 5m45s
Build / finalize-release (push) Successful in 4s
2026-07-25 20:09:35 +10:00
GitHub Actions
c8b57ba217 chore: bump version to 4.11.168 [skip ci] 2026-07-24 06:52:49 +00:00
8d8809d0c7 Bump cinny: open DMs while space parent maps catch up.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 14s
Build / build-linux (push) Successful in 2m44s
Build / build-windows (push) Successful in 3m45s
Build / finalize-release (push) Successful in 4s
2026-07-24 16:52:35 +10:00
GitHub Actions
55a0f4f566 chore: bump version to 4.11.167 [skip ci] 2026-07-24 06:24:58 +00:00
f488f32d88 Fix PowerShell mangling GitHub upload URLs with ?name=.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 13s
Build / build-linux (push) Successful in 2m45s
Build / build-windows (push) Successful in 3m43s
Build / finalize-release (push) Successful in 4s
2026-07-24 16:24:44 +10:00
GitHub Actions
9154929de9 chore: bump version to 4.11.166 [skip ci] 2026-07-24 06:20:05 +00:00
c2cc0d5ed5 Fix PowerShell origin URL trimming for submodule checkout.
Some checks failed
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 13s
Build / build-linux (push) Successful in 2m44s
Build / build-windows (push) Failing after 3m22s
Build / finalize-release (push) Successful in 8s
2026-07-24 16:19:51 +10:00
GitHub Actions
aa38348fdd chore: bump version to 4.11.165 [skip ci] 2026-07-24 06:17:38 +00:00
02ab544a37 Fix CI Windows upload shell and submodule clone host.
Some checks failed
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Failing after 11s
Build / finalize-release (push) Has been cancelled
Build / build-linux (push) Has been cancelled
Use PowerShell + curl.exe on the Windows runner (no WSL), and clone cinny via a relative/origin-derived URL so act runners do not hairpin through public HTTPS.
2026-07-24 16:17:23 +10:00
GitHub Actions
3707a6d38a chore: bump version to 4.11.164 [skip ci] 2026-07-24 06:09:58 +00:00
fe1f4abf2d Bypass flaky Actions artifacts for AppImage/exe uploads.
Some checks failed
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 13s
Build / build-windows (push) Failing after 3m27s
Build / finalize-release (push) Has been cancelled
Build / build-linux (push) Has been cancelled
Publish build outputs straight to Gitea/GitHub releases via the REST API with no HTTP timeout, since Gitea's artifact pipeline keeps timing out on ~130MB binaries.
2026-07-24 16:09:44 +10:00
147389724e Point cinny submodule at git.ruv.wtf. 2026-07-24 16:02:45 +10:00
14 changed files with 1208 additions and 275 deletions

View File

@@ -8,6 +8,9 @@ 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
@@ -105,84 +108,101 @@ jobs:
echo "VERSION=${{ steps.decide.outputs.CURRENT_VERSION }}" >> $GITHUB_OUTPUT echo "VERSION=${{ steps.decide.outputs.CURRENT_VERSION }}" >> $GITHUB_OUTPUT
fi fi
build-windows: prepare-release:
runs-on: windows runs-on: ubuntu-latest
needs: increment-version needs: increment-version
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped') if: always() && needs.increment-version.outputs.should_build == 'true'
outputs:
version: ${{ steps.version.outputs.VERSION }}
tag: ${{ steps.version.outputs.TAG_NAME }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: false fetch-depth: 0
ref: ${{ github.ref_name }} ref: ${{ github.ref_name }}
- name: Checkout submodules manually
shell: powershell
run: |
git submodule sync --recursive
if (git submodule update --init --recursive) {
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 submodule update --init --recursive --remote
Write-Host "Resolved submodule commit:"
git -C cinny rev-parse HEAD
- name: Pull latest (after version bump) - name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
shell: powershell
run: git pull origin main run: git pull origin main
- name: Setup Node.js - name: Resolve version
uses: actions/setup-node@v4 id: version
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: | run: |
if (Test-Path cinny/dist) { Remove-Item -Recurse -Force cinny/dist } if [ -n "${{ needs.increment-version.outputs.version }}" ]; then
if (Test-Path dist-electron) { Remove-Item -Recurse -Force dist-electron } VERSION="${{ needs.increment-version.outputs.version }}"
else
VERSION=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG_NAME=v$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Build Electron app - name: Create and push tag
run: npm run build:local -- --win run: |
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
echo "Tag $TAG_NAME already exists"
else
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
git push origin "$TAG_NAME"
fi
- name: Ensure Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
API_BASE="${{ github.server_url }}/api/v1"
REPO="${{ github.repository }}"
RELEASE_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty' || true)
if [ -z "$RELEASE_ID" ]; then
echo "Creating Gitea release ${TAG_NAME}..."
RELEASE_ID=$(curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"${API_BASE}/repos/${REPO}/releases" | jq -r '.id')
fi
echo "Gitea release id: ${RELEASE_ID}"
- name: Ensure GitHub release
env: env:
GH_TOKEN: ${{ secrets.GHTOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }}
- name: Upload NSIS Installer (x64)
uses: actions/upload-artifact@v3
with:
name: Paarrot-Setup-x64.exe
path: dist-electron/Paarrot-*-win-x64.exe
- name: Upload latest.yml
uses: actions/upload-artifact@v3
with:
name: latest.yml
path: dist-electron/latest.yml
- name: List build output
shell: powershell
run: | run: |
Get-ChildItem -Path dist-electron -Recurse | Select-Object FullName set -euo pipefail
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
GITHUB_REPO="Paarrot/Paarrot-Desktop"
build-linux: 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: ubuntu-latest runs-on: ubuntu-latest
needs: increment-version needs: [increment-version, prepare-release]
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped') if: always() && needs.prepare-release.result == 'success'
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -194,6 +214,14 @@ jobs:
- name: Checkout submodules manually - name: Checkout submodules manually
run: | run: |
set -euo pipefail 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 git submodule sync --recursive
if git submodule update --init --recursive; then if git submodule update --init --recursive; then
@@ -202,7 +230,165 @@ jobs:
fi fi
echo "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD." echo "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD."
git submodule deinit -f --all 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 Wine (NSIS cross-build)
run: |
set -euo pipefail
# electron-builder spawns `wine` by name; wine64 alone is not enough.
sudo dpkg --add-architecture i386
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
wine \
wine64 \
wine32 \
|| sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wine64
if ! command -v wine >/dev/null 2>&1; then
WINE64_BIN=$(command -v wine64)
echo "No wine binary; linking wine64 -> /usr/local/bin/wine (${WINE64_BIN})"
sudo ln -sf "${WINE64_BIN}" /usr/local/bin/wine
fi
command -v wine
wine --version
- 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 -- --win
env:
GH_TOKEN: ${{ secrets.GHTOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- name: Upload Windows assets to releases
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GHTOKEN }}
run: |
set -euo pipefail
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")
echo "Uploading ${FILENAME} ($(du -h "$FILE" | cut -f1))..."
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 "Content-Type: application/octet-stream" \
--data-binary @"${FILE}" \
"${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets?name=${FILENAME}" \
>/dev/null
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-*-win-x64.exe dist-electron/latest.yml; do
upload_one "$FILE"
done
- name: List build output
run: |
find dist-electron -type f -printf '%p\n' | sort
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 git submodule update --init --recursive --remote
echo "Resolved submodule commit:" echo "Resolved submodule commit:"
@@ -233,208 +419,126 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GHTOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }}
- name: Upload AppImage - name: Upload Linux assets to releases
uses: actions/upload-artifact@v3 env:
with: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: Paarrot-Linux-x64.AppImage GH_TOKEN: ${{ secrets.GHTOKEN }}
path: dist-electron/Paarrot-*.AppImage run: |
set -euo pipefail
- name: Upload latest-linux.yml VERSION="${{ needs.prepare-release.outputs.version }}"
uses: actions/upload-artifact@v3 TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
with: API_BASE="${{ github.server_url }}/api/v1"
name: latest-linux.yml REPO="${{ github.repository }}"
path: dist-electron/latest-linux.yml GITHUB_REPO="Paarrot/Paarrot-Desktop"
create-release: 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")
echo "Uploading ${FILENAME} ($(du -h "$FILE" | cut -f1))..."
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 "Content-Type: application/octet-stream" \
--data-binary @"${FILE}" \
"${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets?name=${FILENAME}" \
>/dev/null
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
finalize-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [increment-version, build-windows, build-linux] needs: [increment-version, prepare-release, build-windows, build-linux]
if: always() && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success') if: always() && needs.prepare-release.result == 'success' && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success')
steps: steps:
- name: Checkout repository - name: Cleanup old Gitea releases
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main'
run: git pull origin main
- name: Get version
id: version
run: |
# Use version from increment-version job if available, otherwise read from file
if [ -n "${{ needs.increment-version.outputs.version }}" ]; then
VERSION="${{ needs.increment-version.outputs.version }}"
else
VERSION=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Check if tag exists
id: tag_check
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "Tag v$VERSION already exists"
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "Tag v$VERSION does not exist"
echo "exists=false" >> $GITHUB_OUTPUT
fi
- 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 "*.yml" \) -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: |
VERSION="${{ steps.version.outputs.VERSION }}" set -euo pipefail
TAG_NAME="v${VERSION}" TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
API_BASE="http://synbox.ruv.wtf:8418/api/v1" API_BASE="${{ github.server_url }}/api/v1"
REPO="litruv/cinny-desktop" REPO="${{ github.repository }}"
# Delete old releases (keep only current) echo "Cleaning up old Gitea releases (keeping ${TAG_NAME})..."
echo "Cleaning up old releases..." OLD_RELEASES=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
OLD_RELEASES=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases" | jq -r --arg t "$TAG_NAME" '.[] | select(.tag_name != $t) | .id')
"${API_BASE}/repos/${REPO}/releases" | jq -r '.[] | select(.tag_name != "'${TAG_NAME}'") | .id')
for OLD_ID in $OLD_RELEASES; do for OLD_ID in $OLD_RELEASES; do
echo "Deleting old release ${OLD_ID}..." [ -n "$OLD_ID" ] || continue
OLD_TAG=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ OLD_TAG=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${OLD_ID}" | jq -r '.tag_name') "${API_BASE}/repos/${REPO}/releases/${OLD_ID}" | jq -r '.tag_name')
echo "Deleting Gitea release ${OLD_ID} (${OLD_TAG})..."
# Delete release curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases/${OLD_ID}" >/dev/null || true
"${API_BASE}/repos/${REPO}/releases/${OLD_ID}" if [ -n "$OLD_TAG" ] && [ "$OLD_TAG" != "null" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
# Delete tag "${API_BASE}/repos/${REPO}/tags/${OLD_TAG}" >/dev/null || true
if [ -n "$OLD_TAG" ]; then
echo "Deleting old tag ${OLD_TAG}..."
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/tags/${OLD_TAG}"
fi fi
done done
# Check if release exists
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
echo "Creating new release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"${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
# Upload new assets
echo "Uploading assets..."
for FILE in release-files/*; do
FILENAME=$(basename "$FILE")
echo "Uploading ${FILENAME}..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${FILE}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo ""
done
echo "Release complete!"
- name: Create GitHub Release - name: Cleanup old GitHub releases
env: env:
GH_TOKEN: ${{ secrets.GHTOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }}
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" set -euo pipefail
TAG_NAME="v${VERSION}" TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
GITHUB_REPO="Paarrot/Paarrot-Desktop" GITHUB_REPO="Paarrot/Paarrot-Desktop"
# Delete old GitHub releases (keep only current) echo "Cleaning up old GitHub releases (keeping ${TAG_NAME})..."
echo "Cleaning up old GitHub releases..." OLD_RELEASES=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
OLD_RELEASES=$(curl -s -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r --arg t "$TAG_NAME" '.[] | select(.tag_name != $t) | .id')
"https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r '.[] | select(.tag_name != "'${TAG_NAME}'") | .id')
for OLD_ID in $OLD_RELEASES; do for OLD_ID in $OLD_RELEASES; do
echo "Deleting old GitHub release ${OLD_ID}..." [ -n "$OLD_ID" ] || continue
OLD_TAG=$(curl -s -H "Authorization: token ${GH_TOKEN}" \ OLD_TAG=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" | jq -r '.tag_name') "https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" | jq -r '.tag_name')
echo "Deleting GitHub release ${OLD_ID} (${OLD_TAG})..."
# Delete release curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \
curl -s -X DELETE -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" >/dev/null || true
"https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" if [ -n "$OLD_TAG" ] && [ "$OLD_TAG" != "null" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \
# Delete tag "https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${OLD_TAG}" >/dev/null || true
if [ -n "$OLD_TAG" ]; then
echo "Deleting old GitHub tag ${OLD_TAG}..."
curl -s -X DELETE -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${OLD_TAG}"
fi fi
done done
# Check if release exists on GitHub echo "Release finalize complete for ${TAG_NAME}"
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
echo "Creating new GitHub release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -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')
echo "Created GitHub release with ID: ${RELEASE_ID}"
else
echo "GitHub release exists with ID: ${RELEASE_ID}"
fi
# Upload assets to GitHub
echo "Uploading assets to GitHub..."
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
for FILE in release-files/*; do
FILENAME=$(basename "$FILE")
echo "Uploading ${FILENAME} to GitHub..."
curl -s -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${FILE}" \
"${UPLOAD_URL}?name=${FILENAME}"
echo ""
done
echo "GitHub release complete!"

35
.github/workflows/update-manifest.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Update release notes manifest
on:
push:
paths:
- 'cinny/public/update/*.md'
- 'cinny/scripts/generate-update-manifest.mjs'
permissions:
contents: write
jobs:
manifest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Generate manifest.json
run: node cinny/scripts/generate-update-manifest.mjs
- name: Commit manifest if changed
run: |
if git diff --quiet -- cinny/public/update/manifest.json; then
echo "manifest.json unchanged"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add cinny/public/update/manifest.json
git commit -m "chore: regenerate update manifest.json"
git push

2
.gitmodules vendored
View File

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

2
cinny

Submodule cinny updated: f62200ecd1...47979718eb

View File

@@ -153,6 +153,72 @@ class PaarrotAPIServer {
} }
}); });
// Recent messages across group channels (non-DMs)
this.app.get('/messages/groups', async (req, res) => {
try {
const result = await this.executeAction('get-messages-groups', {
limit: req.query.limit,
});
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Recent messages across DMs
this.app.get('/messages/dms', async (req, res) => {
try {
const result = await this.executeAction('get-messages-dms', {
limit: req.query.limit,
});
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Recent messages across groups + DMs
this.app.get('/messages/combined', async (req, res) => {
try {
const result = await this.executeAction('get-messages-combined', {
limit: req.query.limit,
});
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Unread conversations across group channels (non-DMs)
this.app.get('/unreads/groups', async (req, res) => {
try {
const result = await this.executeAction('get-unreads-groups');
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Unread conversations across DMs
this.app.get('/unreads/dms', async (req, res) => {
try {
const result = await this.executeAction('get-unreads-dms');
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Unread conversations across groups + DMs
this.app.get('/unreads/combined', async (req, res) => {
try {
const result = await this.executeAction('get-unreads-combined');
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 404 handler // 404 handler
this.app.use((req, res) => { this.app.use((req, res) => {
res.status(404).json({ res.status(404).json({
@@ -169,7 +235,13 @@ class PaarrotAPIServer {
'GET /channels', 'GET /channels',
'POST /message', 'POST /message',
'POST /message/current', 'POST /message/current',
'GET /room/current' 'GET /room/current',
'GET /messages/groups',
'GET /messages/dms',
'GET /messages/combined',
'GET /unreads/groups',
'GET /unreads/dms',
'GET /unreads/combined'
] ]
}); });
}); });
@@ -237,6 +309,12 @@ class PaarrotAPIServer {
console.log(' POST /message'); console.log(' POST /message');
console.log(' POST /message/current'); console.log(' POST /message/current');
console.log(' GET /room/current'); console.log(' GET /room/current');
console.log(' GET /messages/groups');
console.log(' GET /messages/dms');
console.log(' GET /messages/combined');
console.log(' GET /unreads/groups');
console.log(' GET /unreads/dms');
console.log(' GET /unreads/combined');
resolve(port); resolve(port);
}).on('error', (err) => { }).on('error', (err) => {
if (err.code === 'EADDRINUSE') { if (err.code === 'EADDRINUSE') {

View File

@@ -0,0 +1,381 @@
/**
* Discord shop collectibles: fetch a published catalog and download selected assets from Discord CDN.
*/
const ITEM_TYPE_FOLDER = {
0: 'avatar-decorations',
1: 'profile-effects',
2: 'nameplates',
};
const ITEM_TYPE_KEY = {
0: 'avatar_decoration',
1: 'profile_effect',
2: 'nameplate',
};
const CDN_BASE = 'https://cdn.discordapp.com';
const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i;
const DEFAULT_USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) discord/1.0 Chrome/120 Electron/28 Safari/537.36';
const PUBLISHED_CATALOG_URLS = [
'https://github.com/litruv/discord-collectibles/releases/download/latest/profileeffects.json',
'https://github.com/litruv/discord-collectibles/releases/download/latest/nameplate.json',
'https://github.com/litruv/discord-collectibles/releases/download/latest/avatardecorations.json',
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function isCdnUrl(value) {
if (typeof value !== 'string' || !value.startsWith('http')) return false;
try {
return CDN_HOST_RE.test(new URL(value).hostname);
} catch {
return false;
}
}
function basenameFromUrl(url) {
try {
const { pathname } = new URL(url);
const last = decodeURIComponent(pathname.split('/').filter(Boolean).pop() || '');
return last || 'asset';
} catch {
return 'asset';
}
}
function guessMimeType(filename) {
const lower = String(filename).toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.webm')) return 'video/webm';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
return 'application/octet-stream';
}
function deepCollectUrls(node, acc = new Set()) {
if (node == null) return acc;
if (typeof node === 'string') {
if (isCdnUrl(node)) acc.add(node);
return acc;
}
if (Array.isArray(node)) {
for (const v of node) deepCollectUrls(v, acc);
return acc;
}
if (typeof node === 'object') {
for (const v of Object.values(node)) deepCollectUrls(v, acc);
}
return acc;
}
function urlsForItem(item) {
const out = [];
const seen = new Set();
function push(url, filename, role) {
if (!url || seen.has(url)) return;
seen.add(url);
out.push({ url, filename, role: role || filename });
}
for (const url of deepCollectUrls(item)) {
let role = basenameFromUrl(url);
if (item.thumbnailPreviewSrc === url) role = 'thumbnail';
else if (item.reducedMotionSrc === url) role = 'reduced_motion';
else if (Array.isArray(item.effects)) {
const effectIndex = item.effects.findIndex((effect) => effect?.src === url);
if (effectIndex >= 0) role = `effect_${effectIndex}`;
}
push(url, basenameFromUrl(url), role);
}
if (item.type === 0 && item.asset) {
const base = `${CDN_BASE}/avatar-decoration-presets/${item.asset}.png`;
push(`${base}?passthrough=true`, 'animated.png', 'animated');
push(`${base}?passthrough=false`, 'static.png', 'static');
}
if (item.type === 2 && item.asset) {
const assetPath = String(item.asset).replace(/^\/+/, '');
push(`${CDN_BASE}/assets/collectibles/${assetPath}static.png`, 'static.png', 'static');
push(`${CDN_BASE}/assets/collectibles/${assetPath}asset.webm`, 'asset.webm', 'animated');
}
return out;
}
function decimalRgbToCss(color) {
const value = Number(color) >>> 0;
const r = (value >> 16) & 0xff;
const g = (value >> 8) & 0xff;
const b = value & 0xff;
return `rgb(${r}, ${g}, ${b})`;
}
function gradientFromColors(colors) {
if (!Array.isArray(colors) || colors.length === 0) return undefined;
if (colors.length === 1) return decimalRgbToCss(colors[0]);
return `linear-gradient(135deg, ${decimalRgbToCss(colors[0])}, ${decimalRgbToCss(colors[1])})`;
}
function previewAspectRatioForItem(item) {
if (item.type === 0) return 1;
if (item.type === 2) return 448 / 84;
const firstEffect = item.effects?.[0];
if (firstEffect?.width && firstEffect?.height) {
return firstEffect.width / firstEffect.height;
}
return 450 / 880;
}
function formatPaletteName(palette) {
if (!palette) return undefined;
return String(palette)
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
const NAMEPLATE_PALETTE_GRADIENTS = {
crimson: 'linear-gradient(135deg, #5c0a1c, #dc143c)',
berry: 'linear-gradient(135deg, #4a1030, #c42d78)',
sky: 'linear-gradient(135deg, #0a2a5c, #3b8eed)',
teal: 'linear-gradient(135deg, #0a3d3d, #2dd4bf)',
forest: 'linear-gradient(135deg, #0a2e1a, #22c55e)',
bubble_gum: 'linear-gradient(135deg, #4a1038, #f472b6)',
violet: 'linear-gradient(135deg, #2d1050, #8b5cf6)',
cobalt: 'linear-gradient(135deg, #0a1448, #3b5bdb)',
clover: 'linear-gradient(135deg, #0a3d20, #4ade80)',
lemon: 'linear-gradient(135deg, #4a3d0a, #fbbf24)',
white: 'linear-gradient(135deg, #888888, #f0f0f0)',
black: 'linear-gradient(135deg, #1a1a1a, #404040)',
};
function nameplatePaletteGradient(palette) {
if (!palette) return undefined;
return NAMEPLATE_PALETTE_GRADIENTS[palette];
}
function thumbnailForItem(item) {
if (item.thumbnailPreviewSrc && isCdnUrl(item.thumbnailPreviewSrc)) {
return item.thumbnailPreviewSrc;
}
if (item.type === 0 && item.asset) {
return `${CDN_BASE}/avatar-decoration-presets/${item.asset}.png?passthrough=false`;
}
if (item.type === 2 && item.asset) {
const assetPath = String(item.asset).replace(/^\/+/, '');
return `${CDN_BASE}/assets/collectibles/${assetPath}static.png`;
}
const assets = urlsForItem(item);
return assets[0]?.url;
}
function* walkProduct(product, category) {
yield { product, category };
for (const bundled of product.bundled_products ?? []) {
yield* walkProduct(bundled, category);
}
for (const variant of product.variants ?? []) {
yield* walkProduct(variant, category);
}
}
function* walkProducts(categories) {
for (const category of categories ?? []) {
for (const product of category.products ?? []) {
yield* walkProduct(product, category);
}
}
}
function extractCollectibleItems(categories) {
const items = [];
const seenItems = new Set();
for (const { product, category } of walkProducts(categories)) {
const categoryName = category?.name || 'Unknown';
for (const item of product.items ?? []) {
if (![0, 1, 2].includes(item.type)) continue;
const skuId = String(item.sku_id ?? item.id ?? product.sku_id ?? 'unknown');
const itemKey = `${item.type}:${skuId}:${item.id ?? ''}`;
if (seenItems.has(itemKey)) continue;
seenItems.add(itemKey);
const assets = urlsForItem(item);
if (assets.length === 0) continue;
const productName = product.name || item.title || item.label || skuId;
const typeKey = ITEM_TYPE_KEY[item.type];
const entry = {
id: `${typeKey}:${skuId}`,
skuId,
name: productName,
type: typeKey,
category: categoryName,
label: item.label ?? item.title ?? productName,
thumbnailUrl: thumbnailForItem(item),
previewAspectRatio: previewAspectRatioForItem(item),
previewColors: product.styles?.background_colors,
palette: item.type === 2 ? item.palette : undefined,
paletteLabel: item.type === 2 ? formatPaletteName(item.palette) : undefined,
previewGradient: item.type === 2 ? nameplatePaletteGradient(item.palette) : undefined,
assets: assets.map((a) => ({
role: a.role,
url: a.url,
filename: a.filename,
mimeType: guessMimeType(a.filename),
})),
};
if (item.type === 1) {
entry.effect = {
animationType: item.animationType,
thumbnailPreviewSrc: item.thumbnailPreviewSrc,
reducedMotionSrc: item.reducedMotionSrc,
effects: item.effects,
};
}
items.push(entry);
}
}
return items;
}
function isPublishedCatalog(document) {
return (
document &&
document.schema_version === 1 &&
typeof document.type === 'string' &&
Array.isArray(document.items) &&
document.items.every(
(item) =>
item &&
typeof item.id === 'string' &&
typeof item.skuId === 'string' &&
typeof item.type === 'string' &&
Array.isArray(item.assets)
)
);
}
async function fetchPublishedCatalog() {
const documents = await Promise.all(
PUBLISHED_CATALOG_URLS.map(async (url) => {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`Published collectibles catalog request failed with HTTP ${response.status}.`);
}
const document = await response.json();
if (!isPublishedCatalog(document)) {
throw new Error('Published collectibles catalog has an unsupported schema.');
}
return document;
})
);
const items = documents.flatMap((document) => document.items);
if (items.length === 0) {
throw new Error('Published collectibles catalog is empty.');
}
return items;
}
async function downloadAsset(url, { retries = 4 } = {}) {
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(url, {
headers: { 'User-Agent': DEFAULT_USER_AGENT, Accept: '*/*' },
});
} catch (err) {
if (attempt >= retries) throw err;
await sleep(500 * 2 ** attempt);
continue;
}
if (res.status === 429) {
const retryAfter = Number(res.headers.get('retry-after')) || 1;
await sleep((retryAfter + 0.5) * 1000);
continue;
}
if (!res.ok) {
if (attempt >= retries) {
throw new Error(`Failed to download asset: ${res.status} ${res.statusText}`);
}
await sleep(500 * 2 ** attempt);
continue;
}
const buf = Buffer.from(await res.arrayBuffer());
return buf;
}
}
function createDiscordCollectiblesService(store) {
let catalogCache = store.get('discordCollectiblesCatalog') || null;
if (
!catalogCache ||
!Array.isArray(catalogCache.items) ||
typeof catalogCache.fetchedAt !== 'string'
) {
catalogCache = null;
}
let catalogCacheAt = catalogCache?.fetchedAt ? Date.parse(catalogCache.fetchedAt) : 0;
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;
store.delete('discordCollectiblesToken');
async function getCatalog({ force = false } = {}) {
const now = Date.now();
if (!force && catalogCache && now - catalogCacheAt < CATALOG_TTL_MS) {
return { success: true, data: catalogCache };
}
try {
const items = await fetchPublishedCatalog();
catalogCache = { items, fetchedAt: new Date().toISOString() };
catalogCacheAt = now;
store.set('discordCollectiblesCatalog', catalogCache);
return { success: true, data: catalogCache };
} catch (err) {
if (catalogCache) {
return {
success: true,
data: { ...catalogCache, stale: true },
};
}
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function downloadAssets(assetUrls) {
const results = [];
for (const asset of assetUrls) {
const buffer = await downloadAsset(asset.url);
results.push({
role: asset.role,
url: asset.url,
filename: asset.filename,
mimeType: asset.mimeType || guessMimeType(asset.filename),
data: buffer,
});
}
return results;
}
return {
getCatalog,
downloadAssets,
ITEM_TYPE_FOLDER,
};
}
module.exports = { createDiscordCollectiblesService, extractCollectibleItems, urlsForItem };

View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer } = require('electron'); const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer, dialog } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { exec, execFile, execFileSync } = require('child_process'); const { exec, execFile, execFileSync } = require('child_process');
@@ -8,6 +8,7 @@ const openPkg = require('open');
const open = openPkg.default; const open = openPkg.default;
const openApps = openPkg.apps; const openApps = openPkg.apps;
const PaarrotAPIServer = require('./api-server'); const PaarrotAPIServer = require('./api-server');
const { createDiscordCollectiblesService } = require('./discord-collectibles');
const https = require('https'); const https = require('https');
const http = require('http'); const http = require('http');
const AdmZip = require('adm-zip'); const AdmZip = require('adm-zip');
@@ -104,6 +105,7 @@ function isAppOwnedUrl(targetUrl, currentUrl) {
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const store = new Store(); const store = new Store();
const discordCollectibles = createDiscordCollectiblesService(store);
const PROTOCOL_SCHEME = 'paarrot'; const PROTOCOL_SCHEME = 'paarrot';
let mainWindow = null; let mainWindow = null;
@@ -1275,13 +1277,103 @@ ipcMain.handle('open-external-url', async (event, url) => {
} }
}); });
/**
* Save a media blob from the renderer via a native Save dialog.
* Payload: { filename, mimeType?, data: Uint8Array | ArrayBuffer | number[] }
*/
ipcMain.handle('media:save-file', async (event, payload = {}) => {
try {
const filename = typeof payload.filename === 'string' && payload.filename.trim()
? path.basename(payload.filename.trim()) || 'download'
: 'download';
const mimeType = typeof payload.mimeType === 'string' ? payload.mimeType : '';
const raw = payload.data;
if (!raw) {
return { success: false, error: 'Missing file data' };
}
const buffer = Buffer.from(
raw instanceof ArrayBuffer
? new Uint8Array(raw)
: ArrayBuffer.isView(raw)
? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
: raw
);
const ext = path.extname(filename).replace(/^\./, '').toLowerCase();
const filters = (() => {
if (mimeType.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) {
return [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mimeType.startsWith('video/') || ['mp4', 'webm', 'mkv', 'mov'].includes(ext)) {
return [
{ name: 'Videos', extensions: ['mp4', 'webm', 'mkv', 'mov'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mimeType.startsWith('audio/') || ['mp3', 'ogg', 'wav', 'm4a', 'flac'].includes(ext)) {
return [
{ name: 'Audio', extensions: ['mp3', 'ogg', 'wav', 'm4a', 'flac'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mimeType === 'application/pdf' || ext === 'pdf') {
return [
{ name: 'PDF', extensions: ['pdf'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (ext) {
return [
{ name: ext.toUpperCase(), extensions: [ext] },
{ name: 'All Files', extensions: ['*'] },
];
}
return [{ name: 'All Files', extensions: ['*'] }];
})();
const win = BrowserWindow.fromWebContents(event.sender) || mainWindow;
const result = await dialog.showSaveDialog(win || undefined, {
title: 'Save file',
defaultPath: filename,
filters,
});
if (result.canceled || !result.filePath) {
return { success: true, canceled: true };
}
await fs.promises.writeFile(result.filePath, buffer);
return { success: true, path: result.filePath };
} catch (error) {
console.error('[media:save-file] failed:', error);
return { success: false, error: error.message || String(error) };
}
});
// Read clipboard image // Read clipboard image
ipcMain.handle('read-clipboard-image', async () => { ipcMain.handle('read-clipboard-image', async () => {
try { try {
// Extra Things: only trust an image when the clipboard actually advertises one.
// On Linux, readImage() can return a stale bitmap after copying plain text/URLs.
const formats = clipboard.availableFormats();
const hasImageFormat = formats.some((format) => format.startsWith('image/'));
if (!hasImageFormat) {
return { success: true, data: null };
}
const image = clipboard.readImage(); const image = clipboard.readImage();
if (image.isEmpty()) { if (image.isEmpty()) {
return { success: true, data: null }; return { success: true, data: null };
} }
const { width, height } = image.getSize();
if (width < 2 || height < 2) {
return { success: true, data: null };
}
// Convert to PNG base64 // Convert to PNG base64
const pngBuffer = image.toPNG(); const pngBuffer = image.toPNG();
@@ -1333,49 +1425,54 @@ ipcMain.handle('play-notification-sound', async (event, soundType = 'message') =
} }
}); });
// Get YouTube stream using yt-dlp // Get a direct YouTube stream using yt-dlp.
ipcMain.handle('get-youtube-stream', async (event, url) => { async function getYouTubeStream(url) {
try { try {
// Check if yt-dlp is available
try { try {
await execAsync('yt-dlp --version'); await execFileAsync('yt-dlp', ['--version']);
} catch { } catch {
return { return {
success: false, success: false,
error: 'yt-dlp is not installed. Please install yt-dlp to use YouTube features.' error: 'yt-dlp is not installed. Please install yt-dlp to use YouTube features.',
}; };
} }
// Get title
let title = 'YouTube Video'; let title = 'YouTube Video';
try { try {
const titleResult = await execAsync(`yt-dlp --get-title "${url}"`); const titleResult = await execFileAsync('yt-dlp', ['--no-playlist', '--get-title', url]);
title = titleResult.stdout.trim(); title = titleResult.stdout.trim();
} catch (e) { } catch (e) {
console.warn('Failed to get video title:', e.message); console.warn('Failed to get video title:', e.message);
} }
// Get video URL const result = await execFileAsync('yt-dlp', [
const result = await execAsync( '--no-playlist',
`yt-dlp -g -f "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best" "${url}"` '-g',
); '-f',
'best[height<=1080]/bestvideo[height<=1080]+bestaudio/best',
url,
]);
const videoUrl = result.stdout.trim().split('\n')[0]; const videoUrl = result.stdout.trim().split('\n')[0];
if (!videoUrl) { if (!videoUrl) {
return { success: false, error: 'yt-dlp returned empty URL' }; return { success: false, error: 'yt-dlp returned empty URL' };
} }
return { return {
success: true, success: true,
data: { video_url: videoUrl, title } data: { video_url: videoUrl, title },
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: `yt-dlp error: ${error.message}` error: `yt-dlp error: ${error.message}`,
}; };
} }
}
// Get YouTube stream using yt-dlp
ipcMain.handle('get-youtube-stream', async (event, url) => {
return getYouTubeStream(url);
}); });
// Background sync stubs (desktop doesn't need it) // Background sync stubs (desktop doesn't need it)
@@ -1811,6 +1908,35 @@ ipcMain.handle('plugin:read-code', async (event, pluginId) => {
} }
}); });
ipcMain.handle('discord-collectibles:fetch-catalog', async (event, { force } = {}) => {
try {
return await discordCollectibles.getCatalog({ force: Boolean(force) });
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('discord-collectibles:download-assets', async (event, { assets }) => {
try {
if (!Array.isArray(assets) || assets.length === 0) {
return { success: false, error: 'No assets to download' };
}
const downloaded = await discordCollectibles.downloadAssets(assets);
return {
success: true,
data: downloaded.map((item) => ({
role: item.role,
url: item.url,
filename: item.filename,
mimeType: item.mimeType,
data: item.data,
})),
};
} catch (error) {
return { success: false, error: error.message };
}
});
console.log('Paarrot Electron main process started'); console.log('Paarrot Electron main process started');
console.log('Development mode:', isDev); console.log('Development mode:', isDev);
console.log('Platform:', process.platform); console.log('Platform:', process.platform);

View File

@@ -104,6 +104,12 @@ contextBridge.exposeInMainWorld('electron', {
readImage: () => ipcRenderer.invoke('read-clipboard-image'), readImage: () => ipcRenderer.invoke('read-clipboard-image'),
writeText: (text) => ipcRenderer.invoke('write-clipboard-text', text) writeText: (text) => ipcRenderer.invoke('write-clipboard-text', text)
}, },
// Media save (native Save dialog for images / videos / files)
media: {
saveFile: ({ filename, mimeType, data }) =>
ipcRenderer.invoke('media:save-file', { filename, mimeType, data }),
},
// Audio // Audio
audio: { audio: {
playNotificationSound: (soundType = 'message') => ipcRenderer.invoke('play-notification-sound', soundType), playNotificationSound: (soundType = 'message') => ipcRenderer.invoke('play-notification-sound', soundType),
@@ -187,7 +193,13 @@ contextBridge.exposeInMainWorld('electron', {
protocol: { protocol: {
getStatus: () => ipcRenderer.invoke('protocol:get-status'), getStatus: () => ipcRenderer.invoke('protocol:get-status'),
repair: () => ipcRenderer.invoke('protocol:repair') repair: () => ipcRenderer.invoke('protocol:repair')
} },
// Discord shop collectibles (published catalog + on-demand CDN download)
discordCollectibles: {
fetchCatalog: (force = false) => ipcRenderer.invoke('discord-collectibles:fetch-catalog', { force }),
downloadAssets: (assets) => ipcRenderer.invoke('discord-collectibles:download-assets', { assets }),
},
}); });
// Preload script ready // Preload script ready

View File

@@ -208,6 +208,81 @@
{ {
"name": "Messaging", "name": "Messaging",
"item": [ "item": [
{
"name": "Get Group Messages",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/messages/groups?limit=10",
"host": [
"{{baseUrl}}"
],
"path": [
"messages",
"groups"
],
"query": [
{
"key": "limit",
"value": "10",
"description": "Number of recent messages (1-100, default 10)"
}
]
},
"description": "Get the most recent messages across joined group channels (non-DMs)."
}
},
{
"name": "Get DM Messages",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/messages/dms?limit=10",
"host": [
"{{baseUrl}}"
],
"path": [
"messages",
"dms"
],
"query": [
{
"key": "limit",
"value": "10",
"description": "Number of recent messages (1-100, default 10)"
}
]
},
"description": "Get the most recent messages across joined direct messages."
}
},
{
"name": "Get Combined Messages",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/messages/combined?limit=10",
"host": [
"{{baseUrl}}"
],
"path": [
"messages",
"combined"
],
"query": [
{
"key": "limit",
"value": "10",
"description": "Number of recent messages (1-100, default 10)"
}
]
},
"description": "Get the most recent messages across groups and DMs together."
}
},
{ {
"name": "Send Message to Room", "name": "Send Message to Room",
"request": { "request": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.163", "version": "4.11.191",
"description": "Paarrot - A Matrix client based on Cinny", "description": "Paarrot - A Matrix client based on Cinny",
"homepage": "https://github.com/Paarrot/Paarrot-Desktop", "homepage": "https://github.com/Paarrot/Paarrot-Desktop",
"repository": { "repository": {
@@ -15,11 +15,13 @@
"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": "cross-env BROWSER=none sh -c 'cd cinny && npm start'",
"dev:electron": "wait-on http://localhost:38347 && cross-env NODE_ENV=development electron .", "dev:electron": "wait-on http://localhost:38347 && cross-env NODE_ENV=development electron .",
"build": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish always", "build": "node scripts/generate-update-manifest.mjs && node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish always",
"build:local": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder", "build:local": "node scripts/generate-update-manifest.mjs && 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" "postman:generate": "node scripts/generate-postman-collection.js",
"generate:update-manifest": "node scripts/generate-update-manifest.mjs",
"playground": "npm --prefix cinny run playground"
}, },
"keywords": [], "keywords": [],
"author": { "author": {

View File

@@ -164,6 +164,87 @@ const collection = {
{ {
name: 'Messaging', name: 'Messaging',
item: [ item: [
{
name: 'Get Group Messages',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/messages/groups?limit=10',
host: ['{{baseUrl}}'],
path: ['messages', 'groups'],
query: [{ key: 'limit', value: '10', description: 'Number of recent messages (1-100, default 10)' }],
},
description: 'Get the most recent messages across joined group channels (non-DMs).',
},
},
{
name: 'Get DM Messages',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/messages/dms?limit=10',
host: ['{{baseUrl}}'],
path: ['messages', 'dms'],
query: [{ key: 'limit', value: '10', description: 'Number of recent messages (1-100, default 10)' }],
},
description: 'Get the most recent messages across joined direct messages.',
},
},
{
name: 'Get Combined Messages',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/messages/combined?limit=10',
host: ['{{baseUrl}}'],
path: ['messages', 'combined'],
query: [{ key: 'limit', value: '10', description: 'Number of recent messages (1-100, default 10)' }],
},
description: 'Get the most recent messages across groups and DMs together.',
},
},
{
name: 'Get Group Unreads',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/unreads/groups',
host: ['{{baseUrl}}'],
path: ['unreads', 'groups'],
},
description: 'List unread group conversations (non-DMs), including counts and latest message preview.',
},
},
{
name: 'Get DM Unreads',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/unreads/dms',
host: ['{{baseUrl}}'],
path: ['unreads', 'dms'],
},
description: 'List unread DM conversations, including counts and latest message preview.',
},
},
{
name: 'Get Combined Unreads',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/unreads/combined',
host: ['{{baseUrl}}'],
path: ['unreads', 'combined'],
},
description: 'List unread conversations across groups and DMs.',
},
},
{ {
name: 'Send Message to Room', name: 'Send Message to Room',
request: { request: {

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env node
/**
* Desktop repo wrapper — manifest generator lives in cinny/scripts/.
*/
import { spawnSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const script = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'cinny',
'scripts',
'generate-update-manifest.mjs'
);
const result = spawnSync(process.execPath, [script], { stdio: 'inherit' });
process.exit(result.status ?? 1);

View File

@@ -18,6 +18,17 @@
"updater:allow-download", "updater:allow-download",
"updater:allow-download-and-install", "updater:allow-download-and-install",
"dialog:default", "dialog:default",
"fs:default",
"fs:allow-write-file",
{
"identifier": "fs:scope",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOWNLOAD/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$DOCUMENT/**" }
]
},
"process:allow-restart", "process:allow-restart",
{ {
"identifier": "http:default", "identifier": "http:default",

View File

@@ -60,6 +60,16 @@ async function runTests() {
// Get current room // Get current room
await testEndpoint('Get Current Room', 'GET', '/room/current'); await testEndpoint('Get Current Room', 'GET', '/room/current');
// Recent messages by scope
await testEndpoint('Get Group Messages', 'GET', '/messages/groups');
await testEndpoint('Get DM Messages', 'GET', '/messages/dms');
await testEndpoint('Get Combined Messages', 'GET', '/messages/combined?limit=10');
// Unread conversations by scope
await testEndpoint('Get Group Unreads', 'GET', '/unreads/groups');
await testEndpoint('Get DM Unreads', 'GET', '/unreads/dms');
await testEndpoint('Get Combined Unreads', 'GET', '/unreads/combined');
// Toggle mute // Toggle mute
await testEndpoint('Toggle Mute', 'POST', '/mute/toggle'); await testEndpoint('Toggle Mute', 'POST', '/mute/toggle');