Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af6615ca98 | |||
| bfd176e338 | |||
| 0800cf2327 | |||
| 32dd6561ab | |||
| 690be31a1d | |||
| 5cb6b44744 | |||
| 0c315e9250 | |||
| ff4a04265b | |||
| 040c8afef2 | |||
| de00d087f6 | |||
| afffe6958b | |||
| 09e489a79b | |||
| 3c7ea720f7 | |||
| a52ee06d73 | |||
| 744e608b1d | |||
| c1e46ddedf | |||
| ca612b33bb | |||
| 8ada70dcf7 | |||
| d21553088e | |||
| f37ef1df8f | |||
| df9cc34c5d | |||
| d176ae396c | |||
| 66190fc7af | |||
| c92b1f1e8b | |||
| d0700d0475 | |||
| 095783baa9 | |||
| de38fceff2 | |||
| 5cc84991f2 | |||
| 150a1921f9 | |||
| a7aad94755 | |||
| ff3d4b4a18 | |||
| f70f749216 | |||
| fd565e1edc | |||
| 83725e1a2a | |||
| a543c98ae1 | |||
| a386226073 | |||
| 6f9db2187f | |||
| 1662fbab2b | |||
| 0eb0b223a2 | |||
| 524fa61c01 | |||
| 0306842284 | |||
| 2d20634107 | |||
| eaacfb2189 | |||
| 75292b0011 | |||
| d73ee5660d | |||
| e5757026fb | |||
| 02021b4446 | |||
| 16173ede84 | |||
| f6723f9723 | |||
| b8f0d7d498 | |||
| a0707e6113 | |||
| 5da2069eba | |||
| 368953c0d6 | |||
| 3a5269acfc | |||
| 5b891074da | |||
| 478e455e24 | |||
| bac3aa25e7 | |||
| cf41b6880d | |||
| 79839951bb | |||
| 56b64a7885 | |||
| 90aec9edf2 | |||
| 9da1e56bc8 | |||
| 352d9085c3 | |||
| f6a6849a9e | |||
| f8f6564d55 | |||
| 18d53ad054 | |||
| 087e3cf92f | |||
| 9cc58c8772 | |||
| 8335a68b0f | |||
| 40392e117e | |||
| 07d6b18ccf | |||
| 08926e76e4 | |||
| 2de368847c | |||
| d34eaafd34 | |||
| e0375bf9a9 | |||
| a306fdf3bd | |||
| a7e0d7bef9 | |||
| 6028a41d65 | |||
| 768c286d4a | |||
| 44d75881f9 | |||
| 3be18b8e5d | |||
| 858f0e13bd | |||
| 49c7075b92 | |||
| 0bdaa66d24 | |||
| 32286d03de | |||
| 817430f11b | |||
| 1dc045ef1e | |||
| 72a03dac33 | |||
| cee7809155 | |||
| 4ccd2845fb | |||
| b4812e6659 | |||
| f0100c0c0c | |||
| ed718704ee | |||
| dffe5fb05a | |||
| 03d7ae05e0 | |||
| 2f55bb3f6d | |||
| c151c2936c | |||
| 7c77c8e850 | |||
| 667ad72583 | |||
| 4b71e80636 | |||
| 07327845b3 | |||
| d043f9c416 | |||
| eadd3bfc48 | |||
| 26bd0722bc | |||
| 443c85c9b5 | |||
| efcc11771e | |||
| 4288eb2c02 | |||
| e009af0575 | |||
| c466d93519 | |||
| d4968b935b | |||
| f45817d4ac | |||
| a6aae9d6f2 | |||
| 838c69f46e | |||
| 0b7ace5dfa | |||
| 8f0f8db201 | |||
| ceda17edc4 | |||
| 12c860de0c | |||
| 12a559dc6d | |||
| 938fa0953b | |||
| 5f678ff088 | |||
| df39489916 | |||
| b2288bc9b2 | |||
| 0923312111 | |||
| 279117ecf2 |
@@ -0,0 +1 @@
|
||||
81e1a25de641f0292863b1404cba728c1eadd00d
|
||||
@@ -10,55 +10,58 @@ env:
|
||||
REPO: LotusGuild/cinny-desktop
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create.outputs.release_id }}
|
||||
version: ${{ steps.create.outputs.version }}
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
release_id: ${{ steps.release.outputs.release_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Compute version
|
||||
id: ver
|
||||
run: echo "version=4.12.${{ github.run_number }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create rolling latest release
|
||||
id: create
|
||||
- name: Create or update release
|
||||
id: release
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(python3 -c "import json; print(json.load(open('src-tauri/tauri.conf.json'))['version'])")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Delete existing latest release+tag if present
|
||||
OLD=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/tags/latest" \
|
||||
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)
|
||||
OLD_ID=$(echo "$OLD" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
if [ -n "$OLD_ID" ] && [ "$OLD_ID" != "None" ] && [ "$OLD_ID" != "" ]; then
|
||||
curl -sf -X DELETE "$GITEA_URL/api/v1/repos/$REPO/releases/$OLD_ID" -H "Authorization: token $TOKEN" || true
|
||||
curl -f -X DELETE "$GITEA_URL/api/v1/repos/$REPO/tags/latest" -H "Authorization: token $TOKEN" || 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
|
||||
|
||||
# Create fresh release
|
||||
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'])")
|
||||
echo "release_id=$RELEASE_ID" >> $GITHUB_OUTPUT
|
||||
|
||||
build-windows:
|
||||
needs: prepare-release
|
||||
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: Get version
|
||||
id: version
|
||||
- name: Patch version
|
||||
shell: powershell
|
||||
run: |
|
||||
$v = (Get-Content src-tauri\tauri.conf.json | ConvertFrom-Json).version
|
||||
"version=$v" >> $env:GITHUB_OUTPUT
|
||||
$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:
|
||||
@@ -78,32 +81,58 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run tauri -- build
|
||||
# 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.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||
VERSION: ${{ needs.prepare-release.outputs.version }}
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
$msi = "src-tauri\target\release\bundle\msi"
|
||||
$releaseId = $env:RELEASE_ID
|
||||
$VERSION = $env:VERSION
|
||||
Write-Host "Version: $VERSION Release: $releaseId"
|
||||
|
||||
$nsis = "src-tauri\target\release\bundle\nsis"
|
||||
$files = @(
|
||||
"$msi\Cinny_${VERSION}_x64_en-US.msi",
|
||||
"$msi\Cinny_${VERSION}_x64_en-US.msi.zip",
|
||||
"$msi\Cinny_${VERSION}_x64_en-US.msi.zip.sig"
|
||||
"$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.msi", "LotusChat-x86_64.msi.zip", "LotusChat-x86_64.msi.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/$RELEASE_ID/assets?name=$($names[$i])" `
|
||||
Invoke-RestMethod -Uri "$env:GITEA_URL/api/v1/repos/$env:REPO/releases/$releaseId/assets?name=$($names[$i])" `
|
||||
-Method Post `
|
||||
-Headers @{ Authorization = "token $TOKEN"; "Content-Type" = "application/octet-stream" } `
|
||||
-Headers @{ Authorization = "token $env:TOKEN"; "Content-Type" = "application/octet-stream" } `
|
||||
-Body $bytes
|
||||
}
|
||||
|
||||
build-linux:
|
||||
needs: prepare-release
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -114,62 +143,159 @@ jobs:
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(python3 -c "import json; print(json.load(open('src-tauri/tauri.conf.json'))['version'])")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install system deps
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
curl wget file \
|
||||
curl wget file gcc imagemagick \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libssl-dev \
|
||||
libxdo-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
xdg-utils
|
||||
xdg-utils \
|
||||
squashfs-tools
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- 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'
|
||||
run: npm run tauri -- build
|
||||
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.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
deb="src-tauri/target/release/bundle/deb/Cinny_${VERSION}_amd64.deb"
|
||||
appimage="src-tauri/target/release/bundle/appimage/Cinny_${VERSION}_amd64.AppImage"
|
||||
declare -A uploads=(
|
||||
["LotusChat-x86_64.deb"]="$deb"
|
||||
["LotusChat-x86_64.AppImage"]="$appimage"
|
||||
["LotusChat-x86_64.AppImage.tar.gz"]="${appimage}.tar.gz"
|
||||
["LotusChat-x86_64.AppImage.tar.gz.sig"]="${appimage}.tar.gz.sig"
|
||||
)
|
||||
for name in "${!uploads[@]}"; do
|
||||
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 @"${uploads[$name]}"
|
||||
done
|
||||
--data-binary @"$path"
|
||||
}
|
||||
|
||||
upload "LotusChat-x86_64.AppImage" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage"
|
||||
upload "LotusChat-x86_64.AppImage.tar.gz" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage.tar.gz"
|
||||
upload "LotusChat-x86_64.AppImage.tar.gz.sig" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage.tar.gz.sig"
|
||||
upload "LotusChat-x86_64.deb" "$DEB_DIR/Lotus Chat_${VERSION}_amd64.deb"
|
||||
|
||||
update-manifest:
|
||||
needs: [prepare, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate and upload release.json
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
BASE="$GITEA_URL/LotusGuild/cinny-desktop/releases/download/latest"
|
||||
|
||||
WIN_SIG=$(curl -sf "$BASE/LotusChat-x86_64-setup.nsis.zip.sig")
|
||||
LIN_SIG=$(curl -sf "$BASE/LotusChat-x86_64.AppImage.tar.gz.sig")
|
||||
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,29 +1,11 @@
|
||||
{
|
||||
"defaultHomeserver": 1,
|
||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org",
|
||||
"matrix.org",
|
||||
"mozilla.org"
|
||||
],
|
||||
"allowCustomHomeservers": true,
|
||||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:unredacted.org",
|
||||
"#librewolf-community:matrix.org",
|
||||
"#stickers-and-emojis:tastytea.de",
|
||||
"#videogames:waywardinn.com",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
],
|
||||
"rooms": [
|
||||
"#tuwunel:grin.hu",
|
||||
"#freesoftware:matrix.org",
|
||||
"#gentoo:matrix.org"
|
||||
],
|
||||
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
"enabled": true,
|
||||
"basename": "/"
|
||||
|
||||
@@ -17,7 +17,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1.0.109"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
tauri = { version = "2", features = [ "devtools"] }
|
||||
tauri = { version = "2", features = ["devtools", "wry", "tray-icon", "image-png"] }
|
||||
tauri-plugin-localhost = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
@@ -29,6 +29,7 @@ tauri-plugin-process = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
@@ -41,6 +42,17 @@ custom-protocol = [ "tauri/custom-protocol" ]
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
webview2-com = "0.38"
|
||||
window-vibrancy = "0.6"
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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,7 @@
|
||||
],
|
||||
"permissions": [
|
||||
"updater:default",
|
||||
"global-shortcut:default"
|
||||
"global-shortcut:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 332 KiB |
@@ -3,31 +3,452 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
// mod menu;
|
||||
|
||||
use tauri::{webview::{NewWindowResponse, WebviewWindowBuilder}, WebviewUrl};
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder},
|
||||
Manager, WebviewUrl,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
/// Bring the main window to the foreground from the tray / a hidden /
|
||||
/// minimized state. Shared by the tray, single-instance, and deep-link paths.
|
||||
fn show_main(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a `matrix:` / `matrix.to` URL to the web app by dispatching a DOM
|
||||
/// CustomEvent the client listens for (see useDeepLinkNavigate.ts). Uses
|
||||
/// `eval` so we don't need the @tauri-apps/api event package on the web side.
|
||||
fn forward_deeplink(app: &tauri::AppHandle, url: &str) {
|
||||
show_main(app);
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let Ok(json) = serde_json::to_string(url) {
|
||||
let _ = window.eval(&format!(
|
||||
"window.dispatchEvent(new CustomEvent('lotus-deeplink',{{detail:{json}}}))"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the first `matrix:` link out of a process's CLI args (Windows/Linux
|
||||
/// pass deep-link URLs as argv to a freshly launched instance).
|
||||
fn matrix_url_from_args(args: &[String]) -> Option<String> {
|
||||
args.iter().find(|a| a.starts_with("matrix:")).cloned()
|
||||
}
|
||||
|
||||
// Injected into every page before app scripts load.
|
||||
// Patches window.Notification to route through tauri-plugin-notification so
|
||||
// WebView2's default "denied" state never reaches cinny's permission check.
|
||||
// Also patches navigator.permissions.query so the React hook sees "granted".
|
||||
const NOTIFICATION_BRIDGE: &str = r#"(function(){
|
||||
function TauriNotification(title,options){
|
||||
var opts=options||{};
|
||||
try{
|
||||
window.__TAURI_INTERNALS__.invoke('send_notification',{
|
||||
title:String(title),
|
||||
body:opts.body!=null?String(opts.body):undefined
|
||||
}).catch(function(){});
|
||||
}catch(_){}
|
||||
}
|
||||
TauriNotification.prototype=Object.create(EventTarget.prototype);
|
||||
TauriNotification.prototype.constructor=TauriNotification;
|
||||
TauriNotification.prototype.close=function(){};
|
||||
Object.defineProperty(TauriNotification,'permission',{get:function(){return 'granted';},configurable:true});
|
||||
TauriNotification.requestPermission=function(){return Promise.resolve('granted');};
|
||||
TauriNotification.maxActions=0;
|
||||
Object.defineProperty(window,'Notification',{value:TauriNotification,writable:true,configurable:true});
|
||||
var _q=navigator.permissions.query.bind(navigator.permissions);
|
||||
navigator.permissions.query=function(desc){
|
||||
if(desc&&desc.name==='notifications'){
|
||||
return Promise.resolve(Object.assign(new EventTarget(),{state:'granted',onchange:null}));
|
||||
}
|
||||
return _q(desc);
|
||||
};
|
||||
})();"#;
|
||||
|
||||
#[tauri::command]
|
||||
fn send_notification(
|
||||
app: tauri::AppHandle,
|
||||
title: String,
|
||||
body: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let mut builder = app.notification().builder().title(&title);
|
||||
if let Some(b) = &body {
|
||||
builder = builder.body(b);
|
||||
}
|
||||
builder.show().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct UpdateInfo {
|
||||
available: bool,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
return match app.updater().map_err(|e| e.to_string())?.check().await {
|
||||
Ok(Some(update)) => Ok(UpdateInfo { available: true, version: Some(update.version) }),
|
||||
Ok(None) => Ok(UpdateInfo { available: false, version: None }),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
}
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
Ok(UpdateInfo { available: false, version: None })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
if let Some(update) = app
|
||||
.updater()
|
||||
.map_err(|e| e.to_string())?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
update
|
||||
.download_and_install(|_chunk, _total| {}, || {})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use windows::{
|
||||
core::{BOOL, PCWSTR},
|
||||
Win32::{
|
||||
Foundation::{COLORREF, HWND, RECT},
|
||||
Graphics::Gdi::{
|
||||
CreateBitmap, CreateCompatibleDC, CreateDIBSection, CreateFontW, CreatePen,
|
||||
CreateSolidBrush, DeleteDC, DeleteObject, DIB_RGB_COLORS, DrawTextW, Ellipse,
|
||||
ReleaseDC, SelectObject, SetBkMode, SetTextColor, BITMAPINFO,
|
||||
BITMAPINFOHEADER, BI_RGB, CLIP_DEFAULT_PRECIS, DEFAULT_CHARSET, DEFAULT_PITCH,
|
||||
DEFAULT_QUALITY, DT_CENTER, DT_SINGLELINE, DT_VCENTER, FF_DONTCARE,
|
||||
FW_BOLD, OUT_DEFAULT_PRECIS, PS_NULL, TRANSPARENT,
|
||||
},
|
||||
UI::{
|
||||
Shell::{ITaskbarList3, TaskbarList},
|
||||
WindowsAndMessaging::{CreateIconIndirect, DestroyIcon, HICON, ICONINFO},
|
||||
},
|
||||
System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
|
||||
},
|
||||
};
|
||||
|
||||
let hwnd = HWND(window.hwnd().map_err(|e| e.to_string())?.0 as _);
|
||||
|
||||
let hicon: Option<HICON> = if count > 0 {
|
||||
let label = if count > 99 {
|
||||
"99+".to_string()
|
||||
} else {
|
||||
count.to_string()
|
||||
};
|
||||
let mut label_wide: Vec<u16> = label.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
unsafe {
|
||||
let size = 20i32;
|
||||
let hdc_screen = windows::Win32::Graphics::Gdi::GetDC(None);
|
||||
let hdc = CreateCompatibleDC(Some(hdc_screen));
|
||||
|
||||
let bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: size,
|
||||
biHeight: -size,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
|
||||
let hbm_color =
|
||||
CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Zero-init so undrawn pixels are fully transparent (CreateDIBSection
|
||||
// does not guarantee zeroed memory; garbage bytes cause a black square).
|
||||
if !bits.is_null() {
|
||||
std::ptr::write_bytes(bits as *mut u8, 0, (size * size * 4) as usize);
|
||||
}
|
||||
let old_bm = SelectObject(hdc, hbm_color.into());
|
||||
|
||||
let hbrush = CreateSolidBrush(COLORREF(0x003030DD));
|
||||
let old_brush = SelectObject(hdc, hbrush.into());
|
||||
let hpen = CreatePen(PS_NULL, 0, COLORREF(0));
|
||||
let old_pen = SelectObject(hdc, hpen.into());
|
||||
let _ = Ellipse(hdc, 0, 0, size, size);
|
||||
|
||||
let hfont = CreateFontW(
|
||||
14,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
FW_BOLD.0 as i32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DEFAULT_CHARSET,
|
||||
OUT_DEFAULT_PRECIS,
|
||||
CLIP_DEFAULT_PRECIS,
|
||||
DEFAULT_QUALITY,
|
||||
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
|
||||
windows::core::w!("Segoe UI"),
|
||||
);
|
||||
let old_font = SelectObject(hdc, hfont.into());
|
||||
SetTextColor(hdc, COLORREF(0x00FFFFFF));
|
||||
let _ = SetBkMode(hdc, TRANSPARENT);
|
||||
let mut rect = RECT { left: 0, top: 0, right: size, bottom: size };
|
||||
let label_len = label_wide.len() - 1;
|
||||
let _ = DrawTextW(
|
||||
hdc,
|
||||
&mut label_wide[..label_len],
|
||||
&mut rect,
|
||||
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
|
||||
);
|
||||
|
||||
SelectObject(hdc, old_brush);
|
||||
SelectObject(hdc, old_pen);
|
||||
SelectObject(hdc, old_font);
|
||||
SelectObject(hdc, old_bm);
|
||||
let _ = DeleteObject(hbrush.into());
|
||||
let _ = DeleteObject(hpen.into());
|
||||
let _ = DeleteObject(hfont.into());
|
||||
|
||||
// GDI drawing leaves the alpha channel at 0 for all pixels.
|
||||
// Set alpha=255 for every painted pixel so Windows uses per-pixel
|
||||
// alpha compositing instead of falling back to the opaque mask,
|
||||
// which would render unpainted corner pixels as a black square.
|
||||
let pixel_count = (size * size) as usize;
|
||||
let pixels =
|
||||
std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
|
||||
for pixel in pixels.iter_mut() {
|
||||
if *pixel != 0 {
|
||||
*pixel |= 0xFF00_0000u32;
|
||||
}
|
||||
}
|
||||
|
||||
let hbm_mask = CreateBitmap(size, size, 1, 1, None);
|
||||
if hbm_mask.0 as usize == 0 {
|
||||
let _ = DeleteObject(hbm_color.into());
|
||||
return Err("CreateBitmap failed".to_string());
|
||||
}
|
||||
|
||||
let icon_info = ICONINFO {
|
||||
fIcon: BOOL(1),
|
||||
xHotspot: 0,
|
||||
yHotspot: 0,
|
||||
hbmMask: hbm_mask,
|
||||
hbmColor: hbm_color,
|
||||
};
|
||||
let hicon = CreateIconIndirect(&icon_info).map_err(|e| {
|
||||
let _ = DeleteObject(hbm_color.into());
|
||||
let _ = DeleteObject(hbm_mask.into());
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let _ = DeleteObject(hbm_color.into());
|
||||
let _ = DeleteObject(hbm_mask.into());
|
||||
let _ = DeleteDC(hdc);
|
||||
let _ = ReleaseDC(None, hdc_screen);
|
||||
|
||||
Some(hicon)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let taskbar: ITaskbarList3 =
|
||||
CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| e.to_string())?;
|
||||
taskbar.HrInit().map_err(|e| e.to_string())?;
|
||||
taskbar
|
||||
.SetOverlayIcon(hwnd, hicon.unwrap_or_default(), PCWSTR::null())
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(icon) = hicon {
|
||||
let _ = DestroyIcon(icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Held in managed state so the tray's unread overlay can be updated at runtime.
|
||||
/// Keeping the TrayIcon handle here also keeps the tray alive.
|
||||
struct TrayUnreadState {
|
||||
tray: tauri::tray::TrayIcon,
|
||||
base_rgba: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Paint a small white-ringed red "unread" dot into the bottom-right corner of
|
||||
/// an RGBA buffer, in place. Cross-platform (operates on raw pixels).
|
||||
fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) {
|
||||
let w = width as i32;
|
||||
let h = height as i32;
|
||||
if w <= 0 || h <= 0 {
|
||||
return;
|
||||
}
|
||||
let r = ((w.min(h) as f32) * 0.30) as i32;
|
||||
let ring = ((r as f32) * 0.18).max(1.0) as i32;
|
||||
let cx = w - r - ((w as f32) * 0.06) as i32;
|
||||
let cy = h - r - ((h as f32) * 0.06) as i32;
|
||||
for y in (cy - r - ring).max(0)..(cy + r + ring).min(h) {
|
||||
for x in (cx - r - ring).max(0)..(cx + r + ring).min(w) {
|
||||
let dx = x - cx;
|
||||
let dy = y - cy;
|
||||
let dist2 = dx * dx + dy * dy;
|
||||
let idx = ((y * w + x) as usize) * 4;
|
||||
if idx + 3 >= rgba.len() {
|
||||
continue;
|
||||
}
|
||||
if dist2 <= r * r {
|
||||
rgba[idx] = 0xDD;
|
||||
rgba[idx + 1] = 0x30;
|
||||
rgba[idx + 2] = 0x30;
|
||||
rgba[idx + 3] = 0xFF;
|
||||
} else if dist2 <= (r + ring) * (r + ring) {
|
||||
rgba[idx] = 0xFF;
|
||||
rgba[idx + 1] = 0xFF;
|
||||
rgba[idx + 2] = 0xFF;
|
||||
rgba[idx + 3] = 0xFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay (or clear) the unread dot on the tray icon.
|
||||
#[tauri::command]
|
||||
fn set_tray_unread(unread: bool, state: tauri::State<'_, TrayUnreadState>) -> Result<(), String> {
|
||||
let mut rgba = state.base_rgba.clone();
|
||||
if unread {
|
||||
draw_unread_dot(&mut rgba, state.width, state.height);
|
||||
}
|
||||
let icon = tauri::image::Image::new_owned(rgba, state.width, state.height);
|
||||
state.tray.set_icon(Some(icon)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Flash the taskbar button to draw attention (e.g. a new mention while the
|
||||
/// window is unfocused). Clears automatically once the window is focused.
|
||||
#[tauri::command]
|
||||
fn flash_window(window: tauri::Window) -> Result<(), String> {
|
||||
window
|
||||
.request_user_attention(Some(tauri::UserAttentionType::Informational))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let port: u16 = 44548;
|
||||
let context = tauri::generate_context!();
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
// #[cfg(target_os = "macos")]
|
||||
// {
|
||||
// builder = builder.menu(menu::menu());
|
||||
// }
|
||||
#[allow(unused_mut)]
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
builder
|
||||
// Single-instance MUST be registered first: a second launch focuses the
|
||||
// existing window (and forwards any matrix: link) instead of colliding on
|
||||
// the localhost port. Desktop-only plugin.
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
show_main(app);
|
||||
if let Some(url) = matrix_url_from_args(&argv) {
|
||||
forward_deeplink(app, &url);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
set_badge_count,
|
||||
set_tray_unread,
|
||||
flash_window,
|
||||
send_notification,
|
||||
check_for_update,
|
||||
install_update,
|
||||
])
|
||||
.plugin(tauri_plugin_localhost::Builder::new(port).build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_deep_link::init());
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
}
|
||||
|
||||
builder
|
||||
.setup(move |app| {
|
||||
// Dev: use devUrl from tauri.conf.json (http://localhost:8080) to support HMR
|
||||
// --- System tray: keeps Lotus Chat running in the background so
|
||||
// notifications keep arriving after the window is closed-to-tray. ---
|
||||
let base_icon = app.default_window_icon().cloned();
|
||||
let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?;
|
||||
let separator = PredefinedMenuItem::separator(app)?;
|
||||
let tray_menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?;
|
||||
let tray = TrayIconBuilder::with_id("main-tray")
|
||||
.icon(base_icon.clone().expect("bundled window icon"))
|
||||
.tooltip("Lotus Chat")
|
||||
.menu(&tray_menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => show_main(app),
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
show_main(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Keep the tray handle (and base icon pixels) in managed state so
|
||||
// set_tray_unread can re-render the icon at runtime.
|
||||
if let Some(img) = base_icon {
|
||||
let base_rgba = img.rgba().to_vec();
|
||||
let (width, height) = (img.width(), img.height());
|
||||
app.manage(TrayUnreadState {
|
||||
tray,
|
||||
base_rgba,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let window_url = WebviewUrl::App(Default::default());
|
||||
|
||||
// Release: tauri-plugin-localhost serves bundled frontend assets on this port
|
||||
#[cfg(not(debug_assertions))]
|
||||
let window_url = {
|
||||
let url = format!("http://localhost:{}", port).parse().unwrap();
|
||||
@@ -35,14 +456,112 @@ pub fn run() {
|
||||
};
|
||||
|
||||
let app_handle = app.handle().clone();
|
||||
WebviewWindowBuilder::new(app, "main".to_string(), window_url)
|
||||
.title("Cinny")
|
||||
let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url)
|
||||
.title("Lotus Chat")
|
||||
// First-run defaults; tauri-plugin-window-state restores geometry
|
||||
// on later launches.
|
||||
.inner_size(1100.0, 720.0)
|
||||
.min_inner_size(480.0, 600.0)
|
||||
.center()
|
||||
// Start hidden and reveal once the page has painted, to avoid the
|
||||
// white launch flash.
|
||||
.visible(false)
|
||||
.initialization_script(NOTIFICATION_BRIDGE)
|
||||
.disable_drag_drop_handler()
|
||||
.on_page_load(|window, payload| {
|
||||
if matches!(payload.event(), PageLoadEvent::Finished) {
|
||||
let _ = window.show();
|
||||
}
|
||||
})
|
||||
.on_new_window(move |url, _features| {
|
||||
let _ = app_handle.opener().open_url(url.as_str(), None::<&str>);
|
||||
NewWindowResponse::Deny
|
||||
})
|
||||
.build()?;
|
||||
|
||||
// Close-to-tray: hide instead of exiting; the app is quit explicitly
|
||||
// from the tray menu.
|
||||
let window_for_close = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window_for_close.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Failsafe: never leave the window stuck hidden if the page-load
|
||||
// event doesn't fire for some reason.
|
||||
let window_for_show = window.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(8));
|
||||
let _ = window_for_show.show();
|
||||
});
|
||||
|
||||
// Deep links (matrix:): route both the cold-start case and the
|
||||
// already-running case (forwarded via single-instance argv) into the
|
||||
// web client.
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
// Runtime scheme registration is a Linux/Windows-only API; macOS
|
||||
// registers the scheme from the bundle config at build time.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
let _ = app.deep_link().register_all();
|
||||
let deep_link_handle = app.handle().clone();
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
forward_deeplink(&deep_link_handle, url.as_str());
|
||||
}
|
||||
});
|
||||
if let Some(url) = matrix_url_from_args(&std::env::args().collect::<Vec<_>>()) {
|
||||
forward_deeplink(&app.handle().clone(), &url);
|
||||
}
|
||||
}
|
||||
|
||||
// Windows 11 Mica backdrop. The app paints an opaque TDS background,
|
||||
// so this is subtle (mainly window chrome); harmless if unsupported.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window_vibrancy::apply_mica(&window, Some(true));
|
||||
}
|
||||
|
||||
// Auto-grant camera, microphone, and notification permissions in WebView2.
|
||||
#[cfg(target_os = "windows")]
|
||||
window.with_webview(|webview| {
|
||||
use webview2_com::{
|
||||
Microsoft::Web::WebView2::Win32::{
|
||||
COREWEBVIEW2_PERMISSION_KIND,
|
||||
COREWEBVIEW2_PERMISSION_KIND_CAMERA,
|
||||
COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
|
||||
COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS,
|
||||
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
|
||||
},
|
||||
PermissionRequestedEventHandler,
|
||||
};
|
||||
|
||||
let controller = webview.controller();
|
||||
if let Ok(core) = unsafe { controller.CoreWebView2() } {
|
||||
let handler = PermissionRequestedEventHandler::create(Box::new(
|
||||
|_sender, args| {
|
||||
if let Some(args) = args {
|
||||
let mut kind = COREWEBVIEW2_PERMISSION_KIND(0);
|
||||
unsafe { args.PermissionKind(&mut kind) }?;
|
||||
if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
|
||||
|| kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA
|
||||
|| kind == COREWEBVIEW2_PERMISSION_KIND_NOTIFICATIONS
|
||||
{
|
||||
unsafe {
|
||||
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)
|
||||
}?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
));
|
||||
let mut token = Default::default();
|
||||
let _ = unsafe { core.add_PermissionRequested(&handler, &mut token) };
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(context)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "",
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
},
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
},
|
||||
"wix": {
|
||||
"bannerPath": "wix/banner.bmp",
|
||||
"dialogImagePath": "wix/dialogImage.bmp"
|
||||
@@ -45,21 +51,26 @@
|
||||
"beforeDevCommand": "cd cinny && npm start",
|
||||
"devUrl": "http://localhost:8080"
|
||||
},
|
||||
"productName": "Cinny",
|
||||
"productName": "Lotus Chat",
|
||||
"mainBinaryName": "cinny",
|
||||
"version": "4.12.2",
|
||||
"identifier": "in.cinny.app",
|
||||
"identifier": "org.lotusguild.lotus-chat",
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE2NDc3NDBGMTAzNTk1NUYKUldSZmxUVVFEM1JIRnRuMjVRTkFOQ21lUFI5KzRMU0s4OWtBS1RNRUVCNE9LcE9GcExNZ2M2NHoK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1N0Y0RThCQTJEQzY1NTkKUldSWlpkeWlpMDUvTlVjejMzN0E1U0FiaVpLK05QVkRXdWlMMm1NNUprMXAvTGZSbU5maVovNmwK",
|
||||
"endpoints": [
|
||||
"https://github.com/cinnyapp/cinny-desktop/releases/download/tauri/release.json"
|
||||
"https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/release.json"
|
||||
]
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["matrix"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"security": {
|
||||
"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:; style-src 'self' 'unsafe-inline' blob: data: filesystem: http: https:; img-src 'self' data: blob: filesystem: http: https:; connect-src 'self' blob: ipc: ws: wss: http: https: http://ipc.localhost"
|
||||
"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:; style-src 'self' 'unsafe-inline' blob: data: filesystem: http: https:; img-src 'self' data: blob: filesystem: http: https:; media-src 'self' blob: data: mediastream:; connect-src 'self' blob: ipc: ws: wss: http: https: http://ipc.localhost"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#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;
|
||||
}
|
||||