This commit is contained in:
litruv
2026-02-21 17:50:50 +11:00
parent caf1869e3e
commit 22f5c5e7e4
92 changed files with 6344 additions and 26256 deletions

View File

@@ -8,12 +8,6 @@ on:
tags: tags:
- 'v*' - 'v*'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
CARGO_INCREMENTAL: 1
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
jobs: jobs:
increment-version: increment-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -32,8 +26,8 @@ jobs:
- name: Bump patch version - name: Bump patch version
id: bump id: bump
run: | run: |
# Get current version # Get current version from package.json
CURRENT=$(grep -Po '"version":\s*"\K[^"]+' src-tauri/tauri.conf.json | head -1) CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
echo "Current version: $CURRENT" echo "Current version: $CURRENT"
# Split and increment patch # Split and increment patch
@@ -44,11 +38,11 @@ jobs:
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New version: $NEW_VERSION" echo "New version: $NEW_VERSION"
# Update tauri.conf.json # Update package.json
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" src-tauri/tauri.conf.json sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json
# Verify the change # Verify the change
grep '"version"' src-tauri/tauri.conf.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 echo "SHOULD_BUILD=true" >> $GITHUB_OUTPUT
@@ -57,7 +51,7 @@ jobs:
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 src-tauri/tauri.conf.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]"
git push git push
@@ -65,39 +59,24 @@ jobs:
runs-on: windows runs-on: windows
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' || needs.increment-version.result == 'skipped')
env:
CARGO_TARGET_DIR: C:\cargo-cache\cinny-desktop
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: false submodules: recursive
ref: ${{ github.ref_name }} ref: ${{ github.ref_name }}
clean: false
- 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 shell: powershell
run: git pull origin main run: git pull origin main
- name: Checkout submodules (Windows workaround)
shell: powershell
run: |
git submodule update --init --recursive
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: Install dependencies (root) - name: Install dependencies (root)
run: npm install --prefer-offline run: npm install --prefer-offline
@@ -105,80 +84,27 @@ jobs:
working-directory: ./cinny working-directory: ./cinny
run: npm install --prefer-offline run: npm install --prefer-offline
- name: Build Tauri app - name: Build Electron app
run: npm run tauri build run: npm run build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Copy and rename MSI files - name: Upload NSIS installer
shell: powershell
run: |
# Clean and recreate output dir
$outDir = "src-tauri/target/release/bundle/msi"
if (Test-Path $outDir) { Remove-Item -Recurse -Force $outDir }
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
# Find and copy MSI from cache location (get NEWEST file by LastWriteTime)
$srcDir = "$env:CARGO_TARGET_DIR/release/bundle/msi"
Write-Host "Looking for MSI in: $srcDir"
Get-ChildItem $srcDir -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending
$msi = Get-ChildItem "$srcDir/*.msi" -ErrorAction Stop | Sort-Object LastWriteTime -Descending | Select-Object -First 1
Write-Host "Found MSI: $($msi.FullName) (Size: $($msi.Length) bytes, Modified: $($msi.LastWriteTime))"
Copy-Item $msi.FullName "$outDir/Cinny-Windows-x64.msi" -Force
$msiZip = Get-ChildItem "$srcDir/*.msi.zip" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($msiZip) {
Write-Host "Found MSI.zip: $($msiZip.FullName)"
Copy-Item $msiZip.FullName "$outDir/Cinny-Windows-x64.msi.zip" -Force
}
$msiZipSig = Get-ChildItem "$srcDir/*.msi.zip.sig" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($msiZipSig) {
Write-Host "Found MSI.zip.sig: $($msiZipSig.FullName)"
Copy-Item $msiZipSig.FullName "$outDir/Cinny-Windows-x64.msi.zip.sig" -Force
}
$msiSig = Get-ChildItem "$srcDir/*.msi.sig" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($msiSig) {
Write-Host "Found MSI.sig: $($msiSig.FullName)"
Copy-Item $msiSig.FullName "$outDir/Cinny-Windows-x64.msi.sig" -Force
}
Write-Host "Final output:"
Get-ChildItem $outDir
- name: Upload MSI installer
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: Cinny-Windows-x64.msi name: Paarrot-Windows-x64.exe
path: src-tauri/target/release/bundle/msi/Cinny-Windows-x64.msi path: dist-electron/Paarrot-*-win-x64.exe
- name: Upload MSI updater zip - name: Upload Portable
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
if: always()
with: with:
name: Cinny-Windows-x64.msi.zip name: Paarrot-Windows-x64-portable.exe
path: src-tauri/target/release/bundle/msi/Cinny-Windows-x64.msi.zip path: dist-electron/Paarrot-*-win-x64.portable.exe
- name: Upload MSI updater signature (zip)
uses: actions/upload-artifact@v3
if: always()
with:
name: Cinny-Windows-x64.msi.zip.sig
path: src-tauri/target/release/bundle/msi/Cinny-Windows-x64.msi.zip.sig
- name: Upload MSI signature
uses: actions/upload-artifact@v3
if: always()
with:
name: Cinny-Windows-x64.msi.sig
path: src-tauri/target/release/bundle/msi/Cinny-Windows-x64.msi.sig
build-linux: build-linux:
runs-on: ubuntu-latest 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' || needs.increment-version.result == 'skipped')
env:
CARGO_TARGET_DIR: /home/runner/cargo-cache/cinny-desktop
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -196,17 +122,10 @@ jobs:
with: with:
node-version: '20' node-version: '20'
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev rpm patchelf xdg-utils sudo apt-get install -y rpm
- name: Install dependencies (root) - name: Install dependencies (root)
run: npm ci --prefer-offline run: npm ci --prefer-offline
@@ -215,224 +134,32 @@ jobs:
working-directory: ./cinny working-directory: ./cinny
run: npm ci --prefer-offline run: npm ci --prefer-offline
- name: Build Tauri app - name: Build Electron app
run: npm run tauri build run: npm run build:linux
env:
- name: Copy and rename Linux packages GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Clean and recreate workspace output directories
rm -rf src-tauri/target/release/bundle/appimage
rm -rf src-tauri/target/release/bundle/deb
rm -rf src-tauri/target/release/bundle/rpm
mkdir -p src-tauri/target/release/bundle/appimage
mkdir -p src-tauri/target/release/bundle/deb
mkdir -p src-tauri/target/release/bundle/rpm
# Show what's available in cache (sorted by time, newest first)
echo "Available AppImage files:"
ls -lt $CARGO_TARGET_DIR/release/bundle/appimage/ 2>/dev/null || true
echo "Available DEB files:"
ls -lt $CARGO_TARGET_DIR/release/bundle/deb/ 2>/dev/null || true
echo "Available RPM files:"
ls -lt $CARGO_TARGET_DIR/release/bundle/rpm/ 2>/dev/null || true
# Copy from persistent cache location (get NEWEST file by modification time)
APPIMAGE=$(ls -t $CARGO_TARGET_DIR/release/bundle/appimage/*.AppImage 2>/dev/null | head -1)
echo "Copying AppImage: $APPIMAGE"
cp "$APPIMAGE" src-tauri/target/release/bundle/appimage/Cinny-Linux-x64.AppImage
DEB=$(ls -t $CARGO_TARGET_DIR/release/bundle/deb/*.deb 2>/dev/null | head -1)
echo "Copying DEB: $DEB"
cp "$DEB" src-tauri/target/release/bundle/deb/Cinny-Linux-x64.deb
RPM=$(ls -t $CARGO_TARGET_DIR/release/bundle/rpm/*.rpm 2>/dev/null | head -1)
echo "Copying RPM: $RPM"
cp "$RPM" src-tauri/target/release/bundle/rpm/Cinny-Linux-x64.rpm
# Copy signature file for updater if it exists (get NEWEST)
if ls $CARGO_TARGET_DIR/release/bundle/appimage/*.AppImage.sig 1> /dev/null 2>&1; then
APPSIG=$(ls -t $CARGO_TARGET_DIR/release/bundle/appimage/*.AppImage.sig 2>/dev/null | head -1)
echo "Copying AppImage sig: $APPSIG"
cp "$APPSIG" src-tauri/target/release/bundle/appimage/Cinny-Linux-x64.AppImage.sig
fi
# List final output
echo "Final output:"
ls -la src-tauri/target/release/bundle/appimage/
ls -la src-tauri/target/release/bundle/deb/
ls -la src-tauri/target/release/bundle/rpm/
- name: Upload AppImage - name: Upload AppImage
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: Cinny-Linux-x64.AppImage name: Paarrot-Linux-x64.AppImage
path: src-tauri/target/release/bundle/appimage/Cinny-Linux-x64.AppImage path: dist-electron/Paarrot-*-x64.AppImage
- name: Upload AppImage Signature
uses: actions/upload-artifact@v3
if: always()
with:
name: Cinny-Linux-x64.AppImage.sig
path: src-tauri/target/release/bundle/appimage/Cinny-Linux-x64.AppImage.sig
- name: Upload DEB package - name: Upload DEB package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: Cinny-Linux-x64.deb name: Paarrot-Linux-x64.deb
path: src-tauri/target/release/bundle/deb/Cinny-Linux-x64.deb path: dist-electron/Paarrot-*-x64.deb
- name: Upload RPM package - name: Upload RPM package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: Cinny-Linux-x64.rpm name: Paarrot-Linux-x64.rpm
path: src-tauri/target/release/bundle/rpm/Cinny-Linux-x64.rpm path: dist-electron/Paarrot-*-x64.rpm
build-android:
runs-on: windows
needs: increment-version
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
env:
CARGO_TARGET_DIR: C:\cargo-cache\cinny-desktop-android
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: false
ref: ${{ github.ref_name }}
clean: false
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main'
shell: powershell
run: git pull origin main
- name: Checkout submodules (Windows workaround)
shell: powershell
run: |
git submodule update --init --recursive
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
target: aarch64-linux-android
- name: Add Android targets
run: |
rustup target add aarch64-linux-android --toolchain stable
- name: Install dependencies (root)
run: npm ci --prefer-offline
- name: Install dependencies (cinny)
working-directory: ./cinny
run: npm ci --prefer-offline
- name: Build Android APK
shell: powershell
timeout-minutes: 30
run: |
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
npm run tauri android build -- --ci --target aarch64
# Kill any lingering processes that might hang
Stop-Process -Name "java" -Force -ErrorAction SilentlyContinue
Stop-Process -Name "gradle" -Force -ErrorAction SilentlyContinue
env:
ANDROID_HOME: ${{ env.ANDROID_HOME }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/25.2.9519653
RUSTUP_TOOLCHAIN: stable
CI: true
- name: Sign APK
shell: powershell
run: |
$unsignedApk = "src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk"
$signedApk = "src-tauri/gen/android/app/build/outputs/apk/universal/release/cinny-release.apk"
$keystoreDir = "$env:USERPROFILE\.android"
$keystore = "$keystoreDir\debug.keystore"
# Find Android SDK
$androidHome = if ($env:ANDROID_HOME) { $env:ANDROID_HOME }
elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT }
elseif (Test-Path "$env:LOCALAPPDATA\Android\Sdk") { "$env:LOCALAPPDATA\Android\Sdk" }
else { "$env:USERPROFILE\AppData\Local\Android\Sdk" }
Write-Host "Using Android SDK at: $androidHome"
# Ensure keystore directory exists
if (-not (Test-Path $keystoreDir)) {
New-Item -ItemType Directory -Path $keystoreDir -Force | Out-Null
}
# Use debug keystore for signing (create if missing)
if (-not (Test-Path $keystore)) {
Write-Host "Creating debug keystore..."
# Use -noprompt to avoid any interactive prompts
& keytool -genkeypair -noprompt -keystore $keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to create keystore"
exit 1
}
}
Write-Host "Using keystore: $keystore"
# Find build tools
$buildTools = Get-ChildItem "$androidHome\build-tools" | Sort-Object Name -Descending | Select-Object -First 1
$apksigner = "$($buildTools.FullName)\apksigner.bat"
$zipalign = "$($buildTools.FullName)\zipalign.exe"
Write-Host "Using build tools: $($buildTools.FullName)"
# Align the APK
Write-Host "Aligning APK..."
& $zipalign -v -p 4 $unsignedApk "$unsignedApk.aligned"
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to align APK"
exit 1
}
# Sign the APK
Write-Host "Signing APK..."
& $apksigner sign --ks $keystore --ks-pass pass:android --key-pass pass:android --out $signedApk "$unsignedApk.aligned"
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to sign APK"
exit 1
}
# Verify
Write-Host "Verifying signature..."
& $apksigner verify $signedApk
Write-Host "Signed APK created at: $signedApk"
# Rename to final name
Copy-Item $signedApk "src-tauri/gen/android/app/build/outputs/apk/universal/release/Cinny-Android.apk"
Write-Host "Done! APK ready at: Cinny-Android.apk"
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: Cinny-Android.apk
path: src-tauri/gen/android/app/build/outputs/apk/universal/release/Cinny-Android.apk
create-release: create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [increment-version, build-windows, build-linux, build-android] needs: [increment-version, build-windows, build-linux]
if: always() && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success') if: always() && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success')
steps: steps:
@@ -453,7 +180,7 @@ jobs:
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[^"]+' src-tauri/tauri.conf.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 "Version: $VERSION" echo "Version: $VERSION"
@@ -483,42 +210,10 @@ jobs:
with: with:
path: artifacts path: artifacts
- name: Create update.json
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
APPIMAGE_SIG=""
MSI_SIG=""
if [ -f "artifacts/Cinny-Linux-x64.AppImage.sig/Cinny-Linux-x64.AppImage.sig" ]; then
APPIMAGE_SIG=$(cat "artifacts/Cinny-Linux-x64.AppImage.sig/Cinny-Linux-x64.AppImage.sig")
fi
if [ -f "artifacts/Cinny-Windows-x64.msi.sig/Cinny-Windows-x64.msi.sig" ]; then
MSI_SIG=$(cat "artifacts/Cinny-Windows-x64.msi.sig/Cinny-Windows-x64.msi.sig")
fi
cat > update.json << EOF
{
"version": "${VERSION}",
"notes": "Update to version ${VERSION}",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"linux-x86_64": {
"signature": "${APPIMAGE_SIG}",
"url": "http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases/download/v${VERSION}/Cinny-Linux-x64.AppImage"
},
"windows-x86_64": {
"signature": "${MSI_SIG}",
"url": "http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases/download/v${VERSION}/Cinny-Windows-x64.msi"
}
}
}
EOF
cat update.json
- name: Prepare release files - name: Prepare release files
run: | run: |
mkdir -p release-files mkdir -p release-files
find artifacts -type f \( -name "*.msi" -o -name "*.msi.zip" -o -name "*.msi.zip.sig" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.apk" -o -name "*.sig" \) -exec cp {} 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/ \;
cp update.json release-files/
ls -la release-files/ ls -la release-files/
- name: Create or Update Release - name: Create or Update Release
@@ -569,41 +264,3 @@ jobs:
done done
echo "Release complete!" echo "Release complete!"
# Also update 'latest' release with update.json for auto-updater
echo "Updating 'latest' release..."
LATEST_TAG="latest"
LATEST_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${LATEST_TAG}" | jq -r '.id // empty')
if [ -z "$LATEST_ID" ]; then
echo "Creating 'latest' release..."
LATEST_ID=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${LATEST_TAG}\", \"name\": \"Latest Release\", \"body\": \"Always points to the most recent version (${VERSION})\", \"draft\": false, \"prerelease\": false}" \
"${API_BASE}/repos/${REPO}/releases" | jq -r '.id')
else
# Update release body
curl -s -X PATCH \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Always points to the most recent version (${VERSION})\"}" \
"${API_BASE}/repos/${REPO}/releases/${LATEST_ID}"
# Delete existing update.json asset
LATEST_ASSETS=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${LATEST_ID}/assets" | jq -r '.[] | select(.name=="update.json") | .id')
for ASSET_ID in $LATEST_ASSETS; do
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${LATEST_ID}/assets/${ASSET_ID}"
done
fi
# Upload update.json to latest
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@release-files/update.json" \
"${API_BASE}/repos/${REPO}/releases/${LATEST_ID}/assets?name=update.json"
echo "Latest release updated!"

View File

@@ -4,29 +4,21 @@
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/cinnyapp/cinny-desktop/total?style=social"></a> <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 made with Tauri. Cinny is a matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron.
## Download ## Download
Installers for macOS, Windows and Linux can be downloaded from [Github releases](https://github.com/cinnyapp/cinny-desktop/releases). Releases are signed with a [Ed25519](https://ed25519.cr.yp.to/) public-key. 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 | <a href='https://github.com/cinnyapp/cinny-desktop/releases/latest/download/Cinny_desktop-x86_64.msi'>Get it on Windows</a> Windows | <a href='https://github.com/cinnyapp/cinny-desktop/releases/latest'>Get it on Windows</a>
macOS | <a href='https://github.com/cinnyapp/cinny-desktop/releases/latest/download/Cinny_desktop-universal.dmg'>Get it on macOS</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/download/Cinny_desktop-x86_64.AppImage'>Get it on Linux</a> · <a href='https://flathub.org/apps/details/in.cinny.Cinny'>Flatpak</a> Linux | <a href='https://github.com/cinnyapp/cinny-desktop/releases/latest'>Get it on Linux</a>
Decoded public key:
> RWRflTUQD3RHFtn25QNANCmePR9+4LSK89kAKTMEEB4OKpOFpLMgc64z
To verify release files, you need to download [minisign](https://jedisct1.github.io/minisign/) tool and [decode](https://www.base64decode.org/) the *.sig* file before running:
> minisign -Vm ***RELEASE_FILE.msi.zip*** -P RWRflTUQD3RHFtn25QNANCmePR9+4LSK89kAKTMEEB4OKpOFpLMgc64z -x ***SINGATURE.msi.zip.sig***
## Local development ## Local development
Firstly, to setup Rust, NodeJS and build tools follow [Tauri documentation](https://tauri.app/v1/guides/getting-started/prerequisites). To setup development locally run the following commands:
Now, to setup development locally run the following commands:
* `git clone --recursive https://github.com/cinnyapp/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`
@@ -34,7 +26,7 @@ Now, to setup development locally run the following commands:
* `npm ci` * `npm ci`
To build the app locally, run: To build the app locally, run:
* `npm run tauri build` * `npm run build`
To start local dev server, run: To start local dev server, run:
* `npm run tauri dev` * `npm run dev`

2
cinny

Submodule cinny updated: 6c741577ce...8b0960d366

131
electron-builder.json5 Normal file
View File

@@ -0,0 +1,131 @@
{
"appId": "com.paarrot.app",
"productName": "Paarrot",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"copyright": "Copyright © 2024",
"directories": {
"output": "dist-electron",
"buildResources": "build-resources"
},
"files": [
"electron/**/*",
"cinny/dist/**/*",
"!**/*.map",
"!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
"!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
"!**/node_modules/*.d.ts",
"!**/node_modules/.bin",
"!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}",
"!.editorconfig",
"!**/._*",
"!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}",
"!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}",
"!**/{appveyor.yml,.travis.yml,circle.yml}",
"!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}"
],
"extraResources": [
{
"from": "icons",
"to": "icons",
"filter": ["**/*"]
}
],
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
}
],
"category": "Network;InstantMessaging",
"icon": "icons/icon.png",
"desktop": {
"Name": "Paarrot",
"GenericName": "Matrix Client",
"Comment": "A Matrix client built with Cinny",
"Categories": "Network;InstantMessaging;",
"Keywords": "matrix;chat;messaging;",
"StartupWMClass": "paarrot"
}
},
"appImage": {
"license": "AGPL-3.0",
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"deb": {
"depends": [
"libnotify4",
"libxtst6",
"libnss3"
],
"afterInstall": "build-resources/linux/after-install.sh",
"afterRemove": "build-resources/linux/after-remove.sh"
},
"rpm": {
"depends": [
"libnotify",
"libXtst",
"nss"
],
"afterInstall": "build-resources/linux/after-install.sh",
"afterRemove": "build-resources/linux/after-remove.sh"
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64", "arm64"]
},
{
"target": "portable",
"arch": ["x64", "arm64"]
}
],
"icon": "icons/icon.ico",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Paarrot"
},
"mac": {
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
},
{
"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"
}
}

490
electron/main.js Normal file
View File

@@ -0,0 +1,490 @@
const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer } = require('electron');
const path = require('path');
const fs = require('fs');
const { exec } = require('child_process');
const { promisify } = require('util');
const Store = require('electron-store');
const open = require('open');
const { autoUpdater } = require('electron-updater');
const execAsync = promisify(exec);
const store = new Store();
let mainWindow = null;
let tray = null;
let isQuitting = false;
// Configure auto-updater
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Auto-updater event handlers
autoUpdater.on('checking-for-update', () => {
console.log('Checking for updates...');
});
autoUpdater.on('update-available', (info) => {
console.log('Update available:', info.version);
if (mainWindow) {
mainWindow.webContents.send('update-available', info);
}
});
autoUpdater.on('update-not-available', (info) => {
console.log('No updates available');
});
autoUpdater.on('error', (err) => {
console.error('Auto-updater error:', err);
});
autoUpdater.on('download-progress', (progressObj) => {
console.log(`Download progress: ${progressObj.percent}%`);
if (mainWindow) {
mainWindow.webContents.send('update-download-progress', progressObj);
}
});
autoUpdater.on('update-downloaded', (info) => {
console.log('Update downloaded:', info.version);
if (mainWindow) {
mainWindow.webContents.send('update-downloaded', info);
}
});
// Single instance lock
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
// Someone tried to run a second instance, focus our window
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
}
// Development mode detection
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
const VITE_DEV_SERVER = 'http://localhost:8080';
const PORT = 44548;
function createWindow() {
// Restore window state or use defaults
const windowState = store.get('windowState', {
width: 1280,
height: 905,
x: undefined,
y: undefined,
isMaximized: false
});
mainWindow = new BrowserWindow({
width: windowState.width,
height: windowState.height,
x: windowState.x,
y: windowState.y,
title: 'Paarrot',
frame: false, // Custom window decorations
resizable: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
allowRunningInsecureContent: false
},
icon: path.join(__dirname, '../icons', process.platform === 'win32' ? 'icon.ico' : 'icon.png'),
show: false // Don't show until ready
});
// Restore maximized state
if (windowState.isMaximized) {
mainWindow.maximize();
}
// Save window state on resize/move
const saveWindowState = () => {
if (!mainWindow.isMaximized() && !mainWindow.isMinimized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds();
store.set('windowState', {
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
isMaximized: false
});
}
};
mainWindow.on('resize', saveWindowState);
mainWindow.on('move', saveWindowState);
mainWindow.on('maximize', () => {
store.set('windowState.isMaximized', true);
});
mainWindow.on('unmaximize', () => {
store.set('windowState.isMaximized', false);
});
// Set up session handlers BEFORE loading the app
// Auto-approve media permissions including screen capture
mainWindow.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
const allowedPermissions = ['media', 'microphone', 'camera', 'display-capture'];
if (allowedPermissions.includes(permission)) {
console.log(`Paarrot: Auto-approving permission: ${permission}`);
callback(true);
} else {
console.log(`Paarrot: Denying permission: ${permission}`);
callback(false);
}
});
// Handle screen capture requests - always allow
mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
console.log(`Paarrot: Permission check - ${permission} from ${requestingOrigin}`);
return true; // Allow all permissions
});
// Handle display media request (screen sharing) - this is the key for Electron screen capture
mainWindow.webContents.session.setDisplayMediaRequestHandler(async (request, callback) => {
console.log('Paarrot: Display media request received', request);
try {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 150, height: 150 }
});
console.log('Paarrot: Available sources:', sources.length);
sources.forEach((s, i) => console.log(` ${i}: ${s.name} (${s.id})`));
if (sources.length === 0) {
console.log('Paarrot: No sources available, denying request');
callback({});
return;
}
// Prefer screens over windows
const source = sources.find(s => s.id.startsWith('screen:')) || sources[0];
console.log('Paarrot: Selected source:', source.name, source.id);
// Return the selected source - video is required
callback({ video: source });
} catch (error) {
console.error('Paarrot: Error getting display sources:', error);
callback({});
}
}, { useSystemPicker: false });
// Load the app
if (isDev) {
mainWindow.loadURL(VITE_DEV_SERVER);
// Open DevTools in development
mainWindow.webContents.openDevTools();
} else {
// In production, serve from built files
mainWindow.loadFile(path.join(__dirname, '../cinny/dist/index.html'));
}
// Show window when ready
mainWindow.once('ready-to-show', () => {
// Check for --minimized flag (autostart)
if (!process.argv.includes('--minimized')) {
mainWindow.show();
}
});
// Handle window close - minimize to tray
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault();
mainWindow.hide();
return false;
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
return { action: 'deny' };
}
return { action: 'allow' };
});
// Navigation handler - open external links in browser
mainWindow.webContents.on('will-navigate', (event, url) => {
const allowedOrigins = [
'http://localhost:8080',
'http://localhost:44548',
'http://127.0.0.1:8080',
'http://127.0.0.1:44548'
];
const isAllowed = allowedOrigins.some(origin => url.startsWith(origin)) ||
url.startsWith('blob:') ||
url.startsWith('data:');
if (!isAllowed && (url.startsWith('http://') || url.startsWith('https://'))) {
event.preventDefault();
shell.openExternal(url);
}
});
return mainWindow;
}
function createTray() {
const iconPath = path.join(__dirname, '../icons', 'icon.png');
const trayIcon = nativeImage.createFromPath(iconPath);
tray = new Tray(trayIcon.resize({ width: 16, height: 16 }));
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show Paarrot',
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
}
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
isQuitting = true;
app.quit();
}
}
]);
tray.setContextMenu(contextMenu);
tray.setToolTip('Paarrot');
// Click tray icon to show window
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
}
});
}
// App lifecycle
app.whenReady().then(() => {
createWindow();
createTray();
// Check for updates (not in development mode)
if (!isDev) {
// Check for updates on start (after 3 seconds)
setTimeout(() => {
autoUpdater.checkForUpdates().catch(err => {
console.error('Failed to check for updates:', err);
});
}, 3000);
// Check for updates every 6 hours
setInterval(() => {
autoUpdater.checkForUpdates().catch(err => {
console.error('Failed to check for updates:', err);
});
}, 6 * 60 * 60 * 1000);
}
});
app.on('window-all-closed', () => {
// On macOS, apps stay active until Cmd+Q
if (process.platform !== 'darwin') {
// But we want to stay in tray, so don't quit
// app.quit();
}
});
app.on('activate', () => {
// On macOS, recreate window when dock icon is clicked
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else if (mainWindow) {
mainWindow.show();
}
});
app.on('before-quit', () => {
isQuitting = true;
});
// ==================== IPC Handlers ====================
// Window controls
ipcMain.handle('window:minimize', () => {
if (mainWindow) mainWindow.minimize();
});
ipcMain.handle('window:maximize', () => {
if (mainWindow) {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
}
});
ipcMain.handle('window:unmaximize', () => {
if (mainWindow) mainWindow.unmaximize();
});
ipcMain.handle('window:close', () => {
if (mainWindow) mainWindow.close();
});
ipcMain.handle('window:is-maximized', () => {
return mainWindow ? mainWindow.isMaximized() : false;
});
ipcMain.handle('window:start-drag', () => {
if (mainWindow) mainWindow.webContents.startDrag({ file: '', icon: nativeImage.createEmpty() });
});
// Open external URL
ipcMain.handle('open-external-url', async (event, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Read clipboard image
ipcMain.handle('read-clipboard-image', async () => {
try {
const image = clipboard.readImage();
if (image.isEmpty()) {
return { success: true, data: null };
}
// Convert to PNG base64
const pngBuffer = image.toPNG();
const base64 = pngBuffer.toString('base64');
return {
success: true,
data: `data:image/png;base64,${base64}`
};
} catch (error) {
return { success: false, error: error.message };
}
});
// Get YouTube stream using yt-dlp
ipcMain.handle('get-youtube-stream', async (event, url) => {
try {
// Check if yt-dlp is available
try {
await execAsync('yt-dlp --version');
} catch {
return {
success: false,
error: 'yt-dlp is not installed. Please install yt-dlp to use YouTube features.'
};
}
// Get title
let title = 'YouTube Video';
try {
const titleResult = await execAsync(`yt-dlp --get-title "${url}"`);
title = titleResult.stdout.trim();
} catch (e) {
console.warn('Failed to get video title:', e.message);
}
// Get video URL
const result = await execAsync(
`yt-dlp -g -f "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best" "${url}"`
);
const videoUrl = result.stdout.trim().split('\n')[0];
if (!videoUrl) {
return { success: false, error: 'yt-dlp returned empty URL' };
}
return {
success: true,
data: { video_url: videoUrl, title }
};
} catch (error) {
return {
success: false,
error: `yt-dlp error: ${error.message}`
};
}
});
// Background sync stubs (desktop doesn't need it)
ipcMain.handle('start-background-sync', async () => {
return { success: true };
});
ipcMain.handle('stop-background-sync', async () => {
return { success: true };
});
ipcMain.handle('get-background-sync-state', async () => {
return { success: true, data: 'NotApplicable' };
});
// Auto-updater IPC handlers
ipcMain.handle('check-for-updates', async () => {
try {
const result = await autoUpdater.checkForUpdates();
return { success: true, data: result };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('download-update', async () => {
try {
await autoUpdater.downloadUpdate();
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall(false, true);
return { success: true };
});
// Screen capture / desktopCapturer handler
ipcMain.handle('get-desktop-sources', async (event, opts) => {
try {
const sources = await desktopCapturer.getSources(opts);
return { success: true, data: sources };
} catch (error) {
console.error('Failed to get desktop sources:', error);
return { success: false, error: error.message };
}
});
console.log('Paarrot Electron main process started');
console.log('Development mode:', isDev);
console.log('Platform:', process.platform);

143
electron/preload.js Normal file
View File

@@ -0,0 +1,143 @@
const { contextBridge, ipcRenderer } = require('electron');
// Map command names to Electron IPC channels
const commandMap = {
'window_minimize': 'window:minimize',
'window_maximize': 'window:maximize',
'window_unmaximize': 'window:unmaximize',
'window_close': 'window:close',
'window_is_maximized': 'window:is-maximized',
'window_start_drag': 'window:start-drag',
'read_clipboard_image': 'read-clipboard-image',
'open_external_url': 'open-external-url',
'get_youtube_stream': 'get-youtube-stream',
'start_background_sync': 'start-background-sync',
'stop_background_sync': 'stop-background-sync',
'get_background_sync_state': 'get-background-sync-state',
'check_for_updates': 'check-for-updates',
'download_update': 'download-update',
'install_update': 'install-update',
'get_desktop_sources': 'get-desktop-sources'
};
// Shared invoke function for legacy API compatibility
const invokeHandler = async (command, args = {}) => {
try {
const channel = commandMap[command] || command;
const result = await ipcRenderer.invoke(channel, args);
// Return response in expected format
if (result && result.success === false) {
throw new Error(result.error || 'Unknown error');
}
// For commands that return data in a 'data' field
if (result && result.data !== undefined) {
return result.data;
}
// For simple commands that just return success/failure
return result;
} catch (error) {
console.error(`[Electron IPC] Error invoking ${command}:`, error);
throw error;
}
};
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('__TAURI_INTERNALS__', {
// Legacy API compatibility layer for existing frontend code
metadata: {
currentVersion: {
version: '2.0.0',
platform: process.platform,
arch: process.arch
}
},
// Add invoke function for legacy plugins
invoke: invokeHandler,
// Add stub for transformCallback to prevent errors with legacy plugins
transformCallback: (callback) => {
return callback;
}
});
contextBridge.exposeInMainWorld('__TAURI__', {
// Core IPC invoke - legacy API
invoke: invokeHandler,
// Event listeners (if needed for future events)
listen: (event, handler) => {
const subscription = (_, ...args) => handler(...args);
ipcRenderer.on(event, subscription);
// Return unsubscribe function
return () => {
ipcRenderer.removeListener(event, subscription);
};
}
});
// Also expose under electron namespace for direct Electron APIs
contextBridge.exposeInMainWorld('electron', {
platform: process.platform,
arch: process.arch,
versions: process.versions,
// Window controls
window: {
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
unmaximize: () => ipcRenderer.invoke('window:unmaximize'),
close: () => ipcRenderer.invoke('window:close'),
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
startDrag: () => ipcRenderer.invoke('window:start-drag')
},
// Clipboard
clipboard: {
readImage: () => ipcRenderer.invoke('read-clipboard-image')
},
// External URLs
shell: {
openExternal: (url) => ipcRenderer.invoke('open-external-url', url)
},
// YouTube
youtube: {
getStream: (url) => ipcRenderer.invoke('get-youtube-stream', url)
},
// Background sync (stubs for desktop)
sync: {
start: (args) => ipcRenderer.invoke('start-background-sync', args),
stop: () => ipcRenderer.invoke('stop-background-sync'),
getState: () => ipcRenderer.invoke('get-background-sync-state')
},
// Auto-updater
updater: {
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (_, info) => callback(info));
},
onUpdateDownloadProgress: (callback) => {
ipcRenderer.on('update-download-progress', (_, progress) => callback(progress));
},
onUpdateDownloaded: (callback) => {
ipcRenderer.on('update-downloaded', (_, info) => callback(info));
}
},
// Desktop capturer (for screen sharing)
desktopCapturer: {
getSources: (opts) => ipcRenderer.invoke('get-desktop-sources', opts)
}
});
// Preload script ready

View File

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

View File

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 295 KiB

5725
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,26 +2,36 @@
"name": "paarrot", "name": "paarrot",
"version": "4.10.2", "version": "4.10.2",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "electron/main.js",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"scripts": { "scripts": {
"tauri": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && tauri", "dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
"release": "node scripts/release.mjs" "dev:vite": "cd cinny && npm start",
"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",
"build:linux": "npm run build -- --linux",
"build:win": "npm run build -- --win",
"build:mac": "npm run build -- --mac"
}, },
"keywords": [], "keywords": [],
"author": "Ajay Bura", "author": "Ajay Bura",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "electron-store": "^8.2.0",
"@tauri-apps/plugin-http": "2.5.7" "electron-updater": "^6.3.9",
"open": "11.0.0"
}, },
"devDependencies": { "devDependencies": {
"@actions/github": "6.0.0", "@actions/github": "6.0.0",
"@tauri-apps/cli": "^2.0.0", "@electron/rebuild": "4.0.3",
"concurrently": "9.2.1",
"electron": "40.6.0",
"electron-builder": "26.8.1",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"png2icons": "2.0.1", "png2icons": "2.0.1",
"sharp": "0.34.5" "sharp": "0.34.5",
"wait-on": "9.0.4"
} }
} }

View File

@@ -2,51 +2,33 @@ import sharp from 'sharp';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import png2icons from 'png2icons';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..'); const rootDir = path.resolve(__dirname, '..');
const sourceIcon = path.join(rootDir, 'appicon-square.png'); const sourceIcon = path.join(rootDir, 'appicon-square.png');
// Tauri desktop icons // Electron desktop icons
const tauriIcons = [ const electronIcons = [
{ name: '16x16.png', size: 16 },
{ name: '24x24.png', size: 24 },
{ name: '32x32.png', size: 32 }, { name: '32x32.png', size: 32 },
{ name: '48x48.png', size: 48 },
{ name: '64x64.png', size: 64 },
{ name: '128x128.png', size: 128 }, { name: '128x128.png', size: 128 },
{ name: '128x128@2x.png', size: 256 }, { name: '256x256.png', size: 256 },
{ name: '512x512.png', size: 512 },
{ name: '1024x1024.png', size: 1024 },
{ name: 'icon.png', size: 512 }, { name: 'icon.png', size: 512 },
{ name: 'Square30x30Logo.png', size: 30 },
{ name: 'Square44x44Logo.png', size: 44 },
{ name: 'Square71x71Logo.png', size: 71 },
{ name: 'Square89x89Logo.png', size: 89 },
{ name: 'Square107x107Logo.png', size: 107 },
{ name: 'Square142x142Logo.png', size: 142 },
{ name: 'Square150x150Logo.png', size: 150 },
{ name: 'Square284x284Logo.png', size: 284 },
{ name: 'Square310x310Logo.png', size: 310 },
{ name: 'StoreLogo.png', size: 50 },
]; ];
// Android mipmap sizes async function generateElectronIcons() {
const androidMipmaps = [ const iconsDir = path.join(rootDir, 'icons');
{ folder: 'mipmap-mdpi', size: 48 },
{ folder: 'mipmap-hdpi', size: 72 },
{ folder: 'mipmap-xhdpi', size: 96 },
{ folder: 'mipmap-xxhdpi', size: 144 },
{ folder: 'mipmap-xxxhdpi', size: 192 },
];
// Android adaptive icon foreground sizes (with padding for safe zone)
const androidForegroundSizes = [
{ folder: 'mipmap-mdpi', size: 108 },
{ folder: 'mipmap-hdpi', size: 162 },
{ folder: 'mipmap-xhdpi', size: 216 },
{ folder: 'mipmap-xxhdpi', size: 324 },
{ folder: 'mipmap-xxxhdpi', size: 432 },
];
async function generateTauriIcons() {
const iconsDir = path.join(rootDir, 'src-tauri', 'icons');
for (const icon of tauriIcons) { // Ensure icons directory exists
await fs.mkdir(iconsDir, { recursive: true });
for (const icon of electronIcons) {
const outputPath = path.join(iconsDir, icon.name); const outputPath = path.join(iconsDir, icon.name);
await sharp(sourceIcon) await sharp(sourceIcon)
.resize(icon.size, icon.size) .resize(icon.size, icon.size)
@@ -56,97 +38,26 @@ async function generateTauriIcons() {
} }
// Generate ICO file (Windows) // Generate ICO file (Windows)
const icoPath = path.join(iconsDir, 'icon.ico'); const sourceBuffer = await fs.readFile(sourceIcon);
const sizes = [16, 24, 32, 48, 64, 128, 256]; const icoBuffer = png2icons.createICO(sourceBuffer, png2icons.BEZIER, 0, true, true);
const icoBuffers = await Promise.all( await fs.writeFile(path.join(iconsDir, 'icon.ico'), icoBuffer);
sizes.map(size => console.log('Generated: icon.ico');
sharp(sourceIcon)
.resize(size, size)
.png()
.toBuffer()
)
);
// For ICO, we'll just use the 256x256 as the main icon
await sharp(sourceIcon)
.resize(256, 256)
.png()
.toFile(icoPath.replace('.ico', '.ico.png'));
// Copy as ico (sharp doesn't support ico directly, need to use png2ico or similar)
console.log('Note: ICO file needs manual conversion or use png2ico tool');
// Generate ICNS file (macOS) - sharp doesn't support icns directly
console.log('Note: ICNS file needs manual conversion or use iconutil on macOS');
}
async function generateAndroidIcons() { // Generate ICNS file (macOS)
const androidResDir = path.join(rootDir, 'src-tauri', 'gen', 'android', 'app', 'src', 'main', 'res'); const icnsBuffer = png2icons.createICNS(sourceBuffer, png2icons.BEZIER, 0);
await fs.writeFile(path.join(iconsDir, 'icon.icns'), icnsBuffer);
for (const mipmap of androidMipmaps) { console.log('Generated: icon.icns');
const outputDir = path.join(androidResDir, mipmap.folder);
// Standard launcher icon
await sharp(sourceIcon)
.resize(mipmap.size, mipmap.size)
.png()
.toFile(path.join(outputDir, 'ic_launcher.png'));
console.log(`Generated: ${mipmap.folder}/ic_launcher.png`);
// Round launcher icon
const roundSize = mipmap.size;
const roundIcon = await sharp(sourceIcon)
.resize(roundSize, roundSize)
.png()
.toBuffer();
// Create circular mask
const circleMask = Buffer.from(
`<svg><circle cx="${roundSize/2}" cy="${roundSize/2}" r="${roundSize/2}" fill="white"/></svg>`
);
await sharp(roundIcon)
.composite([{
input: circleMask,
blend: 'dest-in'
}])
.png()
.toFile(path.join(outputDir, 'ic_launcher_round.png'));
console.log(`Generated: ${mipmap.folder}/ic_launcher_round.png`);
}
// Generate adaptive icon foregrounds
for (const fg of androidForegroundSizes) {
const outputDir = path.join(androidResDir, fg.folder);
const iconSize = Math.floor(fg.size * 0.66); // Icon should be ~66% of the foreground
const padding = Math.floor((fg.size - iconSize) / 2);
await sharp(sourceIcon)
.resize(iconSize, iconSize)
.extend({
top: padding,
bottom: fg.size - iconSize - padding,
left: padding,
right: fg.size - iconSize - padding,
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toFile(path.join(outputDir, 'ic_launcher_foreground.png'));
console.log(`Generated: ${fg.folder}/ic_launcher_foreground.png`);
}
} }
async function main() { async function main() {
console.log('Generating icons from:', sourceIcon); console.log('Generating icons from:', sourceIcon);
console.log(''); console.log('');
console.log('=== Tauri Desktop Icons ==='); console.log('=== Electron Desktop Icons ===');
await generateTauriIcons(); await generateElectronIcons();
console.log(''); console.log('');
console.log('=== Android Icons ==='); console.log('Done!');
await generateAndroidIcons();
console.log('');
console.log('Done! Note: ICO and ICNS files may need manual conversion.');
} }
main().catch(console.error); main().catch(console.error);

View File

@@ -1,108 +0,0 @@
import fetch from "node-fetch";
import { getOctokit, context } from "@actions/github";
async function getAssetSign(url) {
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/octet-stream",
},
});
return response.text();
}
async function createTauriRelease() {
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error("GITHUB_TOKEN is not found!");
}
const github = getOctokit(process.env.GITHUB_TOKEN);
const { repos } = github.rest;
const repoMetaData = {
owner: context.repo.owner,
repo: context.repo.repo,
};
const tagsResult = await repos.listTags({ ...repoMetaData, per_page: 10, page: 1 });
const latestTag = tagsResult.data.find((tag) => tag.name.startsWith("v"));
console.log(latestTag);
const latestRelease = await repos.getReleaseByTag({ ...repoMetaData, tag: latestTag.name });
const latestAssets = latestRelease.data.assets;
const windowsX86_64 = {};
const linuxX86_64 = {};
const darwinX86_64 = {};
const darwinAarch64 = {};
const promises = latestAssets.map(async (asset) => {
const { name, browser_download_url } = asset;
if (/\.msi\.zip$/.test(name)) {
windowsX86_64.url = browser_download_url;
}
if (/\.msi\.zip\.sig$/.test(name)) {
windowsX86_64.signature = await getAssetSign(browser_download_url);
}
if (/\.AppImage\.tar\.gz$/.test(name)) {
linuxX86_64.url = browser_download_url;
}
if (/\.AppImage\.tar\.gz\.sig$/.test(name)) {
linuxX86_64.signature = await getAssetSign(browser_download_url);
}
if (/universal\.app\.tar\.gz$/.test(name)) {
darwinX86_64.url = browser_download_url;
}
if (/universal\.app\.tar\.gz\.sig$/.test(name)) {
darwinX86_64.signature = await getAssetSign(browser_download_url);
}
if (/universal\.app\.tar\.gz$/.test(name)) {
darwinAarch64.url = browser_download_url;
}
if (/universal\.app\.tar\.gz\.sig$/.test(name)) {
darwinAarch64.signature = await getAssetSign(browser_download_url);
}
});
await Promise.allSettled(promises);
const releaseData = {
name: latestTag.name,
notes: `https://github.com/${repoMetaData.owner}/${repoMetaData.repo}/releases/tag/${latestTag.name}`,
pub_date: new Date().toISOString(),
platforms: {},
};
if (windowsX86_64.url) releaseData.platforms["windows-x86_64"] = windowsX86_64;
else console.error('Failed to get release for windowsX86_64');
if (linuxX86_64.url) releaseData.platforms["linux-x86_64"] = linuxX86_64;
else console.error('Failed to get release for linuxX86_64');
if (darwinX86_64.url) releaseData.platforms["darwin-x86_64"] = darwinX86_64;
else console.error('Failed to get release for darwinX86_64');
if (darwinAarch64.url) releaseData.platforms["darwin-aarch64"] = darwinAarch64;
else console.error('Failed to get release for darwinAarch64');
const releaseResult = await repos.getReleaseByTag({ ...repoMetaData, tag: 'tauri' });
const tauriRelease = releaseResult.data;
const prevReleaseAsset = tauriRelease.assets.find((asset) => asset.name === 'release.json');
if (prevReleaseAsset) {
await repos.deleteReleaseAsset({ ...repoMetaData, asset_id: prevReleaseAsset.id });
}
console.log(releaseData);
await repos.uploadReleaseAsset({
...repoMetaData,
release_id: tauriRelease.id,
name: 'release.json',
data: JSON.stringify(releaseData, null, 2),
});
}
createTauriRelease();

View File

@@ -1,9 +0,0 @@
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

View File

@@ -1,6 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
WixTools
.tauri-private.key

View File

@@ -1 +0,0 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDExREU1NkVBMDY4MzcxMjAKUldRZ2NZTUc2bGJlRVNaQldnMXpJcHlocVpCWUNNaUdicjBGMVRsck1GQXhvTnJiOUNhVVRHSzQK

8276
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +0,0 @@
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[package]
name = "paarrot"
version = "4.10.2"
description = "Yet another matrix client"
authors = ["Ajay Bura"]
license = "AGPL-3.0-only"
repository = "https://github.com/cinnyapp/cinny-desktop"
default-run = "paarrot"
edition = "2021"
rust-version = "1.70"
[lib]
name = "paarrot_lib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-notification = "2"
log = "0.4"
tauri-plugin-http = "2"
[target."cfg(target_os = \"linux\")".dependencies]
arboard = { version = "3", features = ["wayland-data-control"] }
png = "0.17"
base64 = "0.22"
webkit2gtk = "2.0"
gtk = "0.18"
[target."cfg(any(target_os = \"android\", target_os = \"ios\"))".dependencies]
tauri-plugin-deep-link = "2"
# Matrix SDK for native background sync (no sqlite on mobile - use in-memory store)
matrix-sdk = { version = "0.7", default-features = false, features = ["rustls-tls", "e2e-encryption"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-localhost = "2"
tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
tauri-plugin-process = "2"
open = "5"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[dependencies.ppv-lite86]
version = "=0.2.20"

View File

@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}

View File

@@ -1,29 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for all windows",
"windows": ["*"],
"platforms": ["linux", "windows", "macOS"],
"permissions": [
"core:default",
"core:webview:allow-internal-toggle-devtools",
"opener:default",
"opener:allow-open-url",
"opener:allow-default-urls",
"notification:default",
"notification:allow-register-listener",
"autostart:default",
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-download-and-install",
"dialog:default",
"process:allow-restart",
{
"identifier": "http:default",
"allow": [
{ "url": "https://api.telegram.org/**" }
]
}
]
}

View File

@@ -1,13 +0,0 @@
{
"$schema": "../gen/schemas/mobile-schema.json",
"identifier": "mobile",
"description": "Default capability for mobile",
"windows": ["*"],
"platforms": ["android", "iOS"],
"permissions": [
"core:default",
"notification:default",
"deep-link:default",
"opener:default"
]
}

View File

@@ -1,12 +0,0 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

View File

@@ -1,19 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
/.tauri
/tauri.settings.gradle

View File

@@ -1,6 +0,0 @@
/src/main/java/wtf/ruv/paarrot/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties

View File

@@ -1,73 +0,0 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
namespace = "wtf.ruv.paarrot"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "wtf.ruv.paarrot"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
ndk {
abiFilters += listOf("arm64-v8a")
}
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,73 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Audio/Video call permissions -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Hardware features for calls (optional) -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.paarrot"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Background sync service -->
<service
android:name=".SyncService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Boot receiver to restart service -->
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -1,22 +0,0 @@
package wtf.ruv.paarrot
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
/**
* Receives BOOT_COMPLETED broadcast to restart the sync service after device reboot.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
val serviceIntent = Intent(context, SyncService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
}
}
}

View File

@@ -1,73 +0,0 @@
package wtf.ruv.paarrot
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
companion object {
private const val TAG = "PaarrotMain"
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// Start the foreground sync service to keep the app alive for notifications
startSyncService()
// Request battery optimization exemption for reliable background operation
requestBatteryOptimizationExemption()
}
override fun onResume() {
super.onResume()
// Ensure service is running when app comes to foreground
startSyncService()
}
private fun startSyncService() {
try {
val serviceIntent = Intent(this, SyncService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
Log.d(TAG, "SyncService started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start SyncService", e)
}
}
private fun requestBatteryOptimizationExemption() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val powerManager = getSystemService(POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
Log.d(TAG, "Requesting battery optimization exemption")
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to request battery optimization exemption", e)
// Try opening battery settings instead
try {
val settingsIntent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(settingsIntent)
} catch (e2: Exception) {
Log.e(TAG, "Failed to open battery settings", e2)
}
}
} else {
Log.d(TAG, "Already exempt from battery optimization")
}
}
}
}

View File

@@ -1,278 +0,0 @@
package wtf.ruv.paarrot
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
/**
* Foreground service that keeps the app alive for background sync.
* This allows Matrix sync to continue even when the app is in the background.
*/
class SyncService : Service() {
companion object {
private const val TAG = "PaarrotSync"
private const val NOTIFICATION_ID = 1
private const val CHANNEL_ID = "paarrot_sync_channel"
private const val CHANNEL_NAME = "Background Sync"
private const val WAKE_LOCK_TAG = "Paarrot:SyncWakeLock"
private const val ALARM_REQUEST_CODE = 1001
private const val KEEP_ALIVE_INTERVAL_MS = 4 * 60 * 1000L // 4 minutes (before 5 min Doze threshold)
private const val WAKE_LOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes max wake lock
}
private var wakeLock: PowerManager.WakeLock? = null
private val handler = Handler(Looper.getMainLooper())
private var isRunning = false
private val keepAliveRunnable = object : Runnable {
override fun run() {
if (!isRunning) return
Log.d(TAG, "Keep-alive tick - refreshing wake lock")
ensureWakeLock()
refreshNotification()
handler.postDelayed(this, KEEP_ALIVE_INTERVAL_MS)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
Log.d(TAG, "SyncService created")
isRunning = true
createNotificationChannel()
acquireWakeLock()
scheduleKeepAlive()
requestBatteryOptimizationExemption()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "SyncService onStartCommand")
startForeground(NOTIFICATION_ID, createNotification())
ensureWakeLock()
// Return REDELIVER_INTENT so the system restarts us with the last intent
return START_REDELIVER_INTENT
}
override fun onDestroy() {
Log.d(TAG, "SyncService being destroyed - scheduling restart")
isRunning = false
handler.removeCallbacks(keepAliveRunnable)
// Schedule immediate restart
scheduleRestart()
releaseWakeLock()
super.onDestroy()
}
override fun onTaskRemoved(rootIntent: Intent?) {
Log.d(TAG, "Task removed - scheduling restart")
scheduleRestart()
super.onTaskRemoved(rootIntent)
}
override fun onLowMemory() {
Log.w(TAG, "Low memory warning")
super.onLowMemory()
}
override fun onTrimMemory(level: Int) {
Log.d(TAG, "Trim memory level: $level")
super.onTrimMemory(level)
}
private fun scheduleRestart() {
// Try to restart the service immediately
val restartIntent = Intent(applicationContext, SyncService::class.java)
restartIntent.setPackage(packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
startForegroundService(restartIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to restart service directly", e)
// Fall back to alarm
scheduleAlarmRestart()
}
} else {
startService(restartIntent)
}
}
private fun scheduleAlarmRestart() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, SyncService::class.java)
val pendingIntent = PendingIntent.getService(
this,
ALARM_REQUEST_CODE + 1,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Schedule restart in 1 second
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + 1000,
pendingIntent
)
} else {
alarmManager.setExact(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + 1000,
pendingIntent
)
}
}
private fun requestBatteryOptimizationExemption() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
Log.d(TAG, "Requesting battery optimization exemption")
// We can't directly request, but the app should prompt the user
// This is just a note that we need the exemption
}
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keeps Paarrot connected for message notifications"
setShowBadge(false)
enableLights(false)
enableVibration(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
private fun createNotification(): android.app.Notification {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Paarrot")
.setContentText("Connected to Matrix")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
}
private fun refreshNotification() {
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID, createNotification())
}
private fun scheduleKeepAlive() {
handler.postDelayed(keepAliveRunnable, KEEP_ALIVE_INTERVAL_MS)
scheduleAlarm()
}
private fun scheduleAlarm() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, SyncService::class.java)
val pendingIntent = PendingIntent.getService(
this,
ALARM_REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Use setExactAndAllowWhileIdle for better Doze mode support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + KEEP_ALIVE_INTERVAL_MS,
pendingIntent
)
} else {
alarmManager.setExact(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + KEEP_ALIVE_INTERVAL_MS,
pendingIntent
)
}
}
private fun cancelAlarm() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, SyncService::class.java)
val pendingIntent = PendingIntent.getService(
this,
ALARM_REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
}
private fun acquireWakeLock() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
WAKE_LOCK_TAG
).apply {
// Acquire with timeout to prevent battery drain if something goes wrong
acquire(WAKE_LOCK_TIMEOUT_MS)
}
Log.d(TAG, "Wake lock acquired")
}
private fun ensureWakeLock() {
wakeLock?.let {
if (!it.isHeld) {
Log.d(TAG, "Wake lock was released, re-acquiring")
it.acquire(WAKE_LOCK_TIMEOUT_MS)
}
} ?: run {
Log.d(TAG, "Wake lock was null, creating new one")
acquireWakeLock()
}
// Re-schedule alarm each time we refresh
scheduleAlarm()
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) {
it.release()
Log.d(TAG, "Wake lock released")
}
}
wakeLock = null
}
}

View File

@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,6 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.paarrot" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -1,4 +0,0 @@
<resources>
<string name="app_name">Paarrot</string>
<string name="main_activity_title">Paarrot</string>
</resources>

View File

@@ -1,6 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.paarrot" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -1,22 +0,0 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}

View File

@@ -1,23 +0,0 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}

View File

@@ -1,68 +0,0 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """npm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}

View File

@@ -1,85 +0,0 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}

View File

@@ -1,24 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false

View File

@@ -1,6 +0,0 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View File

@@ -1,185 +0,0 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

View File

@@ -1,89 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1,3 +0,0 @@
include ':app'
apply from: 'tauri.settings.gradle'

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capability for all windows","local":true,"windows":["*"],"permissions":["core:default","core:webview:allow-internal-toggle-devtools","opener:default","opener:allow-open-url","opener:allow-default-urls","notification:default","notification:allow-register-listener","autostart:default","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","dialog:default","process:allow-restart",{"identifier":"http:default","allow":[{"url":"https://api.telegram.org/**"}]}],"platforms":["linux","windows","macOS"]},"mobile":{"identifier":"mobile","description":"Default capability for mobile","local":true,"windows":["*"],"permissions":["core:default","notification:default","deep-link:default","opener:default"],"platforms":["android","iOS"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,133 +0,0 @@
//! Background sync module for Matrix client
//!
//! This module provides native Rust-based Matrix sync that runs independently
//! of the WebView, allowing notifications to work even when the app is backgrounded.
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
/// Credentials needed to connect to Matrix homeserver
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixCredentials {
pub homeserver_url: String,
pub user_id: String,
pub access_token: String,
pub device_id: String,
}
/// State of the background sync
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncState {
Stopped,
Starting,
Running,
Error(String),
}
/// Background sync manager that handles Matrix sync in native Rust
pub struct BackgroundSyncManager {
credentials: RwLock<Option<MatrixCredentials>>,
sync_state: RwLock<SyncState>,
stop_flag: Mutex<bool>,
}
impl BackgroundSyncManager {
/// Creates a new BackgroundSyncManager
pub fn new() -> Self {
Self {
credentials: RwLock::new(None),
sync_state: RwLock::new(SyncState::Stopped),
stop_flag: Mutex::new(false),
}
}
/// Sets the Matrix credentials for syncing
pub async fn set_credentials(&self, credentials: MatrixCredentials) {
let mut creds = self.credentials.write().await;
*creds = Some(credentials);
}
/// Clears the stored credentials
pub async fn clear_credentials(&self) {
let mut creds = self.credentials.write().await;
*creds = None;
}
/// Gets the current sync state
pub async fn get_state(&self) -> SyncState {
self.sync_state.read().await.clone()
}
/// Starts the background sync
pub async fn start_sync(&self) -> Result<(), String> {
// Check if we have credentials
let creds = self.credentials.read().await;
let credentials = creds.as_ref().ok_or("No credentials set")?;
// Update state
{
let mut state = self.sync_state.write().await;
*state = SyncState::Starting;
}
// Reset stop flag
{
let mut stop = self.stop_flag.lock().await;
*stop = false;
}
log::info!(
"Background sync starting for user: {}",
credentials.user_id
);
// Update state to running
{
let mut state = self.sync_state.write().await;
*state = SyncState::Running;
}
Ok(())
}
/// Stops the background sync
pub async fn stop_sync(&self) {
{
let mut stop = self.stop_flag.lock().await;
*stop = true;
}
let mut state = self.sync_state.write().await;
*state = SyncState::Stopped;
log::info!("Background sync stopped");
}
/// Checks if sync should stop
pub async fn should_stop(&self) -> bool {
*self.stop_flag.lock().await
}
/// Sets the sync state to an error
pub async fn set_error(&self, error: String) {
let mut state = self.sync_state.write().await;
*state = SyncState::Error(error);
}
}
impl Default for BackgroundSyncManager {
fn default() -> Self {
Self::new()
}
}
/// Global instance of the background sync manager
static SYNC_MANAGER: std::sync::OnceLock<Arc<BackgroundSyncManager>> = std::sync::OnceLock::new();
/// Gets or creates the global sync manager instance
pub fn get_sync_manager() -> Arc<BackgroundSyncManager> {
SYNC_MANAGER
.get_or_init(|| Arc::new(BackgroundSyncManager::new()))
.clone()
}

View File

@@ -1,475 +0,0 @@
//! Paarrot Desktop library for cross-platform builds including Android
#[cfg(mobile)]
mod mobile;
#[cfg(any(target_os = "android", target_os = "ios"))]
mod background_sync;
#[cfg(any(target_os = "android", target_os = "ios"))]
mod matrix_sync;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use tauri::{
Manager,
WebviewUrl,
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
webview::WebviewWindowBuilder,
WindowEvent,
};
// Linux: Import for permission handling
#[cfg(target_os = "linux")]
use webkit2gtk::{PermissionRequestExt, WebViewExt};
#[cfg(target_os = "linux")]
use gtk::prelude::*;
/// Read image from clipboard on Linux using arboard with Wayland support
#[cfg(target_os = "linux")]
#[tauri::command]
fn read_clipboard_image() -> Result<Option<String>, String> {
use arboard::Clipboard;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
match clipboard.get_image() {
Ok(img) => {
// Convert RGBA image data to PNG
let width = img.width as u32;
let height = img.height as u32;
let mut png_data = Vec::new();
{
let mut encoder = png::Encoder::new(&mut png_data, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
writer.write_image_data(&img.bytes).map_err(|e| e.to_string())?;
}
let base64_data = BASE64.encode(&png_data);
Ok(Some(format!("data:image/png;base64,{}", base64_data)))
}
Err(_) => Ok(None), // No image in clipboard
}
}
/// Stub for non-Linux platforms - returns None
#[cfg(not(target_os = "linux"))]
#[tauri::command]
fn read_clipboard_image() -> Result<Option<String>, String> {
Ok(None)
}
/// Open a URL in the default browser (bypasses ACL issues with localhost plugin)
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
fn open_external_url(url: String) -> Result<(), String> {
open::that(&url).map_err(|e| e.to_string())
}
/// Open a URL in the default browser on mobile platforms
#[cfg(any(target_os = "android", target_os = "ios"))]
#[tauri::command]
fn open_external_url(url: String) -> Result<(), String> {
tauri_plugin_opener::open_url(&url, None::<&str>).map_err(|e| e.to_string())
}
/// YouTube stream info returned by yt-dlp
#[derive(serde::Serialize)]
struct YouTubeStreamInfo {
/// Direct video stream URL (may include video+audio or video only)
video_url: String,
/// Title of the video
title: String,
}
/// Extract direct YouTube stream URL using yt-dlp
/// Requires yt-dlp to be installed and available in PATH
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn get_youtube_stream(url: String) -> Result<YouTubeStreamInfo, String> {
use std::process::Command;
// First get the title
let title_output = Command::new("yt-dlp")
.args(["--get-title", &url])
.output()
.map_err(|e| format!("Failed to run yt-dlp (is it installed?): {}", e))?;
let title = if title_output.status.success() {
String::from_utf8_lossy(&title_output.stdout).trim().to_string()
} else {
"YouTube Video".to_string()
};
// Get the best format with video+audio combined (up to 1080p)
// -f "best[height<=1080]" gets combined format
// Fallback to "bestvideo[height<=1080]+bestaudio/best" for separate streams
let output = Command::new("yt-dlp")
.args([
"-g", // Get URL only
"-f", "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
&url
])
.output()
.map_err(|e| format!("Failed to run yt-dlp: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("yt-dlp error: {}", stderr));
}
let video_url = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if video_url.is_empty() {
return Err("yt-dlp returned empty URL".to_string());
}
Ok(YouTubeStreamInfo { video_url, title })
}
/// Stub for mobile platforms - YouTube streaming not supported
#[cfg(any(target_os = "android", target_os = "ios"))]
#[tauri::command]
async fn get_youtube_stream(_url: String) -> Result<YouTubeStreamInfo, String> {
Err("YouTube streaming not supported on mobile".to_string())
}
/// Start background Matrix sync with the given credentials (mobile only)
#[cfg(any(target_os = "android", target_os = "ios"))]
#[tauri::command]
async fn start_background_sync(
app_handle: tauri::AppHandle,
homeserver_url: String,
user_id: String,
access_token: String,
device_id: String,
) -> Result<(), String> {
use crate::background_sync::{MatrixCredentials, get_sync_manager};
use crate::matrix_sync::{init_client, run_sync_loop};
let credentials = MatrixCredentials {
homeserver_url,
user_id,
access_token,
device_id,
};
// Set credentials
let manager = get_sync_manager();
manager.set_credentials(credentials.clone()).await;
// Initialize the Matrix client
init_client(&credentials).await?;
// Start sync in background task
let app = app_handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = run_sync_loop(app).await {
log::error!("Sync loop error: {}", e);
}
});
Ok(())
}
/// Stop background Matrix sync (mobile only)
#[cfg(any(target_os = "android", target_os = "ios"))]
#[tauri::command]
async fn stop_background_sync() -> Result<(), String> {
crate::matrix_sync::stop_sync().await;
Ok(())
}
/// Get background sync state (mobile only)
#[cfg(any(target_os = "android", target_os = "ios"))]
#[tauri::command]
async fn get_background_sync_state() -> Result<String, String> {
use crate::background_sync::get_sync_manager;
let manager = get_sync_manager();
let state = manager.get_state().await;
Ok(format!("{:?}", state))
}
/// Stub commands for desktop (no-op since background sync is mobile-only)
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn start_background_sync(
_app_handle: tauri::AppHandle,
_homeserver_url: String,
_user_id: String,
_access_token: String,
_device_id: String,
) -> Result<(), String> {
Ok(()) // Desktop doesn't need background sync
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn stop_background_sync() -> Result<(), String> {
Ok(())
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn get_background_sync_state() -> Result<String, String> {
Ok("NotApplicable".to_string())
}
/// Window control commands for custom decorations
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn window_minimize(window: tauri::Window) -> Result<(), String> {
window.minimize().map_err(|e| e.to_string())
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn window_maximize(window: tauri::Window) -> Result<(), String> {
window.maximize().map_err(|e| e.to_string())
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn window_unmaximize(window: tauri::Window) -> Result<(), String> {
window.unmaximize().map_err(|e| e.to_string())
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn window_close(window: tauri::Window) -> Result<(), String> {
window.close().map_err(|e| e.to_string())
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn window_is_maximized(window: tauri::Window) -> Result<bool, String> {
window.is_maximized().map_err(|e| e.to_string())
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[tauri::command]
async fn window_start_drag(window: tauri::Window) -> Result<(), String> {
window.start_dragging().map_err(|e| e.to_string())
}
/// Runs the Tauri application
pub fn run() {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
let port = 44548;
tauri::Builder::default()
.plugin(tauri_plugin_localhost::Builder::new(port).build())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// When a second instance tries to launch, focus the existing window
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--minimized"]),
))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_http::init())
.setup(move |app| {
// In dev mode, use Vite dev server; in production, use localhost plugin
let url: tauri::Url = if cfg!(dev) {
"http://localhost:8080".parse().unwrap()
} else {
format!("http://localhost:{}", port).parse().unwrap()
};
// Create the main window manually with navigation handler for external links
let window = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
.title("Paarrot")
.inner_size(1280.0, 905.0)
.center()
.resizable(true)
.decorations(false)
.disable_drag_drop_handler()
.on_navigation(|url| {
let url_str = url.as_str();
// Allow navigation to localhost (our app) and special protocols
if url_str.starts_with("http://localhost")
|| url_str.starts_with("https://localhost")
|| url_str.starts_with("tauri://")
|| url_str.starts_with("blob:")
|| url_str.starts_with("data:")
{
return true;
}
// Block external URLs - open them in default browser
if url_str.starts_with("http://") || url_str.starts_with("https://") {
let _ = tauri_plugin_opener::open_url(url_str, None::<&str>);
return false;
}
true
})
.build()?;
// Explicitly ensure decorations are disabled (important for Linux)
window.set_decorations(false)?;
// Linux: Set up permission handler to auto-allow microphone/camera access
#[cfg(target_os = "linux")]
{
if let Some(webview_window) = app.get_webview_window("main") {
let _ = webview_window.with_webview(|webview| {
use webkit2gtk::UserMediaPermissionRequestExt;
let wv = webview.inner();
wv.connect_permission_request(|_webview, permission_request| {
// Check if this is a user media (microphone/camera) permission request
if let Some(user_media_request) = permission_request.downcast_ref::<webkit2gtk::UserMediaPermissionRequest>() {
// Log what's being requested
let is_audio = user_media_request.is_for_audio_device();
let is_video = user_media_request.is_for_video_device();
eprintln!("Paarrot: Media permission request - audio: {}, video: {}", is_audio, is_video);
// Allow the request
permission_request.allow();
return true;
}
// For other permission types, allow by default
permission_request.allow();
true
});
});
}
}
// Create system tray
let show_item = MenuItem::with_id(app, "show", "Show Paarrot", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
// Force decorations off one final time after all plugins have initialized
// This ensures window-state plugin doesn't override our settings
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_decorations(false);
}
Ok(())
})
.on_window_event(|window, event| {
// Minimize to tray on close instead of quitting
if let WindowEvent::CloseRequested { api, .. } = event {
window.hide().unwrap();
api.prevent_close();
}
})
.invoke_handler(tauri::generate_handler![
read_clipboard_image,
open_external_url,
get_youtube_stream,
start_background_sync,
stop_background_sync,
get_background_sync_state,
window_minimize,
window_maximize,
window_unmaximize,
window_close,
window_is_maximized,
window_start_drag
])
.run(tauri::generate_context!())
.expect("error while building tauri application");
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
use tauri::{WebviewUrl, webview::WebviewWindowBuilder};
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
// Create the main window for mobile with navigation handler for external links
WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.on_navigation(|url| {
let url_str = url.as_str();
// Allow navigation to app resources and special protocols
if url_str.starts_with("tauri://")
|| url_str.starts_with("http://tauri.localhost")
|| url_str.starts_with("https://tauri.localhost")
|| url_str.starts_with("blob:")
|| url_str.starts_with("data:")
|| url_str.starts_with("about:")
{
return true;
}
// External URLs - open in default browser
if url_str.starts_with("http://") || url_str.starts_with("https://") {
let _ = tauri_plugin_opener::open_url(url_str, None::<&str>);
return false;
}
true
})
.build()?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
read_clipboard_image,
open_external_url,
get_youtube_stream,
start_background_sync,
stop_background_sync,
get_background_sync_state
])
.run(tauri::generate_context!())
.expect("error while building tauri application");
}
}

View File

@@ -1,8 +0,0 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
fn main() {
paarrot_lib::run();
}

View File

@@ -1,174 +0,0 @@
//! Matrix sync implementation using matrix-sdk
//!
//! This module handles the actual Matrix sync loop and notification triggering.
use matrix_sdk::{
Client,
config::SyncSettings,
matrix_auth::MatrixSession,
ruma::{
events::room::message::{MessageType, SyncRoomMessageEvent},
OwnedUserId, OwnedDeviceId,
},
};
use tauri::AppHandle;
use tauri_plugin_notification::NotificationExt;
use tokio::sync::RwLock;
use crate::background_sync::{MatrixCredentials, get_sync_manager};
/// Active Matrix client for background sync
static MATRIX_CLIENT: std::sync::OnceLock<RwLock<Option<Client>>> = std::sync::OnceLock::new();
fn get_client_lock() -> &'static RwLock<Option<Client>> {
MATRIX_CLIENT.get_or_init(|| RwLock::new(None))
}
/// Initializes the Matrix client with the given credentials
pub async fn init_client(credentials: &MatrixCredentials) -> Result<Client, String> {
// Pass the URL string directly - ClientBuilder::homeserver_url accepts impl AsRef<str>
let client = Client::builder()
.homeserver_url(&credentials.homeserver_url)
.build()
.await
.map_err(|e| format!("Failed to build client: {}", e))?;
// Restore the session
let user_id: OwnedUserId = credentials.user_id.parse()
.map_err(|e| format!("Invalid user ID: {}", e))?;
let device_id: OwnedDeviceId = credentials.device_id.clone().into();
let session = MatrixSession {
meta: matrix_sdk::SessionMeta {
user_id,
device_id,
},
tokens: matrix_sdk::matrix_auth::MatrixSessionTokens {
access_token: credentials.access_token.clone(),
refresh_token: None,
},
};
client.matrix_auth().restore_session(session).await
.map_err(|e| format!("Failed to restore session: {}", e))?;
// Store the client
{
let mut lock = get_client_lock().write().await;
*lock = Some(client.clone());
}
Ok(client)
}
/// Runs the sync loop and triggers notifications for new messages
pub async fn run_sync_loop<R: tauri::Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
let manager = get_sync_manager();
let client = {
let lock = get_client_lock().read().await;
lock.clone().ok_or("Client not initialized")?
};
let own_user_id = client.user_id()
.ok_or("Not logged in")?
.to_owned();
log::info!("Starting sync loop for {}", own_user_id);
// Set up event handler for room messages
let app_handle_clone = app_handle.clone();
let own_user_clone = own_user_id.clone();
client.add_event_handler(move |event: SyncRoomMessageEvent, room: matrix_sdk::Room| {
let app = app_handle_clone.clone();
let own_user = own_user_clone.clone();
async move {
// Only process original messages (not edits/reactions)
if let SyncRoomMessageEvent::Original(original) = event {
// Don't notify for our own messages
if original.sender == own_user {
return;
}
// Get room name for notification
let room_name = room.display_name().await
.map(|n| n.to_string())
.unwrap_or_else(|_| "Unknown room".to_string());
// Get sender display name
let sender_name = room.get_member(&original.sender).await
.ok()
.flatten()
.and_then(|m| m.display_name().map(|s| s.to_string()))
.unwrap_or_else(|| original.sender.to_string());
// Get message body
let body = match &original.content.msgtype {
MessageType::Text(text) => text.body.clone(),
MessageType::Image(_) => "📷 Image".to_string(),
MessageType::Video(_) => "🎥 Video".to_string(),
MessageType::Audio(_) => "🎵 Audio".to_string(),
MessageType::File(_) => "📎 File".to_string(),
MessageType::Location(_) => "📍 Location".to_string(),
MessageType::Emote(emote) => format!("* {} {}", sender_name, emote.body),
_ => "New message".to_string(),
};
// Send notification
let title = format!("{} in {}", sender_name, room_name);
if let Err(e) = app.notification()
.builder()
.title(&title)
.body(&body)
.show()
{
log::error!("Failed to show notification: {}", e);
}
}
}
});
// Run the sync loop
let settings = SyncSettings::default();
loop {
// Check if we should stop
if manager.should_stop().await {
log::info!("Sync loop stopping due to stop flag");
break;
}
// Perform one sync iteration
match client.sync_once(settings.clone()).await {
Ok(response) => {
log::debug!("Sync completed, next_batch: {}", response.next_batch);
}
Err(e) => {
log::error!("Sync error: {}", e);
// Update state to error
manager.set_error(e.to_string()).await;
// Wait a bit before retrying
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
// Small delay between syncs to avoid hammering the server
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
Ok(())
}
/// Stops the sync and cleans up
pub async fn stop_sync() {
let manager = get_sync_manager();
manager.stop_sync().await;
// Clear the client
let mut lock = get_client_lock().write().await;
*lock = None;
}

View File

@@ -1,7 +0,0 @@
//! Mobile-specific entry points for Android and iOS
/// Mobile entry point
#[tauri::mobile_entry_point]
fn main() {
crate::run();
}

View File

@@ -1,82 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Paarrot",
"version": "4.10.26",
"identifier": "wtf.ruv.paarrot",
"build": {
"frontendDist": "../cinny/dist",
"devUrl": "http://localhost:8080",
"beforeDevCommand": "cd cinny && npm start",
"beforeBuildCommand": "cd cinny && npm run build"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Paarrot",
"width": 1200,
"height": 800,
"minWidth": 480,
"minHeight": 480,
"resizable": true,
"fullscreen": false,
"decorations": false,
"transparent": true,
"create": false
}
],
"security": {
"csp": "script-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src 'self' https: http: data: blob:; frame-src https: http:"
}
},
"plugins": {
"updater": {
"endpoints": [
"http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases/download/latest/update.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDExREU1NkVBMDY4MzcxMjAKUldRZ2NZTUc2bGJlRVNaQldnMXpJcHlocVpCWUNNaUdicjBGMVRsck1GQXhvTnJiOUNhVVRHSzQK",
"dangerousInsecureTransportProtocol": true
}
},
"bundle": {
"active": true,
"targets": ["msi", "appimage", "deb", "rpm"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [],
"externalBin": [],
"copyright": "",
"category": "SocialNetworking",
"shortDescription": "Squawk to yer mateys!",
"longDescription": "",
"linux": {
"deb": {
"depends": []
}
},
"macOS": {
"frameworks": [],
"minimumSystemVersion": "",
"exceptionDomain": "",
"signingIdentity": null,
"providerShortName": null,
"entitlements": null
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "",
"wix": {
"bannerPath": "wix/banner.bmp",
"dialogImagePath": "wix/dialogImage.bmp"
},
"nsis": null
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB