Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d682e0d86e | ||
|
|
e7176d0d69 | ||
|
|
491fac772f | ||
|
|
8afc39be7a | ||
|
|
52291ef1bb | ||
|
|
6f1bd6a681 |
@@ -1 +0,0 @@
|
|||||||
81e1a25de641f0292863b1404cba728c1eadd00d
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Catch up cinny submodule
|
|
||||||
|
|
||||||
# [matrix #9] cinny's CI only bumps the submodule at most once an hour (its
|
|
||||||
# trigger-desktop job is debounced), so web commits that land inside that
|
|
||||||
# window would otherwise never reach a desktop build. This nightly run (or a
|
|
||||||
# manual dispatch) moves the submodule to cinny's current `lotus` HEAD; the
|
|
||||||
# resulting push starts release.yml as usual. No-op when already current.
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 4 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
catch-up:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Bump cinny submodule to lotus HEAD
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
run: |
|
|
||||||
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
|
|
||||||
cd desktop
|
|
||||||
git config user.email "ci@lotusguild.org"
|
|
||||||
git config user.name "Lotus CI"
|
|
||||||
git submodule update --init cinny
|
|
||||||
git -C cinny fetch origin lotus
|
|
||||||
git -C cinny checkout origin/lotus
|
|
||||||
git add cinny
|
|
||||||
if git diff --cached --quiet; then
|
|
||||||
echo "Submodule already at lotus HEAD, nothing to do"
|
|
||||||
else
|
|
||||||
SHA=$(git -C cinny rev-parse HEAD)
|
|
||||||
git commit -m "chore: bump cinny submodule to ${SHA:0:8} (nightly catch-up)"
|
|
||||||
git push origin main
|
|
||||||
fi
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
name: Build Lotus Chat Desktop
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
# Docs and the catch-up workflow itself don't need a ~30-min Tauri build.
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
- '.gitea/workflows/catch-up.yml'
|
|
||||||
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"
|
|
||||||
|
|
||||||
build-arch:
|
|
||||||
# Needs build-linux's .deb to already be on the release (this job
|
|
||||||
# downloads it from the fixed "latest" asset URL, same as update-manifest
|
|
||||||
# does for signatures) rather than recompiling from source.
|
|
||||||
needs: [prepare, build-linux]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: archlinux:base-devel
|
|
||||||
steps:
|
|
||||||
# actions/checkout@v4 is a Node.js action and archlinux:base-devel has
|
|
||||||
# no Node runtime — it fails with "node: executable file not found".
|
|
||||||
# This job only needs one file, so skip checkout and fetch it directly
|
|
||||||
# from Gitea's raw endpoint instead.
|
|
||||||
- name: Install build tools
|
|
||||||
run: pacman -Sy --noconfirm --needed curl python
|
|
||||||
|
|
||||||
- name: Fetch PKGBUILD
|
|
||||||
run: |
|
|
||||||
mkdir -p packaging/pacman
|
|
||||||
curl -sf "$GITEA_URL/LotusGuild/cinny-desktop/raw/branch/main/packaging/pacman/PKGBUILD" \
|
|
||||||
-o packaging/pacman/PKGBUILD
|
|
||||||
|
|
||||||
- name: Set package version
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.prepare.outputs.version }}
|
|
||||||
run: sed -i "s/^pkgver=.*/pkgver=$VERSION/" packaging/pacman/PKGBUILD
|
|
||||||
|
|
||||||
- name: Build .pkg.tar.zst
|
|
||||||
run: |
|
|
||||||
# makepkg refuses to run as root. --nodeps: the package() step just
|
|
||||||
# extracts the .deb payload, so the runtime `depends` (webkit2gtk,
|
|
||||||
# gstreamer, ...) don't need to be installed on the build container.
|
|
||||||
useradd -m builder
|
|
||||||
chown -R builder:builder packaging/pacman
|
|
||||||
su builder -c 'cd packaging/pacman && makepkg -f --noconfirm --nodeps'
|
|
||||||
|
|
||||||
- name: Upload to release
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
|
||||||
run: |
|
|
||||||
PKG=$(ls packaging/pacman/*.pkg.tar.zst)
|
|
||||||
NAME="LotusChat-x86_64.pkg.tar.zst"
|
|
||||||
|
|
||||||
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
|
|
||||||
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 @"$PKG"
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
name: "Upload zip-archive"
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
zip-archive:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
- name: Create zip including submodules
|
|
||||||
run: |
|
|
||||||
cd ..
|
|
||||||
zip ${{ github.event.repository.name }}/${{ github.event.repository.name }}-${{ github.ref_name }}.zip ${{ github.event.repository.name }} -r
|
|
||||||
- name: Upload zip to release
|
|
||||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
${{ github.event.repository.name }}-${{ github.ref_name }}.zip
|
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
name: "Publish Tauri App"
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
semantic-release:
|
||||||
|
name: Trigger release
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.vars.outputs.tag }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
|
with:
|
||||||
|
node-version-file: ".node-version"
|
||||||
|
package-manager-cache: false
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run semantic-release
|
||||||
|
run: npm run semantic-release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
TAG=$(git describe --tags --abbrev=0)
|
||||||
|
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
zip-archive:
|
||||||
|
needs: semantic-release
|
||||||
|
env:
|
||||||
|
TAURI_VERSION: ${{ needs.semantic-release.outputs.version }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TAURI_VERSION }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- name: Create zip including submodules
|
||||||
|
run: |
|
||||||
|
cd ..
|
||||||
|
zip ${{ github.event.repository.name }}/${{ github.event.repository.name }}-${{ env.TAURI_VERSION }}.zip ${{ github.event.repository.name }} -r
|
||||||
|
- name: Upload zip to release
|
||||||
|
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAURI_VERSION }}
|
||||||
|
files: |
|
||||||
|
${{ github.event.repository.name }}-${{ env.TAURI_VERSION }}.zip
|
||||||
|
|
||||||
|
# Windows-x86_64
|
||||||
|
windows-x86_64:
|
||||||
|
needs: semantic-release
|
||||||
|
env:
|
||||||
|
TAURI_VERSION: ${{ needs.semantic-release.outputs.version }}
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TAURI_VERSION }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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: Move msi
|
||||||
|
run: |
|
||||||
|
$version = $env:TAURI_VERSION.TrimStart('v')
|
||||||
|
Move-Item "src-tauri\target\release\bundle\msi\Cinny_${version}_x64_en-US.msi" "src-tauri\target\release\bundle\msi\Cinny_desktop-x86_64.msi"
|
||||||
|
shell: pwsh
|
||||||
|
- name: Move msi.zip
|
||||||
|
run: |
|
||||||
|
$version = $env:TAURI_VERSION.TrimStart('v')
|
||||||
|
Move-Item "src-tauri\target\release\bundle\msi\Cinny_${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: |
|
||||||
|
$version = $env:TAURI_VERSION.TrimStart('v')
|
||||||
|
Move-Item "src-tauri\target\release\bundle\msi\Cinny_${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@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAURI_VERSION }}
|
||||||
|
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:
|
||||||
|
needs: semantic-release
|
||||||
|
env:
|
||||||
|
TAURI_VERSION: ${{ needs.semantic-release.outputs.version }}
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TAURI_VERSION }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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: Move deb
|
||||||
|
run: |
|
||||||
|
VERSION="${{ env.TAURI_VERSION }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
mv "src-tauri/target/release/bundle/deb/Cinny_${VERSION}_amd64.deb" "src-tauri/target/release/bundle/deb/Cinny_desktop-x86_64.deb"
|
||||||
|
- name: Move AppImage
|
||||||
|
run: |
|
||||||
|
VERSION="${{ env.TAURI_VERSION }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
mv "src-tauri/target/release/bundle/appimage/Cinny_${VERSION}_amd64.AppImage" "src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage"
|
||||||
|
- name: Move AppImage.tar.gz
|
||||||
|
run: |
|
||||||
|
VERSION="${{ env.TAURI_VERSION }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
mv "src-tauri/target/release/bundle/appimage/Cinny_${VERSION}_amd64.AppImage.tar.gz" "src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz"
|
||||||
|
- name: Move AppImage.tar.gz.sig
|
||||||
|
run: |
|
||||||
|
VERSION="${{ env.TAURI_VERSION }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
mv "src-tauri/target/release/bundle/appimage/Cinny_${VERSION}_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@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAURI_VERSION }}
|
||||||
|
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:
|
||||||
|
needs: semantic-release
|
||||||
|
env:
|
||||||
|
TAURI_VERSION: ${{ needs.semantic-release.outputs.version }}
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TAURI_VERSION }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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: Move dmg
|
||||||
|
run: |
|
||||||
|
VERSION="${{ env.TAURI_VERSION }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
mv "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Cinny_${VERSION}_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@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAURI_VERSION }}
|
||||||
|
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, semantic-release]
|
||||||
|
env:
|
||||||
|
TAURI_VERSION: ${{ needs.semantic-release.outputs.version }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TAURI_VERSION }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Run release.json
|
||||||
|
run: npm run release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: ".node-version"
|
node-version-file: ".node-version"
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
[submodule "cinny"]
|
[submodule "cinny"]
|
||||||
path = cinny
|
path = cinny
|
||||||
url = https://code.lotusguild.org/LotusGuild/cinny.git
|
url = https://github.com/cinnyapp/cinny.git
|
||||||
branch = lotus
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# These are commented until we enable lint and typecheck
|
||||||
|
# npx tsc -p tsconfig.json --noEmit
|
||||||
|
# npx lint-staged
|
||||||
@@ -1,20 +1,37 @@
|
|||||||
{
|
{
|
||||||
"defaultHomeserver": 0,
|
"defaultHomeserver": 1,
|
||||||
"homeserverList": [
|
"homeserverList": [
|
||||||
"matrix.lotusguild.org",
|
"converser.eu",
|
||||||
"matrix.org",
|
"matrix.org",
|
||||||
"mozilla.org"
|
"mozilla.org",
|
||||||
|
"unredacted.org",
|
||||||
|
"xmr.se"
|
||||||
],
|
],
|
||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
|
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
|
"spaces": [
|
||||||
"rooms": ["#jellyfin:matrix.org"],
|
"#cinny-space:matrix.org",
|
||||||
"servers": ["matrixrooms.info"]
|
"#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": {
|
"hashRouter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"basename": "/"
|
"basename": "/"
|
||||||
},
|
}
|
||||||
"gifApiKey": ""
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,59 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.12.2",
|
"version": "4.11.2",
|
||||||
"description": "Yet another matrix client",
|
"description": "Yet another matrix client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "shx cp config.json cinny/ && tauri",
|
"tauri": "cp config.json cinny/ && tauri",
|
||||||
"release": "node scripts/release.mjs",
|
"release": "node scripts/release.mjs",
|
||||||
"bump": "node scripts/update-version.mjs"
|
"lint": "npm run check:eslint && npm run check:prettier",
|
||||||
|
"check:eslint": "eslint src/*",
|
||||||
|
"check:prettier": "prettier --check .",
|
||||||
|
"fix:prettier": "prettier --write .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prepare": "husky install",
|
||||||
|
"commit": "git-cz",
|
||||||
|
"semantic-release": "semantic-release"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx,js,jsx}": "eslint",
|
||||||
|
"*": "prettier --ignore-unknown --write"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"commitizen": {
|
||||||
|
"path": "./node_modules/cz-conventional-changelog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"branches": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"prepareCmd": "node scripts/update-version.mjs ${nextRelease.version}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/git",
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"src-tauri/Cargo.toml",
|
||||||
|
"src-tauri/tauri.conf.json"
|
||||||
|
],
|
||||||
|
"message": "chore(release): ${nextRelease.version} [skip ci]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@semantic-release/github"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ajay Bura",
|
"author": "Ajay Bura",
|
||||||
@@ -29,8 +73,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/github": "6.0.0",
|
"@actions/github": "6.0.0",
|
||||||
|
"@semantic-release/exec": "7.1.0",
|
||||||
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tauri-apps/cli": "2.7.1",
|
"@tauri-apps/cli": "2.7.1",
|
||||||
|
"cz-conventional-changelog": "3.3.0",
|
||||||
|
"husky": "9.1.7",
|
||||||
|
"lint-staged": "16.3.2",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"shx": "0.4.0"
|
"semantic-release": "25.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Built and published by CI (.gitea/workflows/release.yml, build-arch job) —
|
|
||||||
# not published to AUR, just a real .pkg.tar.zst uploaded to the Gitea
|
|
||||||
# release alongside the .deb/AppImage/exe. CI sed-patches pkgver below to the
|
|
||||||
# release version before building.
|
|
||||||
#
|
|
||||||
# Repackages the .deb the same CI already builds (build-linux) rather than
|
|
||||||
# recompiling from source: the web client needs the private @lotusguild/*
|
|
||||||
# npm registry, and build-linux already produced a working Linux binary —
|
|
||||||
# no reason to build it twice.
|
|
||||||
pkgname=lotus-chat
|
|
||||||
pkgver=0.0.0
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="Matrix chat client for Lotus Guild (desktop app)"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://chat.lotusguild.org"
|
|
||||||
license=('AGPL-3.0-only')
|
|
||||||
depends=(
|
|
||||||
'webkit2gtk-4.1'
|
|
||||||
'gtk3'
|
|
||||||
'libayatana-appindicator'
|
|
||||||
# WebRTC calling: WebKitGTK's media pipeline is backed by GStreamer, and
|
|
||||||
# these are the plugin sets that actually carry audio/video codecs.
|
|
||||||
'gst-plugins-good'
|
|
||||||
'gst-plugins-bad'
|
|
||||||
'gst-plugins-ugly'
|
|
||||||
'gst-libav'
|
|
||||||
)
|
|
||||||
provides=('lotus-chat')
|
|
||||||
conflicts=('lotus-chat')
|
|
||||||
options=('!strip')
|
|
||||||
source=("LotusChat-x86_64.deb::https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.deb")
|
|
||||||
sha256sums=('SKIP')
|
|
||||||
|
|
||||||
package() {
|
|
||||||
bsdtar -O -xf "$srcdir/LotusChat-x86_64.deb" data.tar.zst 2>/dev/null \
|
|
||||||
|| bsdtar -O -xf "$srcdir/LotusChat-x86_64.deb" data.tar.xz 2>/dev/null \
|
|
||||||
|| bsdtar -O -xf "$srcdir/LotusChat-x86_64.deb" data.tar.gz \
|
|
||||||
| bsdtar -C "$pkgdir" -xf -
|
|
||||||
}
|
|
||||||
@@ -46,19 +46,17 @@ console.log("Updating cinny web submodule");
|
|||||||
|
|
||||||
execSync("git submodule update --init --recursive", { stdio: "inherit" });
|
execSync("git submodule update --init --recursive", { stdio: "inherit" });
|
||||||
|
|
||||||
execSync("git fetch --tags", { cwd: "cinny", stdio: "inherit" });
|
execSync("cd cinny && git fetch --tags", { stdio: "inherit" });
|
||||||
|
|
||||||
const latestCommit = execSync("git rev-list --tags --max-count=1", {
|
const latestTag = execSync(
|
||||||
cwd: "cinny",
|
"cd cinny && git describe --tags $(git rev-list --tags --max-count=1)"
|
||||||
}).toString().trim();
|
)
|
||||||
|
.toString()
|
||||||
const latestTag = execSync(`git describe --tags ${latestCommit}`, {
|
.trim();
|
||||||
cwd: "cinny",
|
|
||||||
}).toString().trim();
|
|
||||||
|
|
||||||
console.log(`Latest cinny tag: ${latestTag}`);
|
console.log(`Latest cinny tag: ${latestTag}`);
|
||||||
|
|
||||||
execSync(`git checkout ${latestTag}`, { cwd: "cinny", stdio: "inherit" });
|
execSync(`cd cinny && git checkout ${latestTag}`, { stdio: "inherit" });
|
||||||
|
|
||||||
execSync("git add cinny", { stdio: "inherit" });
|
execSync("git add cinny", { stdio: "inherit" });
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "cinny"
|
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.11.2"
|
||||||
description = "Yet another matrix client"
|
description = "Yet another matrix client"
|
||||||
authors = ["Ajay Bura"]
|
authors = ["Ajay Bura"]
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
repository = "https://github.com/cinnyapp/cinny-desktop"
|
repository = "https://github.com/cinnyapp/cinny-desktop"
|
||||||
default-run = "cinny"
|
default-run = "cinny"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.61"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
@@ -17,12 +17,17 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0.109"
|
serde_json = "1.0.109"
|
||||||
serde = { version = "1.0.193", features = ["derive"] }
|
serde = { version = "1.0.193", features = ["derive"] }
|
||||||
tauri = { version = "2", features = ["devtools", "wry", "tray-icon", "image-png"] }
|
tauri = { version = "2", features = [ "devtools"] }
|
||||||
tauri-plugin-localhost = "2"
|
tauri-plugin-localhost = "2"
|
||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
|
tauri-plugin-clipboard-manager = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-http = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
@@ -33,47 +38,8 @@ default = [ "custom-protocol" ]
|
|||||||
custom-protocol = [ "tauri/custom-protocol" ]
|
custom-protocol = [ "tauri/custom-protocol" ]
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-single-instance = "2"
|
|
||||||
tauri-plugin-autostart = "2" # P6-1 launch-on-login
|
|
||||||
# Update retry backoff (already in the tree via tauri; adds only the timer).
|
|
||||||
tokio = { version = "1", features = ["time"] }
|
|
||||||
|
|
||||||
[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"
|
|
||||||
# Same version wry/tauri already pull in transitively (see Cargo.lock) — pinning
|
|
||||||
# it directly lets us reach WebKitSettings/permission APIs wry doesn't expose.
|
|
||||||
webkit2gtk = "2.0"
|
|
||||||
|
|
||||||
[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_Input_KeyboardAndMouse", # cinny-desktop #2 global PTT/deafen poll
|
|
||||||
"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",
|
|
||||||
] }
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "app_lib"
|
name = "app_lib"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -10,6 +10,6 @@
|
|||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"deep-link:default"
|
"global-shortcut:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,16 +2,20 @@
|
|||||||
"identifier": "migrated",
|
"identifier": "migrated",
|
||||||
"description": "permissions that were migrated from v1",
|
"description": "permissions that were migrated from v1",
|
||||||
"local": true,
|
"local": true,
|
||||||
"remote": {
|
|
||||||
"urls": [
|
|
||||||
"http://localhost:44548"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"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-create",
|
||||||
"core:window:allow-center",
|
"core:window:allow-center",
|
||||||
"core:window:allow-request-user-attention",
|
"core:window:allow-request-user-attention",
|
||||||
@@ -45,15 +49,34 @@
|
|||||||
"core:window:allow-set-ignore-cursor-events",
|
"core:window:allow-set-ignore-cursor-events",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:webview:allow-print",
|
"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",
|
"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-show",
|
||||||
"core:app:allow-app-hide",
|
"core:app:allow-app-hide",
|
||||||
"autostart:allow-enable",
|
"clipboard-manager:default"
|
||||||
"autostart:allow-disable",
|
|
||||||
"autostart:allow-is-enabled",
|
|
||||||
{
|
|
||||||
"identifier": "opener:allow-open-url",
|
|
||||||
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["updater:default","global-shortcut:default"],"platforms":["macOS","windows","linux"]},"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"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","core:window:allow-set-resizable","core:window:allow-set-maximizable","core:window:allow-set-minimizable","core:window:allow-set-closable","core:window:allow-set-title","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-minimize","core:window:allow-unminimize","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-decorations","core:window:allow-set-always-on-top","core:window:allow-set-content-protected","core:window:allow-set-size","core:window:allow-set-min-size","core:window:allow-set-max-size","core:window:allow-set-position","core:window:allow-set-fullscreen","core:window:allow-set-focus","core:window:allow-set-icon","core:window:allow-set-skip-taskbar","core:window:allow-set-cursor-grab","core:window:allow-set-cursor-visible","core:window:allow-set-cursor-icon","core:window:allow-set-cursor-position","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","clipboard-manager:default",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]}]}}
|
{"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["updater:default","global-shortcut:default"],"platforms":["macOS","windows","linux"]},"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"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","core:window:allow-set-resizable","core:window:allow-set-maximizable","core:window:allow-set-minimizable","core:window:allow-set-closable","core:window:allow-set-title","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-minimize","core:window:allow-unminimize","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-decorations","core:window:allow-set-always-on-top","core:window:allow-set-content-protected","core:window:allow-set-size","core:window:allow-set-min-size","core:window:allow-set-max-size","core:window:allow-set-position","core:window:allow-set-fullscreen","core:window:allow-set-focus","core:window:allow-set-icon","core:window:allow-set-skip-taskbar","core:window:allow-set-cursor-grab","core:window:allow-set-cursor-visible","core:window:allow-set-cursor-icon","core:window:allow-set-cursor-position","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","clipboard-manager:default"]}}
|
||||||
@@ -2060,174 +2060,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"identifier": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:default",
|
|
||||||
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:allow-default-urls",
|
|
||||||
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the open_path command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:allow-open-path",
|
|
||||||
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the open_url command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:allow-open-url",
|
|
||||||
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:allow-reveal-item-in-dir",
|
|
||||||
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the open_path command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:deny-open-path",
|
|
||||||
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the open_url command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:deny-open-url",
|
|
||||||
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:deny-reveal-item-in-dir",
|
|
||||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"allow": {
|
|
||||||
"items": {
|
|
||||||
"title": "OpenerScopeEntry",
|
|
||||||
"description": "Opener scope entry.",
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"app": {
|
|
||||||
"description": "An application to open this url with, for example: firefox.",
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/Application"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"path"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"app": {
|
|
||||||
"description": "An application to open this path with, for example: xdg-open.",
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/Application"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"path": {
|
|
||||||
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deny": {
|
|
||||||
"items": {
|
|
||||||
"title": "OpenerScopeEntry",
|
|
||||||
"description": "Opener scope entry.",
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"app": {
|
|
||||||
"description": "An application to open this url with, for example: firefox.",
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/Application"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"path"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"app": {
|
|
||||||
"description": "An application to open this path with, for example: xdg-open.",
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/Application"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"path": {
|
|
||||||
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"identifier": {
|
|
||||||
"description": "Identifier of the permission or permission set.",
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/Identifier"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"if": {
|
"if": {
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6590,54 +6422,6 @@
|
|||||||
"const": "notification:deny-show",
|
"const": "notification:deny-show",
|
||||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:default",
|
|
||||||
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:allow-default-urls",
|
|
||||||
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the open_path command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:allow-open-path",
|
|
||||||
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the open_url command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:allow-open-url",
|
|
||||||
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:allow-reveal-item-in-dir",
|
|
||||||
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the open_path command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:deny-open-path",
|
|
||||||
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the open_url command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:deny-open-url",
|
|
||||||
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "opener:deny-reveal-item-in-dir",
|
|
||||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n\n#### This default permission set includes:\n\n- `allow-arch`\n- `allow-exe-extension`\n- `allow-family`\n- `allow-locale`\n- `allow-os-type`\n- `allow-platform`\n- `allow-version`",
|
"description": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n\n#### This default permission set includes:\n\n- `allow-arch`\n- `allow-exe-extension`\n- `allow-family`\n- `allow-locale`\n- `allow-os-type`\n- `allow-platform`\n- `allow-version`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -7028,23 +6812,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Application": {
|
|
||||||
"description": "Opener scope application.",
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"description": "Open in default application.",
|
|
||||||
"type": "null"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "If true, allow open with any application.",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Allow specific application to open with.",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ShellScopeEntryAllowedArg": {
|
"ShellScopeEntryAllowedArg": {
|
||||||
"description": "A command argument allowed to be executed by the webview API.",
|
"description": "A command argument allowed to be executed by the webview API.",
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,35 +0,0 @@
|
|||||||
; Lotus Chat NSIS installer hooks (tauri.conf.json → bundle.windows.nsis.installerHooks).
|
|
||||||
|
|
||||||
; cinny-desktop #1 — auto-update hit "Error opening file for writing: cinny.exe".
|
|
||||||
; The updater launches this installer with /UPDATE and then exits the running
|
|
||||||
; app, but a WebView2 app takes a moment to die; the template only sleeps 500 ms
|
|
||||||
; after its own kill before copying files, so the copy raced the old process and
|
|
||||||
; Ignore left the old exe in place. On an update, wait until the exe can really
|
|
||||||
; be opened for writing (up to 15 s), then let the template carry on as usual.
|
|
||||||
!macro NSIS_HOOK_PREINSTALL
|
|
||||||
Push $R7
|
|
||||||
Push $R8
|
|
||||||
Push $R9
|
|
||||||
ClearErrors
|
|
||||||
${GetOptions} $CMDLINE "/UPDATE" $R7
|
|
||||||
IfErrors lotus_exe_ready
|
|
||||||
IfFileExists "$INSTDIR\${MAINBINARYNAME}.exe" 0 lotus_exe_ready
|
|
||||||
StrCpy $R9 0
|
|
||||||
lotus_exe_probe:
|
|
||||||
ClearErrors
|
|
||||||
FileOpen $R8 "$INSTDIR\${MAINBINARYNAME}.exe" a
|
|
||||||
IfErrors lotus_exe_locked
|
|
||||||
FileClose $R8
|
|
||||||
Goto lotus_exe_ready
|
|
||||||
lotus_exe_locked:
|
|
||||||
IntOp $R9 $R9 + 1
|
|
||||||
; 60 × 250 ms = 15 s; past that fall through to the template's own
|
|
||||||
; running-app handling rather than hang the update.
|
|
||||||
IntCmp $R9 60 lotus_exe_ready 0 lotus_exe_ready
|
|
||||||
Sleep 250
|
|
||||||
Goto lotus_exe_probe
|
|
||||||
lotus_exe_ready:
|
|
||||||
Pop $R9
|
|
||||||
Pop $R8
|
|
||||||
Pop $R7
|
|
||||||
!macroend
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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) {}
|
|
||||||
@@ -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) {
|
|
||||||
// Same rules as the OS close button (tray / quit / first-close dialog,
|
|
||||||
// cinny-desktop #5).
|
|
||||||
crate::handle_close_request(&app);
|
|
||||||
}
|
|
||||||
@@ -1,120 +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 latest
|
|
||||||
//! reading is also kept in [`LAST_STATE`] and served by [`get_focus_assist`]: the
|
|
||||||
//! first read happens during app setup, before the page has loaded, so that
|
|
||||||
//! event is lost, and the web atom resets on every reload anyway. The web hook
|
|
||||||
//! queries it on mount (Gitea cinny-desktop #15).
|
|
||||||
//!
|
|
||||||
//! 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 std::sync::atomic::{AtomicU8, Ordering};
|
|
||||||
use tauri::AppHandle;
|
|
||||||
|
|
||||||
/// Latest poll result: 0 = not read yet (or not Windows), 1 = inactive, 2 = active.
|
|
||||||
static LAST_STATE: AtomicU8 = AtomicU8::new(0);
|
|
||||||
|
|
||||||
/// Return the latest Focus Assist reading so the web side can hydrate
|
|
||||||
/// `focusAssistActiveAtom` on mount. `None` until the first successful poll, and
|
|
||||||
/// always `None` off Windows.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_focus_assist() -> Option<bool> {
|
|
||||||
match LAST_STATE.load(Ordering::Relaxed) {
|
|
||||||
1 => Some(false),
|
|
||||||
2 => Some(true),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
LAST_STATE.store(if active { 2 } else { 1 }, Ordering::Relaxed);
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
//! System-wide voice hotkeys (cinny-desktop #2).
|
|
||||||
//!
|
|
||||||
//! PTT and deafen are DOM key handlers in cinny and only fire while the Lotus
|
|
||||||
//! window (or the Element Call iframe) has focus — the moment a player alt-tabs
|
|
||||||
//! into a fullscreen game the voice controls stop working. This module keeps
|
|
||||||
//! them working while the window is unfocused.
|
|
||||||
//!
|
|
||||||
//! Design: a **non-consuming poll**, not `RegisterHotKey`/global-shortcut. A
|
|
||||||
//! registered hotkey swallows the key system-wide — a bare `Space` PTT would
|
|
||||||
//! stop every other app from typing spaces, and `M` for deafen would eat the
|
|
||||||
//! letter everywhere. Instead, while a call is joined, a background thread
|
|
||||||
//! samples `GetAsyncKeyState` for the two configured virtual keys every ~8 ms
|
|
||||||
//! and emits a DOM event **only on a press/release transition**. The key still
|
|
||||||
//! reaches the game. The web side ignores these events while the Lotus window
|
|
||||||
//! itself has focus (its DOM handlers own that case, with their editable-field
|
|
||||||
//! and modifier checks), so nothing double-fires.
|
|
||||||
//!
|
|
||||||
//! Windows only. Linux X11 could poll `XQueryKeymap` and Wayland has no
|
|
||||||
//! non-consuming path at all; both are no-ops here and `global_hotkeys_supported`
|
|
||||||
//! reports `false` so the web side hides the toggle.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use tauri::{AppHandle, State};
|
|
||||||
|
|
||||||
/// One binding the web side wants watched: `id` is echoed back in the event
|
|
||||||
/// (`ptt` / `deafen`), `code` is the W3C `KeyboardEvent.code` the user picked in
|
|
||||||
/// Settings (the same value the DOM handlers compare against).
|
|
||||||
#[derive(serde::Deserialize, Clone)]
|
|
||||||
pub struct HotkeyBinding {
|
|
||||||
pub id: String,
|
|
||||||
pub code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Managed state: the currently running poll (if any) and its stop flag.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct HotkeyPoll {
|
|
||||||
inner: Mutex<Option<PollHandle>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PollHandle {
|
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
thread: std::thread::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop_current(state: &HotkeyPoll) {
|
|
||||||
let handle = state.inner.lock().ok().and_then(|mut guard| guard.take());
|
|
||||||
if let Some(handle) = handle {
|
|
||||||
handle.stop.store(true, Ordering::Relaxed);
|
|
||||||
let _ = handle.thread.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this platform can watch keys without consuming them.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn global_hotkeys_supported() -> bool {
|
|
||||||
cfg!(target_os = "windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the watched set. An empty list stops the poll (call on hangup /
|
|
||||||
/// setting off / quit). Unknown `code`s are skipped, never an error — the DOM
|
|
||||||
/// path still handles them in-focus.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn set_global_hotkeys(
|
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, HotkeyPoll>,
|
|
||||||
bindings: Vec<HotkeyBinding>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
stop_current(&state);
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let watched: Vec<(String, u16)> = bindings
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|b| vk_from_code(&b.code).map(|vk| (b.id, vk)))
|
|
||||||
.collect();
|
|
||||||
if watched.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
|
||||||
let thread = {
|
|
||||||
let stop = stop.clone();
|
|
||||||
let app = app.clone();
|
|
||||||
std::thread::spawn(move || poll_keys(app, watched, stop))
|
|
||||||
};
|
|
||||||
if let Ok(mut guard) = state.inner.lock() {
|
|
||||||
*guard = Some(PollHandle { stop, thread });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
let _ = (app, bindings);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Payload for the `lotus-global-hotkey` DOM event.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
struct HotkeyEvent<'a> {
|
|
||||||
id: &'a str,
|
|
||||||
state: &'static str,
|
|
||||||
ctrl: bool,
|
|
||||||
alt: bool,
|
|
||||||
meta: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn poll_keys(app: AppHandle, watched: Vec<(String, u16)>, stop: Arc<AtomicBool>) {
|
|
||||||
use std::time::Duration;
|
|
||||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
|
||||||
GetAsyncKeyState, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN,
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_down = |vk: u16| -> bool {
|
|
||||||
// High bit set = currently down. Safe: pure read of key state.
|
|
||||||
(unsafe { GetAsyncKeyState(vk as i32) } as u16) & 0x8000 != 0
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut was_down = vec![false; watched.len()];
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
|
||||||
for (i, (id, vk)) in watched.iter().enumerate() {
|
|
||||||
let down = is_down(*vk);
|
|
||||||
if down == was_down[i] {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
was_down[i] = down;
|
|
||||||
let payload = HotkeyEvent {
|
|
||||||
id,
|
|
||||||
state: if down { "pressed" } else { "released" },
|
|
||||||
ctrl: is_down(VK_CONTROL.0),
|
|
||||||
alt: is_down(VK_MENU.0),
|
|
||||||
meta: is_down(VK_LWIN.0) || is_down(VK_RWIN.0),
|
|
||||||
};
|
|
||||||
if let Ok(json) = serde_json::to_string(&payload) {
|
|
||||||
super::emit_to_web(&app, "lotus-global-hotkey", &json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a W3C `KeyboardEvent.code` to a Windows virtual-key code. Covers the
|
|
||||||
/// keys a user can realistically bind in Settings → Calls; anything else is
|
|
||||||
/// `None` and is simply not watched globally.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn vk_from_code(code: &str) -> Option<u16> {
|
|
||||||
use windows::Win32::UI::Input::KeyboardAndMouse::*;
|
|
||||||
|
|
||||||
if let Some(letter) = code.strip_prefix("Key") {
|
|
||||||
let mut chars = letter.chars();
|
|
||||||
if let (Some(c), None) = (chars.next(), chars.next()) {
|
|
||||||
if c.is_ascii_uppercase() {
|
|
||||||
return Some(c as u16); // VK_A..VK_Z == 'A'..'Z'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if let Some(digit) = code.strip_prefix("Digit") {
|
|
||||||
let mut chars = digit.chars();
|
|
||||||
if let (Some(c), None) = (chars.next(), chars.next()) {
|
|
||||||
if c.is_ascii_digit() {
|
|
||||||
return Some(c as u16); // VK_0..VK_9 == '0'..'9'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if let Some(n) = code.strip_prefix("Numpad") {
|
|
||||||
if let Ok(d) = n.parse::<u16>() {
|
|
||||||
if d <= 9 {
|
|
||||||
return Some(VK_NUMPAD0.0 + d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(n) = code.strip_prefix('F') {
|
|
||||||
if let Ok(f) = n.parse::<u16>() {
|
|
||||||
if (1..=24).contains(&f) {
|
|
||||||
return Some(VK_F1.0 + (f - 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let vk = match code {
|
|
||||||
"Space" => VK_SPACE,
|
|
||||||
"Tab" => VK_TAB,
|
|
||||||
"Enter" | "NumpadEnter" => VK_RETURN,
|
|
||||||
"Backspace" => VK_BACK,
|
|
||||||
"Escape" => VK_ESCAPE,
|
|
||||||
"CapsLock" => VK_CAPITAL,
|
|
||||||
"ShiftLeft" => VK_LSHIFT,
|
|
||||||
"ShiftRight" => VK_RSHIFT,
|
|
||||||
"ControlLeft" => VK_LCONTROL,
|
|
||||||
"ControlRight" => VK_RCONTROL,
|
|
||||||
"AltLeft" => VK_LMENU,
|
|
||||||
"AltRight" => VK_RMENU,
|
|
||||||
"MetaLeft" => VK_LWIN,
|
|
||||||
"MetaRight" => VK_RWIN,
|
|
||||||
"ContextMenu" => VK_APPS,
|
|
||||||
"Insert" => VK_INSERT,
|
|
||||||
"Delete" => VK_DELETE,
|
|
||||||
"Home" => VK_HOME,
|
|
||||||
"End" => VK_END,
|
|
||||||
"PageUp" => VK_PRIOR,
|
|
||||||
"PageDown" => VK_NEXT,
|
|
||||||
"ArrowLeft" => VK_LEFT,
|
|
||||||
"ArrowUp" => VK_UP,
|
|
||||||
"ArrowRight" => VK_RIGHT,
|
|
||||||
"ArrowDown" => VK_DOWN,
|
|
||||||
"NumLock" => VK_NUMLOCK,
|
|
||||||
"ScrollLock" => VK_SCROLL,
|
|
||||||
"Pause" => VK_PAUSE,
|
|
||||||
"PrintScreen" => VK_SNAPSHOT,
|
|
||||||
"NumpadMultiply" => VK_MULTIPLY,
|
|
||||||
"NumpadAdd" => VK_ADD,
|
|
||||||
"NumpadSubtract" => VK_SUBTRACT,
|
|
||||||
"NumpadDecimal" => VK_DECIMAL,
|
|
||||||
"NumpadDivide" => VK_DIVIDE,
|
|
||||||
"Backquote" => VK_OEM_3,
|
|
||||||
"Minus" => VK_OEM_MINUS,
|
|
||||||
"Equal" => VK_OEM_PLUS,
|
|
||||||
"BracketLeft" => VK_OEM_4,
|
|
||||||
"BracketRight" => VK_OEM_6,
|
|
||||||
"Backslash" => VK_OEM_5,
|
|
||||||
"Semicolon" => VK_OEM_1,
|
|
||||||
"Quote" => VK_OEM_7,
|
|
||||||
"Comma" => VK_OEM_COMMA,
|
|
||||||
"Period" => VK_OEM_PERIOD,
|
|
||||||
"Slash" => VK_OEM_2,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(vk.0)
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -1,48 +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 hotkeys;
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -1,343 +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.
|
|
||||||
//!
|
|
||||||
//! Coalescing (cinny-desktop #16): the web notification's `tag` becomes the
|
|
||||||
//! toast's `Tag` (hashed — WinRT caps it at 64 chars) in a fixed `Group`, so a
|
|
||||||
//! newer toast for the same room/thread *replaces* the older one in the Action
|
|
||||||
//! Center instead of stacking, matching the browser's `tag` semantics.
|
|
||||||
//!
|
|
||||||
//! Reply routing (cinny-desktop #17): the reply target is the real `room_id` (+
|
|
||||||
//! `thread_id`), never the tag. Toasts without a room id (invites) get no reply
|
|
||||||
//! box.
|
|
||||||
//!
|
|
||||||
//! Mark as read (cinny-desktop #9): toasts with a room id also carry a
|
|
||||||
//! **Mark as read** button (`arguments="mark_read"`); activating it forwards the
|
|
||||||
//! room id so the web client sends the read receipt, without raising the window.
|
|
||||||
//!
|
|
||||||
//! 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 (no reply box without one) and `thread_id` threads the reply;
|
|
||||||
/// `tag` coalesces toasts; `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>,
|
|
||||||
tag: Option<String>,
|
|
||||||
room_id: Option<String>,
|
|
||||||
thread_id: Option<String>,
|
|
||||||
path: Option<String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
match show_windows_toast(
|
|
||||||
&app,
|
|
||||||
&title,
|
|
||||||
body.as_deref(),
|
|
||||||
tag.as_deref(),
|
|
||||||
room_id.as_deref(),
|
|
||||||
thread_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 _ = (&tag, &room_id, &thread_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())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A live toast plus the (hashed) coalescing tag it was shown under.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
type StoredToast = (Option<String>, windows::UI::Notifications::ToastNotification);
|
|
||||||
|
|
||||||
/// 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<StoredToast>> {
|
|
||||||
static STORE: std::sync::OnceLock<std::sync::Mutex<Vec<StoredToast>>> =
|
|
||||||
std::sync::OnceLock::new();
|
|
||||||
STORE.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toast group shared by every Lotus toast, so `Tag` alone identifies a bucket.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
const TOAST_GROUP: &str = "lotus";
|
|
||||||
|
|
||||||
/// Map a web notification tag (a room id, `room:thread`, `lotus-invites`, …) to
|
|
||||||
/// a WinRT toast tag. WinRT limits `Tag` to 64 characters and room + thread ids
|
|
||||||
/// easily exceed that, so hash it to a fixed 16-hex-char key. Stable within a
|
|
||||||
/// process, which is all replacement needs.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn toast_tag(tag: &str) -> String {
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
||||||
tag.hash(&mut hasher);
|
|
||||||
format!("{:016x}", hasher.finish())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Escape text for inclusion in the toast XML (attribute or element content).
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn xml_escape(input: &str) -> String {
|
|
||||||
input
|
|
||||||
.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace('\'', "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn show_windows_toast(
|
|
||||||
app: &AppHandle,
|
|
||||||
title: &str,
|
|
||||||
body: Option<&str>,
|
|
||||||
tag: Option<&str>,
|
|
||||||
room_id: Option<&str>,
|
|
||||||
thread_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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// An inline reply input, a Send action and Mark as read, only when there is
|
|
||||||
// a room to act on. `hint-inputId="reply"` binds the Send button to the text
|
|
||||||
// box so the reply text arrives in `UserInput()` keyed "reply". Both are
|
|
||||||
// "foreground" because the in-process Activated event (not COM background
|
|
||||||
// activation) is what an unpackaged app receives; `arguments` tells them
|
|
||||||
// apart.
|
|
||||||
let actions = if room_id.is_some() {
|
|
||||||
r#"<actions>
|
|
||||||
<input id="reply" type="text" placeHolder="Reply..."/>
|
|
||||||
<action content="Send" arguments="reply" activationType="foreground" hint-inputId="reply"/>
|
|
||||||
<action content="Mark as read" arguments="mark_read" activationType="foreground"/>
|
|
||||||
</actions>"#
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let xml = format!(
|
|
||||||
r#"<toast activationType="foreground" launch="{launch}">
|
|
||||||
<visual>
|
|
||||||
<binding template="ToastGeneric">
|
|
||||||
<text>{title}</text>
|
|
||||||
{body_line}
|
|
||||||
</binding>
|
|
||||||
</visual>
|
|
||||||
{actions}
|
|
||||||
</toast>"#,
|
|
||||||
launch = xml_escape(launch),
|
|
||||||
title = xml_escape(title),
|
|
||||||
body_line = body_line,
|
|
||||||
actions = actions,
|
|
||||||
);
|
|
||||||
|
|
||||||
let doc = XmlDocument::new()?;
|
|
||||||
doc.LoadXml(&HSTRING::from(xml))?;
|
|
||||||
|
|
||||||
let toast = ToastNotification::CreateToastNotification(&doc)?;
|
|
||||||
|
|
||||||
// Same tag + group → Windows replaces the earlier toast instead of stacking.
|
|
||||||
let win_tag = tag.map(toast_tag);
|
|
||||||
if let Some(t) = &win_tag {
|
|
||||||
toast.SetTag(&HSTRING::from(t.as_str()))?;
|
|
||||||
toast.SetGroup(&HSTRING::from(TOAST_GROUP))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 thread_id_owned = thread_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();
|
|
||||||
let action = activated_args
|
|
||||||
.Arguments()
|
|
||||||
.map(|a| a.to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if action == "mark_read" {
|
|
||||||
// Mark as read: no window raise, like the quick reply.
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"roomId": room_id_owned.as_deref(),
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
super::emit_to_web(&app_activated, "lotus-notification-mark-read", &payload);
|
|
||||||
} else 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(),
|
|
||||||
"threadId": thread_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() {
|
|
||||||
// The toast this one replaces is gone from the Action Center; drop its
|
|
||||||
// keep-alive entry too (its Dismissed event isn't guaranteed to fire).
|
|
||||||
if win_tag.is_some() {
|
|
||||||
store.retain(|(t, _)| *t != win_tag);
|
|
||||||
}
|
|
||||||
store.push((win_tag.clone(), 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,6 @@
|
|||||||
"certificateThumbprint": null,
|
"certificateThumbprint": null,
|
||||||
"digestAlgorithm": "sha256",
|
"digestAlgorithm": "sha256",
|
||||||
"timestampUrl": "",
|
"timestampUrl": "",
|
||||||
"webviewInstallMode": {
|
|
||||||
"type": "downloadBootstrapper"
|
|
||||||
},
|
|
||||||
"nsis": {
|
|
||||||
"installMode": "currentUser",
|
|
||||||
"installerHooks": "./nsis/hooks.nsh"
|
|
||||||
},
|
|
||||||
"wix": {
|
"wix": {
|
||||||
"bannerPath": "wix/banner.bmp",
|
"bannerPath": "wix/banner.bmp",
|
||||||
"dialogImagePath": "wix/dialogImage.bmp"
|
"dialogImagePath": "wix/dialogImage.bmp"
|
||||||
@@ -52,26 +45,21 @@
|
|||||||
"beforeDevCommand": "cd cinny && npm start",
|
"beforeDevCommand": "cd cinny && npm start",
|
||||||
"devUrl": "http://localhost:8080"
|
"devUrl": "http://localhost:8080"
|
||||||
},
|
},
|
||||||
"productName": "Lotus Chat",
|
"productName": "Cinny",
|
||||||
"mainBinaryName": "cinny",
|
"mainBinaryName": "cinny",
|
||||||
"version": "4.12.2",
|
"version": "4.11.2",
|
||||||
"identifier": "org.lotusguild.lotus-chat",
|
"identifier": "in.cinny.app",
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1N0Y0RThCQTJEQzY1NTkKUldSWlpkeWlpMDUvTlVjejMzN0E1U0FiaVpLK05QVkRXdWlMMm1NNUprMXAvTGZSbU5maVovNmwK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE2NDc3NDBGMTAzNTk1NUYKUldSZmxUVVFEM1JIRnRuMjVRTkFOQ21lUFI5KzRMU0s4OWtBS1RNRUVCNE9LcE9GcExNZ2M2NHoK",
|
||||||
"endpoints": [
|
"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": {
|
"app": {
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||