Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d605c09bc | |||
| 74074115d2 | |||
| 12bb585081 | |||
| 665f450878 | |||
| e2e8217b86 | |||
| 6c6a23e672 | |||
| 16d179f8e8 | |||
| 12e91c0bbe | |||
| 8b9a40f059 | |||
| eac19823ea | |||
| a5e930ac17 | |||
| c3e0c47d32 | |||
| 8f9b823d69 | |||
| 7ad8ba82b6 | |||
| b44a785d20 | |||
| 331b1298f8 | |||
| 02ca3e3581 | |||
| eb8f702576 | |||
| bc8a7c5a32 | |||
| 1c67a5c97f | |||
| 6301ebb392 | |||
| 31d21dd2c2 | |||
| 9add0a4d61 | |||
| 500f1c4bc2 | |||
| a38e2f96c0 | |||
| 5aa39b40ff | |||
| eb452bf57e | |||
| 902c0285e6 | |||
| 1303c8e26f | |||
| 48ac9d2922 | |||
| 725a7e0364 | |||
| 44088b35d7 | |||
| 228608ef2c |
@@ -0,0 +1,21 @@
|
||||
# Copy to .env.release (gitignored) and fill in.
|
||||
# Consumed by scripts/release.mjs.
|
||||
|
||||
# Absolute path to the private key file produced by `tauri signer generate`.
|
||||
TAURI_SIGNING_PRIVATE_KEY_PATH=C:/Users/denni/.tauri/chatapp.key
|
||||
|
||||
# Password set when generating the key. Leave empty if none.
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
|
||||
|
||||
# Host serving latest.json + installer artifacts over HTTPS.
|
||||
UPDATE_HOST=update.netralax.cloud
|
||||
|
||||
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
|
||||
UPDATE_SSH_USER=chatapp-deploy
|
||||
|
||||
# Optional: path to the SSH private key. Omit to fall back on ssh-agent or the
|
||||
# default id_rsa.
|
||||
UPDATE_SSH_KEY=
|
||||
|
||||
# Absolute path on the server where windows/ artifacts + latest.json live.
|
||||
UPDATE_REMOTE_PATH=/var/www/updates/windows
|
||||
@@ -1,35 +1,27 @@
|
||||
name: Release desktop app
|
||||
name: Release desktop app (manual backup)
|
||||
|
||||
# Tag a version to trigger a release:
|
||||
# git tag v0.1.0 && git push --tags
|
||||
#
|
||||
# Produces signed Tauri bundles for macOS (arm + intel), Windows, and Linux,
|
||||
# uploads them to a GitHub Release, and publishes `latest.json` for the
|
||||
# updater plugin to discover.
|
||||
# Self-hosted updates run from scripts/release.mjs on Dennis's Windows box.
|
||||
# This workflow is kept as a manual backup — trigger it from the Actions tab
|
||||
# if the local build host is unavailable.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to build (e.g. v0.10.2) — must already exist"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
|
||||
args: "--target universal-apple-darwin --bundles app,updater"
|
||||
- platform: windows-latest
|
||||
args: ""
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -42,8 +34,6 @@ jobs:
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install JS deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -54,18 +44,16 @@ jobs:
|
||||
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 }}
|
||||
# Client-side env vars baked into the bundle — paste your prod values
|
||||
# into the repo's Actions → Secrets so releases point at prod.
|
||||
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
|
||||
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
|
||||
with:
|
||||
projectPath: apps/desktop
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "ChatApp ${{ github.ref_name }}"
|
||||
releaseBody: "See the assets below to download this version."
|
||||
tagName: ${{ inputs.tag }}
|
||||
releaseName: "ChatApp ${{ inputs.tag }}"
|
||||
releaseBody: "Manual build — copy .nsis.zip/.sig/latest.json to the update host."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
tauriScript: pnpm exec tauri
|
||||
args: ${{ matrix.args }}
|
||||
args: "--bundles nsis"
|
||||
|
||||
@@ -15,7 +15,9 @@ out/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.release
|
||||
!.env.example
|
||||
!.env.release.example
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
@@ -56,6 +58,9 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Claude Code per-project local settings
|
||||
.claude/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
@@ -21,6 +21,7 @@
|
||||
"@chat-app/shared": "workspace:*",
|
||||
"@chat-app/ui-web": "workspace:*",
|
||||
"@livekit/components-react": "^2.9.0",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="cp02">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
||||
<g clip-path="url(#cp02)">
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
||||
</g>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
||||
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
|
||||
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chat-app-desktop"
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
description = "ChatApp desktop client"
|
||||
authors = ["Dennis"]
|
||||
edition = "2021"
|
||||
@@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri = { version = "2", features = ["devtools", "tray-icon"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-stronghold = "2"
|
||||
@@ -22,11 +22,74 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Pure-rust libsodium-compatible primitives. No C toolchain required so
|
||||
# cross-compile for mobile stays clean. API output is bit-compatible with
|
||||
# libsodium-wrappers-sumo for the ops we use (secretbox, box, pwhash).
|
||||
dryoc = { version = "0.7", default-features = false, features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
|
||||
# PNG encoding for screen-source thumbnails returned by the
|
||||
# `enumerate_screen_sources` command. `default-features = false` skips the
|
||||
# image-format decoders we don't use (jpeg, gif, webp, …) — keeps the
|
||||
# thumbnail command at ~200KB extra binary size.
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
# Cross-platform screen + window enumeration and capture. Replaces direct
|
||||
# Win32 GDI / macOS CoreGraphics / X11 calls with a small uniform API so
|
||||
# the enumerate-sources command has one code path. The crate pulls in
|
||||
# platform-specific backends automatically (~1.5MB binary growth on
|
||||
# Windows). Marked optional so non-desktop targets don't compile it.
|
||||
xcap = "0.0.14"
|
||||
|
||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
|
||||
# Windows-only screen-source enumeration + thumbnail capture. Pulled in
|
||||
# only on Windows so macOS + Linux builds stay slim. The enumerate command
|
||||
# returns stub-empty on non-Windows until we add native equivalents.
|
||||
[target."cfg(target_os = \"windows\")".dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# WASAPI loopback capture for system-audio screen-share. Lets the custom
|
||||
# picker hand LiveKit a real audio track without falling back to the OS
|
||||
# screen picker (which is the only way getDisplayMedia can grab system
|
||||
# sound). Windows-only for v1; macOS needs ScreenCaptureKit-audio and
|
||||
# Linux needs a PulseAudio / PipeWire path.
|
||||
wasapi = "0.15"
|
||||
|
||||
# LiveKit client SDK — lives behind the `rust-livekit` feature flag so the
|
||||
# baseline build stays unaffected while the JS-SDK path is still the
|
||||
# default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min
|
||||
# to the first build. Tokio runtime is required; the rest of the crate
|
||||
# stays idle when the feature is off.
|
||||
livekit = { version = "0.7", default-features = false, features = ["tokio", "rustls-tls-native-roots"], optional = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"], optional = true }
|
||||
|
||||
[features]
|
||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||
# and disables specific features relevant to the dev build.
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
# Enable the Rust LiveKit client. Off by default so CI + users stay on the
|
||||
# JS-SDK path until the rust bridge reaches feature parity. Turn on via:
|
||||
# cargo build --features rust-livekit
|
||||
rust-livekit = ["dep:livekit", "dep:tokio"]
|
||||
|
||||
# Release-profile tuned for ChatApp: whole-program LTO + single codegen unit
|
||||
# cuts binary size by ~20-30% and trims startup overhead. `strip = "symbols"`
|
||||
# removes debug + symbol tables (the updater already signs separately so
|
||||
# symbol-backed crash reports aren't the recovery path). `panic = "abort"`
|
||||
# skips unwinding metadata since the app doesn't use catch_unwind anywhere.
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
panic = "abort"
|
||||
opt-level = "s"
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
"notification:allow-notify",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"sql:default",
|
||||
"sql:allow-load",
|
||||
"sql:allow-execute",
|
||||
"sql:allow-select",
|
||||
"sql:allow-close",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"global-shortcut:allow-is-registered",
|
||||
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 948 B After Width: | Height: | Size: 709 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 928 B After Width: | Height: | Size: 680 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1021 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 858 B After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -1,14 +1,7 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="cp02">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
||||
<g clip-path="url(#cp02)">
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
||||
</g>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
||||
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
|
||||
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -0,0 +1,287 @@
|
||||
// Native crypto primitives exposed as Tauri commands. The JS side calls
|
||||
// these via `invoke('crypto_…', …)` through `lib/nativeCryptoBackend.ts`.
|
||||
//
|
||||
// All byte arrays cross the IPC boundary as base64 strings to sidestep
|
||||
// serde_json's lack of native bytes support; JS encodes/decodes at the
|
||||
// thin wrapper layer. The extra encode step costs a few µs per call —
|
||||
// negligible against Argon2id's ~200ms and acceptable for bulk AEAD ops
|
||||
// which still outperform the WASM backend after the round-trip.
|
||||
//
|
||||
// Semantics: bit-compatible with libsodium-wrappers-sumo for all inputs.
|
||||
// AEAD authentication failures surface as `Err(String)` so the JS layer
|
||||
// can re-throw a deterministic error that existing callers already handle.
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use dryoc::classic::crypto_box;
|
||||
use dryoc::classic::crypto_pwhash::{self, PasswordHashAlgorithm};
|
||||
use dryoc::classic::crypto_secretbox;
|
||||
use dryoc::constants::{
|
||||
CRYPTO_BOX_PUBLICKEYBYTES, CRYPTO_BOX_SECRETKEYBYTES, CRYPTO_PWHASH_MEMLIMIT_MODERATE,
|
||||
CRYPTO_PWHASH_OPSLIMIT_MODERATE, CRYPTO_PWHASH_SALTBYTES,
|
||||
};
|
||||
use dryoc::rng::randombytes_buf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn encode(bytes: &[u8]) -> String {
|
||||
B64.encode(bytes)
|
||||
}
|
||||
|
||||
fn decode(s: &str) -> Result<Vec<u8>, String> {
|
||||
B64.decode(s).map_err(|e| format!("invalid base64: {}", e))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Random
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn crypto_random_bytes(len: usize) -> Result<String, String> {
|
||||
if len == 0 || len > 1024 * 1024 {
|
||||
return Err("invalid length".into());
|
||||
}
|
||||
let buf = randombytes_buf(len);
|
||||
Ok(encode(&buf))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crypto_secretbox — XSalsa20-Poly1305
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn crypto_secretbox_encrypt(
|
||||
plaintext_b64: String,
|
||||
nonce_b64: String,
|
||||
key_b64: String,
|
||||
) -> Result<String, String> {
|
||||
let plaintext = decode(&plaintext_b64)?;
|
||||
let nonce = decode(&nonce_b64)?;
|
||||
let key = decode(&key_b64)?;
|
||||
if nonce.len() != 24 {
|
||||
return Err("nonce must be 24 bytes".into());
|
||||
}
|
||||
if key.len() != 32 {
|
||||
return Err("key must be 32 bytes".into());
|
||||
}
|
||||
let mut ciphertext = vec![0u8; plaintext.len() + 16];
|
||||
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().unwrap();
|
||||
let key_arr: [u8; 32] = key.as_slice().try_into().unwrap();
|
||||
crypto_secretbox::crypto_secretbox_easy(
|
||||
&mut ciphertext,
|
||||
&plaintext,
|
||||
&nonce_arr,
|
||||
&key_arr,
|
||||
)
|
||||
.map_err(|e| format!("secretbox encrypt failed: {}", e))?;
|
||||
Ok(encode(&ciphertext))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn crypto_secretbox_decrypt(
|
||||
ciphertext_b64: String,
|
||||
nonce_b64: String,
|
||||
key_b64: String,
|
||||
) -> Result<String, String> {
|
||||
let ciphertext = decode(&ciphertext_b64)?;
|
||||
let nonce = decode(&nonce_b64)?;
|
||||
let key = decode(&key_b64)?;
|
||||
if nonce.len() != 24 {
|
||||
return Err("nonce must be 24 bytes".into());
|
||||
}
|
||||
if key.len() != 32 {
|
||||
return Err("key must be 32 bytes".into());
|
||||
}
|
||||
if ciphertext.len() < 16 {
|
||||
return Err("ciphertext too short".into());
|
||||
}
|
||||
let mut plaintext = vec![0u8; ciphertext.len() - 16];
|
||||
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().unwrap();
|
||||
let key_arr: [u8; 32] = key.as_slice().try_into().unwrap();
|
||||
crypto_secretbox::crypto_secretbox_open_easy(
|
||||
&mut plaintext,
|
||||
&ciphertext,
|
||||
&nonce_arr,
|
||||
&key_arr,
|
||||
)
|
||||
.map_err(|_| "secretbox auth failed".to_string())?;
|
||||
Ok(encode(&plaintext))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crypto_box — X25519 + XSalsa20-Poly1305
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct KeyPairB64 {
|
||||
pub public_key: String,
|
||||
pub private_key: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn crypto_box_keypair() -> Result<KeyPairB64, String> {
|
||||
let (pk, sk) = crypto_box::crypto_box_keypair();
|
||||
Ok(KeyPairB64 {
|
||||
public_key: encode(&pk),
|
||||
private_key: encode(&sk),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn crypto_box_encrypt(
|
||||
plaintext_b64: String,
|
||||
nonce_b64: String,
|
||||
recipient_pk_b64: String,
|
||||
sender_sk_b64: String,
|
||||
) -> Result<String, String> {
|
||||
let plaintext = decode(&plaintext_b64)?;
|
||||
let nonce = decode(&nonce_b64)?;
|
||||
let pk = decode(&recipient_pk_b64)?;
|
||||
let sk = decode(&sender_sk_b64)?;
|
||||
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
|
||||
return Err("pk must be 32 bytes".into());
|
||||
}
|
||||
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
|
||||
return Err("sk must be 32 bytes".into());
|
||||
}
|
||||
let mut ciphertext = vec![0u8; plaintext.len() + 16];
|
||||
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().map_err(|_| "bad nonce")?;
|
||||
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
|
||||
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
|
||||
crypto_box::crypto_box_easy(&mut ciphertext, &plaintext, &nonce_arr, &pk_arr, &sk_arr)
|
||||
.map_err(|e| format!("box encrypt failed: {}", e))?;
|
||||
Ok(encode(&ciphertext))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn crypto_box_decrypt(
|
||||
ciphertext_b64: String,
|
||||
nonce_b64: String,
|
||||
sender_pk_b64: String,
|
||||
recipient_sk_b64: String,
|
||||
) -> Result<String, String> {
|
||||
let ciphertext = decode(&ciphertext_b64)?;
|
||||
let nonce = decode(&nonce_b64)?;
|
||||
let pk = decode(&sender_pk_b64)?;
|
||||
let sk = decode(&recipient_sk_b64)?;
|
||||
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
|
||||
return Err("pk must be 32 bytes".into());
|
||||
}
|
||||
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
|
||||
return Err("sk must be 32 bytes".into());
|
||||
}
|
||||
if ciphertext.len() < 16 {
|
||||
return Err("ciphertext too short".into());
|
||||
}
|
||||
let mut plaintext = vec![0u8; ciphertext.len() - 16];
|
||||
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().map_err(|_| "bad nonce")?;
|
||||
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
|
||||
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
|
||||
crypto_box::crypto_box_open_easy(
|
||||
&mut plaintext,
|
||||
&ciphertext,
|
||||
&nonce_arr,
|
||||
&pk_arr,
|
||||
&sk_arr,
|
||||
)
|
||||
.map_err(|_| "box auth failed".to_string())?;
|
||||
Ok(encode(&plaintext))
|
||||
}
|
||||
|
||||
// Sealed-box (anonymous) variant — sender identity not authenticated but
|
||||
// recipient still verified. Used by the conv-key wrapping flow.
|
||||
#[tauri::command]
|
||||
pub fn crypto_box_seal(
|
||||
plaintext_b64: String,
|
||||
recipient_pk_b64: String,
|
||||
) -> Result<String, String> {
|
||||
let plaintext = decode(&plaintext_b64)?;
|
||||
let pk = decode(&recipient_pk_b64)?;
|
||||
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
|
||||
return Err("pk must be 32 bytes".into());
|
||||
}
|
||||
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
|
||||
let mut ciphertext = vec![0u8; plaintext.len() + 48];
|
||||
crypto_box::crypto_box_seal(&mut ciphertext, &plaintext, &pk_arr)
|
||||
.map_err(|e| format!("seal failed: {}", e))?;
|
||||
Ok(encode(&ciphertext))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn crypto_box_seal_open(
|
||||
ciphertext_b64: String,
|
||||
recipient_pk_b64: String,
|
||||
recipient_sk_b64: String,
|
||||
) -> Result<String, String> {
|
||||
let ciphertext = decode(&ciphertext_b64)?;
|
||||
let pk = decode(&recipient_pk_b64)?;
|
||||
let sk = decode(&recipient_sk_b64)?;
|
||||
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
|
||||
return Err("pk must be 32 bytes".into());
|
||||
}
|
||||
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
|
||||
return Err("sk must be 32 bytes".into());
|
||||
}
|
||||
if ciphertext.len() < 48 {
|
||||
return Err("ciphertext too short".into());
|
||||
}
|
||||
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
|
||||
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
|
||||
let mut plaintext = vec![0u8; ciphertext.len() - 48];
|
||||
crypto_box::crypto_box_seal_open(&mut plaintext, &ciphertext, &pk_arr, &sk_arr)
|
||||
.map_err(|_| "seal open failed".to_string())?;
|
||||
Ok(encode(&plaintext))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crypto_pwhash — Argon2id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PwhashArgs {
|
||||
pub password: String,
|
||||
pub salt_b64: String,
|
||||
pub out_len: usize,
|
||||
// Opslimit / memlimit presets map to libsodium constants; callers pass
|
||||
// one of "interactive" | "moderate" | "sensitive". We default to
|
||||
// moderate which matches every current call-site.
|
||||
#[serde(default)]
|
||||
pub preset: Option<String>,
|
||||
}
|
||||
|
||||
fn pwhash_limits(preset: Option<&str>) -> (u64, usize) {
|
||||
match preset {
|
||||
Some("interactive") => (2, 64 * 1024 * 1024),
|
||||
Some("sensitive") => (4, 1024 * 1024 * 1024),
|
||||
_ => (
|
||||
CRYPTO_PWHASH_OPSLIMIT_MODERATE as u64,
|
||||
CRYPTO_PWHASH_MEMLIMIT_MODERATE,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn crypto_pwhash(args: PwhashArgs) -> Result<String, String> {
|
||||
let salt = decode(&args.salt_b64)?;
|
||||
if salt.len() != CRYPTO_PWHASH_SALTBYTES {
|
||||
return Err(format!(
|
||||
"salt must be {} bytes",
|
||||
CRYPTO_PWHASH_SALTBYTES
|
||||
));
|
||||
}
|
||||
if args.out_len < 16 || args.out_len > 64 {
|
||||
return Err("out_len out of range (16..=64)".into());
|
||||
}
|
||||
let salt_arr: [u8; CRYPTO_PWHASH_SALTBYTES] =
|
||||
salt.as_slice().try_into().unwrap();
|
||||
let (opslimit, memlimit) = pwhash_limits(args.preset.as_deref());
|
||||
let mut out = vec![0u8; args.out_len];
|
||||
crypto_pwhash::crypto_pwhash(
|
||||
&mut out,
|
||||
args.password.as_bytes(),
|
||||
&salt_arr,
|
||||
opslimit,
|
||||
memlimit,
|
||||
PasswordHashAlgorithm::Argon2id13,
|
||||
)
|
||||
.map_err(|e| format!("pwhash failed: {}", e))?;
|
||||
Ok(encode(&out))
|
||||
}
|
||||
@@ -1,7 +1,140 @@
|
||||
mod crypto;
|
||||
mod screen_audio;
|
||||
mod screen_capture;
|
||||
mod screen_sources;
|
||||
|
||||
#[cfg(feature = "rust-livekit")]
|
||||
mod livekit_bridge;
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use tauri::{
|
||||
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Listener, Manager,
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use serde::Deserialize;
|
||||
|
||||
// Payload for the `tray-unread-update` event the JS layer emits whenever the
|
||||
// aggregate unread-count changes. 0 hides the badge / resets the tooltip;
|
||||
// non-zero sets a count indicator.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[derive(Deserialize)]
|
||||
struct TrayUnreadPayload {
|
||||
count: u32,
|
||||
}
|
||||
|
||||
// Red-dot overlay icon for the Windows taskbar. Drawn as raw RGBA instead of
|
||||
// shipping a PNG so we don't add another resource to the bundle. Kept small
|
||||
// (32x32) since Windows scales the overlay down anyway.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn unread_overlay_rgba() -> Vec<u8> {
|
||||
const SIZE: u32 = 32;
|
||||
let r = SIZE as f32 / 2.0;
|
||||
let mut buf = Vec::with_capacity((SIZE * SIZE * 4) as usize);
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
let dx = x as f32 - r + 0.5;
|
||||
let dy = y as f32 - r + 0.5;
|
||||
let d = (dx * dx + dy * dy).sqrt();
|
||||
let edge = r - 1.0;
|
||||
if d <= edge {
|
||||
buf.extend_from_slice(&[0xDC, 0x26, 0x26, 0xFF]);
|
||||
} else if d <= r {
|
||||
let alpha = (255.0 * (r - d)).clamp(0.0, 255.0) as u8;
|
||||
buf.extend_from_slice(&[0xDC, 0x26, 0x26, alpha]);
|
||||
} else {
|
||||
buf.extend_from_slice(&[0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
let _ = win.unminimize();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn hide_main_window(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
|
||||
match event.id.as_ref() {
|
||||
"tray-show" => show_main_window(app),
|
||||
"tray-hide" => hide_main_window(app),
|
||||
"tray-quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
#[cfg(not(feature = "rust-livekit"))]
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
crypto::crypto_random_bytes,
|
||||
crypto::crypto_secretbox_encrypt,
|
||||
crypto::crypto_secretbox_decrypt,
|
||||
crypto::crypto_box_keypair,
|
||||
crypto::crypto_box_encrypt,
|
||||
crypto::crypto_box_decrypt,
|
||||
crypto::crypto_box_seal,
|
||||
crypto::crypto_box_seal_open,
|
||||
crypto::crypto_pwhash,
|
||||
screen_sources::enumerate_screen_sources,
|
||||
screen_sources::list_screen_sources,
|
||||
screen_sources::capture_screen_source_thumbnail,
|
||||
screen_sources::capture_screen_source_thumbnail_bytes,
|
||||
screen_capture::start_screen_capture,
|
||||
screen_capture::stop_screen_capture,
|
||||
screen_audio::start_system_audio_capture,
|
||||
screen_audio::stop_system_audio_capture,
|
||||
])
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
|
||||
#[cfg(feature = "rust-livekit")]
|
||||
let mut builder = tauri::Builder::default()
|
||||
.manage(livekit_bridge::LivekitState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
crypto::crypto_random_bytes,
|
||||
crypto::crypto_secretbox_encrypt,
|
||||
crypto::crypto_secretbox_decrypt,
|
||||
crypto::crypto_box_keypair,
|
||||
crypto::crypto_box_encrypt,
|
||||
crypto::crypto_box_decrypt,
|
||||
crypto::crypto_box_seal,
|
||||
crypto::crypto_box_seal_open,
|
||||
crypto::crypto_pwhash,
|
||||
screen_sources::enumerate_screen_sources,
|
||||
screen_sources::list_screen_sources,
|
||||
screen_sources::capture_screen_source_thumbnail,
|
||||
screen_sources::capture_screen_source_thumbnail_bytes,
|
||||
screen_capture::start_screen_capture,
|
||||
screen_capture::stop_screen_capture,
|
||||
screen_audio::start_system_audio_capture,
|
||||
screen_audio::stop_system_audio_capture,
|
||||
livekit_bridge::livekit_connect,
|
||||
livekit_bridge::livekit_disconnect,
|
||||
livekit_bridge::livekit_send_data,
|
||||
livekit_bridge::livekit_set_mic,
|
||||
livekit_bridge::livekit_set_camera,
|
||||
])
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
|
||||
builder = builder
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(
|
||||
@@ -13,12 +146,105 @@ pub fn run() {
|
||||
.build(),
|
||||
);
|
||||
|
||||
// Global shortcut + updater plugins are desktop-only (no mobile support).
|
||||
// Global shortcut + updater + window-state plugins are desktop-only
|
||||
// (no mobile support — mobile windows are OS-managed).
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
builder = builder
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::new().build());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
builder = builder.setup(|app| {
|
||||
// Tray icon with a minimal menu. Left-click toggles window
|
||||
// visibility; right-click shows the menu. Badge / tooltip updates
|
||||
// come from the JS side via `tray-unread-update` events.
|
||||
let show = MenuItem::with_id(app, "tray-show", "Öffnen", true, None::<&str>)?;
|
||||
let hide = MenuItem::with_id(app, "tray-hide", "Ausblenden", true, None::<&str>)?;
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, "tray-quit", "Beenden", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
|
||||
|
||||
let mut tray_builder = TrayIconBuilder::with_id("chatapp-tray")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("ChatApp")
|
||||
.on_menu_event(|app, event| handle_menu_event(app, event))
|
||||
.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(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
let _ = win.hide();
|
||||
} else {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// `default_window_icon` returns Option<&Image>; only attach if
|
||||
// we actually have one bundled (should always be true via the
|
||||
// tauri.conf.json icon list, but guard to stay typesafe).
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
tray_builder = tray_builder.icon(icon.clone());
|
||||
}
|
||||
|
||||
let tray = tray_builder.build(app)?;
|
||||
|
||||
// Listen for JS-side unread updates and mirror them into the tray
|
||||
// tooltip + macOS dock badge. `tray` is cheap to clone (internal
|
||||
// Arc) so we can move it into the listener closure directly.
|
||||
let tray_handle = tray.clone();
|
||||
let badge_window = app.get_webview_window("main");
|
||||
app.listen("tray-unread-update", move |event| {
|
||||
let Ok(payload) = serde_json::from_str::<TrayUnreadPayload>(event.payload())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let tooltip = if payload.count == 0 {
|
||||
"ChatApp".to_string()
|
||||
} else {
|
||||
format!("ChatApp · {} neu", payload.count)
|
||||
};
|
||||
let _ = tray_handle.set_tooltip(Some(tooltip));
|
||||
// Dock/taskbar badge. macOS uses a numeric label; Windows uses
|
||||
// an overlay icon (red dot = unread). Linux has no cross-DE
|
||||
// badge API — skip.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(win) = badge_window.as_ref() {
|
||||
let badge = if payload.count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(payload.count.to_string())
|
||||
};
|
||||
let _ = win.set_badge_label(badge);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(win) = badge_window.as_ref() {
|
||||
if payload.count == 0 {
|
||||
let _ = win.set_overlay_icon(None);
|
||||
} else {
|
||||
let rgba = unread_overlay_rgba();
|
||||
let img = tauri::image::Image::new_owned(rgba, 32, 32);
|
||||
let _ = win.set_overlay_icon(Some(img));
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
let _ = &badge_window;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
builder
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Rust LiveKit bridge — command/event glue between the JS CallContext and
|
||||
// the native livekit client. Feature-gated behind `rust-livekit` so the
|
||||
// baseline build doesn't pay the libwebrtc download / link cost while the
|
||||
// bridge is still evolving.
|
||||
//
|
||||
// Design contract (matches `lib/nativeLiveKit.ts` on the JS side):
|
||||
// command: livekit_connect { url, token, e2ee_key_b64? }
|
||||
// command: livekit_disconnect
|
||||
// command: livekit_set_mic { enabled }
|
||||
// command: livekit_set_camera { enabled }
|
||||
// command: livekit_start_share {}
|
||||
// command: livekit_stop_share {}
|
||||
// command: livekit_send_data { payload_b64, reliable }
|
||||
// event: livekit:room_state { state }
|
||||
// event: livekit:participant_joined { identity, name? }
|
||||
// event: livekit:participant_left { identity }
|
||||
// event: livekit:track_published { identity, sid, kind, source }
|
||||
// event: livekit:track_unpublished { identity, sid }
|
||||
// event: livekit:audio_level { identity, level }
|
||||
// event: livekit:data_received { identity, payload_b64 }
|
||||
// event: livekit:error { message }
|
||||
//
|
||||
// Phase B.1 (this file) only wires connect/disconnect + room-state events
|
||||
// so the JS side can prove round-trip; mic/camera/video come later.
|
||||
|
||||
#![cfg(feature = "rust-livekit")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use livekit::{
|
||||
id::ParticipantIdentity, DataPacketKind, Room, RoomEvent, RoomOptions,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
pub struct LivekitState {
|
||||
room: Mutex<Option<Arc<Room>>>,
|
||||
}
|
||||
|
||||
impl LivekitState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
room: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConnectArgs {
|
||||
pub url: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct RoomStatePayload {
|
||||
state: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct ParticipantPayload {
|
||||
identity: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct DataPayload {
|
||||
identity: String,
|
||||
payload_b64: String,
|
||||
reliable: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn livekit_connect(
|
||||
app: AppHandle,
|
||||
state: State<'_, LivekitState>,
|
||||
args: ConnectArgs,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.room.lock().await;
|
||||
if guard.is_some() {
|
||||
return Err("already connected".into());
|
||||
}
|
||||
let options = RoomOptions::default();
|
||||
let (room, mut events) = Room::connect(&args.url, &args.token, options)
|
||||
.await
|
||||
.map_err(|e| format!("livekit connect failed: {}", e))?;
|
||||
let room = Arc::new(room);
|
||||
*guard = Some(room.clone());
|
||||
drop(guard);
|
||||
|
||||
let _ = app.emit(
|
||||
"livekit:room_state",
|
||||
RoomStatePayload { state: "connected" },
|
||||
);
|
||||
|
||||
// Spawn the event pump. Lives for the duration of the room connection;
|
||||
// stops naturally when the channel closes (disconnect or crash).
|
||||
let app_for_events = app.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = events.recv().await {
|
||||
pump_event(&app_for_events, event);
|
||||
}
|
||||
let _ = app_for_events.emit(
|
||||
"livekit:room_state",
|
||||
RoomStatePayload {
|
||||
state: "disconnected",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pump_event(app: &AppHandle, event: RoomEvent) {
|
||||
match event {
|
||||
RoomEvent::ParticipantConnected(p) => {
|
||||
let _ = app.emit(
|
||||
"livekit:participant_joined",
|
||||
ParticipantPayload {
|
||||
identity: identity_string(p.identity()),
|
||||
},
|
||||
);
|
||||
}
|
||||
RoomEvent::ParticipantDisconnected(p) => {
|
||||
let _ = app.emit(
|
||||
"livekit:participant_left",
|
||||
ParticipantPayload {
|
||||
identity: identity_string(p.identity()),
|
||||
},
|
||||
);
|
||||
}
|
||||
RoomEvent::DataReceived {
|
||||
payload,
|
||||
kind,
|
||||
participant,
|
||||
..
|
||||
} => {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
let identity = participant
|
||||
.map(|p| identity_string(p.identity()))
|
||||
.unwrap_or_default();
|
||||
let _ = app.emit(
|
||||
"livekit:data_received",
|
||||
DataPayload {
|
||||
identity,
|
||||
payload_b64: STANDARD.encode(payload.as_ref()),
|
||||
reliable: matches!(kind, DataPacketKind::Reliable),
|
||||
},
|
||||
);
|
||||
}
|
||||
RoomEvent::Disconnected { .. } => {
|
||||
let _ = app.emit(
|
||||
"livekit:room_state",
|
||||
RoomStatePayload {
|
||||
state: "disconnected",
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Remaining events (TrackPublished, TrackSubscribed, etc.) land
|
||||
// in later phases. Ignoring silently keeps the prototype small.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_string(id: ParticipantIdentity) -> String {
|
||||
// ParticipantIdentity is a newtype around String in the livekit crate.
|
||||
id.0
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn livekit_disconnect(state: State<'_, LivekitState>) -> Result<(), String> {
|
||||
let mut guard = state.room.lock().await;
|
||||
if let Some(room) = guard.take() {
|
||||
let _ = room.close().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn livekit_send_data(
|
||||
state: State<'_, LivekitState>,
|
||||
payload_b64: String,
|
||||
reliable: bool,
|
||||
) -> Result<(), String> {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
let room = state.room.lock().await;
|
||||
let room = room.as_ref().ok_or_else(|| "not connected".to_string())?;
|
||||
let payload = STANDARD
|
||||
.decode(&payload_b64)
|
||||
.map_err(|e| format!("bad base64: {}", e))?;
|
||||
let kind = if reliable {
|
||||
DataPacketKind::Reliable
|
||||
} else {
|
||||
DataPacketKind::Lossy
|
||||
};
|
||||
room.local_participant()
|
||||
.publish_data(livekit::prelude::DataPacket {
|
||||
payload,
|
||||
topic: None,
|
||||
reliable: matches!(kind, DataPacketKind::Reliable),
|
||||
destination_identities: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("publish_data failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Placeholder — Phase B.2 will fill these in.
|
||||
#[tauri::command]
|
||||
pub async fn livekit_set_mic(_enabled: bool) -> Result<(), String> {
|
||||
Err("livekit_set_mic not implemented — Phase B.2".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn livekit_set_camera(_enabled: bool) -> Result<(), String> {
|
||||
Err("livekit_set_camera not implemented — Phase B.2".into())
|
||||
}
|
||||
|
||||
// Unused-send bridge so `mpsc` doesn't get unused-import-warned when the
|
||||
// feature gate is off.
|
||||
#[allow(dead_code)]
|
||||
fn _mpsc_anchor() -> mpsc::Sender<()> {
|
||||
let (tx, _rx) = mpsc::channel::<()>(1);
|
||||
tx
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// Native system-audio capture for the custom screen-share picker. Without
|
||||
// this path the picker has to fall back to getDisplayMedia whenever the
|
||||
// user ticks "Mit System-Sound", because Chromium only wires audio into
|
||||
// desktop captures that the OS picker produced. Here we grab the default
|
||||
// render endpoint's loopback stream via WASAPI, convert it to 48kHz f32
|
||||
// stereo, and ship the samples to the JS side through a Tauri Channel.
|
||||
// An AudioWorklet on the frontend feeds them into a MediaStreamDestination
|
||||
// so LiveKit publishes a plain ScreenShareAudio track.
|
||||
//
|
||||
// Windows-only for v1. macOS + Linux stubs return a clear error so the
|
||||
// frontend can fall back cleanly on those platforms until their native
|
||||
// paths ship (ScreenCaptureKit-audio / PipeWire).
|
||||
|
||||
#![allow(clippy::needless_return)]
|
||||
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
// Output format we always deliver to the frontend. Picking a single fixed
|
||||
// format means the AudioWorklet never has to renegotiate — it just assumes
|
||||
// interleaved f32 stereo at 48kHz. WASAPI mix format is usually already
|
||||
// this on Windows 10+, so the resample branch is rarely hit.
|
||||
const OUTPUT_SAMPLE_RATE: u32 = 48_000;
|
||||
const OUTPUT_CHANNELS: u16 = 2;
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
|
||||
|
||||
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
|
||||
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
struct Session {
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AudioFramePayload {
|
||||
pub capture_id: u32,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
/// Interleaved little-endian f32 stereo samples, base64-encoded.
|
||||
/// Frontend decodes via `atob` → `Uint8Array` → `Float32Array` view.
|
||||
/// Base64 is used instead of a raw `Vec<f32>` because Tauri Channel
|
||||
/// serialises via JSON — a JSON array of floats balloons to ~2–3×
|
||||
/// the byte count, and at 48kHz stereo that's enough IPC traffic
|
||||
/// to matter.
|
||||
pub samples_base64: String,
|
||||
}
|
||||
|
||||
/// Start a loopback capture of the default render endpoint and begin
|
||||
/// streaming audio frames on the provided channel. Returns a numeric
|
||||
/// capture id that must be handed to `stop_system_audio_capture` when
|
||||
/// the share ends.
|
||||
#[tauri::command]
|
||||
pub fn start_system_audio_capture(
|
||||
channel: Channel<AudioFramePayload>,
|
||||
) -> Result<u32, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
|
||||
let handle = thread::Builder::new()
|
||||
.name(format!("screen-audio-{capture_id}"))
|
||||
.spawn(move || {
|
||||
if let Err(err) =
|
||||
windows_loopback::capture_loop(capture_id, channel, stop_clone)
|
||||
{
|
||||
eprintln!("screen-audio {capture_id}: {err}");
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("failed to spawn audio thread: {e}"))?;
|
||||
|
||||
sessions().lock().unwrap().insert(
|
||||
capture_id,
|
||||
Session {
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
},
|
||||
);
|
||||
Ok(capture_id)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Keep the `channel` binding alive so Tauri doesn't complain about
|
||||
// an unused parameter on the non-Windows build.
|
||||
let _ = channel;
|
||||
Err("system audio capture only supported on Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down the capture for the given id. Safe to call on a missing id
|
||||
/// (no-op) so the JS side doesn't have to track whether the stop has
|
||||
/// already been issued by the screen-share teardown path.
|
||||
#[tauri::command]
|
||||
pub fn stop_system_audio_capture(capture_id: u32) -> Result<(), String> {
|
||||
let session = sessions().lock().unwrap().remove(&capture_id);
|
||||
let Some(mut session) = session else {
|
||||
return Ok(());
|
||||
};
|
||||
session.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(handle) = session.handle.take() {
|
||||
// Best-effort join — the capture loop polls `stop` every event
|
||||
// cycle (≤100ms) so this usually returns promptly. If the WASAPI
|
||||
// call is wedged we'd rather drop the handle than hang the stop.
|
||||
let _ = handle.join();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows loopback implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_loopback {
|
||||
use super::*;
|
||||
use wasapi::{initialize_mta, Direction, SampleType, ShareMode};
|
||||
|
||||
pub fn capture_loop(
|
||||
capture_id: u32,
|
||||
channel: Channel<AudioFramePayload>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Result<(), String> {
|
||||
// COM must be initialised on every thread that touches WASAPI.
|
||||
// MTA is the right model for a background capture thread — STA
|
||||
// would require message pumping we don't want to add.
|
||||
initialize_mta()
|
||||
.ok()
|
||||
.map_err(|e| format!("initialize_mta: {e:?}"))?;
|
||||
|
||||
let device = wasapi::get_default_device(&Direction::Render)
|
||||
.map_err(|e| format!("get_default_device: {e:?}"))?;
|
||||
let mut audio_client = device
|
||||
.get_iaudioclient()
|
||||
.map_err(|e| format!("get_iaudioclient: {e:?}"))?;
|
||||
|
||||
// Use the mix format that Windows is already pushing to the
|
||||
// endpoint. Loopback capture won't convert for us — asking for a
|
||||
// fixed format here makes Initialize() fail on non-matching
|
||||
// hardware. We resample + channel-mix ourselves downstream.
|
||||
let mix_format = audio_client
|
||||
.get_mixformat()
|
||||
.map_err(|e| format!("get_mixformat: {e:?}"))?;
|
||||
let input_rate = mix_format.get_samplespersec();
|
||||
let input_channels = mix_format.get_nchannels();
|
||||
let bits_per_sample = mix_format.get_bitspersample();
|
||||
let block_align = mix_format.get_blockalign();
|
||||
let sample_type = mix_format.get_subformat().unwrap_or(SampleType::Int);
|
||||
|
||||
let (def_time, _min_time) = audio_client
|
||||
.get_periods()
|
||||
.map_err(|e| format!("get_periods: {e:?}"))?;
|
||||
|
||||
// Direction::Capture + loopback: WASAPI streams what Windows is
|
||||
// sending to the speakers instead of what an input device is
|
||||
// producing. Shared mode so we coexist with other apps.
|
||||
audio_client
|
||||
.initialize_client(
|
||||
&mix_format,
|
||||
def_time,
|
||||
&Direction::Capture,
|
||||
&ShareMode::Shared,
|
||||
true,
|
||||
)
|
||||
.map_err(|e| format!("initialize_client: {e:?}"))?;
|
||||
|
||||
let h_event = audio_client
|
||||
.set_get_eventhandle()
|
||||
.map_err(|e| format!("set_get_eventhandle: {e:?}"))?;
|
||||
let capture_client = audio_client
|
||||
.get_audiocaptureclient()
|
||||
.map_err(|e| format!("get_audiocaptureclient: {e:?}"))?;
|
||||
|
||||
audio_client
|
||||
.start_stream()
|
||||
.map_err(|e| format!("start_stream: {e:?}"))?;
|
||||
|
||||
// Resampler state — last stereo frame from the previous buffer so
|
||||
// linear interpolation at the buffer boundary doesn't click.
|
||||
// Initialised to silence.
|
||||
let mut last_stereo: [f32; 2] = [0.0, 0.0];
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// 100ms timeout lets the loop check the stop flag even when
|
||||
// the endpoint is silent (WASAPI doesn't signal the event at
|
||||
// all for pure-silence streams on some driver versions).
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drain all packets available since the last wake — there
|
||||
// can be several queued if we were preempted.
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let frames_available = match capture_client.get_next_nbr_frames() {
|
||||
Ok(Some(n)) if n > 0 => n,
|
||||
Ok(_) => break,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"screen-audio {capture_id}: get_next_nbr_frames: {e:?}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let bytes_needed =
|
||||
frames_available as usize * block_align as usize;
|
||||
let mut raw = vec![0u8; bytes_needed];
|
||||
if let Err(e) = capture_client.read_from_device(&mut raw) {
|
||||
eprintln!(
|
||||
"screen-audio {capture_id}: read_from_device: {e:?}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode PCM into interleaved f32 at the device's native
|
||||
// rate + channel count.
|
||||
let decoded = decode_pcm(
|
||||
&raw,
|
||||
input_channels,
|
||||
bits_per_sample,
|
||||
&sample_type,
|
||||
);
|
||||
|
||||
// Channel-fold → 2ch, then resample → 48kHz.
|
||||
let stereo = to_stereo(&decoded, input_channels);
|
||||
let out_samples = resample_linear_stereo(
|
||||
&stereo,
|
||||
input_rate,
|
||||
OUTPUT_SAMPLE_RATE,
|
||||
&mut last_stereo,
|
||||
);
|
||||
|
||||
if out_samples.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pack f32s as little-endian bytes then base64. IPC-wise
|
||||
// this is ~1.3× the raw byte count versus 5–10× for a
|
||||
// JSON array of floats, which is the difference between
|
||||
// "fine" and "wastes a CPU core" at 48kHz stereo.
|
||||
let mut bytes = Vec::with_capacity(out_samples.len() * 4);
|
||||
for s in &out_samples {
|
||||
bytes.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
let samples_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
if channel
|
||||
.send(AudioFramePayload {
|
||||
capture_id,
|
||||
sample_rate: OUTPUT_SAMPLE_RATE,
|
||||
channels: OUTPUT_CHANNELS,
|
||||
samples_base64: samples_b64,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// Frontend went away — stop cleanly.
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = audio_client.stop_stream();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Convert a raw WASAPI buffer into interleaved f32 at the device's
|
||||
// native channel count. Handles the three formats that actually show
|
||||
// up on Windows render endpoints: f32 (most modern hardware), i16
|
||||
// (older onboard codecs), and i32 (pro audio interfaces). Anything
|
||||
// else falls through to zeros so a weird format doesn't crash the
|
||||
// share — the user will notice silence and can retry.
|
||||
fn decode_pcm(
|
||||
raw: &[u8],
|
||||
channels: u16,
|
||||
bits_per_sample: u16,
|
||||
sample_type: &SampleType,
|
||||
) -> Vec<f32> {
|
||||
match (sample_type, bits_per_sample) {
|
||||
(SampleType::Float, 32) => {
|
||||
let mut out = Vec::with_capacity(raw.len() / 4);
|
||||
for chunk in raw.chunks_exact(4) {
|
||||
out.push(f32::from_le_bytes([
|
||||
chunk[0], chunk[1], chunk[2], chunk[3],
|
||||
]));
|
||||
}
|
||||
out
|
||||
}
|
||||
(SampleType::Int, 16) => {
|
||||
let scale = 1.0_f32 / (i16::MAX as f32);
|
||||
let mut out = Vec::with_capacity(raw.len() / 2);
|
||||
for chunk in raw.chunks_exact(2) {
|
||||
let s = i16::from_le_bytes([chunk[0], chunk[1]]);
|
||||
out.push(s as f32 * scale);
|
||||
}
|
||||
out
|
||||
}
|
||||
(SampleType::Int, 32) => {
|
||||
let scale = 1.0_f32 / (i32::MAX as f32);
|
||||
let mut out = Vec::with_capacity(raw.len() / 4);
|
||||
for chunk in raw.chunks_exact(4) {
|
||||
let s = i32::from_le_bytes([
|
||||
chunk[0], chunk[1], chunk[2], chunk[3],
|
||||
]);
|
||||
out.push(s as f32 * scale);
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => {
|
||||
// Unknown format — emit silence of the right frame count
|
||||
// so downstream math stays correct.
|
||||
let bytes_per_frame =
|
||||
(bits_per_sample as usize / 8) * channels as usize;
|
||||
let frames = if bytes_per_frame == 0 {
|
||||
0
|
||||
} else {
|
||||
raw.len() / bytes_per_frame
|
||||
};
|
||||
vec![0.0; frames * channels as usize]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Down- or up-mix to stereo. Surround layouts fold L+R only (center
|
||||
// + surrounds get dropped) which is the simplest defensible choice
|
||||
// for screen-share audio — most content is LR-centric and a proper
|
||||
// ITU-R BS.775 downmix would pull in matrix coefficients we'd rather
|
||||
// avoid in v1.
|
||||
fn to_stereo(interleaved: &[f32], channels: u16) -> Vec<f32> {
|
||||
if channels == 0 || interleaved.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if channels == 2 {
|
||||
return interleaved.to_vec();
|
||||
}
|
||||
let ch = channels as usize;
|
||||
let frames = interleaved.len() / ch;
|
||||
let mut out = Vec::with_capacity(frames * 2);
|
||||
if channels == 1 {
|
||||
for i in 0..frames {
|
||||
let s = interleaved[i];
|
||||
out.push(s);
|
||||
out.push(s);
|
||||
}
|
||||
} else {
|
||||
for i in 0..frames {
|
||||
let base = i * ch;
|
||||
out.push(interleaved[base]);
|
||||
out.push(interleaved[base + 1]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Linear-interpolation resampler for interleaved stereo f32. Not the
|
||||
// prettiest option theoretically, but at 44.1→48 the audible
|
||||
// artefacts stay below threshold for speech + game/music content. The
|
||||
// `last_stereo` state preserves the final frame across invocations so
|
||||
// the interpolation at the buffer boundary doesn't produce a click.
|
||||
fn resample_linear_stereo(
|
||||
input_stereo: &[f32],
|
||||
input_rate: u32,
|
||||
output_rate: u32,
|
||||
last_stereo: &mut [f32; 2],
|
||||
) -> Vec<f32> {
|
||||
if input_stereo.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if input_rate == output_rate {
|
||||
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||
return input_stereo.to_vec();
|
||||
}
|
||||
let ratio = output_rate as f64 / input_rate as f64;
|
||||
let in_frames = input_stereo.len() / 2;
|
||||
let out_frames = (in_frames as f64 * ratio).floor() as usize;
|
||||
if out_frames == 0 {
|
||||
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::with_capacity(out_frames * 2);
|
||||
|
||||
let prev_l = last_stereo[0];
|
||||
let prev_r = last_stereo[1];
|
||||
|
||||
for i in 0..out_frames {
|
||||
let src_pos = i as f64 / ratio;
|
||||
let src_frame = src_pos.floor() as i64;
|
||||
let frac = (src_pos - src_frame as f64) as f32;
|
||||
|
||||
// `src_frame == -1` comes up for the very first output frame
|
||||
// when ratio > 1 — interpolate against the previous buffer's
|
||||
// final sample to bridge the two.
|
||||
let (l0, r0) = if src_frame < 0 {
|
||||
(prev_l, prev_r)
|
||||
} else {
|
||||
let idx = (src_frame as usize).min(in_frames - 1) * 2;
|
||||
(input_stereo[idx], input_stereo[idx + 1])
|
||||
};
|
||||
let next = ((src_frame + 1) as usize).min(in_frames - 1);
|
||||
let l1 = input_stereo[next * 2];
|
||||
let r1 = input_stereo[next * 2 + 1];
|
||||
|
||||
out.push(l0 + (l1 - l0) * frac);
|
||||
out.push(r0 + (r1 - r0) * frac);
|
||||
}
|
||||
|
||||
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Native screen / window capture pipeline. Spawns a Rust thread per active
|
||||
// capture that grabs frames via xcap, downscales them to the user's target
|
||||
// resolution, encodes JPEG, and streams each frame to the JS side through
|
||||
// a Tauri `Channel<FramePayload>`. The JS end decodes into an
|
||||
// ImageBitmap, draws to a <canvas>, and exposes the canvas as a
|
||||
// MediaStream via captureStream() — that stream is what LiveKit publishes.
|
||||
// Net result: the user picks a source in our custom picker and the share
|
||||
// starts directly, without the OS/browser picker appearing.
|
||||
//
|
||||
// JPEG on the Rust side + decode on the JS side introduces a second
|
||||
// encoding hop (LiveKit re-encodes VP9 later) but keeps IPC bandwidth
|
||||
// manageable — raw RGBA at 1920×1080×30fps would be ~240MB/s and cannot
|
||||
// go over Tauri's JSON-serialised IPC. JPEG frames at Q72 land around
|
||||
// 50–150KB each, so 30fps = 2–5MB/s of base64 traffic, which is fine.
|
||||
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
|
||||
|
||||
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
|
||||
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
struct Session {
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FramePayload {
|
||||
pub capture_id: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// JPEG image bytes, base64-encoded (no data-URL prefix). Frontend
|
||||
/// reconstructs via `Uint8Array.from(atob(...))` and decodes with
|
||||
/// `createImageBitmap(blob)`.
|
||||
pub jpeg_base64: String,
|
||||
}
|
||||
|
||||
enum Source {
|
||||
Window(xcap::Window),
|
||||
Monitor(xcap::Monitor),
|
||||
}
|
||||
|
||||
/// Start a continuous capture for the given source id and begin streaming
|
||||
/// JPEG frames via the provided channel. Returns a numeric capture id that
|
||||
/// must be passed to `stop_screen_capture` to tear the pipeline down.
|
||||
#[tauri::command]
|
||||
pub fn start_screen_capture(
|
||||
source_id: String,
|
||||
max_width: u32,
|
||||
max_height: u32,
|
||||
fps: u32,
|
||||
channel: Channel<FramePayload>,
|
||||
) -> Result<u32, String> {
|
||||
let clamped_fps = fps.clamp(5, 30);
|
||||
let clamped_w = max_width.max(320).min(3840);
|
||||
let clamped_h = max_height.max(180).min(2160);
|
||||
|
||||
// Probe once on the command thread so we can surface a clear error
|
||||
// before spawning; the actual capture handle is re-resolved inside the
|
||||
// worker thread (xcap::Window holds a Windows HWND which is !Send).
|
||||
if find_source(&source_id).is_none() {
|
||||
return Err(format!("screen source {source_id} not found"));
|
||||
}
|
||||
|
||||
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let source_id_owned = source_id;
|
||||
|
||||
let handle = thread::Builder::new()
|
||||
.name(format!("screen-capture-{capture_id}"))
|
||||
.spawn(move || {
|
||||
capture_loop(
|
||||
source_id_owned,
|
||||
clamped_w,
|
||||
clamped_h,
|
||||
clamped_fps,
|
||||
capture_id,
|
||||
channel,
|
||||
stop_clone,
|
||||
);
|
||||
})
|
||||
.map_err(|e| format!("failed to spawn capture thread: {e}"))?;
|
||||
|
||||
sessions().lock().unwrap().insert(
|
||||
capture_id,
|
||||
Session {
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
},
|
||||
);
|
||||
Ok(capture_id)
|
||||
}
|
||||
|
||||
/// Signal the capture thread to stop and join it. Safe to call more than
|
||||
/// once — missing ids are silently no-ops so the JS side doesn't need to
|
||||
/// track whether a stop was already issued by the OS "stop sharing" path.
|
||||
#[tauri::command]
|
||||
pub fn stop_screen_capture(capture_id: u32) -> Result<(), String> {
|
||||
let session = sessions().lock().unwrap().remove(&capture_id);
|
||||
let Some(mut session) = session else {
|
||||
return Ok(());
|
||||
};
|
||||
session.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(handle) = session.handle.take() {
|
||||
// Best-effort join. If the thread is stuck in a long OS capture
|
||||
// call we don't want to hang the command — detach after a brief
|
||||
// wait by dropping the handle.
|
||||
let _ = handle.join();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture_loop(
|
||||
source_id: String,
|
||||
max_w: u32,
|
||||
max_h: u32,
|
||||
fps: u32,
|
||||
capture_id: u32,
|
||||
channel: Channel<FramePayload>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let frame_interval = Duration::from_nanos(1_000_000_000 / fps as u64);
|
||||
let jpeg_quality: u8 = 72;
|
||||
|
||||
// Re-resolve the source inside the worker thread — xcap::Window holds
|
||||
// an HWND which is !Send so we can't move it across threads. Caching
|
||||
// the handle for the lifetime of the loop keeps per-frame cost to the
|
||||
// actual pixel capture + encode.
|
||||
let source = match find_source(&source_id) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("screen-capture {capture_id}: source vanished before capture started");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let frame_start = Instant::now();
|
||||
|
||||
let img_result = match &source {
|
||||
Source::Window(w) => w.capture_image(),
|
||||
Source::Monitor(m) => m.capture_image(),
|
||||
};
|
||||
let img = match img_result {
|
||||
Ok(i) => i,
|
||||
Err(err) => {
|
||||
eprintln!("screen-capture {capture_id}: capture failed: {err}");
|
||||
// Brief backoff before retrying — transient Windows GDI
|
||||
// errors (e.g. during screen lock) tend to recover within
|
||||
// a frame or two.
|
||||
thread::sleep(frame_interval);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let (raw_w, raw_h) = img.dimensions();
|
||||
let (tgt_w, tgt_h) = scale_to_fit(raw_w, raw_h, max_w, max_h);
|
||||
let scaled = if (tgt_w, tgt_h) != (raw_w, raw_h) {
|
||||
image::imageops::resize(
|
||||
&img,
|
||||
tgt_w,
|
||||
tgt_h,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
// JPEG doesn't support alpha; strip it before encoding.
|
||||
let rgb = rgba_to_rgb(&scaled);
|
||||
|
||||
let mut jpeg_buf: Vec<u8> = Vec::with_capacity((tgt_w * tgt_h) as usize);
|
||||
{
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
|
||||
if let Err(err) =
|
||||
encoder.encode(&rgb, tgt_w, tgt_h, image::ExtendedColorType::Rgb8)
|
||||
{
|
||||
eprintln!("screen-capture {capture_id}: encode failed: {err}");
|
||||
thread::sleep(frame_interval);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let jpeg_base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);
|
||||
|
||||
let send_result = channel.send(FramePayload {
|
||||
capture_id,
|
||||
width: tgt_w,
|
||||
height: tgt_h,
|
||||
jpeg_base64,
|
||||
});
|
||||
if send_result.is_err() {
|
||||
// Frontend went away (window closed, renderer crashed).
|
||||
break;
|
||||
}
|
||||
|
||||
// Pace to target framerate. If the capture + encode already took
|
||||
// longer than one frame interval, yield a millisecond to avoid
|
||||
// pegging a single core when the target is unreachable.
|
||||
let elapsed = frame_start.elapsed();
|
||||
if elapsed < frame_interval {
|
||||
thread::sleep(frame_interval - elapsed);
|
||||
} else {
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba_to_rgb(buf: &image::RgbaImage) -> Vec<u8> {
|
||||
let (w, h) = buf.dimensions();
|
||||
let mut out = Vec::with_capacity((w * h * 3) as usize);
|
||||
for p in buf.pixels() {
|
||||
out.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn scale_to_fit(w: u32, h: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||||
if w == 0 || h == 0 {
|
||||
return (w, h);
|
||||
}
|
||||
let scale = (max_w as f32 / w as f32)
|
||||
.min(max_h as f32 / h as f32)
|
||||
.min(1.0);
|
||||
if scale >= 1.0 {
|
||||
return (w, h);
|
||||
}
|
||||
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
|
||||
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
|
||||
(new_w, new_h)
|
||||
}
|
||||
|
||||
fn find_source(source_id: &str) -> Option<Source> {
|
||||
if let Some(rest) = source_id.strip_prefix("window:") {
|
||||
let raw = rest.strip_suffix(":0")?;
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let windows = xcap::Window::all().ok()?;
|
||||
return windows
|
||||
.into_iter()
|
||||
.find(|w| w.id() == raw_id)
|
||||
.map(Source::Window);
|
||||
}
|
||||
if let Some(rest) = source_id.strip_prefix("screen:") {
|
||||
let raw = rest.strip_suffix(":0")?;
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let monitors = xcap::Monitor::all().ok()?;
|
||||
return monitors
|
||||
.into_iter()
|
||||
.find(|m| m.id() == raw_id)
|
||||
.map(Source::Monitor);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Source enumeration for the Discord-style screen-share picker. Split
|
||||
// into two commands on the slow-vs-fast axis:
|
||||
//
|
||||
// - list_screen_sources — metadata only (no thumbnails). Fast; the
|
||||
// picker shows names + placeholders instantly.
|
||||
// - capture_screen_source_thumbnail — one thumbnail at a time, keyed by
|
||||
// the id returned from the list.
|
||||
//
|
||||
// The frontend fans out the thumbnail calls via Promise.all so Tauri's
|
||||
// command thread pool captures them in parallel — wall-clock time ends up
|
||||
// bounded by the *slowest* source rather than the sum of all captures.
|
||||
// The `id` field is emitted in Chromium's internal desktopCapturer format
|
||||
// ("screen:<id>:0" / "window:<hwnd>:0") so the JS side can try to pass it
|
||||
// straight into getUserMedia's `chromeMediaSourceId` constraint, or use
|
||||
// it as the source key for the native capture pipeline in screen_capture.
|
||||
//
|
||||
// xcap abstracts the platform-specific capture APIs (Windows GDI + DXGI,
|
||||
// macOS CoreGraphics/ScreenCaptureKit, X11) so the code here stays flat.
|
||||
// Thumbnails are captured at native resolution, then letterbox-downscaled
|
||||
// to fit a 320×180 box to keep the base64 payload small.
|
||||
|
||||
use base64::Engine;
|
||||
use image::{ImageBuffer, Rgba};
|
||||
use serde::Serialize;
|
||||
|
||||
// Thumbnail dimensions tuned for the picker grid: even smaller than the
|
||||
// first pass because we're now streaming raw JPEG bytes (no base64) —
|
||||
// smaller payload = less IPC postMessage work on the main thread. At
|
||||
// 192×108 / Q60 the typical window thumbnail is 5–10 KB and decodes to
|
||||
// the grid in a frame or two.
|
||||
const THUMB_MAX_W: u32 = 192;
|
||||
const THUMB_MAX_H: u32 = 108;
|
||||
const THUMB_JPEG_QUALITY: u8 = 60;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScreenSource {
|
||||
/// Chromium-format source id — stable within one enumeration call.
|
||||
pub id: String,
|
||||
/// Human-readable label for the picker (monitor name or window title).
|
||||
pub name: String,
|
||||
/// Discriminator for the grid grouping.
|
||||
pub kind: &'static str,
|
||||
/// Base64-encoded PNG, no data-URL prefix. None when the capture
|
||||
/// fails (minimised window, permission-denied surface, transient
|
||||
/// race). The UI renders a name-only card in that case.
|
||||
pub thumbnail_png: Option<String>,
|
||||
/// Native width of the full-res source — mostly informational, used
|
||||
/// by the UI for aspect-ratio styling of the card.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
// Fast metadata-only enumeration. No image capture happens here — that's
|
||||
// why it returns in tens of milliseconds instead of the multi-second
|
||||
// wait the single-shot enumerate_screen_sources command had.
|
||||
#[tauri::command]
|
||||
pub fn list_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||
let mut out: Vec<ScreenSource> = Vec::new();
|
||||
append_monitor_metadata(&mut out);
|
||||
append_window_metadata(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// One-shot thumbnail capture by source id. Called N times in parallel from
|
||||
// the frontend after the list lands so the total wall-clock is bounded by
|
||||
// the slowest capture rather than the sum. Legacy base64 variant — kept
|
||||
// for rollback; the preferred path is `capture_screen_source_thumbnail_bytes`.
|
||||
#[tauri::command]
|
||||
pub fn capture_screen_source_thumbnail(source_id: String) -> Result<Option<String>, String> {
|
||||
if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
|
||||
return Ok(capture_monitor_by_id(raw));
|
||||
}
|
||||
if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
|
||||
return Ok(capture_window_by_id(raw));
|
||||
}
|
||||
Err(format!("unknown source id format: {source_id}"))
|
||||
}
|
||||
|
||||
// Binary variant: returns raw JPEG bytes wrapped in `tauri::ipc::Response`
|
||||
// so Tauri ships them over IPC without JSON-encoding / base64. On the JS
|
||||
// side, `invoke` resolves to an ArrayBuffer that we wrap in a Blob and
|
||||
// expose via `URL.createObjectURL` — skips the base64-decode step
|
||||
// entirely and keeps the main thread responsive during fan-in.
|
||||
//
|
||||
// The return type MUST be `Response` directly (not `Result<Response, E>`):
|
||||
// a Result wrapper forces Tauri to JSON-serialise the variant so the
|
||||
// frontend gets a JSON object instead of raw bytes. Failures — bad id
|
||||
// format, capture errors, source vanished — all funnel into an empty
|
||||
// byte buffer; the caller treats `byteLength === 0` as the "no thumbnail"
|
||||
// signal.
|
||||
#[tauri::command]
|
||||
pub fn capture_screen_source_thumbnail_bytes(source_id: String) -> tauri::ipc::Response {
|
||||
let bytes = if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
|
||||
capture_monitor_bytes_by_id(raw).unwrap_or_default()
|
||||
} else if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
|
||||
capture_window_bytes_by_id(raw).unwrap_or_default()
|
||||
} else {
|
||||
eprintln!("capture_screen_source_thumbnail_bytes: unknown id format: {source_id}");
|
||||
Vec::new()
|
||||
};
|
||||
tauri::ipc::Response::new(bytes)
|
||||
}
|
||||
|
||||
fn strip_zero_suffix(s: &str) -> Option<&str> {
|
||||
s.strip_suffix(":0")
|
||||
}
|
||||
|
||||
// Legacy one-shot all-in-one enumeration. Kept around so the frontend can
|
||||
// fall back during rollout if the split pair throws; marked dead_code so
|
||||
// the linker doesn't grumble when only the split variant is wired up.
|
||||
#[allow(dead_code)]
|
||||
#[tauri::command]
|
||||
pub fn enumerate_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||
let mut out: Vec<ScreenSource> = Vec::new();
|
||||
append_monitors(&mut out);
|
||||
append_windows(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monitors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn append_monitor_metadata(out: &mut Vec<ScreenSource>) {
|
||||
let monitors = match xcap::Monitor::all() {
|
||||
Ok(m) => m,
|
||||
Err(err) => {
|
||||
eprintln!("xcap Monitor::all failed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for (idx, m) in monitors.iter().enumerate() {
|
||||
out.push(ScreenSource {
|
||||
id: format!("screen:{}:0", m.id()),
|
||||
name: monitor_label(m, idx),
|
||||
kind: "screen",
|
||||
thumbnail_png: None,
|
||||
width: m.width(),
|
||||
height: m.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn append_monitors(out: &mut Vec<ScreenSource>) {
|
||||
let monitors = match xcap::Monitor::all() {
|
||||
Ok(m) => m,
|
||||
Err(err) => {
|
||||
eprintln!("xcap Monitor::all failed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for (idx, m) in monitors.iter().enumerate() {
|
||||
out.push(ScreenSource {
|
||||
id: format!("screen:{}:0", m.id()),
|
||||
name: monitor_label(m, idx),
|
||||
kind: "screen",
|
||||
thumbnail_png: capture_monitor_thumbnail(m),
|
||||
width: m.width(),
|
||||
height: m.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_monitor_by_id(raw: &str) -> Option<String> {
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let monitors = xcap::Monitor::all().ok()?;
|
||||
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
|
||||
capture_monitor_thumbnail(&target)
|
||||
}
|
||||
|
||||
fn capture_monitor_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let monitors = xcap::Monitor::all().ok()?;
|
||||
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
|
||||
let image = target.capture_image().ok()?;
|
||||
encode_scaled_jpeg_bytes(image)
|
||||
}
|
||||
|
||||
fn monitor_label(m: &xcap::Monitor, idx: usize) -> String {
|
||||
let name = m.name();
|
||||
if name.is_empty() {
|
||||
format!("Bildschirm {}", idx + 1)
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option<String> {
|
||||
let image = m.capture_image().ok()?;
|
||||
encode_scaled_jpeg(image)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn append_window_metadata(out: &mut Vec<ScreenSource>) {
|
||||
let windows = match xcap::Window::all() {
|
||||
Ok(w) => w,
|
||||
Err(err) => {
|
||||
eprintln!("xcap Window::all failed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for w in windows.iter() {
|
||||
if !is_shareable_window(w) {
|
||||
continue;
|
||||
}
|
||||
let title = w.title();
|
||||
if title.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(ScreenSource {
|
||||
id: format!("window:{}:0", w.id()),
|
||||
name: title.to_string(),
|
||||
kind: "window",
|
||||
thumbnail_png: None,
|
||||
width: w.width(),
|
||||
height: w.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn append_windows(out: &mut Vec<ScreenSource>) {
|
||||
let windows = match xcap::Window::all() {
|
||||
Ok(w) => w,
|
||||
Err(err) => {
|
||||
eprintln!("xcap Window::all failed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for w in windows.iter() {
|
||||
if !is_shareable_window(w) {
|
||||
continue;
|
||||
}
|
||||
let title = w.title();
|
||||
if title.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(ScreenSource {
|
||||
id: format!("window:{}:0", w.id()),
|
||||
name: title.to_string(),
|
||||
kind: "window",
|
||||
thumbnail_png: capture_window_thumbnail(w),
|
||||
width: w.width(),
|
||||
height: w.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_window_by_id(raw: &str) -> Option<String> {
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let windows = xcap::Window::all().ok()?;
|
||||
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
|
||||
capture_window_thumbnail(&target)
|
||||
}
|
||||
|
||||
fn capture_window_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let windows = xcap::Window::all().ok()?;
|
||||
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
|
||||
let image = target.capture_image().ok()?;
|
||||
encode_scaled_jpeg_bytes(image)
|
||||
}
|
||||
|
||||
fn is_shareable_window(w: &xcap::Window) -> bool {
|
||||
if w.is_minimized() {
|
||||
return false;
|
||||
}
|
||||
let width = w.width();
|
||||
let height = w.height();
|
||||
// Tooltips, invisible tray-helpers, etc. sit at or near zero size —
|
||||
// they'd clutter the picker grid and usually can't be captured anyway.
|
||||
if width < 80 || height < 60 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn capture_window_thumbnail(w: &xcap::Window) -> Option<String> {
|
||||
let image = w.capture_image().ok()?;
|
||||
encode_scaled_jpeg(image)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scaling + encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Letterbox-shrink the captured image so the long edge is at most
|
||||
// THUMB_MAX_W / THUMB_MAX_H. Keeps aspect ratio, skips upscaling entirely
|
||||
// (tiny windows stay their captured size). Returns raw JPEG bytes — the
|
||||
// binary-IPC path ships these directly, the legacy base64 wrapper
|
||||
// (`encode_scaled_jpeg`) wraps in base64 for the old command.
|
||||
fn encode_scaled_jpeg_bytes(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<Vec<u8>> {
|
||||
let (w, h) = src.dimensions();
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let scale = (THUMB_MAX_W as f32 / w as f32)
|
||||
.min(THUMB_MAX_H as f32 / h as f32)
|
||||
.min(1.0);
|
||||
let scaled = if scale < 1.0 {
|
||||
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
|
||||
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
|
||||
image::imageops::resize(&src, new_w, new_h, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
src
|
||||
};
|
||||
// JPEG encoder doesn't accept RGBA — strip alpha into a packed RGB
|
||||
// buffer first. Alpha carries no info for a visible thumbnail anyway.
|
||||
let (sw, sh) = (scaled.width(), scaled.height());
|
||||
let mut rgb: Vec<u8> = Vec::with_capacity((sw * sh * 3) as usize);
|
||||
for p in scaled.pixels() {
|
||||
rgb.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
|
||||
}
|
||||
let mut buf: Vec<u8> = Vec::with_capacity((sw * sh / 8) as usize);
|
||||
{
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut buf, THUMB_JPEG_QUALITY);
|
||||
encoder
|
||||
.encode(&rgb, sw, sh, image::ExtendedColorType::Rgb8)
|
||||
.ok()?;
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
// Legacy base64 wrapper — used by `capture_screen_source_thumbnail`
|
||||
// (Result<Option<String>, String>) which predates the binary variant.
|
||||
fn encode_scaled_jpeg(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
|
||||
let bytes = encode_scaled_jpeg_bytes(src)?;
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ChatApp",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0",
|
||||
"identifier": "com.meinname.chatapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm vite:dev",
|
||||
@@ -42,8 +42,10 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": ["https://github.com/byGalax/chat-app/releases/latest/download/latest.json"],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK",
|
||||
"endpoints": [
|
||||
"https://update.netralax.cloud/windows/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI1Mzc0QjVBQUZEQTA3RUIKUldUckI5cXZXa3MzdGM3QkE4WWFPd3NnVzRZeXdpcUM0eUtjRDlGN09ySEdzNXhLNlo3azBPajYK",
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { SpinnerIcon } from './components/icons';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
@@ -10,14 +12,39 @@ import { CallProvider } from './context/CallContext';
|
||||
import { ConversationsProvider } from './context/ConversationsContext';
|
||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
||||
import { AuthPage } from './pages/AuthPage';
|
||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||
import { ConversationPage } from './pages/ConversationPage';
|
||||
import { DevicePage } from './pages/DevicePage';
|
||||
import { FriendsPage } from './pages/FriendsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
// Routes rarely visited on first render are pulled out of the initial bundle.
|
||||
// AuthPage stays eager because it's the first screen unauthenticated users
|
||||
// see; ChatsPage + ConversationPage stay eager because every authenticated
|
||||
// session renders them immediately.
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })));
|
||||
const AuthCallbackPage = lazy(() =>
|
||||
import('./pages/AuthCallbackPage').then((m) => ({ default: m.AuthCallbackPage })),
|
||||
);
|
||||
const DevicePage = lazy(() => import('./pages/DevicePage').then((m) => ({ default: m.DevicePage })));
|
||||
const FriendsPage = lazy(() =>
|
||||
import('./pages/FriendsPage').then((m) => ({ default: m.FriendsPage })),
|
||||
);
|
||||
const SettingsPage = lazy(() =>
|
||||
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||
);
|
||||
|
||||
function RouteSuspense({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-full w-full items-center justify-center bg-surface-3">
|
||||
<SpinnerIcon className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Isolates each top-level route so a crash in one page doesn't take the whole
|
||||
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
|
||||
@@ -53,11 +80,25 @@ export function App() {
|
||||
<Routes>
|
||||
<Route element={<RouteBoundary scope="auth" />}>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route
|
||||
path="/auth/callback"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<AuthCallbackPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RouteBoundary scope="device" />}>
|
||||
<Route path="/device" element={<DevicePage />} />
|
||||
<Route
|
||||
path="/device"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<DevicePage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireDevice />}>
|
||||
<Route element={<AppShell />}>
|
||||
@@ -76,14 +117,35 @@ export function App() {
|
||||
</Route>
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="friends" />}>
|
||||
<Route path="/friends" element={<FriendsPage />} />
|
||||
<Route
|
||||
path="/friends"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<FriendsPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="settings" />}>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<SettingsPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route element={<RouteBoundary scope="admin" />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<AdminPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -31,19 +32,30 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
setBlobUrl(null);
|
||||
setArrayBuf(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then(async (blob) => {
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
if (cached) {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(cached);
|
||||
setBlobUrl(url);
|
||||
const buf = await cached.arrayBuffer();
|
||||
if (!cancelled) setArrayBuf(buf);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
const buf = await blob.arrayBuffer();
|
||||
if (!cancelled) setArrayBuf(buf);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -20,7 +21,11 @@ export function AttachmentGeneric({ handle }: Props) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
let blob = await getCachedAttachment(handle.id);
|
||||
if (!blob) {
|
||||
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
@@ -8,35 +9,95 @@ interface Props {
|
||||
handle: AttachmentHandle;
|
||||
}
|
||||
|
||||
// Max inline-preview dimension. Full-resolution stays available for the
|
||||
// lightbox. Animated formats (gif/webp/apng) are passed through untouched
|
||||
// so animation isn't lost; everything else is downscaled to this box.
|
||||
const THUMB_MAX_DIM = 640;
|
||||
const ANIMATED_MIME = /^image\/(gif|apng|webp)/;
|
||||
|
||||
async function makeThumbnail(blob: Blob): Promise<Blob | null> {
|
||||
if (ANIMATED_MIME.test(blob.type)) return null;
|
||||
if (typeof createImageBitmap !== 'function') return null;
|
||||
if (typeof OffscreenCanvas !== 'function') return null;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const largest = Math.max(bitmap.width, bitmap.height);
|
||||
if (largest <= THUMB_MAX_DIM) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
const scale = THUMB_MAX_DIM / largest;
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
return await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AttachmentImage({ handle }: Props) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [fullUrl, setFullUrl] = useState<string | null>(null);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let url: string | null = null;
|
||||
const created: string[] = [];
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
setFullUrl(null);
|
||||
setThumbUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
const take = (blob: Blob): string => {
|
||||
const u = URL.createObjectURL(blob);
|
||||
created.push(u);
|
||||
return u;
|
||||
};
|
||||
|
||||
// OPFS cache → decrypt → generate thumbnail for inline display.
|
||||
// Lightbox swaps to the full blob when opened.
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
if (cached) {
|
||||
blob = cached;
|
||||
} else {
|
||||
try {
|
||||
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (cancelled) return;
|
||||
const full = take(blob);
|
||||
setFullUrl(full);
|
||||
const thumb = await makeThumbnail(blob);
|
||||
if (cancelled) return;
|
||||
if (thumb) {
|
||||
setThumbUrl(take(thumb));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
for (const u of created) URL.revokeObjectURL(u);
|
||||
};
|
||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||
|
||||
const blobUrl = thumbUrl ?? fullUrl;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||
@@ -66,10 +127,11 @@ export function AttachmentImage({ handle }: Props) {
|
||||
src={blobUrl}
|
||||
alt="attachment"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
{lightboxOpen && <Lightbox url={blobUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -22,19 +23,29 @@ export function AttachmentPdf({ handle }: Props) {
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
if (cached) {
|
||||
if (cancelled) return;
|
||||
const typed = new Blob([cached], { type: 'application/pdf' });
|
||||
url = URL.createObjectURL(typed);
|
||||
setBlobUrl(url);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
if (cancelled) return;
|
||||
// Force the application/pdf type so the browser plugin engages.
|
||||
const typed = new Blob([blob], { type: 'application/pdf' });
|
||||
url = URL.createObjectURL(typed);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -20,17 +21,26 @@ export function AttachmentVideo({ handle }: Props) {
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
if (cached) {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(cached);
|
||||
setBlobUrl(url);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
||||
// letter circle. Use this everywhere the app needs to render a profile.
|
||||
|
||||
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||
|
||||
interface Props {
|
||||
url?: string | null | undefined;
|
||||
displayName?: string | null | undefined;
|
||||
@@ -18,10 +20,11 @@ export function Avatar({
|
||||
fallbackClass = 'bg-accent/20 text-accent',
|
||||
alt,
|
||||
}: Props) {
|
||||
if (url) {
|
||||
const effectiveUrl = useCachedAvatarUrl(url);
|
||||
if (effectiveUrl) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
src={effectiveUrl}
|
||||
alt={alt ?? displayName ?? ''}
|
||||
className={'shrink-0 rounded-full object-cover ' + className}
|
||||
draggable={false}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DeviceRestore } from './DeviceRestore';
|
||||
import { AlertIcon, ShieldIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Modal wrapper around DeviceRestore for the already-signed-in case. A
|
||||
// successful restore swaps the local device-identity for the one embedded
|
||||
// in the backup string — the app then hard-reloads so every hook
|
||||
// re-initialises against the restored keys (simpler than invalidating
|
||||
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
|
||||
// individually).
|
||||
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Backup wiederherstellen"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-brand-300" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Gerät aus Backup wiederherstellen
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<p className="min-w-0 flex-1">
|
||||
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
|
||||
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
|
||||
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
|
||||
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
|
||||
Device-ID wrappen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DeviceRestore
|
||||
userId={userId}
|
||||
onRestored={() => {
|
||||
// Hard reload — cleanest way to reset every hook, supabase
|
||||
// realtime channel, stronghold handle, and cached state.
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,10 @@ interface Props {
|
||||
deafened: boolean;
|
||||
onToggleMute: () => void;
|
||||
onToggleShare: () => void;
|
||||
/** Right-click on the share button opens the quality picker dialog while
|
||||
* left-click just starts with last-used settings. Optional so pages that
|
||||
* don't need the advanced path (mobile, etc.) can skip it. */
|
||||
onShareContextMenu?: (e: React.MouseEvent) => void;
|
||||
onToggleVideo?: () => void;
|
||||
onToggleDeafen: () => void;
|
||||
onHangup: () => void;
|
||||
@@ -27,6 +31,7 @@ interface Props {
|
||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||
onToggleSoundboard?: () => void;
|
||||
soundboardOpen?: boolean;
|
||||
participantsOpen?: boolean;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
compact?: boolean;
|
||||
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||
@@ -42,12 +47,14 @@ export function CallControls({
|
||||
deafened,
|
||||
onToggleMute,
|
||||
onToggleShare,
|
||||
onShareContextMenu,
|
||||
onToggleVideo,
|
||||
onToggleDeafen,
|
||||
onHangup,
|
||||
onOpenParticipants,
|
||||
onToggleSoundboard,
|
||||
soundboardOpen = false,
|
||||
participantsOpen = false,
|
||||
compact = false,
|
||||
glass = false,
|
||||
disabledMedia = false,
|
||||
@@ -111,6 +118,7 @@ export function CallControls({
|
||||
active={sharing}
|
||||
activeTone="accent"
|
||||
onClick={onToggleShare}
|
||||
{...(onShareContextMenu ? { onContextMenu: onShareContextMenu } : {})}
|
||||
disabled={disabledMedia}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
@@ -137,6 +145,9 @@ export function CallControls({
|
||||
<CallButton
|
||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
onClick={onOpenParticipants}
|
||||
active={participantsOpen}
|
||||
activeTone="accent"
|
||||
dataTrigger="participants"
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
@@ -159,24 +170,30 @@ export function CallControls({
|
||||
interface CallButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
activeTone?: 'accent' | 'danger';
|
||||
tone?: 'default' | 'danger';
|
||||
glass?: boolean;
|
||||
className?: string;
|
||||
/** Stable trigger id so portals (popovers) can skip outside-click dismiss
|
||||
* when the user is toggling their own trigger. */
|
||||
dataTrigger?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function CallButton({
|
||||
label,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
disabled,
|
||||
active,
|
||||
activeTone = 'accent',
|
||||
tone = 'default',
|
||||
glass = false,
|
||||
className = '',
|
||||
dataTrigger,
|
||||
children,
|
||||
}: CallButtonProps) {
|
||||
const base =
|
||||
@@ -200,11 +217,13 @@ function CallButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={`${base} ${toneClass} ${className}`}
|
||||
{...(dataTrigger ? { [`data-${dataTrigger}-trigger`]: 'true' } : {})}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -75,7 +75,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
} = props;
|
||||
|
||||
const small = size === 'small';
|
||||
const borderClass = speaking
|
||||
// Split the speaking indicator per-mode so we don't stack a tile border
|
||||
// + inset glow on top of the avatar pulse (visual double-chrome). Video
|
||||
// tiles get the border (the avatar is hidden behind the stream so the
|
||||
// pulse wouldn't be visible anyway); audio tiles rely on the avatar
|
||||
// pulse rendered inside AudioContent.
|
||||
const videoSpeaking = speaking && video;
|
||||
const borderClass = videoSpeaking
|
||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
||||
: focused
|
||||
? 'border-accent'
|
||||
@@ -98,9 +104,10 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
|
||||
{/* Speaking indicator visible regardless of content type (video or
|
||||
audio). z-10 ensures it sits above the video element. */}
|
||||
{speaking && (
|
||||
{/* Video-only speaking indicator. Audio tiles use the avatar pulse
|
||||
from AudioContent so we don't double-render chrome. z-10 keeps
|
||||
it above the video element. */}
|
||||
{videoSpeaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -158,6 +158,7 @@ function PipCall() {
|
||||
const active =
|
||||
state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting' ||
|
||||
state.kind === 'outgoing';
|
||||
if (!active) return null;
|
||||
|
||||
@@ -172,6 +173,13 @@ function PipCall() {
|
||||
: conv?.peer?.displayName ?? '—';
|
||||
const participantCount = 1 + remoteParticipants.length;
|
||||
const someoneSharing = remoteScreenShares.length > 0;
|
||||
// Duration ticks while connected or reconnecting (LiveKit holds the room
|
||||
// across reconnects, so the timer shouldn't reset on a wobble). Absent
|
||||
// on outgoing/connecting where the call hasn't started yet.
|
||||
const startedAt =
|
||||
state.kind === 'connected' || state.kind === 'reconnecting'
|
||||
? state.startedAt
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -196,7 +204,11 @@ function PipCall() {
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
|
||||
/>
|
||||
<span>Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}</span>
|
||||
<span className="tabular-nums">
|
||||
{startedAt
|
||||
? <PipDuration startedAt={startedAt} />
|
||||
: t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -213,3 +225,24 @@ function PipCall() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Live-ticking `mm:ss` / `hh:mm:ss` for the PiP. Duplicated from InCallPanel
|
||||
// deliberately — the two widgets have different typography + tabular
|
||||
// contexts, and extracting a shared component would be heavier than the
|
||||
// 8-line countup it replaces.
|
||||
function PipDuration({ startedAt }: { startedAt: string }) {
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => tick((v) => v + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const total = Math.max(
|
||||
0,
|
||||
Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000),
|
||||
);
|
||||
const hh = Math.floor(total / 3600);
|
||||
const mm = Math.floor((total % 3600) / 60);
|
||||
const ss = total % 60;
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return <>{hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${pad(mm)}:${pad(ss)}`}</>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteParticipant, Room } from 'livekit-client';
|
||||
import { Track } from 'livekit-client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -11,12 +11,18 @@ import {
|
||||
type PttSettings,
|
||||
subscribePttSettings,
|
||||
} from '../lib/pttSettings';
|
||||
import {
|
||||
listSounds,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile } from './CallParticipantTile';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||
import { ScreenSourcePicker } from './ScreenSourcePicker';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
import { SoundboardPanel } from './SoundboardPanel';
|
||||
|
||||
@@ -74,23 +80,105 @@ export function InCallPanel({ conversation }: Props) {
|
||||
hangup,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
micError,
|
||||
clearMicError,
|
||||
retryMic,
|
||||
dismissedShareUserIds,
|
||||
} = useCall();
|
||||
const { session } = useAuth();
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
||||
const [participantsOpen, setParticipantsOpen] = useState(false);
|
||||
const [volumeMenu, setVolumeMenu] = useState<
|
||||
{ userId: string; displayName: string; x: number; y: number } | null
|
||||
>(null);
|
||||
const [shareMenu, setShareMenu] = useState<
|
||||
{ userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
|
||||
>(null);
|
||||
// Soundboard-count so the in-call bar only surfaces the music button when
|
||||
// the user actually has something to play. Matches Discord's "hide soundboard
|
||||
// when empty" behaviour — no point dangling a button that opens to a blank
|
||||
// "Keine Sounds" popover. Subscribes live so a sound added mid-call makes
|
||||
// the button pop in without reopening the call.
|
||||
const [soundboardCount, setSoundboardCount] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const all = await listSounds();
|
||||
if (!cancelled) setSoundboardCount(all.length);
|
||||
} catch {
|
||||
if (!cancelled) setSoundboardCount(0);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const unsub = subscribeSoundboardChanges(() => {
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsub();
|
||||
};
|
||||
}, []);
|
||||
// Close the popover if the user just cleared their last sound while it was
|
||||
// open — keeps the panel from lingering over an empty list.
|
||||
useEffect(() => {
|
||||
if (soundboardCount === 0) setSoundboardOpen(false);
|
||||
}, [soundboardCount]);
|
||||
|
||||
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
|
||||
// Active-speaker auto-focus uses "who most recently started speaking"
|
||||
// rather than "exactly one speaker" — matches Discord more closely and
|
||||
// handles the case where two people talk briefly without the focus
|
||||
// collapsing to nobody.
|
||||
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
|
||||
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
for (const id of activeSpeakers) {
|
||||
if (!prevActiveSpeakersRef.current.has(id)) {
|
||||
setLastStartedSpeakerId(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
||||
}, [activeSpeakers]);
|
||||
|
||||
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
||||
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
||||
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||
// stop your own share from the control bar.
|
||||
const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => {
|
||||
if (tile.self) return;
|
||||
if (tile.kind !== 'user') return;
|
||||
e.preventDefault();
|
||||
setVolumeMenu({
|
||||
if (tile.kind === 'user') {
|
||||
setShareMenu(null);
|
||||
setVolumeMenu({
|
||||
userId: tile.userId,
|
||||
displayName: tile.displayName,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Screen-tile. Check whether the participant has a published screen-share
|
||||
// audio track so the menu can hide the volume/mute rows when there's
|
||||
// nothing to control.
|
||||
const participant = remoteParticipants.find((p) => p.identity === tile.userId);
|
||||
let hasAudio = false;
|
||||
if (participant) {
|
||||
for (const pub of participant.audioTrackPublications.values()) {
|
||||
if (pub.source === Track.Source.ScreenShareAudio) {
|
||||
hasAudio = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setVolumeMenu(null);
|
||||
setShareMenu({
|
||||
userId: tile.userId,
|
||||
displayName: tile.displayName,
|
||||
displayName: tile.displayName.replace(/\s·\sBildschirm$/, ''),
|
||||
hasAudio,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
@@ -99,6 +187,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const active =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting' ||
|
||||
state.kind === 'outgoing') &&
|
||||
state.conversationId === conversation.id;
|
||||
if (!active) return null;
|
||||
@@ -114,9 +203,20 @@ export function InCallPanel({ conversation }: Props) {
|
||||
remoteMute,
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
||||
// Sharer ids that survived the user's dismiss-set. If the user did
|
||||
// "Zuschauen beenden" on someone's share, they drop out of the tile
|
||||
// grid until that sharer stops + restarts (TrackUnsubscribed clears
|
||||
// dismissedShareUserIds — see CallContext).
|
||||
remoteSharerIds: new Set(
|
||||
remoteScreenShares
|
||||
.map((s) => s.participantId)
|
||||
.filter((id) => !dismissedShareUserIds.has(id)),
|
||||
),
|
||||
});
|
||||
|
||||
// Duration keeps ticking during reconnecting so the user sees the call is
|
||||
// still alive — but the status label below takes precedence in the header
|
||||
// so the "Verbinde neu…" message is prominent, not buried under the timer.
|
||||
const duration =
|
||||
state.kind === 'connected'
|
||||
? <LiveDuration startedAt={state.startedAt} />
|
||||
@@ -127,15 +227,17 @@ export function InCallPanel({ conversation }: Props) {
|
||||
? t('app:call.outgoing_ringing')
|
||||
: state.kind === 'connecting'
|
||||
? t('app:call.connecting')
|
||||
: remoteParticipants.length === 0
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
: state.kind === 'reconnecting'
|
||||
? t('app:call.reconnecting', { defaultValue: 'Verbinde neu…' })
|
||||
: remoteParticipants.length === 0
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
|
||||
// A screen-share tile becomes the auto-focus target when no one explicitly
|
||||
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
|
||||
// distinguish a user's own avatar tile from their screen tile.
|
||||
const screenTile = tiles.find((p) => p.kind === 'screen');
|
||||
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null;
|
||||
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||
// plays. Focus falls back to the first tile so focus-mode always has
|
||||
// something to show when no tile was explicitly picked.
|
||||
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||
|
||||
const controls = (
|
||||
@@ -145,40 +247,86 @@ export function InCallPanel({ conversation }: Props) {
|
||||
video={isCameraEnabled}
|
||||
deafened={isDeafened}
|
||||
onToggleMute={toggleMute}
|
||||
// Click opens the Discord-style source picker (thumbnails + quality +
|
||||
// audio). Clicking again while a share is live stops it. Right-click
|
||||
// also opens the picker in case the user wants to swap sources.
|
||||
onToggleShare={() => {
|
||||
if (isScreenSharing) {
|
||||
void stopScreenShare();
|
||||
} else {
|
||||
setShareDialogOpen(true);
|
||||
setPickerOpen(true);
|
||||
}
|
||||
}}
|
||||
onShareContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (!isScreenSharing) setPickerOpen(true);
|
||||
}}
|
||||
onToggleVideo={() => void toggleCamera()}
|
||||
onToggleDeafen={toggleDeafen}
|
||||
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
|
||||
soundboardOpen={soundboardOpen}
|
||||
onOpenParticipants={() => setParticipantsOpen((v) => !v)}
|
||||
participantsOpen={participantsOpen}
|
||||
// Soundboard-Button nur wenn mind. ein Sound existiert. Bis der Count
|
||||
// aus IndexedDB geladen ist (null), auch nicht rendern — verhindert
|
||||
// einen Flash des Buttons beim Call-Start wenn der User eh keine
|
||||
// Sounds hat.
|
||||
{...(soundboardCount && soundboardCount > 0
|
||||
? {
|
||||
onToggleSoundboard: () => setSoundboardOpen((v) => !v),
|
||||
soundboardOpen,
|
||||
}
|
||||
: {})}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
disabledMedia={state.kind !== 'connected'}
|
||||
disabledMedia={state.kind !== 'connected' && state.kind !== 'reconnecting'}
|
||||
/>
|
||||
);
|
||||
|
||||
// Only participant-tiles feed the popover (screen-share tiles aren't
|
||||
// people). Own row is always first, rest follows conversation order.
|
||||
const participantRows: ParticipantRow[] = tiles
|
||||
.filter((t) => t.kind === 'user')
|
||||
.map((t) => ({
|
||||
userId: t.userId,
|
||||
displayName: t.displayName,
|
||||
avatarUrl: t.avatarUrl,
|
||||
self: t.self,
|
||||
muted: t.muted,
|
||||
deafened: t.deafened,
|
||||
}));
|
||||
|
||||
if (callMode === 'fullscreen') {
|
||||
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
|
||||
// someone is sharing a screen, OR exactly one non-self speaker is talking
|
||||
// (auto-promote). Without that we show an even grid of all participants
|
||||
// (Discord default). Clicking a tile switches to the big-speaker layout.
|
||||
const speakingNonSelf = tiles.filter(
|
||||
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
|
||||
);
|
||||
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
||||
// the person who most recently started speaking (tracked in
|
||||
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
|
||||
// trigger; they stay as equal-size grid tiles until the user clicks
|
||||
// one. "Most recent speaker" beats "exactly one currently speaking"
|
||||
// because two people briefly overlapping shouldn't kick us out of
|
||||
// auto-focus.
|
||||
const autoSpeaker =
|
||||
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
|
||||
? speakingNonSelf[0]
|
||||
focusedId === null && lastStartedSpeakerId !== null
|
||||
? tiles.find(
|
||||
(t) =>
|
||||
t.kind === 'user' &&
|
||||
!t.self &&
|
||||
t.userId === lastStartedSpeakerId,
|
||||
)
|
||||
: undefined;
|
||||
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
|
||||
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
|
||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||
return (
|
||||
<>
|
||||
{micError && (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-5 z-[65] flex justify-center px-4">
|
||||
<div className="pointer-events-auto max-w-[520px] w-full">
|
||||
<MicErrorBanner
|
||||
message={micError}
|
||||
onRetry={() => void retryMic()}
|
||||
onDismiss={clearMicError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={effectiveSpeaker}
|
||||
@@ -191,8 +339,18 @@ export function InCallPanel({ conversation }: Props) {
|
||||
// Toggle: click the already-focused tile to return to grid.
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
onTileContextMenu={openVolumeMenu}
|
||||
onTileContextMenu={openTileContextMenu}
|
||||
controls={controls}
|
||||
// Any active popover / menu / banner pins the controls so the user
|
||||
// can interact with them without the chrome fading out under their
|
||||
// cursor while they're mid-action.
|
||||
keepControlsVisible={
|
||||
soundboardOpen ||
|
||||
participantsOpen ||
|
||||
volumeMenu !== null ||
|
||||
shareMenu !== null ||
|
||||
micError !== null
|
||||
}
|
||||
/>
|
||||
{volumeMenu && (
|
||||
<ParticipantVolumeMenu
|
||||
@@ -203,10 +361,26 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{shareMenu && (
|
||||
<ScreenShareContextMenu
|
||||
userId={shareMenu.userId}
|
||||
displayName={shareMenu.displayName}
|
||||
hasAudio={shareMenu.hasAudio}
|
||||
x={shareMenu.x}
|
||||
y={shareMenu.y}
|
||||
onClose={() => setShareMenu(null)}
|
||||
/>
|
||||
)}
|
||||
<SoundboardPopover
|
||||
open={soundboardOpen}
|
||||
onClose={() => setSoundboardOpen(false)}
|
||||
/>
|
||||
<ParticipantsPopover
|
||||
open={participantsOpen}
|
||||
rows={participantRows}
|
||||
activeSpeakers={activeSpeakers}
|
||||
onClose={() => setParticipantsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -253,6 +427,14 @@ export function InCallPanel({ conversation }: Props) {
|
||||
<ModeToggles mode={callMode} onChange={setCallMode} />
|
||||
</div>
|
||||
|
||||
{micError && (
|
||||
<MicErrorBanner
|
||||
message={micError}
|
||||
onRetry={() => void retryMic()}
|
||||
onDismiss={clearMicError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
@@ -265,7 +447,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
onTileContextMenu={openVolumeMenu}
|
||||
onTileContextMenu={openTileContextMenu}
|
||||
compact
|
||||
/>
|
||||
|
||||
@@ -273,9 +455,9 @@ export function InCallPanel({ conversation }: Props) {
|
||||
|
||||
<PttHint />
|
||||
|
||||
<ScreenShareDialog
|
||||
open={shareDialogOpen}
|
||||
onClose={() => setShareDialogOpen(false)}
|
||||
<ScreenSourcePicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onStart={async (opts) => {
|
||||
await startScreenShare(opts);
|
||||
}}
|
||||
@@ -291,10 +473,28 @@ export function InCallPanel({ conversation }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareMenu && (
|
||||
<ScreenShareContextMenu
|
||||
userId={shareMenu.userId}
|
||||
displayName={shareMenu.displayName}
|
||||
hasAudio={shareMenu.hasAudio}
|
||||
x={shareMenu.x}
|
||||
y={shareMenu.y}
|
||||
onClose={() => setShareMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SoundboardPopover
|
||||
open={soundboardOpen}
|
||||
onClose={() => setSoundboardOpen(false)}
|
||||
/>
|
||||
|
||||
<ParticipantsPopover
|
||||
open={participantsOpen}
|
||||
rows={participantRows}
|
||||
activeSpeakers={activeSpeakers}
|
||||
onClose={() => setParticipantsOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -583,6 +783,7 @@ function TileRender({
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
||||
>
|
||||
<ScreenShareViewer
|
||||
@@ -761,8 +962,13 @@ interface FullscreenProps {
|
||||
onFocusTile: (id: string) => void;
|
||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||
controls: React.ReactNode;
|
||||
/** When true, controls stay visible regardless of mouse idle (used while
|
||||
* a popover / menu / error banner is open). */
|
||||
keepControlsVisible?: boolean;
|
||||
}
|
||||
|
||||
const CONTROLS_IDLE_MS = 5_000;
|
||||
|
||||
function FullscreenCall({
|
||||
tiles,
|
||||
speaker,
|
||||
@@ -774,13 +980,47 @@ function FullscreenCall({
|
||||
onFocusTile,
|
||||
onTileContextMenu,
|
||||
controls,
|
||||
keepControlsVisible = false,
|
||||
}: FullscreenProps) {
|
||||
const [hintGone, setHintGone] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
// Discord-style auto-hide: controls fade out after 5s of mouse idle in
|
||||
// fullscreen so tiles aren't partially obscured. Any mousemove (or a
|
||||
// popover opening via keepControlsVisible) brings them back immediately.
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
// Session-only toggle to collapse the participant strip while watching a
|
||||
// focused tile (screen share, speaker). Matches Discord's "Hide non-video
|
||||
// participants" — gives the focused content the full fullscreen height.
|
||||
const [stripHidden, setStripHidden] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||
return () => window.clearTimeout(id);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (keepControlsVisible) {
|
||||
setControlsVisible(true);
|
||||
return;
|
||||
}
|
||||
let timer: number | null = window.setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
CONTROLS_IDLE_MS,
|
||||
);
|
||||
const reset = () => {
|
||||
setControlsVisible(true);
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
CONTROLS_IDLE_MS,
|
||||
);
|
||||
};
|
||||
window.addEventListener('mousemove', reset);
|
||||
window.addEventListener('touchstart', reset);
|
||||
return () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
window.removeEventListener('mousemove', reset);
|
||||
window.removeEventListener('touchstart', reset);
|
||||
};
|
||||
}, [keepControlsVisible]);
|
||||
|
||||
const hasFocus = speaker !== undefined;
|
||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||
@@ -826,7 +1066,7 @@ function FullscreenCall({
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
{others.length > 0 && !stripHidden && (
|
||||
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
|
||||
{others.map((p) => (
|
||||
<div
|
||||
@@ -905,13 +1145,76 @@ function FullscreenCall({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
{/* Hide-participant-strip toggle, only meaningful when there's a focus
|
||||
+ extras to hide. Fades alongside the bottom control bar so idle
|
||||
fullscreen still goes clean. */}
|
||||
{hasFocus && others.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStripHidden((v) => !v)}
|
||||
aria-pressed={stripHidden}
|
||||
title={
|
||||
stripHidden
|
||||
? 'Teilnehmer einblenden'
|
||||
: 'Teilnehmer ausblenden'
|
||||
}
|
||||
aria-label={
|
||||
stripHidden
|
||||
? 'Teilnehmer einblenden'
|
||||
: 'Teilnehmer ausblenden'
|
||||
}
|
||||
className={
|
||||
'absolute right-5 top-5 z-20 flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||
(controlsVisible
|
||||
? 'pointer-events-auto opacity-100 '
|
||||
: 'pointer-events-none opacity-0 ') +
|
||||
(stripHidden
|
||||
? 'border-accent bg-accent/20 text-accent-fg'
|
||||
: 'border-white/15 bg-white/10 text-white hover:bg-white/15')
|
||||
}
|
||||
>
|
||||
<StripToggleIcon hidden={stripHidden} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
|
||||
(controlsVisible
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0')
|
||||
}
|
||||
>
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Users-icon with a diagonal slash when the strip is hidden — mirrors the
|
||||
// MicOff/HeadphonesOff naming convention used elsewhere.
|
||||
function StripToggleIcon({ hidden }: { hidden: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
{hidden && <line x1="2" y1="2" x2="22" y2="22" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PttHint() {
|
||||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||||
useEffect(() => subscribePttSettings(setPtt), []);
|
||||
@@ -925,3 +1228,65 @@ function PttHint() {
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-terminal mic error banner. Shown inside the call panel when mic setup
|
||||
// fails — the call itself stays alive, the user just can't be heard. Retry
|
||||
// invokes the pipeline setup again with the current audioSettings so a
|
||||
// permission granted in OS settings mid-call works without rejoin.
|
||||
function MicErrorBanner({
|
||||
message,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 border-b border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-xs text-rose-700 dark:text-rose-200"
|
||||
>
|
||||
<MicOffIconInline />
|
||||
<p className="flex-1 leading-relaxed">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="cursor-pointer rounded-md bg-rose-600 px-2.5 py-1 text-[11px] font-semibold text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Schließen"
|
||||
className="cursor-pointer rounded-md p-1 text-rose-700/70 transition hover:bg-rose-500/10 hover:text-rose-700 dark:text-rose-200/70 dark:hover:text-rose-100"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tiny inline variant so we don't pull MicOffIcon's default sizing.
|
||||
function MicOffIconInline() {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 shrink-0"
|
||||
>
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
|
||||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
||||
role="group"
|
||||
aria-label="Language"
|
||||
className={
|
||||
'inline-flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 text-[11px] font-medium ' +
|
||||
(compact ? '' : 'backdrop-blur')
|
||||
'inline-flex items-center rounded-full border border-line bg-surface-2 p-0.5 text-[11px] font-medium ' +
|
||||
(compact ? '' : '')
|
||||
}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
@@ -30,10 +30,10 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
||||
if (!active) void changeLocale(locale);
|
||||
}}
|
||||
className={
|
||||
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus:ring-2 focus:ring-brand-400/40 ' +
|
||||
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||||
: 'text-neutral-400 hover:text-neutral-200')
|
||||
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{LABELS[locale]}
|
||||
|
||||
@@ -56,6 +56,8 @@ interface Props {
|
||||
onReply?: (m: DecryptedMessage) => void;
|
||||
/** Hover action: parent opens forward dialog for current message. */
|
||||
onForward?: (m: DecryptedMessage) => void;
|
||||
/** Click on the message's avatar surfaces the author's profile card. */
|
||||
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
||||
/** Highlighted state — set briefly after a jump. */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
@@ -76,6 +78,7 @@ export function MessageBubble({
|
||||
onJumpToMessage,
|
||||
onReply,
|
||||
onForward,
|
||||
onAvatarClick,
|
||||
highlighted = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
@@ -97,6 +100,47 @@ export function MessageBubble({
|
||||
const withinEditWindow = age < EDIT_WINDOW_MS;
|
||||
const bodyText = initialText;
|
||||
const attachments = initialAttachments;
|
||||
|
||||
// /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender
|
||||
// fires the soft-delete when the clock runs out. Receivers just watch
|
||||
// the deletedAt flip via realtime.
|
||||
const expireMs = parsed.kind === 'text' ? parsed.expireMs : undefined;
|
||||
const [tickNow, setTickNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (expireMs === undefined) return;
|
||||
const id = window.setInterval(() => setTickNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [expireMs]);
|
||||
const msLeft =
|
||||
expireMs !== undefined
|
||||
? Math.max(0, expireMs - (tickNow - createdAt.getTime()))
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mine) return;
|
||||
if (expireMs === undefined) return;
|
||||
if (message.deletedAt) return;
|
||||
const remaining = Math.max(0, expireMs - age);
|
||||
const timer = window.setTimeout(() => {
|
||||
void softDeleteMessage(supabase, message.id).catch((err: unknown) => {
|
||||
console.warn('ephemeral auto-delete failed', err);
|
||||
});
|
||||
}, remaining);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mine, expireMs, age, message.id, message.deletedAt]);
|
||||
|
||||
// Receiver-side auto-hide when the expiry window elapses even if the
|
||||
// sender's delete hasn't propagated yet (network hiccup, offline-sender).
|
||||
const [localExpired, setLocalExpired] = useState<boolean>(
|
||||
msLeft !== null && msLeft <= 0,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (expireMs === undefined) return;
|
||||
if (localExpired) return;
|
||||
const remaining = Math.max(0, expireMs - age);
|
||||
const t = window.setTimeout(() => setLocalExpired(true), remaining);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [expireMs, age, localExpired]);
|
||||
const canEdit =
|
||||
parsed.kind === 'text' &&
|
||||
mine &&
|
||||
@@ -181,13 +225,16 @@ export function MessageBubble({
|
||||
[onToggleReaction],
|
||||
);
|
||||
|
||||
if (message.deletedAt) {
|
||||
if (message.deletedAt || localExpired) {
|
||||
return (
|
||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
{...(onAvatarClick
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
/>
|
||||
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
|
||||
{t('app:chats.deleted')}
|
||||
@@ -274,23 +321,30 @@ export function MessageBubble({
|
||||
type="button"
|
||||
onClick={() => onJumpToMessage?.(quoted.id)}
|
||||
className={
|
||||
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
|
||||
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md pl-2 pr-2.5 py-1.5 text-left text-xs transition hover:brightness-110 hover:shadow-sm ' +
|
||||
(mine
|
||||
? 'bg-white/15 text-accent-fg/90'
|
||||
: 'bg-surface-2 text-fg-muted')
|
||||
? 'bg-white/10 text-accent-fg/90'
|
||||
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
|
||||
'-ml-1 w-1 shrink-0 rounded-full ' +
|
||||
(mine ? 'bg-white/70' : 'bg-accent')
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
|
||||
{quoted.senderName}
|
||||
<span className="min-w-0 flex-1 pl-1">
|
||||
<span
|
||||
className={
|
||||
'flex items-center gap-1 truncate text-[11px] font-semibold ' +
|
||||
(mine ? 'text-accent-fg' : 'text-accent')
|
||||
}
|
||||
>
|
||||
<ReplyIcon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{quoted.senderName}</span>
|
||||
</span>
|
||||
<span className="block truncate italic opacity-90">
|
||||
<span className="mt-0.5 block truncate opacity-80">
|
||||
{quoted.deleted
|
||||
? t('app:chats.deleted')
|
||||
: quoted.isAttachment && !quoted.snippet
|
||||
@@ -337,6 +391,17 @@ export function MessageBubble({
|
||||
{message.editedAt && !message.deletedAt && (
|
||||
<span className="italic">· {t('app:chats.edited')}</span>
|
||||
)}
|
||||
{msLeft !== null && msLeft > 0 && (
|
||||
<span
|
||||
className={
|
||||
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ' +
|
||||
(mine ? 'bg-white/20' : 'bg-rose-500/15 text-rose-600 dark:text-rose-300')
|
||||
}
|
||||
title="Selbstzerstörung"
|
||||
>
|
||||
⏱ {Math.ceil(msLeft / 1000)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -457,14 +522,29 @@ function AvatarSlot({
|
||||
show,
|
||||
url,
|
||||
displayName,
|
||||
onClick,
|
||||
}: {
|
||||
show: boolean;
|
||||
url: string | null;
|
||||
displayName: string | null;
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
}) {
|
||||
if (!show) {
|
||||
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-user-popover-trigger
|
||||
onClick={onClick}
|
||||
className="shrink-0 cursor-pointer rounded-full transition hover:ring-2 hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
aria-label={displayName ?? 'Profil'}
|
||||
>
|
||||
<Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Avatar
|
||||
url={url}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const MENU_W = 240;
|
||||
const MENU_H = 84;
|
||||
const MENU_H = 96;
|
||||
|
||||
export function ParticipantVolumeMenu({
|
||||
userId,
|
||||
@@ -63,14 +63,20 @@ export function ParticipantVolumeMenu({
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-semibold text-fg">{displayName}</span>
|
||||
<span className="tabular-nums text-fg-muted">
|
||||
<span
|
||||
className={
|
||||
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||
}
|
||||
>
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Up to 200% via WebAudio gain. Values above 100% can clip on loud
|
||||
mics — the amber count-up hints at that without a verbose warning. */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
@@ -81,6 +87,11 @@ export function ParticipantVolumeMenu({
|
||||
aria-label={'Lautstärke ' + displayName}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-fg-muted">
|
||||
<span>0%</span>
|
||||
<span className="tabular-nums">100%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
getParticipantVolume,
|
||||
setParticipantVolume,
|
||||
subscribeParticipantVolumes,
|
||||
} from '../lib/participantVolumes';
|
||||
import {
|
||||
AvatarColorKey,
|
||||
colorKeyFor,
|
||||
} from './CallParticipantTile';
|
||||
import { HeadphonesOffIcon, MicOffIcon, UsersIcon, XIcon } from './icons';
|
||||
|
||||
// Rows the popover knows how to render. Subset of InCallPanel's Tile so this
|
||||
// component can be reused without the screen-share / video fields.
|
||||
export interface ParticipantRow {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
self: boolean;
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
rows: ParticipantRow[];
|
||||
/** Set of userIds currently above the speaking-threshold. */
|
||||
activeSpeakers: Set<string>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AVATAR_TONES: Record<AvatarColorKey, string> = {
|
||||
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
||||
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
||||
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
||||
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
||||
};
|
||||
|
||||
// Call-scoped participant list. Portal-mounted + fixed-positioned so it
|
||||
// floats above whichever call layout the user is in (docked, focus, or
|
||||
// fullscreen cinema). Mirrors the ParticipantVolumeMenu pattern for
|
||||
// close-on-outside / close-on-Esc behaviour so both feel consistent.
|
||||
export function ParticipantsPopover({ open, rows, activeSpeakers, onClose }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-participants-popover]')) return;
|
||||
// Clicks on the triggering button also bubble here; the button itself
|
||||
// handles toggle, so we only close on genuine outside clicks. The
|
||||
// trigger uses `data-participants-trigger` — ignore those.
|
||||
if (target?.closest('[data-participants-trigger]')) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-participants-popover
|
||||
role="dialog"
|
||||
aria-label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
className="fixed bottom-20 right-5 z-[70] flex max-h-[60vh] w-[300px] flex-col overflow-hidden rounded-xl border border-line bg-surface-2/95 shadow-xl backdrop-blur-md"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-line px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||
<UsersIcon className="h-4 w-4 text-fg-muted" />
|
||||
<span>
|
||||
{t('app:call.participants', { defaultValue: 'Teilnehmer' })} · {rows.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-xs text-fg-muted">
|
||||
{t('app:call.no_participants', { defaultValue: 'Keine Teilnehmer.' })}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{rows.map((row) => (
|
||||
<li key={row.userId}>
|
||||
<Row row={row} speaking={activeSpeakers.has(row.userId)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
|
||||
const key = colorKeyFor(row.userId);
|
||||
const tone = AVATAR_TONES[key];
|
||||
const letter = row.displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
const [volume, setVolume] = useState<number>(() =>
|
||||
row.self ? 1 : getParticipantVolume(row.userId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (row.self) return;
|
||||
return subscribeParticipantVolumes(() => {
|
||||
setVolume(getParticipantVolume(row.userId));
|
||||
});
|
||||
}, [row.self, row.userId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 rounded-lg px-2 py-1.5 hover:bg-surface-3/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative shrink-0">
|
||||
{row.avatarUrl ? (
|
||||
<img
|
||||
src={row.avatarUrl}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ' +
|
||||
tone
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
{speaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -inset-0.5 rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
||||
{row.displayName}
|
||||
{row.self && <span className="ml-1 text-fg-muted">(du)</span>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{row.muted && (
|
||||
<span
|
||||
aria-label="Mikro stumm"
|
||||
title="Mikro stumm"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||
>
|
||||
<MicOffIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
{row.deafened && (
|
||||
<span
|
||||
aria-label="Ton aus"
|
||||
title="Ton aus"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||
>
|
||||
<HeadphonesOffIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!row.self && (
|
||||
<div className="flex items-center gap-2 pl-10 text-[11px] text-fg-muted">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
setParticipantVolume(row.userId, v);
|
||||
}}
|
||||
aria-label={'Lautstärke ' + row.displayName}
|
||||
className="flex-1 accent-accent"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
'w-10 text-right tabular-nums ' +
|
||||
(volume > 1 ? 'text-amber-500' : '')
|
||||
}
|
||||
>
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
getAudioSettings,
|
||||
subscribeAudioSettings,
|
||||
updateAudioSettings,
|
||||
} from '../lib/audioSettings';
|
||||
import {
|
||||
clearIncomingRingtone,
|
||||
getIncomingRingtone,
|
||||
@@ -30,6 +35,10 @@ export function RingtoneSettings({ disabled = false }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [volume, setVolume] = useState<number>(() => getAudioSettings().ringtoneVolume);
|
||||
|
||||
// Subscribe so cross-tab / in-call slider moves stay in sync here too.
|
||||
useEffect(() => subscribeAudioSettings((s) => setVolume(s.ringtoneVolume)), []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -82,7 +91,7 @@ export function RingtoneSettings({ disabled = false }: Props) {
|
||||
if (code === 'ringtone_too_large') {
|
||||
setError(
|
||||
t('app:settings.ringtone_error_too_large', {
|
||||
defaultValue: 'Datei zu groß (max 2 MB).',
|
||||
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
||||
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
|
||||
}),
|
||||
);
|
||||
@@ -128,7 +137,7 @@ export function RingtoneSettings({ disabled = false }: Props) {
|
||||
const url = URL.createObjectURL(current.blob);
|
||||
const el = new Audio(url);
|
||||
el.loop = false;
|
||||
el.volume = 0.85;
|
||||
el.volume = volume;
|
||||
el.onended = () => stopPreview();
|
||||
el.onerror = () => {
|
||||
setError(
|
||||
@@ -233,10 +242,40 @@ export function RingtoneSettings({ disabled = false }: Props) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="ringtone-volume"
|
||||
className="shrink-0 text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
|
||||
</label>
|
||||
<input
|
||||
id="ringtone-volume"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
updateAudioSettings({ ringtoneVolume: v });
|
||||
// Apply to the currently-playing preview so the user hears the
|
||||
// slider effect immediately while dragging.
|
||||
if (previewRef.current) previewRef.current.volume = v;
|
||||
}}
|
||||
aria-label={t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
|
||||
className="flex-1 accent-accent"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-fg-muted">
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t('app:settings.ringtone_hint', {
|
||||
defaultValue:
|
||||
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
|
||||
'MP3, WAV, OGG oder M4A bis 8 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCall } from '../context/CallContext';
|
||||
import {
|
||||
getScreenShareVolume,
|
||||
setScreenShareVolume,
|
||||
subscribeScreenShareVolumes,
|
||||
} from '../lib/screenShareVolumes';
|
||||
import { HeadphonesIcon, HeadphonesOffIcon, MonitorShareIcon, PhoneOffIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
/** Participant whose screen share the user right-clicked. */
|
||||
userId: string;
|
||||
displayName: string;
|
||||
/** Whether the share has an audio track published. Controls whether the
|
||||
* volume / mute rows render — without audio those would be no-ops. */
|
||||
hasAudio: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MENU_W = 260;
|
||||
const MENU_H_WITH_AUDIO = 200;
|
||||
const MENU_H_NO_AUDIO = 96;
|
||||
|
||||
// Context menu surfaced on right-click of a remote screen share. Mirrors
|
||||
// Discord's stream menu: volume slider, audio mute toggle (independent of
|
||||
// volume — matches HTMLMediaElement's `muted` field), and "stop watching"
|
||||
// which both un-subscribes locally and dismisses the tile from the grid.
|
||||
export function ScreenShareContextMenu({
|
||||
userId,
|
||||
displayName,
|
||||
hasAudio,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const {
|
||||
dismissShare,
|
||||
screenShareAudioMutedIds,
|
||||
setScreenShareAudioMuted,
|
||||
} = useCall();
|
||||
const [volume, setVolume] = useState<number>(() => getScreenShareVolume(userId));
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeScreenShareVolumes(() => {
|
||||
setVolume(getScreenShareVolume(userId));
|
||||
}),
|
||||
[userId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-share-menu]')) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const muted = screenShareAudioMutedIds.has(userId);
|
||||
const height = hasAudio ? MENU_H_WITH_AUDIO : MENU_H_NO_AUDIO;
|
||||
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
|
||||
const top = Math.min(Math.max(8, y), window.innerHeight - height - 8);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-share-menu
|
||||
role="menu"
|
||||
aria-label={t('app:call.share_menu_title', {
|
||||
defaultValue: 'Bildschirmfreigabe von {{name}}',
|
||||
name: displayName,
|
||||
})}
|
||||
style={{ left, top, width: MENU_W }}
|
||||
className="fixed z-[80] overflow-hidden rounded-xl border border-line bg-surface-2/95 text-sm shadow-xl backdrop-blur-md"
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-fg-muted">
|
||||
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
<span className="truncate">
|
||||
{t('app:call.share_menu_owner', {
|
||||
defaultValue: 'Bildschirmfreigabe · {{name}}',
|
||||
name: displayName,
|
||||
})}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{hasAudio && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-fg">
|
||||
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||
}
|
||||
>
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
setScreenShareVolume(userId, v);
|
||||
}}
|
||||
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-fg-muted">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScreenShareAudioMuted(userId, !muted)}
|
||||
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
{muted ? (
|
||||
<HeadphonesOffIcon className="h-4 w-4 text-rose-500" />
|
||||
) : (
|
||||
<HeadphonesIcon className="h-4 w-4 text-fg-muted" />
|
||||
)}
|
||||
<span className="flex-1">
|
||||
{muted
|
||||
? t('app:call.share_unmute_audio', { defaultValue: 'Audio einschalten' })
|
||||
: t('app:call.share_mute_audio', { defaultValue: 'Audio stumm' })}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
dismissShare(userId);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs font-semibold text-rose-600 transition hover:bg-rose-500/10 dark:text-rose-300"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
|
||||
</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
type DisplaySurfaceHint,
|
||||
getPresetParams,
|
||||
getScreenShareSettings,
|
||||
PRESET_ORDER,
|
||||
type ScreenSharePreset,
|
||||
} from '../lib/screenShareSettings';
|
||||
import {
|
||||
MonitorShareIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onStart: (opts: {
|
||||
preset: ScreenSharePreset;
|
||||
displaySurface: DisplaySurfaceHint;
|
||||
framerate: number | null;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
|
||||
{ value: null, label: 'Preset-Standard' },
|
||||
{ value: 15, label: '15 fps' },
|
||||
{ value: 30, label: '30 fps' },
|
||||
{ value: 60, label: '60 fps' },
|
||||
];
|
||||
|
||||
// Discord-style pre-share dialog. The OS still owns the final source picker
|
||||
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
|
||||
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
|
||||
// in quality + framerate up-front so the user doesn't have to re-open the
|
||||
// system picker to adjust them mid-call.
|
||||
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const initial = getScreenShareSettings();
|
||||
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
|
||||
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
async function handleStart() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onStart({ preset, displaySurface: surface, framerate });
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'screenshare failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const presetParams = getPresetParams(preset);
|
||||
const effectiveFps = framerate ?? presetParams.framerate;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="space-y-5 p-5">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<SurfaceOption
|
||||
active={surface === null}
|
||||
onClick={() => setSurface(null)}
|
||||
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
|
||||
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
|
||||
/>
|
||||
<SurfaceOption
|
||||
active={surface === 'monitor'}
|
||||
onClick={() => setSurface('monitor')}
|
||||
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
|
||||
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
|
||||
/>
|
||||
<SurfaceOption
|
||||
active={surface === 'window'}
|
||||
onClick={() => setSurface('window')}
|
||||
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
|
||||
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
|
||||
</p>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
|
||||
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{getPresetParams(p).label} · ≤ {getPresetParams(p).bitrateKbps} kbps
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FRAMERATE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
onClick={() => setFramerate(opt.value)}
|
||||
className={
|
||||
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
|
||||
(framerate === opt.value
|
||||
? 'border-accent bg-accent/15 text-accent'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-fg-muted">
|
||||
{t('app:call.share_fps_effective', {
|
||||
defaultValue: 'Effektiv: {{fps}} fps',
|
||||
fps: effectiveFps,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t('app:call.share_hint', {
|
||||
defaultValue:
|
||||
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStart()}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
<span>
|
||||
{t('app:call.share_start', { defaultValue: 'Teilen starten' })}
|
||||
</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SurfaceOption({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
sub,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
sub: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
|
||||
(active
|
||||
? 'border-accent bg-accent/10 text-fg'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<span className="text-xs font-semibold">{label}</span>
|
||||
<span className="text-[10px] text-fg-muted">{sub}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { RemoteTrack } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { RemoteScreenShare } from '../context/CallContext';
|
||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||
import { MonitorShareIcon } from './icons';
|
||||
|
||||
interface ScreenShareViewerProps {
|
||||
@@ -12,12 +12,21 @@ interface ScreenShareViewerProps {
|
||||
}
|
||||
|
||||
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
||||
// Lifted out of the old InCallPanel so the new CallDock stays lean.
|
||||
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
||||
// The watch-state lives in CallContext (not local useState) so it survives
|
||||
// layout-mode changes (grid → focus → fullscreen) without resetting. Same
|
||||
// reason the ScreenShareAudio mute follows this state — see attachTrack.
|
||||
// Right-click handling happens one level up in TileRender — the wrapping
|
||||
// div catches the event before it reaches the viewer's inner content.
|
||||
export function ScreenShareViewer({
|
||||
share,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
}: ScreenShareViewerProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [watching, setWatching] = useState(false);
|
||||
const { watchingShareUserIds, watchShare } = useCall();
|
||||
const watching = watchingShareUserIds.has(share.participantId);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
@@ -68,31 +77,15 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
void document.exitFullscreen();
|
||||
}
|
||||
setWatching(false);
|
||||
}}
|
||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -108,7 +101,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWatching(true)}
|
||||
onClick={() => watchShare(share.participantId)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { memo, startTransition, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
type DisplaySurfaceHint,
|
||||
getPresetParams,
|
||||
getScreenShareSettings,
|
||||
PRESET_ORDER,
|
||||
type ScreenSharePreset,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import {
|
||||
captureScreenSourceThumbnailBytes,
|
||||
listScreenSources,
|
||||
type ScreenSource,
|
||||
} from '../lib/screenSources';
|
||||
import { MonitorShareIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Parent handles the actual share start. `sourceId` is null when the user
|
||||
* clicks "Teilen" without picking a specific source — fallback to the
|
||||
* OS-level getDisplayMedia picker. */
|
||||
onStart: (opts: {
|
||||
sourceId: string | null;
|
||||
preset: ScreenSharePreset;
|
||||
displaySurface: DisplaySurfaceHint;
|
||||
framerate: number | null;
|
||||
includeAudio: boolean;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
// Discord-style picker. Replaces the old form-field dialog with a thumbnail
|
||||
// grid sourced from the Rust `enumerate_screen_sources` command. Clicking a
|
||||
// thumbnail stashes its Chromium-format id; the parent then attempts a
|
||||
// `chromeMediaSourceId`-constrained getUserMedia call. If WebView2 ignores
|
||||
// the constraint (it may), the fallback OS picker still runs — but at least
|
||||
// the user already saw + chose from a real preview first.
|
||||
export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const initial = getScreenShareSettings();
|
||||
const [sources, setSources] = useState<ScreenSource[] | null>(null);
|
||||
// Thumbnails are kept in a separate state from the source list so an
|
||||
// arriving thumbnail never creates a new `ScreenSource` object for
|
||||
// unrelated cards — memo compares `thumbnailUrl` by string identity,
|
||||
// so only the one card whose URL changes rerenders.
|
||||
const [thumbnailUrls, setThumbnailUrls] = useState<Record<string, string>>({});
|
||||
// All blob URLs we've handed out this session. Revoked on picker close
|
||||
// so the native buffers they point at don't leak across opens.
|
||||
const blobUrlsRef = useRef<string[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Two-phase load: (1) fast list returns names + placeholders so the grid
|
||||
// paints instantly, (2) capture thumbnails in a bounded worker-pool
|
||||
// using the binary-IPC variant. Arriving bytes are wrapped in a Blob
|
||||
// and exposed via URL.createObjectURL — no base64 on either side,
|
||||
// which is the single biggest main-thread win compared to the old
|
||||
// JSON-of-base64 flow. Combined with rAF-batched state updates, the
|
||||
// picker stays responsive even on 20+ source enumerations.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Revoke blob URLs created during the last session so native
|
||||
// buffers don't linger after close.
|
||||
for (const url of blobUrlsRef.current) URL.revokeObjectURL(url);
|
||||
blobUrlsRef.current = [];
|
||||
setSources(null);
|
||||
setThumbnailUrls({});
|
||||
setSelectedId(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Coalesce thumbnail arrivals within a single animation frame into
|
||||
// one setState — cuts re-renders from O(N) to O(frames) during the
|
||||
// initial fan-in and prevents consecutive 10-30 ms long tasks from
|
||||
// stacking in one frame.
|
||||
//
|
||||
// Previously `batch` was aliased to `pendingUrls` and then we cleared
|
||||
// pendingUrls via `delete` — which emptied batch too (same reference)
|
||||
// and every flush ended up spreading nothing into the state. Clone
|
||||
// first, then clear, so the batch keeps its entries.
|
||||
let pendingUrls: Record<string, string> = {};
|
||||
let rafScheduled = false;
|
||||
const flush = () => {
|
||||
rafScheduled = false;
|
||||
const batch = pendingUrls;
|
||||
if (Object.keys(batch).length === 0) return;
|
||||
pendingUrls = {};
|
||||
startTransition(() => {
|
||||
setThumbnailUrls((prev) => ({ ...prev, ...batch }));
|
||||
});
|
||||
};
|
||||
const queueUrl = (id: string, url: string) => {
|
||||
pendingUrls[id] = url;
|
||||
if (!rafScheduled) {
|
||||
rafScheduled = true;
|
||||
requestAnimationFrame(flush);
|
||||
}
|
||||
};
|
||||
|
||||
// Concurrency 2: Windows GDI BitBlt / PrintWindow contends for the
|
||||
// desktop compositor, so 4+ parallel captures stutter the whole Tauri
|
||||
// window. 2 in parallel keeps the compositor breathing.
|
||||
const CONCURRENCY = 2;
|
||||
void (async () => {
|
||||
const list = await listScreenSources();
|
||||
if (cancelled) return;
|
||||
setSources(list);
|
||||
const queue = [...list];
|
||||
const pickOne = (src: typeof list[number]) => {
|
||||
void (async () => {
|
||||
const blob = await captureScreenSourceThumbnailBytes(src.id);
|
||||
if (cancelled) {
|
||||
// Edge case: picker closed while this request was in flight.
|
||||
// blob may still exist; nothing holds a URL to it, so it GCs.
|
||||
return;
|
||||
}
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlsRef.current.push(url);
|
||||
queueUrl(src.id, url);
|
||||
}
|
||||
const nextSrc = queue.shift();
|
||||
if (nextSrc) pickOne(nextSrc);
|
||||
})();
|
||||
};
|
||||
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
|
||||
const s = queue.shift();
|
||||
if (s) pickOne(s);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const screens = sources?.filter((s) => s.kind === 'screen') ?? [];
|
||||
const windows = sources?.filter((s) => s.kind === 'window') ?? [];
|
||||
const hasAny = (sources?.length ?? 0) > 0;
|
||||
|
||||
async function handleStart(): Promise<void> {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Persist user's audio + preset choice so subsequent shares start with
|
||||
// the same prefs when they skip the picker. The picker itself stays
|
||||
// as the entry for future starts (right-click on share button also
|
||||
// opens it — see InCallPanel wiring).
|
||||
updateScreenShareSettings({ preset, includeSystemAudio: includeAudio });
|
||||
// Infer a displaySurface hint from the selection so the fallback OS
|
||||
// picker jumps to the right tab when our direct-publish path is
|
||||
// rejected by WebView2.
|
||||
const selected = sources?.find((s) => s.id === selectedId) ?? null;
|
||||
const hint: DisplaySurfaceHint =
|
||||
selected?.kind === 'screen'
|
||||
? 'monitor'
|
||||
: selected?.kind === 'window'
|
||||
? 'window'
|
||||
: null;
|
||||
await onStart({
|
||||
sourceId: selected?.id ?? null,
|
||||
preset,
|
||||
displaySurface: hint,
|
||||
framerate: null,
|
||||
includeAudio,
|
||||
});
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'screenshare failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[88vh] w-full max-w-[860px] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sources === null ? (
|
||||
<div className="flex h-40 items-center justify-center gap-2 text-sm text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{t('app:call.share_loading', { defaultValue: 'Lade Quellen…' })}
|
||||
</span>
|
||||
</div>
|
||||
) : !hasAny ? (
|
||||
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center text-sm text-fg-muted">
|
||||
<MonitorShareIcon className="h-6 w-6 opacity-60" />
|
||||
<span>
|
||||
{t('app:call.share_no_sources', {
|
||||
defaultValue:
|
||||
'Keine Quellen-Previews verfügbar. Klick auf „Teilen" öffnet den System-Picker.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5 p-5">
|
||||
{screens.length > 0 && (
|
||||
<SourceSection
|
||||
title={t('app:call.share_screens', { defaultValue: 'Bildschirme' })}
|
||||
sources={screens}
|
||||
thumbnailUrls={thumbnailUrls}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
)}
|
||||
{windows.length > 0 && (
|
||||
<SourceSection
|
||||
title={t('app:call.share_windows', { defaultValue: 'Fenster' })}
|
||||
sources={windows}
|
||||
thumbnailUrls={thumbnailUrls}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-col gap-3 border-t border-line bg-surface-2 px-5 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
|
||||
<span className="font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
|
||||
</span>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-xs text-fg focus:border-accent focus:outline-none"
|
||||
>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{getPresetParams(p).label} · ≤ {getPresetParams(p).bitrateKbps} kbps
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeAudio}
|
||||
onChange={(e) => setIncludeAudio(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span>
|
||||
{t('app:call.share_system_audio', {
|
||||
defaultValue: 'System-Sound mit übertragen',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStart()}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
<span>
|
||||
{selectedId
|
||||
? t('app:call.share_start', { defaultValue: 'Teilen' })
|
||||
: t('app:call.share_pick_system', {
|
||||
defaultValue: 'Ohne Auswahl weiter',
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceSection({
|
||||
title,
|
||||
sources,
|
||||
thumbnailUrls,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
sources: ScreenSource[];
|
||||
thumbnailUrls: Record<string, string>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{title}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
|
||||
{sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
source={src}
|
||||
thumbnailUrl={thumbnailUrls[src.id] ?? null}
|
||||
selected={selectedId === src.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized so a thumbnail arriving for card B doesn't re-render card A.
|
||||
// Keeps re-render work proportional to the number of updates instead of
|
||||
// "whole grid on every update" — which was the main reason scrolling felt
|
||||
// frozen during the initial thumbnail fan-in.
|
||||
//
|
||||
// The parent passes `onSelect(id)` rather than an inline `onClick`-arrow
|
||||
// so the callback reference stays stable across renders; otherwise
|
||||
// React.memo would always see a fresh function prop and re-render every
|
||||
// card on every parent update.
|
||||
const SourceCard = memo(function SourceCard({
|
||||
source,
|
||||
thumbnailUrl,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
source: ScreenSource;
|
||||
thumbnailUrl: string | null;
|
||||
selected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(source.id)}
|
||||
aria-pressed={selected}
|
||||
title={source.name}
|
||||
className={
|
||||
'group flex cursor-pointer flex-col overflow-hidden rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||
(selected
|
||||
? 'border-accent ring-2 ring-accent/30'
|
||||
: 'border-line hover:border-accent/70')
|
||||
}
|
||||
>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-black">
|
||||
{thumbnailUrl ? (
|
||||
// decoding="async" keeps image decode off the main-thread paint
|
||||
// step; loading="lazy" means cards outside the viewport don't
|
||||
// ask the browser to decode until the user scrolls to them. The
|
||||
// URL is a blob: URL backed by the ArrayBuffer Rust sent over
|
||||
// IPC — no base64 decode, no data-URL parse, just direct bytes
|
||||
// into the decoder.
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-contain transition group-hover:brightness-110"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-surface-2 to-surface-3 text-fg-muted">
|
||||
<MonitorShareIcon className="h-6 w-6 opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate px-2.5 py-1.5 text-left text-xs font-medium text-fg">
|
||||
{source.name}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ProfileBrief } from '@chat-app/shared/friends';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
profile: ProfileBrief | null;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
onStartDm?: (userId: string) => void;
|
||||
}
|
||||
|
||||
const PRESENCE_DOT: Record<string, string> = {
|
||||
online: 'bg-emerald-500',
|
||||
idle: 'bg-amber-400',
|
||||
dnd: 'bg-rose-500',
|
||||
invisible: 'bg-neutral-500',
|
||||
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
||||
};
|
||||
|
||||
const CARD_W = 280;
|
||||
const CARD_H = 180;
|
||||
|
||||
// Hover/click card surfaced from avatars around the app. Shows display name,
|
||||
// @handle, presence state + status message, plus a DM-start button when
|
||||
// the clicked profile isn't the caller.
|
||||
export function UserProfilePopover({
|
||||
userId,
|
||||
profile,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onStartDm,
|
||||
}: Props) {
|
||||
// Subscribe to the same presence feed that the conversation header uses,
|
||||
// so status updates flow in live.
|
||||
const presence = usePeerPresence(userId);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t?.closest('[data-user-popover]')) return;
|
||||
if (t?.closest('[data-user-popover-trigger]')) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const left = Math.min(Math.max(8, x), window.innerWidth - CARD_W - 8);
|
||||
const top = Math.min(Math.max(8, y), window.innerHeight - CARD_H - 8);
|
||||
|
||||
const displayName = profile?.displayName ?? '?';
|
||||
const username = profile?.username ?? '';
|
||||
const state = presence?.state ?? 'offline';
|
||||
const showPresence = state !== 'invisible';
|
||||
const statusMessage = presence?.statusMessage?.trim() ?? '';
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-user-popover
|
||||
role="dialog"
|
||||
aria-label={displayName}
|
||||
style={{ left, top, width: CARD_W }}
|
||||
className="fixed z-[80] flex flex-col gap-3 rounded-xl border border-line bg-surface-2/95 p-4 shadow-xl backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
url={profile?.avatarUrl ?? null}
|
||||
displayName={displayName}
|
||||
className="h-14 w-14 text-lg"
|
||||
/>
|
||||
{showPresence && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full ring-2 ring-surface-2 ' +
|
||||
(PRESENCE_DOT[state] ?? PRESENCE_DOT.offline)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">
|
||||
{displayName}
|
||||
</p>
|
||||
{username && (
|
||||
<p className="truncate text-xs text-fg-muted">@{username}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusMessage && state !== 'offline' && (
|
||||
<p className="rounded-md bg-surface-3 px-2.5 py-1.5 text-xs italic text-fg-muted">
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onStartDm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onStartDm(userId);
|
||||
onClose();
|
||||
}}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
|
||||
>
|
||||
Nachricht senden
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -519,86 +519,53 @@ export function AddUserIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
||||
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
||||
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
||||
// element via `className`.
|
||||
export function LogoMark(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="logo-hex-clip">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon
|
||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="#2e1065"
|
||||
/>
|
||||
<g clipPath="url(#logo-hex-clip)">
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
||||
fill="#7c4dff"
|
||||
/>
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
<polygon
|
||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
|
||||
<path
|
||||
d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||
fill="none"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.4"
|
||||
stroke="#fff"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
|
||||
// "dark" = white text (use on dark background), "light" = black text.
|
||||
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
||||
// "dark" = white text (use on dark background), "light" = near-black.
|
||||
export function LogoLockup({
|
||||
tone = 'dark',
|
||||
...props
|
||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
|
||||
const textFill = tone === 'dark' ? '#ffffff' : '#14121c';
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 260 64"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="logo-lockup-hex-clip">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
|
||||
<g clipPath="url(#logo-lockup-hex-clip)">
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
||||
fill="#7c4dff"
|
||||
/>
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
<polygon
|
||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
|
||||
<path
|
||||
d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||
fill="none"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.4"
|
||||
stroke="#fff"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<text
|
||||
x="78"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { playNotificationTone } from '../lib/notificationSound';
|
||||
import { notify } from '../lib/osNotify';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { updateTrayUnread } from '../lib/trayBadge';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
|
||||
@@ -253,6 +254,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
return s;
|
||||
}, [unread]);
|
||||
|
||||
// Mirror unread count into the tray tooltip + dock badge. Runs in Tauri
|
||||
// only; no-op in browser-preview.
|
||||
useEffect(() => {
|
||||
void updateTrayUnread(totalUnread);
|
||||
}, [totalUnread]);
|
||||
|
||||
const value = useMemo<ConversationsContextValue>(
|
||||
() => ({
|
||||
conversations,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Blob cache for decrypted attachments. Stores the Blob body in OPFS
|
||||
// (Origin Private File System — always sandbox-scoped to the origin, no
|
||||
// user prompt) keyed by attachment id. Avoids re-downloading + re-decrypting
|
||||
// the same blob on every scroll-into-view.
|
||||
//
|
||||
// Tauri WebKit + WebView2 both support OPFS as of the versions targeted by
|
||||
// tauri 2. On unsupported hosts the cache degrades to a no-op and callers
|
||||
// fall back to the fetch path.
|
||||
|
||||
const DIR_NAME = 'attachments';
|
||||
const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000;
|
||||
|
||||
interface OpfsRoot {
|
||||
getDirectoryHandle: (
|
||||
name: string,
|
||||
opts?: { create?: boolean },
|
||||
) => Promise<OpfsDir>;
|
||||
}
|
||||
|
||||
interface OpfsDir {
|
||||
getFileHandle: (
|
||||
name: string,
|
||||
opts?: { create?: boolean },
|
||||
) => Promise<OpfsFile>;
|
||||
removeEntry: (name: string, opts?: { recursive?: boolean }) => Promise<void>;
|
||||
entries?: () => AsyncIterableIterator<[string, OpfsFile]>;
|
||||
}
|
||||
|
||||
interface OpfsFile {
|
||||
getFile: () => Promise<File>;
|
||||
createWritable: () => Promise<{
|
||||
write: (data: ArrayBuffer | Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
let dirPromise: Promise<OpfsDir | null> | null = null;
|
||||
|
||||
async function getDir(): Promise<OpfsDir | null> {
|
||||
if (!dirPromise) {
|
||||
dirPromise = (async () => {
|
||||
const storage = (navigator as unknown as { storage?: { getDirectory?: () => Promise<OpfsRoot> } }).storage;
|
||||
const getDirectory = storage?.getDirectory;
|
||||
if (!storage || !getDirectory) return null;
|
||||
try {
|
||||
const root = await getDirectory.call(storage);
|
||||
return await root.getDirectoryHandle(DIR_NAME, { create: true });
|
||||
} catch (err: unknown) {
|
||||
console.warn('opfs attachment cache init failed', err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return dirPromise;
|
||||
}
|
||||
|
||||
function safeName(id: string): string {
|
||||
// OPFS file names can't contain slashes; attachment ids are UUIDs so this
|
||||
// is mostly defensive.
|
||||
return id.replace(/[^A-Za-z0-9_.-]/g, '_') + '.bin';
|
||||
}
|
||||
|
||||
export async function getCachedAttachment(id: string): Promise<Blob | null> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return null;
|
||||
try {
|
||||
const handle = await dir.getFileHandle(safeName(id), { create: false });
|
||||
const file = await handle.getFile();
|
||||
// Evict stale entries lazily — if older than TTL, drop and miss.
|
||||
if (Date.now() - file.lastModified > DEFAULT_TTL_MS) {
|
||||
await dir.removeEntry(safeName(id)).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
} catch {
|
||||
// File doesn't exist → cache miss.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function putCachedAttachment(id: string, blob: Blob): Promise<void> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return;
|
||||
try {
|
||||
const handle = await dir.getFileHandle(safeName(id), { create: true });
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} catch (err: unknown) {
|
||||
console.warn('putCachedAttachment failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function evictCachedAttachment(id: string): Promise<void> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return;
|
||||
try {
|
||||
await dir.removeEntry(safeName(id));
|
||||
} catch {
|
||||
/* ignore — probably never existed */
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,37 @@ export interface AudioSettings {
|
||||
// Preferred output (speaker/headphone) deviceId. null = system default.
|
||||
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
|
||||
outputDeviceId: string | null;
|
||||
// RMS threshold (0..1) for the "is this participant talking" ring. Lower =
|
||||
// more sensitive. Default 0.03 catches soft speech without lighting up on
|
||||
// keyboard noise. Users on quiet mics can lower; users in noisy rooms bump up.
|
||||
voiceThreshold: number;
|
||||
// Per-publish DSP toggle. Off lets hifi-style music go through unmodified;
|
||||
// on cleans up voice when the quality preset doesn't already imply it.
|
||||
// Defaults to "follow the quality preset".
|
||||
noiseSuppression: boolean;
|
||||
// Background blur on the local camera track. Lazy — requires
|
||||
// @livekit/track-processors + its MediaPipe selfie-segmentation model
|
||||
// (~1.5MB) which downloads on first activation.
|
||||
videoBackgroundBlur: boolean;
|
||||
// Ringtone volume for both the generated oscillator fallback and the
|
||||
// custom incoming-call audio file. 0..1; applied on top of the base
|
||||
// oscillator gain so the fallback stays audible at 100% without being
|
||||
// harsh at 25%. Separate from any system / call audio volume so users
|
||||
// can have loud rings + soft in-call audio.
|
||||
ringtoneVolume: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: AudioSettings = {
|
||||
quality: 'voice',
|
||||
inputDeviceId: null,
|
||||
outputDeviceId: null,
|
||||
voiceThreshold: 0.03,
|
||||
// Off by default — browser-native NS colours voice audibly on some
|
||||
// mics and is a frequent "why does my voice sound weird" report.
|
||||
// Users who want it enable it explicitly in Settings → Sprache.
|
||||
noiseSuppression: false,
|
||||
videoBackgroundBlur: false,
|
||||
ringtoneVolume: 0.9,
|
||||
};
|
||||
|
||||
export interface AudioQualityParams {
|
||||
@@ -80,6 +105,7 @@ function read(): AudioSettings {
|
||||
return cached;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
||||
const rawThreshold = typeof parsed.voiceThreshold === 'number' ? parsed.voiceThreshold : NaN;
|
||||
cached = {
|
||||
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
||||
inputDeviceId:
|
||||
@@ -90,6 +116,25 @@ function read(): AudioSettings {
|
||||
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
|
||||
? parsed.outputDeviceId
|
||||
: DEFAULTS.outputDeviceId,
|
||||
voiceThreshold:
|
||||
Number.isFinite(rawThreshold) && rawThreshold >= 0.005 && rawThreshold <= 0.2
|
||||
? rawThreshold
|
||||
: DEFAULTS.voiceThreshold,
|
||||
noiseSuppression:
|
||||
typeof parsed.noiseSuppression === 'boolean'
|
||||
? parsed.noiseSuppression
|
||||
: DEFAULTS.noiseSuppression,
|
||||
videoBackgroundBlur:
|
||||
typeof parsed.videoBackgroundBlur === 'boolean'
|
||||
? parsed.videoBackgroundBlur
|
||||
: DEFAULTS.videoBackgroundBlur,
|
||||
ringtoneVolume:
|
||||
typeof parsed.ringtoneVolume === 'number' &&
|
||||
Number.isFinite(parsed.ringtoneVolume) &&
|
||||
parsed.ringtoneVolume >= 0 &&
|
||||
parsed.ringtoneVolume <= 1
|
||||
? parsed.ringtoneVolume
|
||||
: DEFAULTS.ringtoneVolume,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Session-level avatar cache. Avatars are small (<100KB), referenced from
|
||||
// many places (chat list, bubbles, dialogs), and rarely change — so keeping
|
||||
// one blob-URL per remote URL avoids repeated decodes + network 304s across
|
||||
// remounts. OPFS is optional; the in-memory map is enough for daily use.
|
||||
|
||||
const blobUrls = new Map<string, string>(); // remote URL → blob URL
|
||||
const inflight = new Map<string, Promise<string | null>>();
|
||||
|
||||
async function fetchAsBlobUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'force-cache' });
|
||||
if (!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a cached blob URL for the avatar. Falls back to the original URL
|
||||
// if caching fails so rendering never breaks.
|
||||
export function useCachedAvatarUrl(url: string | null | undefined): string | null | undefined {
|
||||
if (!url) return url;
|
||||
return blobUrls.get(url) ?? url;
|
||||
}
|
||||
|
||||
// Eagerly populate the cache for a list of URLs (e.g. conversation members)
|
||||
// so subsequent renders hit the Map directly.
|
||||
export function warmAvatarCache(urls: Array<string | null | undefined>): void {
|
||||
for (const url of urls) {
|
||||
if (!url) continue;
|
||||
if (blobUrls.has(url)) continue;
|
||||
if (inflight.has(url)) continue;
|
||||
const p = fetchAsBlobUrl(url).then((blobUrl) => {
|
||||
if (blobUrl) blobUrls.set(url, blobUrl);
|
||||
inflight.delete(url);
|
||||
return blobUrl;
|
||||
});
|
||||
inflight.set(url, p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Main-thread wrapper around the decrypt Web Worker.
|
||||
//
|
||||
// Spawns a single worker lazily on first use. Uses request-id correlation so
|
||||
// concurrent batches (e.g. a backfill arriving while a realtime insert
|
||||
// dispatches) can't mix up their results. Falls back to inline decrypt if
|
||||
// the Worker constructor isn't available (non-browser environments, some
|
||||
// strict CSPs).
|
||||
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
export interface DecryptItem {
|
||||
id: string;
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
key: Uint8Array;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (results: DecryptResult[]) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}
|
||||
|
||||
interface DecryptResult {
|
||||
id: string;
|
||||
plaintext: string | null;
|
||||
}
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let workerBroken = false;
|
||||
const pending = new Map<string, Pending>();
|
||||
|
||||
function spawn(): Worker | null {
|
||||
if (workerBroken) return null;
|
||||
if (worker) return worker;
|
||||
try {
|
||||
worker = new Worker(new URL('../workers/decrypt.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
worker.addEventListener('message', (ev: MessageEvent) => {
|
||||
const data = ev.data as { id?: string; results?: DecryptResult[] };
|
||||
if (!data.id) return;
|
||||
const entry = pending.get(data.id);
|
||||
if (!entry) return;
|
||||
pending.delete(data.id);
|
||||
entry.resolve(data.results ?? []);
|
||||
});
|
||||
worker.addEventListener('error', (ev) => {
|
||||
console.warn('decrypt worker error, falling back to inline', ev.message);
|
||||
workerBroken = true;
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
for (const entry of pending.values()) {
|
||||
entry.reject(new Error('decrypt worker crashed'));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
return worker;
|
||||
} catch (err: unknown) {
|
||||
console.warn('decrypt worker spawn failed, falling back to inline', err);
|
||||
workerBroken = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptBatch(items: DecryptItem[]): Promise<DecryptResult[]> {
|
||||
if (items.length === 0) return [];
|
||||
const w = spawn();
|
||||
if (!w) return inlineDecrypt(items);
|
||||
|
||||
return new Promise<DecryptResult[]>((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
pending.set(id, { resolve, reject });
|
||||
try {
|
||||
w.postMessage({ id, items });
|
||||
} catch (err: unknown) {
|
||||
pending.delete(id);
|
||||
// postMessage can fail if the Uint8Array view is detached — retry inline.
|
||||
console.warn('decrypt worker postMessage failed, using inline', err);
|
||||
inlineDecrypt(items).then(resolve, reject);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback path — same semantics as the worker but on the main thread. Used
|
||||
// when the worker failed to spawn or crashed mid-session.
|
||||
async function inlineDecrypt(items: DecryptItem[]): Promise<DecryptResult[]> {
|
||||
await sodium.ready;
|
||||
return items.map((item) => {
|
||||
try {
|
||||
const plain = sodium.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
|
||||
return {
|
||||
id: item.id,
|
||||
plaintext: new TextDecoder('utf-8', { fatal: false }).decode(plain),
|
||||
};
|
||||
} catch {
|
||||
return { id: item.id, plaintext: null };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||
|
||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||
// so the backup string can be safely written down or stored in a password
|
||||
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||
@@ -36,15 +38,13 @@ function unb64url(s: string): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return sodiumLib.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return pwhashArgon2id({
|
||||
password: passphrase,
|
||||
salt,
|
||||
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
outLen: KEY_LEN,
|
||||
preset: 'moderate',
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportDeviceKey(
|
||||
|
||||
@@ -48,6 +48,38 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Press-only global shortcut (for toggles like Mute/Deafen). Accepts an
|
||||
// already-formatted accelerator string (e.g. "CommandOrControl+Shift+M")
|
||||
// since these bindings may include modifier chords — the KeyboardEvent.code
|
||||
// variant used by PTT can't express that.
|
||||
export async function registerGlobalShortcutPress(
|
||||
shortcut: string,
|
||||
onPress: () => void,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
await register(shortcut, (event: ShortcutEvent) => {
|
||||
if (event.state === 'Pressed') onPress();
|
||||
});
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerGlobalShortcutPress failed', { shortcut, err });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterGlobalShortcut(shortcut: string): Promise<void> {
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterGlobalShortcut failed', { shortcut, err });
|
||||
}
|
||||
}
|
||||
|
||||
// Detects whether we're running under Tauri. When running in a pure web
|
||||
// preview (vite dev in a browser without Tauri), importing the plugin still
|
||||
// works but calls fall through to window.__TAURI_INTERNALS__ which doesn't
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Client-side image compression before upload. Downscales huge photos
|
||||
// (phone cameras regularly emit 4000×3000+ at 5MB+) to a size that
|
||||
// actually makes sense for chat display. Preserves aspect ratio.
|
||||
//
|
||||
// Skips animated formats (gif/apng/webp) to keep motion, and skips
|
||||
// already-small files to avoid useless re-encode overhead.
|
||||
|
||||
const MAX_DIM = 2048;
|
||||
const TARGET_QUALITY = 0.85;
|
||||
const SKIP_BELOW_BYTES = 512 * 1024; // 512KB — not worth re-encoding
|
||||
const ANIMATED_MIME = /^image\/(gif|apng|webp)$/;
|
||||
|
||||
export async function compressImage(file: File): Promise<File> {
|
||||
if (!file.type.startsWith('image/')) return file;
|
||||
if (ANIMATED_MIME.test(file.type)) return file;
|
||||
if (file.size < SKIP_BELOW_BYTES) return file;
|
||||
if (typeof createImageBitmap !== 'function') return file;
|
||||
if (typeof OffscreenCanvas !== 'function') return file;
|
||||
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const largest = Math.max(bitmap.width, bitmap.height);
|
||||
const scale = largest > MAX_DIM ? MAX_DIM / largest : 1;
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: 'image/webp',
|
||||
quality: TARGET_QUALITY,
|
||||
});
|
||||
// If the re-encoded blob is actually larger (small PNGs can expand as
|
||||
// WebP), keep the original.
|
||||
if (blob.size >= file.size) return file;
|
||||
const name = renameToWebp(file.name);
|
||||
return new File([blob], name, { type: 'image/webp', lastModified: file.lastModified });
|
||||
} catch (err: unknown) {
|
||||
console.warn('compressImage failed — keeping original', err);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
function renameToWebp(original: string): string {
|
||||
const dot = original.lastIndexOf('.');
|
||||
const base = dot > 0 ? original.slice(0, dot) : original;
|
||||
return base + '.webp';
|
||||
}
|
||||
|
||||
export async function compressImages(files: File[]): Promise<File[]> {
|
||||
return Promise.all(files.map((f) => compressImage(f)));
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Local SQLite message cache. Persists decrypted messages so startup +
|
||||
// conversation-switch hydrate from disk instantly instead of waiting on a
|
||||
// network round-trip + batch decrypt.
|
||||
//
|
||||
// The cache is per-device-local (app-local-data dir, same trust boundary as
|
||||
// the device private key). Plaintext is stored because the threat model
|
||||
// already assumes local-disk access means compromise — same as the existing
|
||||
// outbox + secret store. Ciphertext + nonce are kept alongside so a future
|
||||
// key-rotation migration can re-derive plaintext when a new bundle arrives.
|
||||
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
interface Database {
|
||||
execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>;
|
||||
select: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
|
||||
close: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface DatabaseStatic {
|
||||
load: (path: string) => Promise<Database>;
|
||||
}
|
||||
|
||||
const DB_NAME = 'chatapp-cache.db';
|
||||
|
||||
let dbPromise: Promise<Database | null> | null = null;
|
||||
|
||||
async function loadDb(): Promise<Database | null> {
|
||||
if (!isTauriRuntime()) return null;
|
||||
try {
|
||||
// Dynamic import so browser-preview builds don't choke on the tauri
|
||||
// plugin module. Vite will statically analyse this + split it into a
|
||||
// chunk that only loads inside Tauri.
|
||||
const mod = (await import('@tauri-apps/plugin-sql')) as {
|
||||
default: DatabaseStatic;
|
||||
Database?: DatabaseStatic;
|
||||
};
|
||||
const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database;
|
||||
const db = await DatabaseCtor.load('sqlite:' + DB_NAME);
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
sender_device_id TEXT,
|
||||
reply_to_id TEXT,
|
||||
edited_at TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
plaintext TEXT
|
||||
);
|
||||
`);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
|
||||
);
|
||||
// FTS5 virtual table for instant full-text search across the cache.
|
||||
// Keeps only the searchable text columns (plaintext + sender), keyed
|
||||
// by the message id so we can join back to the main row. Triggers
|
||||
// mirror inserts/updates/deletes so the index never drifts.
|
||||
//
|
||||
// Falls back gracefully on FTS5-less builds: the IF NOT EXISTS keeps
|
||||
// the call idempotent, and the surrounding try/catch already handles
|
||||
// a CREATE failure by skipping the whole cache.
|
||||
try {
|
||||
await db.execute(
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
message_id UNINDEXED,
|
||||
conversation_id UNINDEXED,
|
||||
plaintext,
|
||||
tokenize = 'unicode61 remove_diacritics 2'
|
||||
);`,
|
||||
);
|
||||
await db.execute(
|
||||
`CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
|
||||
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
|
||||
END;`,
|
||||
);
|
||||
await db.execute(
|
||||
`CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE message_id = old.id;
|
||||
END;`,
|
||||
);
|
||||
await db.execute(
|
||||
`CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE message_id = old.id;
|
||||
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
|
||||
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
|
||||
END;`,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.warn('FTS5 init failed — search falls back to in-memory scan', err);
|
||||
}
|
||||
return db;
|
||||
} catch (err: unknown) {
|
||||
console.warn('messageCache init failed — falling back to memory-only', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDb(): Promise<Database | null> {
|
||||
if (!dbPromise) dbPromise = loadDb();
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — all calls are no-ops when the cache isn't available (browser
|
||||
// preview, init error, etc.), so callers never need to guard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string;
|
||||
sender_device_id: string | null;
|
||||
reply_to_id: string | null;
|
||||
edited_at: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
plaintext: string | null;
|
||||
}
|
||||
|
||||
export async function loadCachedMessages(
|
||||
conversationId: string,
|
||||
limit = 500,
|
||||
): Promise<DecryptedMessage[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
try {
|
||||
const rows = await db.select<Row>(
|
||||
'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2',
|
||||
[conversationId, limit],
|
||||
);
|
||||
return rows
|
||||
.reverse()
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
conversationId: r.conversation_id,
|
||||
senderId: r.sender_id,
|
||||
senderDeviceId: r.sender_device_id,
|
||||
replyToId: r.reply_to_id,
|
||||
editedAt: r.edited_at,
|
||||
deletedAt: r.deleted_at,
|
||||
createdAt: r.created_at,
|
||||
plaintext: r.plaintext,
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
console.warn('loadCachedMessages failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistMessages(
|
||||
conversationId: string,
|
||||
messages: DecryptedMessage[],
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db || messages.length === 0) return;
|
||||
try {
|
||||
// Replace strategy: each message is keyed by id so upsert is "INSERT OR
|
||||
// REPLACE". Attachments + forward chains are part of `plaintext` JSON so
|
||||
// nothing lives outside this table.
|
||||
for (const m of messages) {
|
||||
await db.execute(
|
||||
`INSERT OR REPLACE INTO messages
|
||||
(id, conversation_id, sender_id, sender_device_id, reply_to_id,
|
||||
edited_at, deleted_at, created_at, plaintext)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
m.id,
|
||||
conversationId,
|
||||
m.senderId,
|
||||
m.senderDeviceId,
|
||||
m.replyToId,
|
||||
m.editedAt,
|
||||
m.deletedAt,
|
||||
m.createdAt,
|
||||
m.plaintext,
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('persistMessages failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCachedMessage(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
await db.execute('DELETE FROM messages WHERE id = $1', [id]);
|
||||
} catch (err: unknown) {
|
||||
console.warn('deleteCachedMessage failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Full-text search across cached messages. Returns rows in newest-first
|
||||
// order; caller maps to DecryptedMessage. Empty result on cache-miss or
|
||||
// FTS5 not available — caller should fall back to in-memory regex.
|
||||
export async function searchCachedMessages(
|
||||
conversationId: string,
|
||||
query: string,
|
||||
limit = 200,
|
||||
): Promise<DecryptedMessage[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) return [];
|
||||
// Sanitize for FTS5 MATCH syntax. Strip quotes + control chars; wrap in
|
||||
// an OR over each token with a trailing wildcard so partial words match.
|
||||
// Hyphens + colons are FTS5 operators so we drop them.
|
||||
const tokens = trimmed
|
||||
.replace(/["'\u0000-\u001f]/g, ' ')
|
||||
.replace(/[-:^]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) return [];
|
||||
const matchExpr = tokens.map((t) => '"' + t.replace(/"/g, '') + '"*').join(' AND ');
|
||||
try {
|
||||
const rows = await db.select<Row>(
|
||||
`SELECT m.* FROM messages m
|
||||
JOIN messages_fts f ON f.message_id = m.id
|
||||
WHERE f.conversation_id = $1 AND messages_fts MATCH $2
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3`,
|
||||
[conversationId, matchExpr, limit],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
conversationId: r.conversation_id,
|
||||
senderId: r.sender_id,
|
||||
senderDeviceId: r.sender_device_id,
|
||||
replyToId: r.reply_to_id,
|
||||
editedAt: r.edited_at,
|
||||
deletedAt: r.deleted_at,
|
||||
createdAt: r.created_at,
|
||||
plaintext: r.plaintext,
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
console.warn('searchCachedMessages failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Housekeeping — run once per session to bound the cache size. Keeps the
|
||||
// latest KEEP_PER_CONV messages per conversation.
|
||||
const KEEP_PER_CONV = 1000;
|
||||
|
||||
export async function pruneCache(): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
await db.execute(
|
||||
`DELETE FROM messages WHERE id IN (
|
||||
SELECT id FROM messages m
|
||||
WHERE (
|
||||
SELECT COUNT(*) FROM messages m2
|
||||
WHERE m2.conversation_id = m.conversation_id
|
||||
AND m2.created_at > m.created_at
|
||||
) >= $1
|
||||
)`,
|
||||
[KEEP_PER_CONV],
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.warn('pruneCache failed', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Native accelerators for the crypto ops whose JS/WASM runtime is slow
|
||||
// enough to matter. The sync `CryptoBackend` interface stays on WASM because
|
||||
// migrating every call-site to async would break most of the shared crypto
|
||||
// code for no runtime win — per-message AEAD already runs in a worker and
|
||||
// its 15µs-vs-80µs delta is lost in IPC overhead (~40µs round-trip).
|
||||
//
|
||||
// The two ops we actually accelerate:
|
||||
// * Argon2id password hashing (vault unlock, backup import/export)
|
||||
// * Randomness (fast enough inline, but we expose it so future Rust-only
|
||||
// flows don't need to route through the WASM backend).
|
||||
//
|
||||
// All fall back to WASM when the Tauri runtime isn't present (browser
|
||||
// preview, dev server) so the same code paths keep working.
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import _sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
function b64ToBytes(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Environment flag. Default ON in Tauri, OFF in browser-preview. Can be
|
||||
// force-disabled by setting VITE_USE_NATIVE_CRYPTO=false for debugging
|
||||
// a regression against the WASM baseline.
|
||||
const flagEnabled = (() => {
|
||||
const raw = (import.meta as unknown as { env?: { VITE_USE_NATIVE_CRYPTO?: string } })
|
||||
.env?.VITE_USE_NATIVE_CRYPTO;
|
||||
if (raw === 'false' || raw === '0') return false;
|
||||
return true;
|
||||
})();
|
||||
|
||||
function nativeAvailable(): boolean {
|
||||
return flagEnabled && isTauriRuntime();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argon2id password hashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PwhashOpts {
|
||||
password: string;
|
||||
salt: Uint8Array;
|
||||
outLen: number;
|
||||
preset?: 'interactive' | 'moderate' | 'sensitive';
|
||||
}
|
||||
|
||||
export async function pwhashArgon2id(opts: PwhashOpts): Promise<Uint8Array> {
|
||||
if (nativeAvailable()) {
|
||||
try {
|
||||
const b64 = await invoke<string>('crypto_pwhash', {
|
||||
args: {
|
||||
password: opts.password,
|
||||
saltB64: bytesToB64(opts.salt),
|
||||
outLen: opts.outLen,
|
||||
preset: opts.preset ?? 'moderate',
|
||||
},
|
||||
});
|
||||
return b64ToBytes(b64);
|
||||
} catch (err: unknown) {
|
||||
// Tauri command not registered (e.g. older build) or unexpected failure.
|
||||
// Fall through to WASM so the app stays functional.
|
||||
console.warn('[nativeCryptoOps] pwhash native failed, falling back', err);
|
||||
}
|
||||
}
|
||||
await _sodium.ready;
|
||||
const s = _sodium;
|
||||
const presetPair = (() => {
|
||||
switch (opts.preset ?? 'moderate') {
|
||||
case 'interactive':
|
||||
return [s.crypto_pwhash_OPSLIMIT_INTERACTIVE, s.crypto_pwhash_MEMLIMIT_INTERACTIVE];
|
||||
case 'sensitive':
|
||||
return [s.crypto_pwhash_OPSLIMIT_SENSITIVE, s.crypto_pwhash_MEMLIMIT_SENSITIVE];
|
||||
default:
|
||||
return [s.crypto_pwhash_OPSLIMIT_MODERATE, s.crypto_pwhash_MEMLIMIT_MODERATE];
|
||||
}
|
||||
})();
|
||||
return s.crypto_pwhash(
|
||||
opts.outLen,
|
||||
opts.password,
|
||||
opts.salt,
|
||||
presetPair[0]!,
|
||||
presetPair[1]!,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Randombytes — native-first, WASM fallback. Used for non-hot-path nonces
|
||||
// and tokens; inline callers that need sync random should stay on the
|
||||
// backend interface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function randomBytesAsync(len: number): Promise<Uint8Array> {
|
||||
if (nativeAvailable()) {
|
||||
try {
|
||||
const b64 = await invoke<string>('crypto_random_bytes', { len });
|
||||
return b64ToBytes(b64);
|
||||
} catch (err: unknown) {
|
||||
console.warn('[nativeCryptoOps] random native failed, falling back', err);
|
||||
}
|
||||
}
|
||||
await _sodium.ready;
|
||||
return _sodium.randombytes_buf(len);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secretbox + box accelerators — exposed but not auto-adopted. Call-sites
|
||||
// can migrate individually if they measure a win. Round-trip IPC is ~40µs
|
||||
// so per-op wins under 100µs usually lose to the WASM path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function secretboxEncryptNative(
|
||||
plaintext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
if (!nativeAvailable()) throw new Error('native crypto unavailable');
|
||||
const b64 = await invoke<string>('crypto_secretbox_encrypt', {
|
||||
plaintextB64: bytesToB64(plaintext),
|
||||
nonceB64: bytesToB64(nonce),
|
||||
keyB64: bytesToB64(key),
|
||||
});
|
||||
return b64ToBytes(b64);
|
||||
}
|
||||
|
||||
export async function secretboxDecryptNative(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
if (!nativeAvailable()) throw new Error('native crypto unavailable');
|
||||
const b64 = await invoke<string>('crypto_secretbox_decrypt', {
|
||||
ciphertextB64: bytesToB64(ciphertext),
|
||||
nonceB64: bytesToB64(nonce),
|
||||
keyB64: bytesToB64(key),
|
||||
});
|
||||
return b64ToBytes(b64);
|
||||
}
|
||||