Compare commits

..
4 Commits
Author SHA1 Message Date
Krishan 926191afd6 update key from name to version as required by v2 tauri 2026-05-31 19:16:57 +10:00
Krishan cbe0df86cc add a dialog 2026-05-30 22:27:55 +10:00
Krishan 2aba2f4a45 rerun ci 2026-05-30 21:39:31 +10:00
Krishan d39ba6d30f add tauri updater check 2026-05-30 21:01:29 +10:00
46 changed files with 353 additions and 3520 deletions
-1
View File
@@ -1 +0,0 @@
81e1a25de641f0292863b1404cba728c1eadd00d
-326
View File
@@ -1,326 +0,0 @@
name: Build Lotus Chat Desktop
on:
push:
branches: [main]
workflow_dispatch:
env:
GITEA_URL: https://code.lotusguild.org
REPO: LotusGuild/cinny-desktop
# Continuous "latest" builds track lotus HEAD, so only the newest submodule bump
# needs to build. Cancel a superseded in-flight release: rapid lotus pushes would
# otherwise queue several ~30-min Tauri builds back-to-back, and build-linux runs
# on ubuntu-latest — the same runner pool as cinny's web CI — so the queue also
# starves web CI/deploys. update-manifest only publishes release.json on full
# success, so a cancelled run leaves the last good manifest untouched.
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: true
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
release_id: ${{ steps.release.outputs.release_id }}
steps:
- name: Compute version
id: ver
run: echo "version=4.12.${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Create or update release
id: release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
# NOTE (partial-failure window): this renames the `latest` release's
# name/body to the new version up front, before the platform builds run.
# If a build later fails, the release keeps the OLD binary assets but the
# NEW name. That's cosmetic: the auto-updater reads release.json, which is
# only regenerated by the update-manifest job — and that job `needs:` BOTH
# build-windows and build-linux, so a failed/skipped build prevents any
# manifest (and therefore updater) change. Clients keep the last good
# release.json until a fully successful run replaces it.
run: |
VERSION="4.12.${{ github.run_number }}"
EXISTING=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/tags/latest" \
-H "Authorization: token $TOKEN" 2>/dev/null || true)
RELEASE_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ] && [ "$RELEASE_ID" != "" ]; then
curl -sf -X PATCH "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"Lotus Chat $VERSION\",\"body\":\"Built from ${{ github.sha }}\"}" > /dev/null
else
RELEASE_ID=$(curl -sf -X POST "$GITEA_URL/api/v1/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"latest\",\"name\":\"Lotus Chat $VERSION\",\"prerelease\":true,\"body\":\"Built from ${{ github.sha }}\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
fi
echo "release_id=$RELEASE_ID" >> $GITHUB_OUTPUT
build-windows:
needs: prepare
runs-on: windows
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Checkout submodules (shallow)
shell: powershell
run: git submodule update --init --depth=1
- name: Patch version
shell: powershell
run: |
$ver = '${{ needs.prepare.outputs.version }}'
node -e "const fs=require('fs');const d=JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json','utf8'));d.version='$ver';fs.writeFileSync('src-tauri/tauri.conf.json',JSON.stringify(d,null,2),'utf8');"
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend deps
shell: powershell
run: cd cinny; npm ci
- name: Install Tauri deps
shell: powershell
run: npm ci
- name: Build
shell: powershell
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
NODE_OPTIONS: '--max_old_space_size=4096'
# Sparse registry avoids a full git clone of the crates.io index —
# eliminates the curl SSL handshake failures seen on Windows runners.
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
# Disable HTTP/2 multiplexing — ALPN negotiation can reset on Windows Schannel.
CARGO_HTTP_MULTIPLEXING: 'false'
# Retry transient network errors before failing.
CARGO_NET_RETRY: '5'
run: |
# USERPROFILE is set by Windows directly; more reliable than C:\Users\$USERNAME
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
# Also add the actual toolchain bin to bypass rustup shim execution issues
$toolchain = Get-ChildItem "$env:USERPROFILE\.rustup\toolchains" -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'stable' } | Select-Object -First 1
if ($toolchain) { $env:PATH = "$($toolchain.FullName)\bin;$env:PATH" }
Write-Host "cargo: $((Get-Command cargo -ErrorAction SilentlyContinue).Source)"
cargo --version
npm run tauri -- build --bundles nsis
- name: Upload to release
shell: powershell
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
$releaseId = $env:RELEASE_ID
$VERSION = $env:VERSION
Write-Host "Version: $VERSION Release: $releaseId"
$nsis = "src-tauri\target\release\bundle\nsis"
$files = @(
"$nsis\Lotus Chat_${VERSION}_x64-setup.exe",
"$nsis\Lotus Chat_${VERSION}_x64-setup.nsis.zip",
"$nsis\Lotus Chat_${VERSION}_x64-setup.nsis.zip.sig"
)
$names = @("LotusChat-x86_64-setup.exe", "LotusChat-x86_64-setup.nsis.zip", "LotusChat-x86_64-setup.nsis.zip.sig")
for ($i = 0; $i -lt $files.Length; $i++) {
$existing = (Invoke-RestMethod -Uri "$env:GITEA_URL/api/v1/repos/$env:REPO/releases/$releaseId/assets" `
-Headers @{ Authorization = "token $env:TOKEN" }) | Where-Object { $_.name -eq $names[$i] }
if ($existing) {
Invoke-RestMethod -Uri "$env:GITEA_URL/api/v1/repos/$env:REPO/releases/$releaseId/assets/$($existing.id)" `
-Method Delete -Headers @{ Authorization = "token $env:TOKEN" }
}
$bytes = [System.IO.File]::ReadAllBytes($files[$i])
Invoke-RestMethod -Uri "$env:GITEA_URL/api/v1/repos/$env:REPO/releases/$releaseId/assets?name=$($names[$i])" `
-Method Post `
-Headers @{ Authorization = "token $env:TOKEN"; "Content-Type" = "application/octet-stream" } `
-Body $bytes
}
build-linux:
needs: prepare
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Install system deps
run: |
apt-get update
apt-get install -y \
curl wget file gcc imagemagick \
libwebkit2gtk-4.1-dev \
libssl-dev \
libxdo-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
xdg-utils \
squashfs-tools
- name: Ensure icons are RGBA PNG
run: |
for f in src-tauri/icons/*.png; do
info=$(identify -verbose "$f" 2>/dev/null | grep "Type:" | head -1)
echo "$f: $info"
convert "$f" -type TrueColorAlpha PNG32:"$f"
done
- name: Set up Rust toolchain
run: |
source "$HOME/.cargo/env" 2>/dev/null || true
if command -v cargo >/dev/null 2>&1; then
echo "Using existing Rust: $(cargo --version)"
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable --profile minimal
source "$HOME/.cargo/env"
fi
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Patch version
run: python3 -c "import json; d=json.load(open('src-tauri/tauri.conf.json')); d['version']='${{ needs.prepare.outputs.version }}'; open('src-tauri/tauri.conf.json','w').write(json.dumps(d,indent=2))"
- name: Install frontend deps
run: cd cinny && npm ci
- name: Install Tauri deps
run: npm ci
- name: Stage AppRun and linuxdeploy for AppImage bundler
run: |
set -e
mkdir -p ~/.cache/tauri
cp tools/AppRun-x86_64 ~/.cache/tauri/AppRun-x86_64
chmod +x ~/.cache/tauri/AppRun-x86_64
wget -q \
"https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy/linuxdeploy-x86_64.AppImage" \
-O /tmp/linuxdeploy.AppImage
chmod +x /tmp/linuxdeploy.AppImage
rm -rf /root/linuxdeploy-root
(cd /root && /tmp/linuxdeploy.AppImage --appimage-extract)
mv /root/squashfs-root /root/linuxdeploy-root
echo "Extracted linuxdeploy:"
ls /root/linuxdeploy-root/
ls /root/linuxdeploy-root/usr/bin/ 2>/dev/null || echo "no usr/bin"
# Pre-stage plugin scripts next to linuxdeploy so it finds them via /proc/self/exe lookup
wget -q "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh" \
-O /root/linuxdeploy-root/usr/bin/linuxdeploy-plugin-gtk.sh
wget -q "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
-O /root/linuxdeploy-root/usr/bin/linuxdeploy-plugin-gstreamer.sh
chmod +x /root/linuxdeploy-root/usr/bin/linuxdeploy-plugin-gtk.sh \
/root/linuxdeploy-root/usr/bin/linuxdeploy-plugin-gstreamer.sh
gcc -o ~/.cache/tauri/linuxdeploy-x86_64.AppImage tools/ld_wrapper.c
chmod +x ~/.cache/tauri/linuxdeploy-x86_64.AppImage
- name: Build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
NODE_OPTIONS: '--max_old_space_size=4096'
RUST_LOG: tauri_bundler=debug
run: npm run tauri -- build --bundles appimage,deb
- name: Show linuxdeploy wrapper log
if: always()
run: cat /tmp/ld-wrapper.log 2>/dev/null || echo "no wrapper log found"
- name: Upload to release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
APPIMAGE_DIR="src-tauri/target/release/bundle/appimage"
DEB_DIR="src-tauri/target/release/bundle/deb"
upload() {
local name="$1" path="$2"
local existing_id
existing_id=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$name'), ''))" 2>/dev/null || true)
if [ -n "$existing_id" ]; then
curl -sf -X DELETE "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$existing_id" \
-H "Authorization: token $TOKEN" || true
fi
echo "Uploading $name"
curl -sf -X POST \
"$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$path"
}
upload "LotusChat-x86_64.AppImage" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage"
upload "LotusChat-x86_64.AppImage.tar.gz" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage.tar.gz"
upload "LotusChat-x86_64.AppImage.tar.gz.sig" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage.tar.gz.sig"
upload "LotusChat-x86_64.deb" "$DEB_DIR/Lotus Chat_${VERSION}_amd64.deb"
update-manifest:
needs: [prepare, build-windows, build-linux]
runs-on: ubuntu-latest
steps:
- name: Generate and upload release.json
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
BASE="$GITEA_URL/LotusGuild/cinny-desktop/releases/download/latest"
WIN_SIG=$(curl -sf "$BASE/LotusChat-x86_64-setup.nsis.zip.sig")
LIN_SIG=$(curl -sf "$BASE/LotusChat-x86_64.AppImage.tar.gz.sig")
# Never publish a manifest with a missing/empty signature: the updater
# would reject (or worse, accept an unsigned) artifact. Fail the job so
# the previous good release.json stays in place.
[ -n "$WIN_SIG" ] || { echo "ERROR: empty Windows signature" >&2; exit 1; }
[ -n "$LIN_SIG" ] || { echo "ERROR: empty Linux signature" >&2; exit 1; }
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
python3 -c "import json,sys; v,d,wu,ws,lu,ls=sys.argv[1:]; print(json.dumps({'version':v,'notes':'Latest Lotus Chat release','pub_date':d,'platforms':{'windows-x86_64':{'url':wu,'signature':ws},'linux-x86_64':{'url':lu,'signature':ls}}},indent=2))" \
"$VERSION" "$DATE" \
"$BASE/LotusChat-x86_64-setup.nsis.zip" "$WIN_SIG" \
"$BASE/LotusChat-x86_64.AppImage.tar.gz" "$LIN_SIG" \
> release.json
cat release.json
OLD=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; print(next((str(a['id']) for a in json.load(sys.stdin) if a['name']=='release.json'), ''))" 2>/dev/null || true)
[ -n "$OLD" ] && curl -sf -X DELETE \
"$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$OLD" \
-H "Authorization: token $TOKEN" || true
curl -sf -X POST \
"$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=release.json" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @release.json
+168
View File
@@ -0,0 +1,168 @@
name: "Publish Tauri App"
on:
release:
types: [published]
jobs:
# Windows-x86_64
windows-x86_64:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: true
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".node-version"
package-manager-cache: false
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable # They use branch based releases
- 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@73fb865345c54760d875b94642314f8c0c894afa # v0.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NODE_OPTIONS: "--max_old_space_size=4096"
- name: Get app version (windows)
run: |
$json = (Get-Content "src-tauri\tauri.conf.json" -Raw) | ConvertFrom-Json
$version = $json.version
echo "Version: ${version}"
echo "TAURI_VERSION=${version}" >> $Env:GITHUB_ENV
echo "${Env:TAURI_VERSION}"
shell: pwsh
- name: Move msi
run: Move-Item "src-tauri\target\release\bundle\msi\Cinny_${{ env.TAURI_VERSION }}_x64_en-US.msi" "src-tauri\target\release\bundle\msi\Cinny_desktop-x86_64.msi"
shell: pwsh
- name: Move msi.zip
run: Move-Item "src-tauri\target\release\bundle\msi\Cinny_${{ env.TAURI_VERSION }}_x64_en-US.msi.zip" "src-tauri\target\release\bundle\msi\Cinny_desktop-x86_64.msi.zip"
shell: pwsh
- name: Move msi.zip.sig
run: Move-Item "src-tauri\target\release\bundle\msi\Cinny_${{ env.TAURI_VERSION }}_x64_en-US.msi.zip.sig" "src-tauri\target\release\bundle\msi\Cinny_desktop-x86_64.msi.zip.sig"
shell: pwsh
- name: Upload tagged release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
files: |
src-tauri/target/release/bundle/msi/Cinny_desktop-x86_64.msi
src-tauri/target/release/bundle/msi/Cinny_desktop-x86_64.msi.zip
src-tauri/target/release/bundle/msi/Cinny_desktop-x86_64.msi.zip.sig
# Linux-x86_64
linux-x86_64:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: true
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".node-version"
package-manager-cache: false
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable # They use branch based releases
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev 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@73fb865345c54760d875b94642314f8c0c894afa # v0.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NODE_OPTIONS: "--max_old_space_size=4096"
- name: Get app version
id: vars
run: echo "tag=$(jq .version src-tauri/tauri.conf.json | tr -d '"')" >> $GITHUB_OUTPUT
- name: Move deb
run: mv "src-tauri/target/release/bundle/deb/Cinny_${{ steps.vars.outputs.tag }}_amd64.deb" "src-tauri/target/release/bundle/deb/Cinny_desktop-x86_64.deb"
- name: Move AppImage
run: mv "src-tauri/target/release/bundle/appimage/Cinny_${{ steps.vars.outputs.tag }}_amd64.AppImage" "src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage"
- name: Move AppImage.tar.gz
run: mv "src-tauri/target/release/bundle/appimage/Cinny_${{ steps.vars.outputs.tag }}_amd64.AppImage.tar.gz" "src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz"
- name: Move AppImage.tar.gz.sig
run: mv "src-tauri/target/release/bundle/appimage/Cinny_${{ steps.vars.outputs.tag }}_amd64.AppImage.tar.gz.sig" "src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz.sig"
- name: Upload tagged release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
files: |
src-tauri/target/release/bundle/deb/Cinny_desktop-x86_64.deb
src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage
src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz
src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz.sig
# macos-universal
macos-universal:
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: true
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".node-version"
package-manager-cache: false
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable # They use branch based releases
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- 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@73fb865345c54760d875b94642314f8c0c894afa # v0.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NODE_OPTIONS: "--max_old_space_size=4096"
with:
args: "--target universal-apple-darwin"
- name: Get app version
id: vars
run: echo "tag=$(jq .version src-tauri/tauri.conf.json | tr -d '"')" >> $GITHUB_OUTPUT
- name: Move dmg
run: mv "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Cinny_${{ steps.vars.outputs.tag }}_universal.dmg" "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Cinny_desktop-universal.dmg"
- name: Move app.tar.gz
run: mv "src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny.app.tar.gz" "src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny_desktop-universal.app.tar.gz"
- name: Move app.tar.gz.sig
run: mv "src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny.app.tar.gz.sig" "src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny_desktop-universal.app.tar.gz.sig"
- name: Upload tagged release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
files: |
src-tauri/target/universal-apple-darwin/release/bundle/dmg/Cinny_desktop-universal.dmg
src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny_desktop-universal.app.tar.gz
src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny_desktop-universal.app.tar.gz.sig
# Upload release.json
release-update:
if: always()
needs: [windows-x86_64, linux-x86_64, macos-universal]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install dependencies
run: npm ci
- name: Run release.json
run: npm run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -2
View File
@@ -1,4 +1,3 @@
[submodule "cinny"]
path = cinny
url = https://code.lotusguild.org/LotusGuild/cinny.git
branch = lotus
url = https://github.com/cinnyapp/cinny.git
+1 -1
View File
@@ -36,4 +36,4 @@ To build the app locally, run:
* `npm run tauri build`
To start local dev server, run:
* `npm run tauri dev`
* `npm run tauri dev`
+1 -1
Submodule cinny updated: 4656f08802...e89b8f7d12
+28 -4
View File
@@ -1,13 +1,37 @@
{
"defaultHomeserver": 0,
"defaultHomeserver": 1,
"homeserverList": [
"matrix.lotusguild.org",
"converser.eu",
"matrix.org",
"mozilla.org"
"mozilla.org",
"unredacted.org",
"xmr.se"
],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
},
"hashRouter": {
"enabled": true,
"basename": "/"
}
}
}
+3 -596
View File
@@ -1,12 +1,12 @@
{
"name": "cinny",
"version": "4.12.2",
"version": "4.12.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinny",
"version": "4.12.2",
"version": "4.12.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@tauri-apps/api": "2.7.0",
@@ -24,8 +24,7 @@
"devDependencies": {
"@actions/github": "6.0.0",
"@tauri-apps/cli": "2.7.1",
"node-fetch": "3.3.2",
"shx": "0.4.0"
"node-fetch": "3.3.2"
},
"engines": {
"node": ">=16.0.0"
@@ -62,44 +61,6 @@
"node": ">=14"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@octokit/auth-token": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz",
@@ -530,36 +491,6 @@
"integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==",
"dev": true
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cross-spawn": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz",
"integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"nice-try": "^1.0.4",
"path-key": "^2.0.1",
"semver": "^5.5.0",
"shebang-command": "^1.2.0",
"which": "^1.2.9"
},
"engines": {
"node": ">=4.8"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz",
@@ -575,72 +506,6 @@
"integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==",
"dev": true
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/execa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
"integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^6.0.0",
"get-stream": "^4.0.0",
"is-stream": "^1.1.0",
"npm-run-path": "^2.0.0",
"p-finally": "^1.0.0",
"signal-exit": "^3.0.0",
"strip-eof": "^1.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -664,19 +529,6 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -689,114 +541,6 @@
"node": ">=12.20.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
"integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/interpret": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
"integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
@@ -806,64 +550,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
"integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true,
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -901,19 +587,6 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/npm-run-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
"integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^2.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -923,259 +596,6 @@
"wrappy": "1"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/path-key": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
"integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/rechoir": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
"integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==",
"dev": true,
"dependencies": {
"resolve": "^1.1.6"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/shebang-command": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
"integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/shebang-regex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
"integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/shelljs": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.9.2.tgz",
"integrity": "sha512-S3I64fEiKgTZzKCC46zT/Ib9meqofLrQVbpSswtjFfAVDW+AZ54WTnAM/3/yENoxz/V1Cy6u3kiiEbQ4DNphvw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"execa": "^1.0.0",
"fast-glob": "^3.3.2",
"interpret": "^1.0.0",
"rechoir": "^0.6.2"
},
"bin": {
"shjs": "bin/shjs"
},
"engines": {
"node": ">=18"
}
},
"node_modules/shx": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/shx/-/shx-0.4.0.tgz",
"integrity": "sha512-Z0KixSIlGPpijKgcH6oCMCbltPImvaKy0sGH8AkLRXw1KyzpKtaCTizP2xen+hNDqVF4xxgvA0KXSb9o4Q6hnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.8",
"shelljs": "^0.9.2"
},
"bin": {
"shx": "lib/cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true,
"license": "ISC"
},
"node_modules/strip-eof": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
"integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
@@ -1212,19 +632,6 @@
"node": ">= 8"
}
},
"node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+4 -6
View File
@@ -1,15 +1,14 @@
{
"name": "cinny",
"version": "4.12.2",
"version": "4.12.1",
"description": "Yet another matrix client",
"main": "index.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"tauri": "shx cp config.json cinny/ && tauri",
"release": "node scripts/release.mjs",
"bump": "node scripts/update-version.mjs"
"tauri": "cp config.json cinny/ && tauri",
"release": "node scripts/release.mjs"
},
"keywords": [],
"author": "Ajay Bura",
@@ -30,7 +29,6 @@
"devDependencies": {
"@actions/github": "6.0.0",
"@tauri-apps/cli": "2.7.1",
"node-fetch": "3.3.2",
"shx": "0.4.0"
"node-fetch": "3.3.2"
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ async function createTauriRelease() {
await Promise.allSettled(promises);
const releaseData = {
name: latestTag.name,
version: latestTag.name,
notes: `https://github.com/${repoMetaData.owner}/${repoMetaData.repo}/releases/tag/${latestTag.name}`,
pub_date: new Date().toISOString(),
platforms: {},
-65
View File
@@ -1,65 +0,0 @@
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
const version = process.argv[2];
if (!version) {
console.error("Missing version");
process.exit(1);
}
console.log(`Preparing release ${version}`);
// 1. Update npm versions
execSync(`npm version ${version} --no-git-tag-version`, {
stdio: "inherit"
});
console.log(`Updated package.json and package-lock.json → ${version}`);
// 2. Update Cargo.toml
const cargoToml = "src-tauri/Cargo.toml";
let cargoContent = fs.readFileSync(cargoToml, "utf8");
cargoContent = cargoContent.replace(
/^version\s*=\s*".*"/m,
`version = "${version}"`
);
fs.writeFileSync(cargoToml, cargoContent);
console.log(`Updated ${cargoToml}${version}`);
// 3. Update tauri.conf.json
const tauriConfigPath = "src-tauri/tauri.conf.json";
const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath));
tauriConfig.version = version;
fs.writeFileSync(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2) + "\n"
);
console.log(`Updated ${tauriConfigPath}${version}`);
// 4. Update Cinny web submodule to latest tag
console.log("Updating cinny web submodule");
execSync("git submodule update --init --recursive", { stdio: "inherit" });
execSync("git fetch --tags", { cwd: "cinny", stdio: "inherit" });
const latestCommit = execSync("git rev-list --tags --max-count=1", {
cwd: "cinny",
}).toString().trim();
const latestTag = execSync(`git describe --tags ${latestCommit}`, {
cwd: "cinny",
}).toString().trim();
console.log(`Latest cinny tag: ${latestTag}`);
execSync(`git checkout ${latestTag}`, { cwd: "cinny", stdio: "inherit" });
execSync("git add cinny", { stdio: "inherit" });
console.log("Release preparation complete");
+17 -44
View File
@@ -2,27 +2,33 @@
[package]
name = "cinny"
version = "4.12.2" # CI patches src-tauri/tauri.conf.json at build time; that file is the source of truth for the shipped version.
version = "4.12.1"
description = "Yet another matrix client"
authors = ["Ajay Bura"]
license = "AGPL-3.0-only"
repository = "https://github.com/cinnyapp/cinny-desktop"
default-run = "cinny"
edition = "2021"
rust-version = "1.77.2"
rust-version = "1.61"
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
serde_json = "1.0.109"
serde = { version = "1.0.193", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "wry", "tray-icon", "image-png"] }
tauri-plugin-localhost = "2"
tauri-plugin-window-state = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2"
tauri = { version = "2.11.2", features = [ "devtools"] }
tauri-plugin-localhost = "2.3.2"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-clipboard-manager = "2.3.2"
tauri-plugin-notification = "2.3.3"
tauri-plugin-fs = "2.5.1"
tauri-plugin-shell = "2.3.5"
tauri-plugin-http = "2.5.9"
tauri-plugin-process = "2.3.1"
tauri-plugin-os = "2.3.2"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-opener = "2.5.4"
[features]
# by default Tauri runs in production mode
@@ -33,41 +39,8 @@ default = [ "custom-protocol" ]
custom-protocol = [ "tauri/custom-protocol" ]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2" # P6-1 launch-on-login
[target.'cfg(target_os = "linux")'.dependencies]
# P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher
# badge, both via the session D-Bus. zbus 5.x ships the blocking API under the
# default `blocking-api` feature and the default `async-io` runtime, so plain
# default features suffice (no tokio integration needed here).
zbus = "5"
[target.'cfg(target_os = "windows")'.dependencies]
webview2-com = "0.38"
window-vibrancy = "0.6"
windows = { version = "0.61", features = [
# WinRT namespaces
"Data_Xml_Dom", # P5-41 toast XML
"Foundation",
"Foundation_Collections", # P5-41 toast UserInput IMap
"Media",
"UI_Notifications", # P5-41 WinRT toast notifications
# Win32 namespaces
"Win32_Foundation",
"Win32_Storage_EnhancedStorage", # P5-36 jump list (PKEY_Title)
"Win32_Graphics_Gdi",
"Win32_Networking_NetworkListManager", # P5-49 network awareness
"Win32_System_Com",
"Win32_System_Com_StructuredStorage", # P5-36 jump list (PROPVARIANT)
"Win32_System_Power", # P5-46 no-sleep
"Win32_System_WinRT", # P5-43 SMTC interop
"Win32_UI_Shell",
"Win32_UI_Shell_Common", # P5-36 jump list (IObjectArray/IObjectCollection)
"Win32_UI_Shell_PropertiesSystem", # P5-36 jump list (IPropertyStore/PKEY_Title)
"Win32_UI_WindowsAndMessaging",
] }
tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-updater = { version = "2.10.1", features = ["rustls-tls"] }
[lib]
name = "app_lib"
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Request camera access for WebRTC calls.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Request microphone access for WebRTC calls.</string>
</dict>
</plist>
+1 -1
View File
@@ -10,6 +10,6 @@
],
"permissions": [
"updater:default",
"deep-link:default"
"global-shortcut:default"
]
}
+35 -3
View File
@@ -12,6 +12,15 @@
],
"permissions": [
"core:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-read-dir",
"fs:allow-copy-file",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-exists",
"core:window:allow-create",
"core:window:allow-center",
"core:window:allow-request-user-attention",
@@ -45,12 +54,35 @@
"core:window:allow-set-ignore-cursor-events",
"core:window:allow-start-dragging",
"core:webview:allow-print",
"shell:allow-execute",
"shell:allow-open",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"http:default",
"notification:default",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"os:allow-platform",
"os:allow-version",
"os:allow-os-type",
"os:allow-family",
"os:allow-arch",
"os:allow-exe-extension",
"os:allow-locale",
"os:allow-hostname",
"process:allow-restart",
"process:allow-exit",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"core:app:allow-app-show",
"core:app:allow-app-hide",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"clipboard-manager:default",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+42 -757
View File
@@ -3,634 +3,59 @@
windows_subsystem = "windows"
)]
use tauri::{
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder},
Manager, WebviewUrl,
};
// mod menu;
use tauri::{webview::{NewWindowResponse, WebviewWindowBuilder}, WebviewUrl};
use tauri_plugin_opener::OpenerExt;
mod native;
/// Bring the main window to the foreground from the tray / a hidden /
/// minimized state. Shared by the tray, single-instance, and deep-link paths.
fn show_main(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
/// Hand a `matrix:` / `matrix.to` URL to the web app by dispatching a DOM
/// CustomEvent the client listens for (see useDeepLinkNavigate.ts). Uses
/// `eval` so we don't need the @tauri-apps/api event package on the web side.
fn forward_deeplink(app: &tauri::AppHandle, url: &str) {
// Dedupe: on a cold start the deep-link plugin's `on_open_url` AND the argv
// fallback (`matrix_url_from_args(std::env::args())`) can both forward the
// same launch URL, navigating the room twice (re-show/re-focus + a duplicate
// `lotus-deeplink`). Drop a repeat of the same URL within a short window.
{
use std::sync::Mutex;
use std::time::{Duration, Instant};
static LAST: Mutex<Option<(String, Instant)>> = Mutex::new(None);
if let Ok(mut last) = LAST.lock() {
let now = Instant::now();
if let Some((prev_url, prev_at)) = last.as_ref() {
if prev_url == url && now.duration_since(*prev_at) < Duration::from_millis(1000) {
return;
}
}
*last = Some((url.to_string(), now));
}
}
show_main(app);
if let Some(window) = app.get_webview_window("main") {
if let Ok(json) = serde_json::to_string(url) {
let _ = window.eval(&format!(
"window.dispatchEvent(new CustomEvent('lotus-deeplink',{{detail:{json}}}))"
));
}
}
}
/// Pull the first `matrix:` link out of a process's CLI args (Windows/Linux
/// pass deep-link URLs as argv to a freshly launched instance).
fn matrix_url_from_args(args: &[String]) -> Option<String> {
args.iter().find(|a| a.starts_with("matrix:")).cloned()
}
// Injected into every page before app scripts load.
// Patches window.Notification to route through tauri-plugin-notification so
// WebView2's default "denied" state never reaches cinny's permission check.
// Also patches navigator.permissions.query so the React hook sees "granted".
const NOTIFICATION_BRIDGE: &str = r#"(function(){
function TauriNotification(title,options){
var opts=options||{};
try{
var body=opts.body!=null?String(opts.body):undefined;
// cinny tags message notifications with the roomId (options.tag) and the
// in-app route (options.data.path). When present, route to the rich WinRT
// toast (click-opens-room + quick reply); otherwise a plain toast.
var roomId=opts.tag!=null?String(opts.tag):undefined;
var path=(opts.data&&opts.data.path!=null)?String(opts.data.path):undefined;
if(roomId){
window.__TAURI_INTERNALS__.invoke('show_rich_toast',{
title:String(title),body:body,roomId:roomId,path:path
}).catch(function(){});
}else{
window.__TAURI_INTERNALS__.invoke('send_notification',{
title:String(title),body:body
}).catch(function(){});
}
}catch(_){}
}
TauriNotification.prototype=Object.create(EventTarget.prototype);
TauriNotification.prototype.constructor=TauriNotification;
TauriNotification.prototype.close=function(){};
// get-only 'permission' threw "Cannot set property permission ... which has
// only a getter" when the notification plugin / a polyfill assigned it. Add a
// no-op setter so the value stays 'granted' but assignment can't crash.
Object.defineProperty(TauriNotification,'permission',{get:function(){return 'granted';},set:function(){},configurable:true});
TauriNotification.requestPermission=function(){return Promise.resolve('granted');};
TauriNotification.maxActions=0;
Object.defineProperty(window,'Notification',{value:TauriNotification,writable:true,configurable:true});
var _q=navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query=function(desc){
if(desc&&desc.name==='notifications'){
return Promise.resolve(Object.assign(new EventTarget(),{state:'granted',onchange:null}));
}
return _q(desc);
};
})();"#;
#[tauri::command]
fn send_notification(
app: tauri::AppHandle,
title: String,
body: Option<String>,
) -> Result<(), String> {
use tauri_plugin_notification::NotificationExt;
let mut builder = app.notification().builder().title(&title);
if let Some(b) = &body {
builder = builder.body(b);
}
builder.show().map_err(|e| e.to_string())
}
#[derive(serde::Serialize)]
struct UpdateInfo {
available: bool,
version: Option<String>,
}
#[tauri::command]
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use tauri_plugin_updater::UpdaterExt;
return match app.updater().map_err(|e| e.to_string())?.check().await {
Ok(Some(update)) => Ok(UpdateInfo { available: true, version: Some(update.version) }),
Ok(None) => Ok(UpdateInfo { available: false, version: None }),
Err(e) => Err(e.to_string()),
};
}
#[cfg(any(target_os = "android", target_os = "ios"))]
Ok(UpdateInfo { available: false, version: None })
}
#[tauri::command]
async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use tauri_plugin_updater::UpdaterExt;
if let Some(update) = app
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
{
update
.download_and_install(|_chunk, _total| {}, || {})
.await
.map_err(|e| e.to_string())?;
// Only reached on a successful download+install (the `?` above bails
// otherwise). Relaunch so the freshly installed version actually
// runs — without this the UI hangs on "installing", and on a Linux
// AppImage the running process is still the old binary. `restart()`
// exits the current process and never returns, so nothing after it
// runs for the update case.
app.restart();
}
}
Ok(())
}
#[tauri::command]
fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
// `window` is only consulted on Windows (needs the HWND for the taskbar
// overlay). Bind it elsewhere so cross-platform builds don't warn.
#[cfg(not(target_os = "windows"))]
let _ = &window;
#[cfg(target_os = "windows")]
{
use windows::{
core::{BOOL, PCWSTR},
Win32::{
Foundation::{COLORREF, HWND, RECT},
Graphics::Gdi::{
CreateBitmap, CreateCompatibleDC, CreateDIBSection, CreateFontW, CreatePen,
CreateSolidBrush, DeleteDC, DeleteObject, DIB_RGB_COLORS, DrawTextW, Ellipse,
ReleaseDC, SelectObject, SetBkMode, SetTextColor, BITMAPINFO,
BITMAPINFOHEADER, BI_RGB, CLIP_DEFAULT_PRECIS, DEFAULT_CHARSET, DEFAULT_PITCH,
DEFAULT_QUALITY, DT_CENTER, DT_SINGLELINE, DT_VCENTER, FF_DONTCARE,
FW_BOLD, OUT_DEFAULT_PRECIS, PS_NULL, TRANSPARENT,
},
UI::{
Shell::{ITaskbarList3, TaskbarList},
WindowsAndMessaging::{CreateIconIndirect, DestroyIcon, HICON, ICONINFO},
},
System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
},
};
let hwnd = HWND(window.hwnd().map_err(|e| e.to_string())?.0 as _);
let hicon: Option<HICON> = if count > 0 {
let label = if count > 99 {
"99+".to_string()
} else {
count.to_string()
};
let mut label_wide: Vec<u16> = label.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
let size = 20i32;
let hdc_screen = windows::Win32::Graphics::Gdi::GetDC(None);
let hdc = CreateCompatibleDC(Some(hdc_screen));
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size,
biHeight: -size,
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
let hbm_color =
match CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0) {
Ok(bm) => bm,
Err(e) => {
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
return Err(e.to_string());
}
};
// Zero-init so undrawn pixels are fully transparent (CreateDIBSection
// does not guarantee zeroed memory; garbage bytes cause a black square).
if !bits.is_null() {
std::ptr::write_bytes(bits as *mut u8, 0, (size * size * 4) as usize);
}
let old_bm = SelectObject(hdc, hbm_color.into());
let hbrush = CreateSolidBrush(COLORREF(0x003030DD));
let old_brush = SelectObject(hdc, hbrush.into());
let hpen = CreatePen(PS_NULL, 0, COLORREF(0));
let old_pen = SelectObject(hdc, hpen.into());
let _ = Ellipse(hdc, 0, 0, size, size);
let hfont = CreateFontW(
14,
0,
0,
0,
FW_BOLD.0 as i32,
0,
0,
0,
DEFAULT_CHARSET,
OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS,
DEFAULT_QUALITY,
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
windows::core::w!("Segoe UI"),
);
let old_font = SelectObject(hdc, hfont.into());
SetTextColor(hdc, COLORREF(0x00FFFFFF));
let _ = SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT { left: 0, top: 0, right: size, bottom: size };
let label_len = label_wide.len() - 1;
let _ = DrawTextW(
hdc,
&mut label_wide[..label_len],
&mut rect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
);
SelectObject(hdc, old_brush);
SelectObject(hdc, old_pen);
SelectObject(hdc, old_font);
SelectObject(hdc, old_bm);
let _ = DeleteObject(hbrush.into());
let _ = DeleteObject(hpen.into());
let _ = DeleteObject(hfont.into());
// GDI drawing leaves the alpha channel at 0 for all pixels.
// Set alpha=255 for every painted pixel so Windows uses per-pixel
// alpha compositing instead of falling back to the opaque mask,
// which would render unpainted corner pixels as a black square.
let pixel_count = (size * size) as usize;
let pixels =
std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
for pixel in pixels.iter_mut() {
if *pixel != 0 {
*pixel |= 0xFF00_0000u32;
}
}
let hbm_mask = CreateBitmap(size, size, 1, 1, None);
if hbm_mask.0 as usize == 0 {
let _ = DeleteObject(hbm_color.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
return Err("CreateBitmap failed".to_string());
}
let icon_info = ICONINFO {
fIcon: BOOL(1),
xHotspot: 0,
yHotspot: 0,
hbmMask: hbm_mask,
hbmColor: hbm_color,
};
let hicon = CreateIconIndirect(&icon_info).map_err(|e| {
let _ = DeleteObject(hbm_color.into());
let _ = DeleteObject(hbm_mask.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
e.to_string()
})?;
let _ = DeleteObject(hbm_color.into());
let _ = DeleteObject(hbm_mask.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
Some(hicon)
}
} else {
None
};
unsafe {
let taskbar: ITaskbarList3 =
CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER)
.map_err(|e| e.to_string())?;
taskbar.HrInit().map_err(|e| e.to_string())?;
taskbar
.SetOverlayIcon(hwnd, hicon.unwrap_or_default(), PCWSTR::null())
.map_err(|e| e.to_string())?;
if let Some(icon) = hicon {
let _ = DestroyIcon(icon);
}
}
}
// Linux (P6-1): emit the Unity `LauncherEntry.Update` broadcast signal so
// launchers/docks that speak the com.canonical.Unity.LauncherEntry protocol
// (GNOME "Dash to Dock", KDE task manager, Unity, etc.) render a count
// badge on the app's launcher icon. Best-effort: any D-Bus failure is
// logged and swallowed so a headless/unsupported environment never breaks
// the badge call.
#[cfg(target_os = "linux")]
{
use std::collections::HashMap;
use zbus::zvariant::Value;
// application://<desktop-file-id>.desktop — the installed .desktop
// basename. Tauri v2's Linux bundler names it after mainBinaryName
// ("cinny"), NOT the identifier, so the file is `cinny.desktop`. If the
// badge doesn't attach at runtime, verify against
// /usr/share/applications/ and adjust.
let app_uri = "application://cinny.desktop";
let mut props: HashMap<&str, Value> = HashMap::new();
props.insert("count", Value::from(count as i64));
props.insert("count-visible", Value::from(count > 0));
match zbus::blocking::Connection::session() {
Ok(conn) => {
if let Err(e) = conn.emit_signal(
None::<&str>,
"/com/canonical/unity/launcherentry/lotuschat",
"com.canonical.Unity.LauncherEntry",
"Update",
&(app_uri, props),
) {
eprintln!("badge: Unity LauncherEntry emit failed: {e}");
}
}
Err(e) => eprintln!("badge: D-Bus session connection failed: {e}"),
}
}
Ok(())
}
/// Held in managed state so the tray's unread overlay can be updated at runtime.
/// Keeping the TrayIcon handle here also keeps the tray alive.
struct TrayUnreadState {
tray: tauri::tray::TrayIcon,
base_rgba: Vec<u8>,
width: u32,
height: u32,
}
/// Holds a clone of the tray "Do Not Disturb" `CheckMenuItem` so `get_tray_dnd`
/// can read its live checkstate. The tray only emits `lotus-dnd-changed` on
/// click, but the web `manualDndAtom` is in-memory and resets on every reload,
/// so the web hook re-hydrates from this on mount. `CheckMenuItem` is a cheap
/// clonable handle to the same underlying menu item.
struct TrayDndState(CheckMenuItem<tauri::Wry>);
/// Return the tray DND toggle's current checkstate so the web side can
/// re-hydrate `manualDndAtom` after a reload. Returns `false` when the tray
/// wasn't created (e.g. missing bundled icon) rather than erroring the call.
#[tauri::command]
fn get_tray_dnd(app: tauri::AppHandle) -> bool {
app.try_state::<TrayDndState>()
.map(|s| s.0.is_checked().unwrap_or(false))
.unwrap_or(false)
}
/// Paint a small white-ringed red "unread" dot into the bottom-right corner of
/// an RGBA buffer, in place. Cross-platform (operates on raw pixels).
fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) {
let w = width as i32;
let h = height as i32;
if w <= 0 || h <= 0 {
return;
}
let r = ((w.min(h) as f32) * 0.30) as i32;
let ring = ((r as f32) * 0.18).max(1.0) as i32;
let cx = w - r - ((w as f32) * 0.06) as i32;
let cy = h - r - ((h as f32) * 0.06) as i32;
for y in (cy - r - ring).max(0)..(cy + r + ring).min(h) {
for x in (cx - r - ring).max(0)..(cx + r + ring).min(w) {
let dx = x - cx;
let dy = y - cy;
let dist2 = dx * dx + dy * dy;
let idx = ((y * w + x) as usize) * 4;
if idx + 3 >= rgba.len() {
continue;
}
if dist2 <= r * r {
rgba[idx] = 0xDD;
rgba[idx + 1] = 0x30;
rgba[idx + 2] = 0x30;
rgba[idx + 3] = 0xFF;
} else if dist2 <= (r + ring) * (r + ring) {
rgba[idx] = 0xFF;
rgba[idx + 1] = 0xFF;
rgba[idx + 2] = 0xFF;
rgba[idx + 3] = 0xFF;
}
}
}
}
/// Overlay (or clear) the unread dot on the tray icon.
#[tauri::command]
fn set_tray_unread(unread: bool, state: tauri::State<'_, TrayUnreadState>) -> Result<(), String> {
let mut rgba = state.base_rgba.clone();
if unread {
draw_unread_dot(&mut rgba, state.width, state.height);
}
let icon = tauri::image::Image::new_owned(rgba, state.width, state.height);
state.tray.set_icon(Some(icon)).map_err(|e| e.to_string())
}
/// Flash the taskbar button to draw attention (e.g. a new mention while the
/// window is unfocused). Clears automatically once the window is focused.
#[tauri::command]
fn flash_window(window: tauri::Window) -> Result<(), String> {
window
.request_user_attention(Some(tauri::UserAttentionType::Informational))
.map_err(|e| e.to_string())
}
/// Bring the main window to the foreground. Invoked from the web side when a
/// notification is clicked — the service-worker/page path can't raise the native
/// OS window on its own (a WebView2 `client.focus()` only focuses the document).
#[tauri::command]
fn focus_main_window(app: tauri::AppHandle) {
show_main(&app);
}
use tauri_plugin_updater::UpdaterExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
pub fn run() {
// Advertise the process AUMID BEFORE the main window is built (in `.setup`,
// below) so the window's taskbar button groups under the same identity as the
// pinned installer shortcut. Must precede the WebviewWindowBuilder.
native::aumid::set_process_aumid();
let port: u16 = 44548;
let context = tauri::generate_context!();
let builder = tauri::Builder::default();
#[allow(unused_mut)]
let mut builder = tauri::Builder::default();
// Single-instance MUST be registered first: a second launch focuses the
// existing window (and forwards any matrix: link) instead of colliding on
// the localhost port. Desktop-only plugin.
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
show_main(app);
if let Some(url) = matrix_url_from_args(&argv) {
forward_deeplink(app, &url);
}
}));
}
builder = builder
.invoke_handler(tauri::generate_handler![
set_badge_count,
set_tray_unread,
get_tray_dnd,
flash_window,
focus_main_window,
send_notification,
check_for_update,
install_update,
native::power::set_call_active,
native::jumplist::set_jump_list,
native::thumbbar::set_thumbbar,
native::smtc::set_smtc_call_state,
native::chrome::set_custom_chrome,
native::chrome::window_minimize,
native::chrome::window_toggle_maximize,
native::chrome::window_start_drag,
native::chrome::window_close,
native::toast::show_rich_toast,
])
.plugin(tauri_plugin_localhost::Builder::new(port).build())
.plugin(
// DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome)
// owns the decorated flag. Letting window-state restore a saved
// decorated=false at startup would re-create the frameless window
// BEFORE lib.rs applies Mica (a broken combination) and before the web
// side has pushed the user's current setting.
tauri_plugin_window_state::Builder::default()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(),
)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_deep_link::init());
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
// P6-1 launch-on-login. The web side drives it via the plugin's own
// `plugin:autostart|enable`/`disable`/`is-enabled` commands (no wrapper
// command). The MacosLauncher arg is mandatory by the plugin API even
// though macOS is out of scope; `None` = no extra launch args.
builder = builder.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
));
}
// #[cfg(target_os = "macos")]
// {
// builder = builder.menu(menu::menu());
// }
builder
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_localhost::Builder::new(port).build())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_opener::init())
.setup(move |app| {
// --- System tray: keeps Lotus Chat running in the background so
// notifications keep arriving after the window is closed-to-tray. ---
// Degrade gracefully if the bundled icon is missing rather than
// panicking at startup (the tray simply isn't created).
if let Some(base_icon) = app.default_window_icon().cloned() {
let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?;
// P6-1: Do Not Disturb toggle. The CheckMenuItem auto-flips its
// own checkstate on click; we read the new state and push it to
// the web client, which owns the actual notification-muting.
let dnd_item =
CheckMenuItem::with_id(app, "dnd", "Do Not Disturb", true, false, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let tray_menu =
Menu::with_items(app, &[&open_item, &dnd_item, &separator, &quit_item])?;
// Clone the handle into the menu-event closure so we can query
// is_checked() after the auto-toggle. CheckMenuItem is a cheap
// clonable handle to the same underlying menu item.
let dnd_for_event = dnd_item.clone();
let tray = TrayIconBuilder::with_id("main-tray")
.icon(base_icon.clone())
.tooltip("Lotus Chat")
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id.as_ref() {
"open" => show_main(app),
"quit" => app.exit(0),
"dnd" => {
let checked = dnd_for_event.is_checked().unwrap_or(false);
native::emit_to_web(
app,
"lotus-dnd-changed",
&serde_json::to_string(&serde_json::json!({ "active": checked }))
.unwrap_or_default(),
);
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Ok(Some(update)) = handle.updater().unwrap().check().await {
let version = update.version.clone();
let should_update = handle
.dialog()
.message(format!(
"Version {} is available.\n\nWould you like to update now?",
version
))
.title("Update Available")
.kind(MessageDialogKind::Info)
.buttons(MessageDialogButtons::YesNo)
.blocking_show();
if should_update {
if update.download_and_install(|_, _| {}, || {}).await.is_ok() {
handle.restart();
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
show_main(app);
}
}
}
})
.build(app)?;
// Keep the tray handle (and base icon pixels) in managed state so
// set_tray_unread can re-render the icon at runtime.
let base_rgba = base_icon.rgba().to_vec();
let (width, height) = (base_icon.width(), base_icon.height());
app.manage(TrayUnreadState {
tray,
base_rgba,
width,
height,
});
// Keep a handle to the DND CheckMenuItem so `get_tray_dnd` can
// report its live checkstate for web re-hydration after reload.
app.manage(TrayDndState(dnd_item));
} else {
eprintln!("tray: no bundled window icon; skipping system tray setup");
}
}
}
});
// Dev: use devUrl from tauri.conf.json (http://localhost:8080) to support HMR
#[cfg(debug_assertions)]
let window_url = WebviewUrl::App(Default::default());
// Release: tauri-plugin-localhost serves bundled frontend assets on this port
#[cfg(not(debug_assertions))]
let window_url = {
let url = format!("http://localhost:{}", port).parse().unwrap();
@@ -638,153 +63,13 @@ pub fn run() {
};
let app_handle = app.handle().clone();
// Tracks whether the window's visibility has already been decided:
// set true by the on_page_load reveal (window shown) and by the
// close-to-tray handler (window intentionally hidden). The 8s failsafe
// below only reveals the window if neither of those has happened.
let window_settled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let settled_page_load = window_settled.clone();
let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url)
.title("Lotus Chat")
// First-run defaults; tauri-plugin-window-state restores geometry
// on later launches.
.inner_size(1100.0, 720.0)
.min_inner_size(480.0, 600.0)
.center()
// Start hidden and reveal once the page has painted, to avoid the
// white launch flash.
.visible(false)
.initialization_script(NOTIFICATION_BRIDGE)
.disable_drag_drop_handler()
// P5-42: keep the WebView2 renderer running full-speed while the
// app is closed to the tray, so the Matrix /sync loop and
// notifications aren't throttled/backgrounded by Chromium. Preserves
// Tauri's default WebView2 args (setting this overrides them) and
// appends the Chromium background-throttling disables. Windows-only
// in effect; harmless elsewhere. Does not block system sleep.
.additional_browser_args(
"--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows",
)
.on_page_load(move |window, payload| {
if matches!(payload.event(), PageLoadEvent::Finished) {
// Reveal only on the FIRST settle: later page loads (e.g. a
// logout reload) must not re-show a window the user has
// since closed to the tray.
if !settled_page_load.swap(true, std::sync::atomic::Ordering::SeqCst) {
let _ = window.show();
}
}
})
WebviewWindowBuilder::new(app, "main".to_string(), window_url)
.title("Cinny")
.on_new_window(move |url, _features| {
// Only hand well-known web/mail schemes to the OS opener.
// Forwarding arbitrary schemes (file://, custom protocols)
// bypasses the opener capability scope and reaches the OS.
match url.scheme() {
"http" | "https" | "mailto" => {
let _ = app_handle.opener().open_url(url.as_str(), None::<&str>);
}
other => {
eprintln!("opener: refusing to open URL with scheme '{other}'");
}
}
let _ = app_handle.opener().open_url(url.as_str(), None::<&str>);
NewWindowResponse::Deny
})
.build()?;
// Close-to-tray: hide instead of exiting; the app is quit explicitly
// from the tray menu.
let window_for_close = window.clone();
let settled_close = window_settled.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
// Mark the window state as settled so the failsafe below can't
// re-show a window the user just closed to the tray.
settled_close.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = window_for_close.hide();
}
});
// Failsafe: never leave the window stuck hidden if the page-load
// reveal never fires. Skips if the window state was already settled
// (revealed on page load, or intentionally hidden to the tray).
let window_for_show = window.clone();
let settled_failsafe = window_settled.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(8));
if !settled_failsafe.load(std::sync::atomic::Ordering::SeqCst) {
let _ = window_for_show.show();
}
});
// Deep links (matrix:): route both the cold-start case and the
// already-running case (forwarded via single-instance argv) into the
// web client.
{
use tauri_plugin_deep_link::DeepLinkExt;
// Runtime scheme registration is a Linux/Windows-only API; macOS
// registers the scheme from the bundle config at build time.
#[cfg(any(target_os = "linux", target_os = "windows"))]
let _ = app.deep_link().register_all();
let deep_link_handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
forward_deeplink(&deep_link_handle, url.as_str());
}
});
if let Some(url) = matrix_url_from_args(&std::env::args().collect::<Vec<_>>()) {
forward_deeplink(&app.handle().clone(), &url);
}
}
// Windows 11 Mica backdrop. The app paints an opaque TDS background,
// so this is subtle (mainly window chrome); harmless if unsupported.
#[cfg(target_os = "windows")]
{
let _ = window_vibrancy::apply_mica(&window, Some(true));
}
// Auto-grant camera, microphone, and notification permissions in WebView2.
#[cfg(target_os = "windows")]
window.with_webview(|webview| {
use webview2_com::{
Microsoft::Web::WebView2::Win32::{
COREWEBVIEW2_PERMISSION_KIND,
COREWEBVIEW2_PERMISSION_KIND_CAMERA,
COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
},
PermissionRequestedEventHandler,
};
let controller = webview.controller();
if let Ok(core) = unsafe { controller.CoreWebView2() } {
let handler = PermissionRequestedEventHandler::create(Box::new(
|_sender, args| {
if let Some(args) = args {
let mut kind = COREWEBVIEW2_PERMISSION_KIND(0);
unsafe { args.PermissionKind(&mut kind) }?;
if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
|| kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA
|| kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS
{
unsafe {
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)
}?;
}
}
Ok(())
},
));
let mut token = Default::default();
let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) };
}
})?;
// Native desktop feature modules (power/call-continuity, etc.).
native::setup(app.handle())?;
Ok(())
})
.run(context)
+44
View File
@@ -0,0 +1,44 @@
use tauri::menu::{MenuBuilder, SubmenuBuilder};
use tauri::AppHandle;
pub fn menu() -> tauri::menu::Menu {
let app_menu = SubmenuBuilder::new(app, "Cinny")
.about(Some(Default::default()))
.separator()
.hide()
.hide_others()
.show_all()
.separator()
.quit()
.build()
.unwrap();
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()
.unwrap();
let view_menu = SubmenuBuilder::new(app, "View")
.fullscreen() // `.fullscreen()` works instead of `.enter_fullscreen()`
.build()
.unwrap();
let window_menu = SubmenuBuilder::new(app, "Window")
.minimize()
.build() // no `.zoom()` method directly available
.unwrap();
MenuBuilder::new(app)
.item(&app_menu)
.item(&edit_menu)
.item(&view_menu)
.item(&window_menu)
.build()
.unwrap()
}
-127
View File
@@ -1,127 +0,0 @@
//! P5-41 / P5-35 — Register an AppUserModelID (AUMID) so the WinRT rich toasts in
//! `toast.rs` actually work on Windows.
//!
//! `ToastNotificationManager::CreateToastNotifierWithId` (and the ambient
//! `CreateToastNotifier`) require the process to run under an AUMID that maps to a
//! Start-Menu shortcut carrying `System.AppUserModel.ID`. An unpackaged Win32 app
//! (our NSIS build) has none by default, so `Show()` errored and the rich toast
//! (reply box + click-to-open-room) silently fell back to the plain plugin toast.
//!
//! Two pieces: (1) `set_process_aumid` advertises the AUMID for this process —
//! called at the very top of `run()` so it precedes the main window's taskbar
//! button (which otherwise groups under a mismatched implicit AUMID and shows a
//! second taskbar icon); (2) `ensure_app_user_model_id` installs/refreshes a
//! Start-Menu `.lnk` (same name → overwrites the installer's, no duplicate)
//! carrying the AUMID, reusing the `IShellLinkW` + `IPropertyStore` + `PROPVARIANT`
//! pattern proven in `jumplist.rs`. Best-effort: any failure is logged and
//! swallowed (the toast just keeps falling back, as before — never crash boot).
//!
//! Non-Windows: a no-op.
use tauri::AppHandle;
/// The AUMID this process advertises and that the Start-Menu shortcut carries.
/// `toast.rs` binds the toast notifier to it via `CreateToastNotifierWithId`.
///
/// This MUST equal the AUMID Tauri's NSIS installer stamps on its Desktop /
/// Start-Menu shortcuts (`System.AppUserModel.ID` = the bundle `identifier`),
/// otherwise the running window and a pinned shortcut group under different
/// identities and Windows shows two taskbar icons.
pub const APP_USER_MODEL_ID: &str = "org.lotusguild.lotus-chat";
/// Advertise this process's AUMID. MUST run before the main window is built so
/// the window's taskbar button groups under the same AUMID as the pinned
/// installer shortcut. Plain shell32 export — no COM init required, so this is
/// deliberately separate from the `.lnk` install (which needs its own STA COM).
#[cfg(target_os = "windows")]
pub fn set_process_aumid() {
use windows::core::HSTRING;
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
if let Err(e) =
unsafe { SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(APP_USER_MODEL_ID)) }
{
eprintln!("aumid: SetCurrentProcessExplicitAppUserModelID failed: {e}");
}
}
#[cfg(not(target_os = "windows"))]
pub fn set_process_aumid() {}
#[cfg(target_os = "windows")]
pub fn ensure_app_user_model_id(_app: &AppHandle) {
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, PCWSTR};
// PKEY_AppUserModel_ID lives in EnhancedStorage (same module as jumplist's
// PKEY_Title), NOT PropertiesSystem — use the ready-made constant rather than
// hand-rolling the PROPERTYKEY.
use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, IPersistFile,
StructuredStorage::PROPVARIANT, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Shell::{
PropertiesSystem::IPropertyStore, IShellLinkW, ShellLink,
};
// The process AUMID is advertised earlier (see `set_process_aumid`, called at
// the top of `run()` before the window is built). Here we only install/refresh
// the Start-Menu shortcut carrying the AUMID so Action Center attributes toasts
// to "Lotus Chat". Path via %APPDATA% (avoids the SHGetKnownFolderPath free-mem
// dance); dir already exists for installed apps.
let appdata = match std::env::var_os("APPDATA") {
Some(v) => v,
None => return,
};
let mut lnk = std::path::PathBuf::from(appdata);
lnk.push(r"Microsoft\Windows\Start Menu\Programs");
let _ = std::fs::create_dir_all(&lnk);
lnk.push("Lotus Chat.lnk");
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(_) => return,
};
let exe_wide: Vec<u16> = exe
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let lnk_wide: Vec<u16> = lnk
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
// STA apartment for the shell link objects, mirroring jumplist.rs. All COM
// interfaces are dropped before CoUninitialize.
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
let result = (|| -> windows::core::Result<()> {
unsafe {
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
link.SetPath(PCWSTR(exe_wide.as_ptr()))?;
link.SetIconLocation(PCWSTR(exe_wide.as_ptr()), 0)?;
// Stamp the AUMID onto the link's property store (VT_LPWSTR, exactly
// like PKEY_Title in jumplist.rs).
let store: IPropertyStore = link.cast()?;
let value = PROPVARIANT::from(APP_USER_MODEL_ID);
store.SetValue(&PKEY_AppUserModel_ID, &value)?;
store.Commit()?;
// Persist the .lnk to the Start-Menu Programs folder.
let persist: IPersistFile = link.cast()?;
persist.Save(PCWSTR(lnk_wide.as_ptr()), true)?;
Ok(())
}
})();
if hr.is_ok() {
unsafe { CoUninitialize() };
}
if let Err(e) = result {
eprintln!("aumid: failed to install Start-Menu shortcut: {e}");
}
}
#[cfg(not(target_os = "windows"))]
pub fn ensure_app_user_model_id(_app: &AppHandle) {}
-85
View File
@@ -1,85 +0,0 @@
//! P5-47 — TDS Custom Window Chrome (opt-in, runtime-reversible).
//!
//! When the user opts into custom window chrome, the web client renders its own
//! `<TitleBar/>` (folds/TDS styled) and we strip the OS-native window frame so
//! the two don't stack. This is entirely opt-in: the window is built with native
//! `decorations(true)` and only `set_custom_chrome(true)` makes it frameless, so
//! the safe default is the untouched native frame.
//!
//! Everything here goes through the cross-platform Tauri v2 window API (plus a
//! Windows-only `window_vibrancy` dance in `set_custom_chrome`, since Mica and a
//! frameless window can't coexist). Each command resolves the "main" window and
//! silently no-ops if it isn't present (e.g. during teardown); the `Result`s are
//! intentionally ignored since a failed chrome tweak should never surface as an
//! error to the user.
use tauri::{AppHandle, Manager};
/// Toggle the native window frame. `enabled` = custom chrome on, which means the
/// OS decorations must come **off** (`set_decorations(!enabled)`). Passing
/// `false` restores the native frame, making the feature fully reversible at
/// runtime without a restart.
#[tauri::command]
pub fn set_custom_chrome(app: AppHandle, enabled: bool) {
if let Some(window) = app.get_webview_window("main") {
// Windows: the Mica backdrop (applied at startup in lib.rs) and a
// frameless window are a known-bad combo — stripping WS_CAPTION under a
// system backdrop glitches the whole surface (black/blank window). Drop
// the backdrop before undecorating, and restore it together with the
// native frame when custom chrome turns off.
#[cfg(target_os = "windows")]
if enabled {
let _ = window_vibrancy::clear_mica(&window);
}
let _ = window.set_decorations(!enabled);
// Re-assert the DWM shadow so a frameless window keeps its drop shadow
// and resize borders on Windows (no-op / harmless elsewhere).
let _ = window.set_shadow(true);
#[cfg(target_os = "windows")]
if !enabled {
let _ = window_vibrancy::apply_mica(&window, Some(true));
}
}
}
/// Minimize the main window (custom titlebar min button).
#[tauri::command]
pub fn window_minimize(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.minimize();
}
}
/// Toggle maximize/restore the main window (custom titlebar max button and
/// drag-region double-click).
#[tauri::command]
pub fn window_toggle_maximize(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_maximized().unwrap_or(false) {
let _ = window.unmaximize();
} else {
let _ = window.maximize();
}
}
}
/// Begin an OS-level window drag from the custom titlebar drag region. The web
/// side also marks the drag area with `data-tauri-drag-region`; this command is
/// the explicit fallback so behaviour is identical across platforms.
#[tauri::command]
pub fn window_start_drag(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.start_dragging();
}
}
/// Close from the custom titlebar. Mirrors the app's close-to-tray behaviour
/// (see the `CloseRequested` handler in `lib.rs`): we `hide()` the window rather
/// than exiting, so the tray keeps the app running and the tray menu remains the
/// single explicit quit path.
#[tauri::command]
pub fn window_close(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
-100
View File
@@ -1,100 +0,0 @@
//! P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync.
//!
//! Mirrors the shell's own notification-suppression state so Lotus Chat stops
//! popping desktop notifications while the user is in Focus Assist / Quiet Hours,
//! presenting, gaming full-screen, or otherwise "busy". The web client keeps this
//! in a live jotai atom (`focusAssistActiveAtom`) that the notification gate reads
//! alongside its existing quiet-hours check.
//!
//! Windows: a lightweight background thread polls `SHQueryUserNotificationState`
//! (the same API the shell exposes for "should I show a toast right now?") every
//! ~5 seconds. We prefer a robust poll over hooking shell events — the poll is
//! trivial to reason about and a 5s cadence is more than responsive enough for a
//! notification-suppression hint. We emit **only on a boolean transition**, so the
//! web side gets one event per change rather than a steady heartbeat; the first
//! read always emits so the frontend learns the initial state.
//!
//! Other platforms are a no-op: there's no equivalent cross-platform signal, and
//! the web hook stays unconditional so nothing there needs guarding.
use tauri::AppHandle;
/// Payload for the `focus-assist-changed` DOM event (`{ active: bool }`).
#[cfg(target_os = "windows")]
#[derive(serde::Serialize)]
struct St {
active: bool,
}
/// Called once from lib.rs `native::setup()`. On Windows, spawns the poll
/// thread; elsewhere it does nothing.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
#[cfg(target_os = "windows")]
{
// Own a handle inside the thread so the poll outlives this call and runs
// for the lifetime of the app.
let app = app.clone();
std::thread::spawn(move || watch_focus_assist(app));
}
#[cfg(not(target_os = "windows"))]
{
// No-op on non-Windows platforms (see module docs). Bind the arg so the
// signature stays identical cross-platform with no unused warning.
let _ = app;
}
Ok(())
}
/// Poll loop, runs on its own thread for the app's lifetime.
#[cfg(target_os = "windows")]
fn watch_focus_assist(app: AppHandle) {
use std::time::Duration;
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
use windows::Win32::UI::Shell::{
SHQueryUserNotificationState, QUNS_BUSY, QUNS_PRESENTATION_MODE, QUNS_QUIET_TIME,
QUNS_RUNNING_D3D_FULL_SCREEN,
};
// Initialize COM for this thread in the multithreaded apartment. This is a
// dedicated thread, so it should be the first to init and succeed (S_FALSE —
// "already initialized, same mode" — also counts as success). If it fails
// outright (e.g. RPC_E_CHANGED_MODE) we can't proceed, so bail.
// Safety: FFI call; `None` reserved param per the API contract.
if unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.is_err() {
return;
}
// `None` = unknown; the first successful read is treated as a transition so
// the web side always learns the initial suppression state.
let mut last: Option<bool> = None;
loop {
// `SHQueryUserNotificationState` reports the shell's current
// notification-presentation state. Treat the states where the shell
// itself suppresses toasts as "focus/DND active". Skip transient read
// errors without emitting.
// Safety: FFI call; writes the state into the provided out-param.
if let Ok(state) = unsafe { SHQueryUserNotificationState() } {
let active = state == QUNS_QUIET_TIME
|| state == QUNS_PRESENTATION_MODE
|| state == QUNS_RUNNING_D3D_FULL_SCREEN
|| state == QUNS_BUSY;
if last != Some(active) {
last = Some(active);
super::emit_to_web(
&app,
"focus-assist-changed",
&serde_json::to_string(&St { active }).unwrap_or_default(),
);
}
}
std::thread::sleep(Duration::from_secs(5));
}
// Note: the loop never returns, so we intentionally don't call
// `CoUninitialize` here — COM stays initialized for this thread until the
// process exits, which is exactly the desired lifetime.
}
-147
View File
@@ -1,147 +0,0 @@
//! P5-36 — Windows taskbar Jump List ("Recent Rooms").
//!
//! Publishes a custom Jump List category so users can right-click the taskbar
//! (or Start) icon and jump straight into a recently-active room. The web client
//! calls `set_jump_list([{ title, uri }])` from `useTauriJumpList` whenever its
//! recent-room list changes; each `uri` is a `matrix:` deep link.
//!
//! Windows: builds an `ICustomDestinationList` with an `IObjectCollection` of
//! `IShellLinkW` task links. Each link relaunches the current executable with the
//! room's `matrix:` URI as its single argument — the existing deep-link handler
//! in lib.rs (`forward_deeplink` → `lotus-deeplink`) then routes it to the room.
//! The link's visible label is set via `IPropertyStore` + `PKEY_Title`
//! (System.Title) using a `PROPVARIANT`.
//!
//! COM here runs on the command's (thread-pool) thread, so we initialize an STA
//! apartment with `CoInitializeEx` and balance it with `CoUninitialize` only when
//! we were the ones that initialized it (mirrors the COM usage in
//! `set_badge_count`). All COM interfaces are scoped so they release before the
//! apartment is torn down.
//!
//! Other platforms are a no-op (macOS has no direct equivalent; Linux desktop
//! files differ) — the command stays cross-platform so the web side is
//! unconditional.
use tauri::AppHandle;
/// One Jump List entry supplied by the web client. `uri` is a `matrix:` deep
/// link accepted by the deep-link handler in lib.rs.
#[derive(serde::Deserialize)]
pub struct JumpItem {
pub title: String,
pub uri: String,
}
#[tauri::command]
pub fn set_jump_list(app: AppHandle, items: Vec<JumpItem>) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
// `app` is unused on Windows (COM runs on the calling thread); bind it so
// the signature stays identical cross-platform and no warning fires.
let _ = &app;
use std::os::windows::ffi::OsStrExt;
use windows::{
core::{w, Interface, PCWSTR},
Win32::{
Storage::EnhancedStorage::PKEY_Title,
System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize,
StructuredStorage::PROPVARIANT, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
},
UI::Shell::{
// IObjectArray/IObjectCollection live in Shell::Common
// (feature Win32_UI_Shell_Common), NOT Shell or System::Com.
Common::{IObjectArray, IObjectCollection},
DestinationList, EnumerableObjectCollection, ICustomDestinationList,
IShellLinkW, PropertiesSystem::IPropertyStore, ShellLink,
},
},
};
// Wide, NUL-terminated path to the running executable; reused for every
// link's target and icon. Computed before touching COM so a failure here
// doesn't leak an initialized apartment.
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let exe_wide: Vec<u16> = exe
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
// STA is required for the shell Jump List objects. S_OK means we
// initialized (and must uninitialize); S_FALSE means it was already
// initialized on this thread (still balance it); RPC_E_CHANGED_MODE (an
// error) means don't touch it. Note: `unsafe` does not reach into the
// closure below, so its body carries its own `unsafe` block; the COM
// interfaces it creates are all released (dropped) before we uninitialize.
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
let result = (|| -> windows::core::Result<()> {
unsafe {
let list: ICustomDestinationList =
CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)?;
if items.is_empty() {
// Nothing to show — clear any list we previously published.
list.DeleteList(PCWSTR::null())?;
return Ok(());
}
// BeginList hands back the items the user manually removed; we
// don't re-add anything, so we can ignore it. `min_slots` is the
// max entries the shell will display.
let mut min_slots: u32 = 0;
let _removed: IObjectArray = list.BeginList(&mut min_slots)?;
let collection: IObjectCollection =
CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?;
for item in &items {
let link: IShellLinkW =
CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
// Relaunch this exe with the matrix: URI as its only argument.
link.SetPath(PCWSTR(exe_wide.as_ptr()))?;
let arg_wide: Vec<u16> =
item.uri.encode_utf16().chain(std::iter::once(0)).collect();
link.SetArguments(PCWSTR(arg_wide.as_ptr()))?;
// Use the app's own icon for the entry.
link.SetIconLocation(PCWSTR(exe_wide.as_ptr()), 0)?;
// The visible label comes from System.Title on the link's
// property store (a bare IShellLink has no display name).
let store: IPropertyStore = link.cast()?;
// From<&str> builds a VT_LPWSTR PROPVARIANT (what System.Title expects).
let title = PROPVARIANT::from(item.title.as_str());
store.SetValue(&PKEY_Title, &title)?;
store.Commit()?;
collection.AddObject(&link)?;
}
let array: IObjectArray = collection.cast()?;
list.AppendCategory(w!("Recent Rooms"), &array)?;
list.CommitList()?;
Ok(())
}
})();
// All interfaces above are dropped (released) by the time we get here, so
// it's safe to tear the apartment down.
if hr.is_ok() {
unsafe { CoUninitialize() };
}
result.map_err(|e| e.to_string())?;
}
#[cfg(not(target_os = "windows"))]
{
// No-op on non-Windows platforms (see module docs). Bind the args so the
// signature stays identical cross-platform and no unused warnings fire.
let _ = (&app, &items);
}
Ok(())
}
-47
View File
@@ -1,47 +0,0 @@
//! Native desktop feature modules (Lotus Chat).
//!
//! Each feature lives in its own submodule exposing `#[tauri::command]`(s) and,
//! when it needs to register listeners/state, a `setup(&AppHandle)`. lib.rs adds
//! the commands to `generate_handler!` and calls `native::setup()` once during
//! app setup. Windows-only pieces are guarded with `#[cfg(target_os = "windows")]`
//! and compile-verified in CI (Gitea `windows` runner / GitHub `windows-latest`).
use tauri::{AppHandle, Manager};
pub mod aumid;
pub mod chrome;
pub mod focus_assist;
pub mod jumplist;
pub mod network;
pub mod power;
pub mod smtc;
pub mod thumbbar;
pub mod toast;
/// Dispatch a DOM `CustomEvent` to the web client (mirrors `forward_deeplink` in
/// lib.rs) so native modules can push data to the frontend without pulling in
/// `@tauri-apps/api` on the web side. `detail_json` MUST be valid JSON (use
/// `serde_json::to_string`). `event` is a static, trusted name.
#[allow(dead_code)]
pub fn emit_to_web(app: &AppHandle, event: &str, detail_json: &str) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.eval(&format!(
"window.dispatchEvent(new CustomEvent('{event}',{{detail:{detail_json}}}))"
));
}
}
/// Called once from lib.rs `.setup()`. Feature modules that need to register OS
/// listeners or managed state get initialized here. (jumplist/chrome are
/// command-only and need no setup.)
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
// Register the AUMID + Start-Menu shortcut FIRST so the WinRT rich toast can
// create its notifier (before any notification path fires). Best-effort.
aumid::ensure_app_user_model_id(app);
power::setup(app)?;
thumbbar::setup(app)?;
smtc::setup(app)?;
network::setup(app)?;
focus_assist::setup(app)?;
Ok(())
}
-109
View File
@@ -1,109 +0,0 @@
//! P5-49 — Network awareness (Windows connectivity / NCSI).
//!
//! Proactively detects when the machine gains or loses internet connectivity so
//! the web client can surface an offline state and, more importantly, nudge the
//! matrix client to retry its backed-off `/sync` the instant the network comes
//! back instead of waiting out the sync-loop backoff timer.
//!
//! Windows: a lightweight background thread polls the Network List Manager
//! (`INetworkListManager::IsConnectedToInternet`, the same NCSI signal the shell
//! uses) every ~3 seconds. We prefer a robust poll over a COM event sink
//! (`INetworkEvents`) — the poll is far simpler to reason about, needs no
//! connection-point plumbing, and a 3s cadence is more than responsive enough
//! for a "retry sync now" hint. We emit **only on a state transition**, so the
//! web side gets one event per change rather than a steady heartbeat.
//!
//! Other platforms are a no-op: the browser already fires `online`/`offline`
//! events, and the desktop shells (macOS/Linux) can adopt their own reachability
//! APIs later; the web hook stays unconditional so nothing there needs guarding.
use tauri::AppHandle;
/// Payload for the `network-changed` DOM event (`{ online: bool }`).
#[cfg(target_os = "windows")]
#[derive(serde::Serialize)]
struct NetworkState {
online: bool,
}
/// Called once from lib.rs `native::setup()`. On Windows, spawns the poll
/// thread; elsewhere it does nothing.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
#[cfg(target_os = "windows")]
{
// Own a handle inside the thread so the poll outlives this call and runs
// for the lifetime of the app.
let app = app.clone();
std::thread::spawn(move || watch_network(app));
}
#[cfg(not(target_os = "windows"))]
{
// No-op on non-Windows platforms (see module docs). Bind the arg so the
// signature stays identical cross-platform with no unused warning.
let _ = app;
}
Ok(())
}
/// Poll loop, runs on its own thread for the app's lifetime.
#[cfg(target_os = "windows")]
fn watch_network(app: AppHandle) {
use std::time::Duration;
use windows::Win32::Networking::NetworkListManager::{INetworkListManager, NetworkListManager};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
COINIT_MULTITHREADED,
};
// Initialize COM for this thread in the multithreaded apartment. This is a
// dedicated thread, so it should be the first to init and succeed (S_FALSE —
// "already initialized, same mode" — also counts as success). If it fails
// outright (e.g. RPC_E_CHANGED_MODE) we can't proceed, so bail.
// Safety: FFI call; `None` reserved param per the API contract.
if unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.is_err() {
return;
}
// Create the Network List Manager COM object (mirrors the `CoCreateInstance`
// idiom in lib.rs `set_badge_count`). If it's unavailable, tear COM back down
// and stop the thread cleanly.
// Safety: standard COM instantiation; type is inferred from the annotation.
let manager: INetworkListManager =
match unsafe { CoCreateInstance(&NetworkListManager, None, CLSCTX_INPROC_SERVER) } {
Ok(manager) => manager,
Err(_) => {
// Safety: balances the successful CoInitializeEx above.
unsafe { CoUninitialize() };
return;
}
};
// `None` = unknown; the first successful read is treated as a transition so
// the web side always learns the initial connectivity state.
let mut last: Option<bool> = None;
loop {
// `IsConnectedToInternet` yields a VARIANT_BOOL (VARIANT_TRUE == -1 when
// connected). Skip transient read errors without emitting.
// Safety: FFI call on a live COM interface owned by this thread.
if let Ok(connected) = unsafe { manager.IsConnectedToInternet() } {
let online = connected.as_bool();
if last != Some(online) {
last = Some(online);
super::emit_to_web(
&app,
"network-changed",
&serde_json::to_string(&NetworkState { online }).unwrap_or_default(),
);
}
}
std::thread::sleep(Duration::from_secs(3));
}
// Note: the loop never returns, so we intentionally don't call
// `CoUninitialize` here — COM stays initialized for this thread until the
// process exits, which is exactly the desired lifetime.
}
-175
View File
@@ -1,175 +0,0 @@
//! P5-46 / P6-1 — System power management (call continuity).
//!
//! Prevents the system from sleeping / turning off the display while a voice or
//! video call is active, then releases the request when the call ends. The web
//! client calls `set_call_active(true|false)` from `useTauriCallPower` as the
//! call-embed atom transitions.
//!
//! Windows: `SetThreadExecutionState`. The request is per-thread and persists
//! until cleared, so we run every set/clear on the **main thread** (via
//! `run_on_main_thread`) to guarantee the clear cancels the matching set even
//! though Tauri commands otherwise run on a pool thread.
//!
//! Linux (P6-1): `org.freedesktop.ScreenSaver` `Inhibit`/`UnInhibit` over the
//! session bus (zbus, blocking API). The inhibit cookie returned by `Inhibit`
//! is stored in Tauri managed state (`ScreenSaverInhibit`) so the later
//! `UnInhibit` can release exactly that request. The owning D-Bus **connection**
//! is stored alongside the cookie and kept alive for the inhibit's whole
//! duration: `org.freedesktop.ScreenSaver` auto-releases an inhibit the instant
//! the connection that took it disappears, so a per-call function-local
//! connection would drop the inhibit immediately. The one connection is opened
//! lazily on first inhibit and reused for the matching `UnInhibit`. All D-Bus
//! failures are logged and swallowed — a missing/absent screensaver service must
//! never break a call.
//!
//! macOS is out of scope for P6-1 (would use `IOPMAssertionCreate`) and stays a
//! no-op; the command stays cross-platform so the web side is unconditional.
use tauri::AppHandle;
/// The long-lived screensaver-inhibit state (Linux only). Both fields live
/// behind ONE mutex so the connection and the cookie it produced can never
/// desync: `conn` is the session-bus connection that *owns* the inhibit, and
/// `cookie` is the handle returned by `Inhibit`. The connection is opened once
/// (lazily, on the first inhibit) and reused for the matching `UnInhibit`;
/// keeping it alive here is what stops the screensaver service from
/// auto-releasing the inhibit.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct InhibitState {
conn: Option<zbus::blocking::Connection>,
cookie: Option<u32>,
}
/// Tauri managed state wrapper. Registered in `setup()` and read by
/// `set_call_active` via `AppHandle::state()`.
#[cfg(target_os = "linux")]
pub struct ScreenSaverInhibit(std::sync::Mutex<InhibitState>);
/// Register the Linux screensaver-inhibit managed state. No-op elsewhere.
/// Called once from `native::setup()`.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
#[cfg(target_os = "linux")]
{
use tauri::Manager;
app.manage(ScreenSaverInhibit(std::sync::Mutex::new(
InhibitState::default(),
)));
}
#[cfg(not(target_os = "linux"))]
let _ = app;
Ok(())
}
#[tauri::command]
pub fn set_call_active(app: AppHandle, active: bool) {
#[cfg(target_os = "windows")]
{
let _ = app.run_on_main_thread(move || {
use windows::Win32::System::Power::{
SetThreadExecutionState, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED,
};
let flags = if active {
ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
} else {
// Clearing to ES_CONTINUOUS alone releases the sleep/display
// requirement while leaving no lingering per-thread state.
ES_CONTINUOUS
};
// Safety: FFI call with no pointers; returns the previous state,
// which we don't need.
unsafe {
SetThreadExecutionState(flags);
}
});
}
#[cfg(target_os = "linux")]
{
use tauri::Manager;
// Serialize access to the stored connection+cookie for the duration of
// the D-Bus round-trip. This command is the only touch point, so holding
// the lock across the (short, blocking) call cannot deadlock.
let state = app.state::<ScreenSaverInhibit>();
let mut inner = match state.0.lock() {
Ok(guard) => guard,
Err(e) => {
eprintln!("power: ScreenSaverInhibit mutex poisoned: {e}");
return;
}
};
if active {
// Only take a new inhibit if one isn't already held, so repeated
// set_call_active(true) calls don't leak cookies.
if inner.cookie.is_none() {
// Lazily open the ONE long-lived session connection. Because the
// screensaver service auto-releases an inhibit when the owning
// connection disappears, this connection must outlive the
// inhibit — it stays in managed state and is reused below for
// UnInhibit. Never reopened once established.
if inner.conn.is_none() {
match zbus::blocking::Connection::session() {
Ok(conn) => inner.conn = Some(conn),
Err(e) => {
eprintln!("power: D-Bus session connection failed: {e}");
return;
}
}
}
// Scope the connection borrow so it ends before we mutate
// `inner.cookie` (both go through the MutexGuard's Deref, which
// borrows the whole guard, so the borrows must not overlap).
let res: zbus::Result<u32> = {
let conn = inner
.conn
.as_ref()
.expect("connection set immediately above");
match zbus::blocking::Proxy::new(
conn,
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver",
) {
Ok(proxy) => proxy.call("Inhibit", &("Lotus Chat", "In a Lotus Chat call")),
Err(e) => {
eprintln!("power: ScreenSaver proxy init failed: {e}");
return;
}
}
};
match res {
Ok(cookie) => inner.cookie = Some(cookie),
Err(e) => eprintln!("power: ScreenSaver Inhibit failed: {e}"),
}
}
} else if let Some(cookie) = inner.cookie.take() {
// Release on the SAME connection that took the inhibit. If it's
// somehow gone the inhibit was already auto-released, so nothing to do.
if let Some(conn) = inner.conn.as_ref() {
match zbus::blocking::Proxy::new(
conn,
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver",
) {
Ok(proxy) => {
let res: zbus::Result<()> = proxy.call("UnInhibit", &(cookie,));
if let Err(e) = res {
eprintln!("power: ScreenSaver UnInhibit failed: {e}");
}
}
Err(e) => eprintln!("power: ScreenSaver proxy init failed: {e}"),
}
}
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
// No-op on other platforms (see module docs). Bind the args so the
// signature stays identical cross-platform and no unused warnings fire.
let _ = (&app, active);
}
}
-190
View File
@@ -1,190 +0,0 @@
//! P5-43 — System Media Transport Controls (SMTC) call surface (Windows).
//!
//! Surfaces the active voice/video call to the Windows media overlay (the
//! volume flyout / SMTC card) so the user can mute or hang up from the OS
//! media controls. We create the SMTC via the WinRT interop factory
//! (`ISystemMediaTransportControlsInterop::GetForWindow`), keep the object in
//! managed state, and let the web client drive its state through
//! `set_smtc_call_state` as the call-embed atom / mic state changes.
//!
//! Button mapping: **Play/Pause → mute toggle**, **Stop → end call**. Presses
//! are forwarded to the web client as a `smtc-action` DOM CustomEvent (see
//! `super::emit_to_web`) with `action` in `"mute" | "end"`; the web hook
//! (`useTauriSmtc`) translates them into `CallControl.toggleMicrophone()` /
//! `CallEmbed.hangup()`.
//!
//! RUNTIME NOTE: SMTC is designed for real media apps. For a non-media app the
//! card may not actually appear unless the process owns an active audio session
//! recognised by the system. This module prioritises a clean compile and
//! correct WinRT API usage; visibility of the overlay at runtime is uncertain
//! and may depend on the embedded Element Call iframe holding an audio session.
//!
//! Other platforms are a no-op (SMTC is Windows-only); the command keeps an
//! identical cross-platform signature so the web side stays unconditional.
use tauri::AppHandle;
/// Payload for the `smtc-action` DOM event forwarded to the web client.
#[cfg(target_os = "windows")]
#[derive(serde::Serialize)]
struct Ev {
action: String,
}
/// Holds the SMTC object (and its `ButtonPressed` registration token) in Tauri
/// managed state so `set_smtc_call_state` can update it at runtime. Mirrors the
/// `TrayUnreadState` managed-state pattern in lib.rs.
#[cfg(target_os = "windows")]
struct SmtcState {
controls: std::sync::Mutex<Option<windows::Media::SystemMediaTransportControls>>,
// Kept alive so the ButtonPressed handler stays registered for the app's
// lifetime; never unregistered. (windows 0.61 event registrations return a
// plain i64 token — the EventRegistrationToken newtype is gone.)
_token: i64,
}
/// Called once from `native::setup()`. Creates and configures the SMTC on
/// Windows; no-op elsewhere. SMTC init failures are logged and swallowed so a
/// missing/unsupported overlay never blocks app startup.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
#[cfg(target_os = "windows")]
{
if let Err(err) = init_smtc(app) {
eprintln!("smtc: failed to initialize System Media Transport Controls: {err:?}");
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = app;
}
Ok(())
}
#[cfg(target_os = "windows")]
fn init_smtc(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use tauri::Manager;
use windows::core::{factory, HSTRING};
use windows::Foundation::TypedEventHandler;
use windows::Media::{
MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControls,
SystemMediaTransportControlsButton, SystemMediaTransportControlsButtonPressedEventArgs,
};
use windows::Win32::Foundation::HWND;
use windows::Win32::System::WinRT::ISystemMediaTransportControlsInterop;
let window = app
.get_webview_window("main")
.ok_or("smtc: main window not found")?;
// Match the HWND conversion used by set_badge_count in lib.rs.
let hwnd = HWND(window.hwnd()?.0 as _);
// SMTC has no WinRT constructor; it's obtained per-window via the interop
// factory. `factory::<C, I>()` fetches the activation factory for the
// runtime class `C` cast to the classic COM interop interface `I`.
let interop =
factory::<SystemMediaTransportControls, ISystemMediaTransportControlsInterop>()?;
let controls: SystemMediaTransportControls = unsafe { interop.GetForWindow(hwnd)? };
controls.SetIsEnabled(true)?;
controls.SetIsPlayEnabled(true)?;
controls.SetIsPauseEnabled(true)?;
controls.SetIsStopEnabled(true)?;
// Configure the card metadata once ("In call"); the web side only toggles
// playback status afterwards.
let updater = controls.DisplayUpdater()?;
updater.SetType(MediaPlaybackType::Music)?;
let music = updater.MusicProperties()?;
music.SetTitle(&HSTRING::from("In call"))?;
updater.Update()?;
// Idle until a call becomes active (set_smtc_call_state flips this).
controls.SetPlaybackStatus(MediaPlaybackStatus::Closed)?;
// ButtonPressed → forward a normalized action to the web client.
let app_for_handler = app.clone();
let handler = TypedEventHandler::<
SystemMediaTransportControls,
SystemMediaTransportControlsButtonPressedEventArgs,
>::new(move |_sender, args| {
if let Some(args) = args.as_ref() {
let button = args.Button()?;
let action = if button == SystemMediaTransportControlsButton::Play
|| button == SystemMediaTransportControlsButton::Pause
{
Some("mute")
} else if button == SystemMediaTransportControlsButton::Stop {
Some("end")
} else {
None
};
if let Some(action) = action {
let payload = serde_json::to_string(&Ev {
action: action.to_string(),
})
.unwrap_or_default();
super::emit_to_web(&app_for_handler, "smtc-action", &payload);
}
}
Ok(())
});
let token = controls.ButtonPressed(&handler)?;
app.manage(SmtcState {
controls: std::sync::Mutex::new(Some(controls)),
_token: token,
});
Ok(())
}
/// Reflect the call state onto the SMTC. When `active`, enable the controls and
/// set playback status to Playing (unmuted) / Paused (muted); when inactive,
/// mark the card Closed and disable it. Windows-only; no-op elsewhere.
#[tauri::command]
pub fn set_smtc_call_state(app: AppHandle, active: bool, muted: bool) {
#[cfg(target_os = "windows")]
{
use tauri::Manager;
if let Some(state) = app.try_state::<SmtcState>() {
if let Ok(guard) = state.controls.lock() {
if let Some(controls) = guard.as_ref() {
let _ = apply_call_state(controls, active, muted);
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
// No-op off Windows; bind args so the signature is identical everywhere
// and no unused warnings fire.
let _ = (&app, active, muted);
}
}
#[cfg(target_os = "windows")]
fn apply_call_state(
controls: &windows::Media::SystemMediaTransportControls,
active: bool,
muted: bool,
) -> windows::core::Result<()> {
use windows::Media::MediaPlaybackStatus;
if active {
controls.SetIsEnabled(true)?;
let status = if muted {
MediaPlaybackStatus::Paused
} else {
MediaPlaybackStatus::Playing
};
controls.SetPlaybackStatus(status)?;
} else {
controls.SetPlaybackStatus(MediaPlaybackStatus::Closed)?;
controls.SetIsEnabled(false)?;
}
Ok(())
}
-382
View File
@@ -1,382 +0,0 @@
//! P5-44 — Taskbar thumbnail toolbar (call controls).
//!
//! While a voice/video call is active the web client calls `set_thumbbar` from
//! `useTauriThumbbar`, which mirrors the call-embed atom + mic/sound state onto
//! three buttons on the taskbar thumbnail toolbar: **Mute/Unmute**,
//! **Deafen/Undeafen** and **End Call**. Clicking a button pushes a
//! `thumbbar-action` DOM event back to the web side (`"mute" | "deafen" | "end"`)
//! which drives the real call controls.
//!
//! Windows: `ITaskbarList3::ThumbBarAddButtons` (first call for the window) then
//! `ThumbBarUpdateButtons` (subsequent calls) — mirrors the COM + GDI/HICON idiom
//! in `set_badge_count`. Thumb-button clicks arrive as `WM_COMMAND` with
//! `HIWORD(wParam) == THBN_CLICKED`, so we subclass the main window (installed
//! once in `setup`) to catch them. The main window HWND comes from the "main"
//! webview window; the "buttons added" flag lives in managed `ThumbbarState`
//! (like lib.rs's `TrayUnreadState`) so add-vs-update works across calls.
//!
//! Other platforms are a no-op — the command stays cross-platform so the web
//! side is unconditional.
use tauri::{AppHandle, Manager};
/// Managed state shared with lib.rs (registered in `setup`). Only the Windows
/// path reads `added`; kept cross-platform so `set_thumbbar` can inject it.
#[derive(Default)]
pub struct ThumbbarState {
#[allow(dead_code)]
added: std::sync::atomic::AtomicBool,
}
// Thumb-button ids (LOWORD of wParam on WM_COMMAND / THBN_CLICKED).
#[cfg(target_os = "windows")]
const BTN_MUTE: u32 = 1;
#[cfg(target_os = "windows")]
const BTN_DEAFEN: u32 = 2;
#[cfg(target_os = "windows")]
const BTN_END: u32 = 3;
/// HIWORD(wParam) value on a thumb-button click (CommCtrl `THBN_CLICKED`).
#[cfg(target_os = "windows")]
const THBN_CLICKED: u16 = 0x1800;
/// uIdSubclass passed to SetWindowSubclass — identifies our subclass instance.
#[cfg(target_os = "windows")]
const SUBCLASS_ID: usize = 1;
/// Payload emitted to the web on a thumb-button click.
#[cfg(target_os = "windows")]
#[derive(serde::Serialize)]
struct Action<'a> {
action: &'a str,
}
/// Build a single THUMBBUTTON, attaching an icon (and the THB_ICON mask) when one
/// was created. Always carries a tooltip and enabled/hidden flags.
#[cfg(target_os = "windows")]
fn thumb_button(
id: u32,
hidden: bool,
tip: &str,
icon: Option<windows::Win32::UI::WindowsAndMessaging::HICON>,
) -> windows::Win32::UI::Shell::THUMBBUTTON {
use windows::Win32::UI::Shell::{
THUMBBUTTON, THUMBBUTTONFLAGS, THUMBBUTTONMASK, THBF_ENABLED, THBF_HIDDEN, THB_FLAGS,
THB_ICON, THB_TOOLTIP,
};
use windows::Win32::UI::WindowsAndMessaging::HICON;
let mut mask: THUMBBUTTONMASK = THB_TOOLTIP | THB_FLAGS;
let flags: THUMBBUTTONFLAGS = if hidden { THBF_HIDDEN } else { THBF_ENABLED };
let mut hicon = HICON::default();
if let Some(i) = icon {
mask = mask | THB_ICON;
hicon = i;
}
let mut sz_tip = [0u16; 260];
for (dst, ch) in sz_tip.iter_mut().zip(tip.encode_utf16().take(259)) {
*dst = ch;
}
THUMBBUTTON {
dwMask: mask,
iId: id,
iBitmap: 0,
hIcon: hicon,
szTip: sz_tip,
dwFlags: flags,
}
}
/// Update (or hide) the three thumb-toolbar buttons for the given call state.
#[tauri::command]
pub fn set_thumbbar(
app: AppHandle,
state: tauri::State<'_, ThumbbarState>,
active: bool,
muted: bool,
deafened: bool,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::sync::atomic::Ordering;
use windows::Win32::{
Foundation::HWND,
System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
UI::{
Shell::{ITaskbarList3, TaskbarList},
WindowsAndMessaging::DestroyIcon,
},
};
// Nothing to do (and nothing to hide) if a toolbar was never added.
if !active && !state.added.load(Ordering::SeqCst) {
return Ok(());
}
let window = app
.get_webview_window("main")
.ok_or_else(|| "no main window".to_string())?;
let hwnd = HWND(window.hwnd().map_err(|e| e.to_string())?.0 as _);
let mic_icon = make_icon(Glyph::Mic, muted);
let deaf_icon = make_icon(Glyph::Head, deafened);
let end_icon = make_icon(Glyph::End, false);
let buttons = [
thumb_button(BTN_MUTE, !active, if muted { "Unmute" } else { "Mute" }, mic_icon),
thumb_button(
BTN_DEAFEN,
!active,
if deafened { "Undeafen" } else { "Deafen" },
deaf_icon,
),
thumb_button(BTN_END, !active, "End Call", end_icon),
];
let result = unsafe {
let taskbar: ITaskbarList3 = CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER)
.map_err(|e| e.to_string())?;
taskbar.HrInit().map_err(|e| e.to_string())?;
let r = if state.added.load(Ordering::SeqCst) {
taskbar.ThumbBarUpdateButtons(hwnd, &buttons)
} else {
let r = taskbar.ThumbBarAddButtons(hwnd, &buttons);
if r.is_ok() {
state.added.store(true, Ordering::SeqCst);
}
r
};
r.map_err(|e| e.to_string())
};
// The shell copies the icons on add/update, so release ours (mirrors the
// DestroyIcon after SetOverlayIcon in set_badge_count).
for icon in [mic_icon, deaf_icon, end_icon].into_iter().flatten() {
unsafe {
let _ = DestroyIcon(icon);
}
}
result?;
}
#[cfg(not(target_os = "windows"))]
{
// No-op elsewhere; bind args so the signature stays identical and no
// unused warnings fire.
let _ = (&app, &state, active, muted, deafened);
}
Ok(())
}
/// Which glyph a thumb-button icon draws.
#[cfg(target_os = "windows")]
#[derive(Clone, Copy)]
enum Glyph {
Mic,
Head,
End,
}
/// Draw a simple white monochrome glyph onto a 32x32 32-bpp DIB and wrap it in an
/// `HICON`. Mirrors the CreateDIBSection → alpha-fixup → CreateIconIndirect idiom
/// in `set_badge_count`. Returns `None` on any GDI failure (the button is then
/// added tooltip-only). `slashed` overlays a transparent diagonal cut to signal
/// the muted / deafened state.
#[cfg(target_os = "windows")]
fn make_icon(
glyph: Glyph,
slashed: bool,
) -> Option<windows::Win32::UI::WindowsAndMessaging::HICON> {
use windows::core::BOOL;
use windows::Win32::Foundation::COLORREF;
use windows::Win32::Graphics::Gdi::{
Arc, CreateBitmap, CreateCompatibleDC, CreateDIBSection, CreatePen, CreateSolidBrush,
DeleteDC, DeleteObject, Ellipse, GetDC, LineTo, MoveToEx, ReleaseDC, RoundRect,
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, PS_SOLID,
};
use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, ICONINFO};
unsafe {
let size = 32i32;
let hdc_screen = GetDC(None);
let hdc = CreateCompatibleDC(Some(hdc_screen));
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size,
biHeight: -size,
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
let hbm_color = CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0).ok()?;
if !bits.is_null() {
std::ptr::write_bytes(bits as *mut u8, 0, (size * size * 4) as usize);
}
let old_bm = SelectObject(hdc, hbm_color.into());
let white = COLORREF(0x00FF_FFFF);
let hbrush = CreateSolidBrush(white);
let old_brush = SelectObject(hdc, hbrush.into());
let hpen = CreatePen(PS_SOLID, 2, white);
let old_pen = SelectObject(hdc, hpen.into());
match glyph {
Glyph::Mic => {
// Capsule mic head + stand.
let _ = RoundRect(hdc, 13, 5, 19, 19, 6, 6);
let _ = MoveToEx(hdc, 16, 19, None);
let _ = LineTo(hdc, 16, 25);
let _ = MoveToEx(hdc, 11, 25, None);
let _ = LineTo(hdc, 21, 25);
}
Glyph::Head => {
// Headphone band + two ear cups.
let _ = Arc(hdc, 6, 7, 26, 27, 6, 17, 26, 17);
let _ = RoundRect(hdc, 6, 16, 11, 26, 2, 2);
let _ = RoundRect(hdc, 21, 16, 26, 26, 2, 2);
}
Glyph::End => {
// Filled disc (end-call button).
let _ = Ellipse(hdc, 6, 6, 26, 26);
}
}
if slashed {
// Draw the slash in black (pixel 0), which the alpha fixup below
// leaves fully transparent — carving a visible diagonal gap.
let black_pen = CreatePen(PS_SOLID, 4, COLORREF(0));
let prev = SelectObject(hdc, black_pen.into());
let _ = MoveToEx(hdc, 6, 6, None);
let _ = LineTo(hdc, 26, 26);
SelectObject(hdc, prev);
let _ = DeleteObject(black_pen.into());
}
SelectObject(hdc, old_brush);
SelectObject(hdc, old_pen);
SelectObject(hdc, old_bm);
let _ = DeleteObject(hbrush.into());
let _ = DeleteObject(hpen.into());
// GDI leaves alpha at 0; mark every painted pixel opaque so Windows uses
// per-pixel alpha instead of the opaque mask (same fix as set_badge_count).
let pixel_count = (size * size) as usize;
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
for pixel in pixels.iter_mut() {
if *pixel != 0 {
*pixel |= 0xFF00_0000u32;
}
}
let hbm_mask = CreateBitmap(size, size, 1, 1, None);
if hbm_mask.0 as usize == 0 {
let _ = DeleteObject(hbm_color.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
return None;
}
let icon_info = ICONINFO {
fIcon: BOOL(1),
xHotspot: 0,
yHotspot: 0,
hbmMask: hbm_mask,
hbmColor: hbm_color,
};
let hicon = CreateIconIndirect(&icon_info).ok();
let _ = DeleteObject(hbm_color.into());
let _ = DeleteObject(hbm_mask.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
hicon
}
}
/// Window subclass proc: catches thumb-button clicks (`WM_COMMAND` /
/// `THBN_CLICKED`) and forwards them to the web as `thumbbar-action`. `dwrefdata`
/// is a leaked `Box<AppHandle>` installed by `setup`; it is reclaimed on
/// `WM_NCDESTROY`.
#[cfg(target_os = "windows")]
unsafe extern "system" fn subclass_proc(
hwnd: windows::Win32::Foundation::HWND,
umsg: u32,
wparam: windows::Win32::Foundation::WPARAM,
lparam: windows::Win32::Foundation::LPARAM,
_uidsubclass: usize,
dwrefdata: usize,
) -> windows::Win32::Foundation::LRESULT {
use windows::Win32::Foundation::LRESULT;
use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass};
use windows::Win32::UI::WindowsAndMessaging::{WM_COMMAND, WM_NCDESTROY};
match umsg {
WM_COMMAND => {
let w = wparam.0;
let notif = ((w >> 16) & 0xFFFF) as u16;
let id = (w & 0xFFFF) as u32;
if notif == THBN_CLICKED {
let action = match id {
BTN_MUTE => Some("mute"),
BTN_DEAFEN => Some("deafen"),
BTN_END => Some("end"),
_ => None,
};
if let Some(action) = action {
if dwrefdata != 0 {
// Borrow (do not take ownership of) the leaked AppHandle.
let app = &*(dwrefdata as *const AppHandle);
let detail =
serde_json::to_string(&Action { action }).unwrap_or_default();
super::emit_to_web(app, "thumbbar-action", &detail);
}
return LRESULT(0);
}
}
DefSubclassProc(hwnd, umsg, wparam, lparam)
}
WM_NCDESTROY => {
let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID);
if dwrefdata != 0 {
drop(Box::from_raw(dwrefdata as *mut AppHandle));
}
DefSubclassProc(hwnd, umsg, wparam, lparam)
}
_ => DefSubclassProc(hwnd, umsg, wparam, lparam),
}
}
/// Called once from `native::setup`. Registers `ThumbbarState` and, on Windows,
/// subclasses the main window so thumb-button clicks reach the web client.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
app.manage(ThumbbarState::default());
#[cfg(target_os = "windows")]
{
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::Shell::SetWindowSubclass;
if let Some(window) = app.get_webview_window("main") {
if let Ok(handle) = window.hwnd() {
let hwnd = HWND(handle.0 as _);
// Leak an AppHandle for the proc; reclaimed on WM_NCDESTROY.
let refdata = Box::into_raw(Box::new(app.clone())) as usize;
unsafe {
let _ = SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, refdata);
}
}
}
}
Ok(())
}
-269
View File
@@ -1,269 +0,0 @@
//! P5-41 — Native WinRT toast notifications (+ P5-35 click-opens-room, P5-41 quick reply).
//!
//! The web notification bridge calls `show_rich_toast` (see lib.rs
//! `NOTIFICATION_BRIDGE`) instead of the basic plugin notification so desktop
//! notifications gain a text reply box and a body-click that reopens the room.
//!
//! Windows: we build a `Windows.UI.Notifications.ToastNotification` from a toast
//! XML document (`Windows.Data.Xml.Dom.XmlDocument`) carrying the title + body,
//! an inline `<input id="reply" type="text"/>` and a Send `<action>`. Because the
//! app lives in the tray (always alive) we subscribe to the toast's **in-process**
//! `Activated` event rather than relying on COM activation: the handler downcasts
//! the event args to `ToastActivatedEventArgs`, reads the reply text from
//! `UserInput()` (keyed `"reply"`) and forwards it to the web client. A body click
//! (no reply text) forwards the launch `path` so the web side can route to the
//! room. Live `ToastNotification` objects are parked in a process-global `Vec`
//! (behind a `Mutex`) so their handlers survive until the toast is dismissed.
//!
//! If ANY WinRT step fails (most importantly: no registered AppUserModelID — see
//! the runtime note below), we fall back to the plain `tauri-plugin-notification`
//! notification so notifications always work.
//!
//! Other platforms always take the fallback path; the command keeps an identical
//! cross-platform signature so the web bridge stays unconditional.
//!
//! RUNTIME NOTE (AppUserModelID): WinRT toasts require the process to run under an
//! AppUserModelID that maps to a Start-menu shortcut. The installed app's bundle
//! id is `org.lotusguild.lotus-chat`; if no matching shortcut/AUMID is registered,
//! `CreateToastNotifier()` / `Show()` will error and we silently fall back. Wiring
//! `SetCurrentProcessExplicitAppUserModelID` (+ shortcut install) is handled
//! separately.
use tauri::AppHandle;
/// Show a rich desktop notification. On Windows this is a WinRT toast with a
/// reply box and click-to-open; elsewhere (or on any WinRT error) it degrades to
/// a basic plugin notification. `room_id` is the raw Matrix room id used for the
/// reply payload; `path` is the web hash route used for a body click.
#[tauri::command]
pub fn show_rich_toast(
app: AppHandle,
title: String,
body: Option<String>,
room_id: Option<String>,
path: Option<String>,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
match show_windows_toast(
&app,
&title,
body.as_deref(),
room_id.as_deref(),
path.as_deref(),
) {
Ok(()) => return Ok(()),
Err(err) => {
// Most commonly a missing AppUserModelID (see module note). Fall
// through to the plugin notification so the user still sees it.
eprintln!("toast: WinRT toast failed, falling back to plugin: {err:?}");
}
}
}
// Bind the routing args so the signature is identical cross-platform and no
// unused warnings fire on the fallback (non-Windows) path.
let _ = (&room_id, &path);
show_fallback(&app, &title, body.as_deref())
}
/// Cross-platform fallback: a basic notification via `tauri-plugin-notification`
/// (mirrors `send_notification` in lib.rs). Used off Windows and whenever the
/// WinRT toast path errors.
fn show_fallback(app: &AppHandle, title: &str, body: Option<&str>) -> Result<(), String> {
use tauri_plugin_notification::NotificationExt;
let mut builder = app.notification().builder().title(title);
if let Some(b) = body {
builder = builder.body(b);
}
builder.show().map_err(|e| e.to_string())
}
/// Process-global store keeping live `ToastNotification` objects (and therefore
/// their `Activated`/`Dismissed` handler registrations) alive until dismissed.
/// Lazily initialized so no `native::setup()` wiring is required.
#[cfg(target_os = "windows")]
fn toast_store() -> &'static std::sync::Mutex<Vec<windows::UI::Notifications::ToastNotification>> {
static STORE: std::sync::OnceLock<
std::sync::Mutex<Vec<windows::UI::Notifications::ToastNotification>>,
> = std::sync::OnceLock::new();
STORE.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}
/// Escape text for inclusion in the toast XML (attribute or element content).
#[cfg(target_os = "windows")]
fn xml_escape(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(target_os = "windows")]
fn show_windows_toast(
app: &AppHandle,
title: &str,
body: Option<&str>,
room_id: Option<&str>,
path: Option<&str>,
) -> windows::core::Result<()> {
use windows::core::{HSTRING, IInspectable, Interface};
use windows::Data::Xml::Dom::XmlDocument;
use windows::Foundation::TypedEventHandler;
use windows::UI::Notifications::{
ToastActivatedEventArgs, ToastDismissedEventArgs, ToastNotification,
ToastNotificationManager,
};
// A body click carries the launch arguments back to us; prefer the web hash
// route (`path`), falling back to the raw room id so clicks are never inert.
let launch = path.or(room_id).unwrap_or_default();
let body_line = match body {
Some(b) if !b.is_empty() => format!("<text>{}</text>", xml_escape(b)),
_ => String::new(),
};
// ToastGeneric visual + an inline reply input and a foreground Send action.
// `hint-inputId="reply"` binds the Send button to the text box so the reply
// text arrives in `UserInput()` keyed "reply".
let xml = format!(
r#"<toast activationType="foreground" launch="{launch}">
<visual>
<binding template="ToastGeneric">
<text>{title}</text>
{body_line}
</binding>
</visual>
<actions>
<input id="reply" type="text" placeHolder="Reply..."/>
<action content="Send" arguments="reply" activationType="foreground" hint-inputId="reply"/>
</actions>
</toast>"#,
launch = xml_escape(launch),
title = xml_escape(title),
body_line = body_line,
);
let doc = XmlDocument::new()?;
doc.LoadXml(&HSTRING::from(xml))?;
let toast = ToastNotification::CreateToastNotification(&doc)?;
// In-process activation: the app is always alive in the tray, so we handle
// clicks/replies directly instead of via COM activation.
let app_activated = app.clone();
let room_id_owned = room_id.map(|s| s.to_string());
let path_owned = path.map(|s| s.to_string());
let activated = TypedEventHandler::<ToastNotification, IInspectable>::new(
move |sender, args| {
// Activation means this toast is done; drop it from the keep-alive
// store now. A Dismissed event doesn't reliably fire for a toast the
// user activated, so pruning only on Dismissed would leak it.
if let Some(sender) = sender.as_ref() {
if let Ok(mut store) = toast_store().lock() {
store.retain(|t| t != sender);
}
}
let Some(args) = args.as_ref() else {
return Ok(());
};
let Ok(activated_args) = args.cast::<ToastActivatedEventArgs>() else {
return Ok(());
};
// Extract the reply text (if the Send action / input was used).
let reply = read_reply(&activated_args).unwrap_or_default();
if !reply.is_empty() {
// Quick reply: forward the room id + text to the web client.
let payload = serde_json::json!({
"roomId": room_id_owned.as_deref(),
"text": reply,
})
.to_string();
super::emit_to_web(&app_activated, "lotus-notification-reply", &payload);
} else {
// Plain body click: raise the window to the foreground (the
// "foreground" activationType is unreliable for an unpackaged app,
// so do it explicitly), then forward the launch path so the web
// routes to the room. `show_main` is the shared tray/deep-link
// helper. Not done for the reply branch — an inline quick-reply
// shouldn't yank the window forward.
crate::show_main(&app_activated);
let payload = serde_json::json!({
"path": path_owned.as_deref(),
})
.to_string();
super::emit_to_web(&app_activated, "lotus-notification-activate", &payload);
}
Ok(())
},
);
let _ = toast.Activated(&activated)?;
// Prune the store once the toast leaves the action center so we don't leak
// handler registrations for the app's lifetime.
let dismissed = TypedEventHandler::<ToastNotification, ToastDismissedEventArgs>::new(
move |sender, _args| {
if let Some(sender) = sender.as_ref() {
if let Ok(mut store) = toast_store().lock() {
store.retain(|t| t != sender);
}
}
Ok(())
},
);
let _ = toast.Dismissed(&dismissed)?;
// Keep the toast (and its handlers) alive until dismissed/activated.
if let Ok(mut store) = toast_store().lock() {
store.push(toast.clone());
// Hard cap: if some Dismissed/Activated events are missed, retain only
// the most recent 20 toasts (dropping the oldest) so the store can't
// grow unbounded for the app's lifetime.
let len = store.len();
if len > 20 {
store.drain(0..len - 20);
}
}
// Bind the notifier to our registered AUMID (native::aumid) so it resolves to
// the "Lotus Chat" Start-Menu shortcut rather than an ambient/absent default.
let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(
crate::native::aumid::APP_USER_MODEL_ID,
))?;
notifier.Show(&toast)?;
Ok(())
}
/// Read the quick-reply text from a toast activation. Returns `None` when the
/// toast was activated without submitting the "reply" input (a plain click).
#[cfg(target_os = "windows")]
fn read_reply(
args: &windows::UI::Notifications::ToastActivatedEventArgs,
) -> Option<String> {
use windows::core::{HSTRING, Interface};
use windows::Foundation::IReference;
// UserInput() returns a ValueSet; windows 0.61 exposes its IMap methods
// (HasKey/Lookup) directly on the class (the generic IMap interface itself
// moved to the separate windows-collections crate). The text input value is
// boxed as an IReference<HSTRING>.
let inputs = args.UserInput().ok()?;
let key = HSTRING::from("reply");
if !inputs.HasKey(&key).ok()? {
return None;
}
let value = inputs.Lookup(&key).ok()?;
let reference: IReference<HSTRING> = value.cast().ok()?;
let text = reference.Value().ok()?.to_string();
if text.is_empty() {
None
} else {
Some(text)
}
}
+7 -18
View File
@@ -6,12 +6,6 @@
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "",
"webviewInstallMode": {
"type": "downloadBootstrapper"
},
"nsis": {
"installMode": "currentUser"
},
"wix": {
"bannerPath": "wix/banner.bmp",
"dialogImagePath": "wix/dialogImage.bmp"
@@ -51,26 +45,21 @@
"beforeDevCommand": "cd cinny && npm start",
"devUrl": "http://localhost:8080"
},
"productName": "Lotus Chat",
"productName": "Cinny",
"mainBinaryName": "cinny",
"version": "4.12.2",
"identifier": "org.lotusguild.lotus-chat",
"version": "4.12.1",
"identifier": "in.cinny.app",
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1N0Y0RThCQTJEQzY1NTkKUldSWlpkeWlpMDUvTlVjejMzN0E1U0FiaVpLK05QVkRXdWlMMm1NNUprMXAvTGZSbU5maVovNmwK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE2NDc3NDBGMTAzNTk1NUYKUldSZmxUVVFEM1JIRnRuMjVRTkFOQ21lUFI5KzRMU0s4OWtBS1RNRUVCNE9LcE9GcExNZ2M2NHoK",
"endpoints": [
"https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/release.json"
"https://github.com/cinnyapp/cinny-desktop/releases/download/tauri/release.json"
]
},
"deep-link": {
"desktop": {
"schemes": ["matrix"]
}
}
},
"app": {
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'"
"csp": "default-src 'self' blob: data: filesystem: ws: wss: http: https: tauri:; script-src 'self' 'unsafe-eval' 'unsafe-inline' blob: data: filesystem: ws: wss: http: https: tauri:; img-src 'self' data: blob: filesystem: http: https:; connect-src 'self' blob: ipc: ws: wss: http: https: http://ipc.localhost"
}
}
}
}
Binary file not shown.
-53
View File
@@ -1,53 +0,0 @@
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
int main(int argc, char **argv) {
const char *appdir = "/root/linuxdeploy-root";
char apprun[256], ldbin[256], new_path[4096], new_ldpath[4096];
struct stat st;
snprintf(apprun, sizeof(apprun), "%s/AppRun", appdir);
snprintf(ldbin, sizeof(ldbin), "%s/usr/bin/linuxdeploy", appdir);
/* Strip --appimage-extract-and-run: AppImage runtime flag, not a linuxdeploy flag */
char **new_argv = malloc((argc + 1) * sizeof(char *));
int new_argc = 0;
new_argv[new_argc++] = argv[0];
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--appimage-extract-and-run") != 0)
new_argv[new_argc++] = argv[i];
}
new_argv[new_argc] = NULL;
setenv("APPDIR", appdir, 1);
char *old_path = getenv("PATH");
snprintf(new_path, sizeof(new_path), "%s/usr/bin:%s", appdir, old_path ? old_path : "");
setenv("PATH", new_path, 1);
char *old_ldpath = getenv("LD_LIBRARY_PATH");
snprintf(new_ldpath, sizeof(new_ldpath), "%s/usr/lib:%s/usr/lib/x86_64-linux-gnu:%s",
appdir, appdir, old_ldpath ? old_ldpath : "");
setenv("LD_LIBRARY_PATH", new_ldpath, 1);
/* Write diagnostic log visible in the always() post-step */
FILE *log = fopen("/tmp/ld-wrapper.log", "w");
if (log) {
fprintf(log, "APPDIR=%s\n", appdir);
fprintf(log, "AppRun exists: %s\n", stat(apprun, &st) == 0 ? "yes" : "NO");
fprintf(log, "linuxdeploy exists: %s\n", stat(ldbin, &st) == 0 ? "yes" : "NO");
fprintf(log, "argc=%d new_argc=%d\n", argc, new_argc);
fprintf(log, "args:");
for (int i = 0; i < new_argc; i++) fprintf(log, " [%s]", new_argv[i]);
fprintf(log, "\nPATH=%s\n", getenv("PATH") ? getenv("PATH") : "(null)");
fclose(log);
}
if (stat(ldbin, &st) == 0)
execv(ldbin, new_argv);
execv(apprun, new_argv);
return 1;
}