1 Commits

Author SHA1 Message Date
GitHub Actions
f90f6561be chore: bump version to 4.10.27 [skip ci] 2026-02-21 05:17:26 +00:00
142 changed files with 26945 additions and 16610 deletions

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1
.gitignore vendored
View File

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

2
.gitmodules vendored
View File

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

1
.npmrc
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
cinny

Submodule cinny updated: 2603ca8e5c...6c741577ce

View File

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

View File

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

View File

@@ -1,114 +0,0 @@
{
"appId": "com.paarrot.app",
"productName": "Paarrot",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"copyright": "Copyright © 2026 Mates.Media",
"directories": {
"output": "dist-electron",
"buildResources": "build-resources"
},
"files": [
"electron/**/*",
"cinny/dist/**/*",
"dev-app-update.yml",
"!**/*.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": ["**/*"]
},
{
"from": "cinny/public/sound",
"to": "sound",
"filter": ["**/*"]
}
],
"protocols": [
{
"name": "Paarrot URL",
"schemes": ["paarrot"]
}
],
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64"]
}
],
"category": "Network;InstantMessaging",
"maintainer": "Mates.Media <mates.media@users.noreply.github.com>",
"icon": "icons/icon.png",
"desktop": {
"entry": {
"Name": "Paarrot",
"GenericName": "Matrix Client",
"Comment": "A Matrix client built with Cinny",
"MimeType": "x-scheme-handler/paarrot;",
"Categories": "Network;InstantMessaging;",
"Keywords": "matrix;chat;messaging;",
"StartupWMClass": "paarrot"
}
}
},
"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"]
}
],
"icon": "icons/icon.ico",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}"
},
"nsis": {
"oneClick": true,
"perMachine": false,
"allowToChangeInstallationDirectory": false,
"deleteAppDataOnUninstall": false,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Paarrot",
"differentialPackage": false
},
"compression": "normal",
"publish": [
{
"provider": "github",
"owner": "Paarrot",
"repo": "Paarrot-Desktop",
"releaseType": "release"
}
]
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,194 +0,0 @@
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',
'write_clipboard_text': 'write-clipboard-text',
'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',
'get_protocol_status': 'protocol:get-status'
};
// 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'),
focus: () => ipcRenderer.invoke('window:focus'),
flashFrame: (flash = true) => ipcRenderer.invoke('window:flash-frame', flash)
},
// Clipboard
clipboard: {
readImage: () => ipcRenderer.invoke('read-clipboard-image'),
writeText: (text) => ipcRenderer.invoke('write-clipboard-text', text)
},
// Audio
audio: {
playNotificationSound: (soundType = 'message') => ipcRenderer.invoke('play-notification-sound', soundType),
getSoundBaseUrl: () => ipcRenderer.invoke('get-sound-base-url'),
},
// 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'),
downloadAndInstallUpdate: () => ipcRenderer.invoke('download-and-install-update'),
isMock: () => ipcRenderer.invoke('updater-is-mock'),
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));
},
onUpdateNotAvailable: (callback) => {
ipcRenderer.on('update-not-available', (_, info) => callback(info));
},
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
},
// Desktop capturer (for screen sharing)
desktopCapturer: {
getSources: (opts) => ipcRenderer.invoke('get-desktop-sources', opts)
},
// API action handler (for external API server)
api: {
onAction: (callback) => {
const handler = (_, data) => callback(data);
ipcRenderer.on('api-action', handler);
return () => ipcRenderer.removeListener('api-action', handler);
},
sendResponse: (channel, result) => {
ipcRenderer.invoke(channel, result);
}
},
// Desktop notifications
notification: {
show: (options) => ipcRenderer.invoke('show-notification', options),
onNavigate: (callback) => {
const handler = (_, data) => callback(data);
ipcRenderer.on('notification:navigate', handler);
return () => ipcRenderer.removeListener('notification:navigate', handler);
}
},
// Plugin management
plugins: {
getPath: () => ipcRenderer.invoke('plugin:get-path'),
download: (pluginId, downloadUrl, name) => ipcRenderer.invoke('plugin:download', { pluginId, downloadUrl, name }),
list: () => ipcRenderer.invoke('plugin:list'),
uninstall: (pluginId) => ipcRenderer.invoke('plugin:uninstall', pluginId),
readPluginCode: (pluginId) => ipcRenderer.invoke('plugin:read-code', pluginId)
},
// Desktop protocol diagnostics
protocol: {
getStatus: () => ipcRenderer.invoke('protocol:get-status'),
repair: () => ipcRenderer.invoke('protocol:repair')
}
});
// Preload script ready

View File

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

View File

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

View File

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

5859
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,33 +2,51 @@ 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');
// Electron desktop icons // Tauri desktop icons
const electronIcons = [ const tauriIcons = [
{ 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: '256x256.png', size: 256 }, { name: '128x128@2x.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 },
]; ];
async function generateElectronIcons() { // Android mipmap sizes
const iconsDir = path.join(rootDir, 'icons'); const androidMipmaps = [
{ 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 },
];
// Ensure icons directory exists // Android adaptive icon foreground sizes (with padding for safe zone)
await fs.mkdir(iconsDir, { recursive: true }); 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 },
];
for (const icon of electronIcons) { async function generateTauriIcons() {
const iconsDir = path.join(rootDir, 'src-tauri', 'icons');
for (const icon of tauriIcons) {
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)
@@ -38,26 +56,97 @@ async function generateElectronIcons() {
} }
// Generate ICO file (Windows) // Generate ICO file (Windows)
const sourceBuffer = await fs.readFile(sourceIcon); const icoPath = path.join(iconsDir, 'icon.ico');
const icoBuffer = png2icons.createICO(sourceBuffer, png2icons.BEZIER, 0, true, true); const sizes = [16, 24, 32, 48, 64, 128, 256];
await fs.writeFile(path.join(iconsDir, 'icon.ico'), icoBuffer); const icoBuffers = await Promise.all(
console.log('Generated: icon.ico'); sizes.map(size =>
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) // Generate ICNS file (macOS) - sharp doesn't support icns directly
const icnsBuffer = png2icons.createICNS(sourceBuffer, png2icons.BEZIER, 0); console.log('Note: ICNS file needs manual conversion or use iconutil on macOS');
await fs.writeFile(path.join(iconsDir, 'icon.icns'), icnsBuffer); }
console.log('Generated: icon.icns');
async function generateAndroidIcons() {
const androidResDir = path.join(rootDir, 'src-tauri', 'gen', 'android', 'app', 'src', 'main', 'res');
for (const mipmap of androidMipmaps) {
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('=== Electron Desktop Icons ==='); console.log('=== Tauri Desktop Icons ===');
await generateElectronIcons(); await generateTauriIcons();
console.log(''); console.log('');
console.log('Done!'); console.log('=== Android Icons ===');
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,224 +0,0 @@
#!/usr/bin/env node
/**
* Generate Postman collection from API documentation
* This script reads the API.md file and generates a Postman collection
*/
const fs = require('fs');
const path = require('path');
const collection = {
info: {
_postman_id: 'paarrot-api-collection',
name: 'Paarrot API',
description: 'API endpoints for controlling Paarrot (Cinny Desktop) features like muting, deafening, channel navigation, and messaging.',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
_exporter_id: 'paarrot-api',
},
item: [
{
name: 'Health & Status',
item: [
{
name: 'Health Check',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/health',
host: ['{{baseUrl}}'],
path: ['health'],
},
description: 'Check if the API server is running and healthy.',
},
},
{
name: 'Get Status',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/status',
host: ['{{baseUrl}}'],
path: ['status'],
},
description: 'Get current application status including mute/deafen state, current room, connection status, and user ID.',
},
},
],
},
{
name: 'Audio Controls',
item: [
{
name: 'Toggle Mute',
request: {
method: 'POST',
header: [],
url: {
raw: '{{baseUrl}}/mute/toggle',
host: ['{{baseUrl}}'],
path: ['mute', 'toggle'],
},
description: 'Toggle microphone mute state. Works only when in an active call.',
},
},
{
name: 'Set Mute',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify({ muted: true }, null, 2),
},
url: {
raw: '{{baseUrl}}/mute',
host: ['{{baseUrl}}'],
path: ['mute'],
},
description: 'Set microphone mute to a specific state (true/false). Works only when in an active call.',
},
},
{
name: 'Toggle Deafen',
request: {
method: 'POST',
header: [],
url: {
raw: '{{baseUrl}}/deafen/toggle',
host: ['{{baseUrl}}'],
path: ['deafen', 'toggle'],
},
description: 'Toggle deafen state (mutes all incoming audio and your microphone). Works only when in an active call.',
},
},
{
name: 'Set Deafen',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify({ deafened: true }, null, 2),
},
url: {
raw: '{{baseUrl}}/deafen',
host: ['{{baseUrl}}'],
path: ['deafen'],
},
description: 'Set deafen to a specific state (true/false). Works only when in an active call.',
},
},
],
},
{
name: 'Rooms & Channels',
item: [
{
name: 'Get Channels',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/channels',
host: ['{{baseUrl}}'],
path: ['channels'],
},
description: 'Get list of all available rooms/channels with their details (roomId, name, isDirect, avatar).',
},
},
{
name: 'Get Current Room',
request: {
method: 'GET',
header: [],
url: {
raw: '{{baseUrl}}/room/current',
host: ['{{baseUrl}}'],
path: ['room', 'current'],
},
description: 'Get information about the currently active room.',
},
},
{
name: 'Change Channel',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify({ roomId: '!YourRoomId:server.com' }, null, 2),
},
url: {
raw: '{{baseUrl}}/channel',
host: ['{{baseUrl}}'],
path: ['channel'],
},
description: 'Navigate to a different room/channel by providing its roomId.',
},
},
],
},
{
name: 'Messaging',
item: [
{
name: 'Send Message to Room',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify(
{
roomId: '!YourRoomId:server.com',
message: 'Hello from Postman!',
},
null,
2
),
},
url: {
raw: '{{baseUrl}}/message',
host: ['{{baseUrl}}'],
path: ['message'],
},
description: 'Send a text message to a specific room by providing roomId and message content.',
},
},
{
name: 'Send Message to Current Room',
request: {
method: 'POST',
header: [{ key: 'Content-Type', value: 'application/json' }],
body: {
mode: 'raw',
raw: JSON.stringify({ message: 'Hello from Postman!' }, null, 2),
},
url: {
raw: '{{baseUrl}}/message/current',
host: ['{{baseUrl}}'],
path: ['message', 'current'],
},
description: 'Send a text message to the currently active room.',
},
},
],
},
],
variable: [
{
key: 'baseUrl',
value: 'http://127.0.0.1:33384',
type: 'string',
},
],
};
// Write the collection to file (in the project root)
const outputPath = path.join(__dirname, '..', 'paarrot-api.postman_collection.json');
fs.writeFileSync(outputPath, JSON.stringify(collection, null, 2) + '\n');
console.log('✅ Postman collection generated:', outputPath);

View File

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

108
scripts/release.mjs Normal file
View File

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

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

6
src-tauri/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDExREU1NkVBMDY4MzcxMjAKUldRZ2NZTUc2bGJlRVNaQldnMXpJcHlocVpCWUNNaUdicjBGMVRsck1GQXhvTnJiOUNhVVRHSzQK

8276
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

59
src-tauri/Cargo.toml Normal file
View File

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

3
src-tauri/build.rs Normal file
View File

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

View File

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

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

19
src-tauri/gen/android/.gitignore vendored Normal file
View File

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

6
src-tauri/gen/android/app/.gitignore vendored Normal file
View File

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

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

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

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

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

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

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

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

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

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

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

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

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

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

View File

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

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

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

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

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

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

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

Binary file not shown.

View File

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

185
src-tauri/gen/android/gradlew vendored Normal file
View File

@@ -0,0 +1,185 @@
#!/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" "$@"

89
src-tauri/gen/android/gradlew.bat vendored Normal file
View File

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

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

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

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Some files were not shown because too many files have changed in this diff Show More